Rotate SCNCamera node looking at an object around an imaginary sphere
It might help to break down your issue into subproblems.
Setting the Scene
First, think about how to organize your scene to enable the kind of motion you want. You talk about moving the camera as if it's attached to an invisible sphere. Use that idea! Instead of trying to work out the math to set your cameraNode.position
to some point on an imaginary sphere, just think about what you would do to move the camera if it were attached to a sphere. That is, just rotate the sphere.
If you wanted to rotate a sphere separately from the rest of your scene contents, you'd attach it to a separate node. Of course, you don't actually need to insert a sphere geometry into your scene. Just make a node whose position
is concentric with the object you want your camera to orbit around, then attach the camera to a child node of that node. Then you can rotate that node to move the camera. Here's a quick demo of that, absent the scroll-event handling business:
let camera = SCNCamera()
camera.usesOrthographicProjection = true
camera.orthographicScale = 9
camera.zNear = 0
camera.zFar = 100
let cameraNode = SCNNode()
cameraNode.position = SCNVector3(x: 0, y: 0, z: 50)
cameraNode.camera = camera
let cameraOrbit = SCNNode()
cameraOrbit.addChildNode(cameraNode)
cubeScene.rootNode.addChildNode(cameraOrbit)
// rotate it (I've left out some animation code here to show just the rotation)
cameraOrbit.eulerAngles.x -= CGFloat(M_PI_4)
cameraOrbit.eulerAngles.y -= CGFloat(M_PI_4*3)
Here's what you see on the left, and a visualization of how it works on the right. The checkered sphere is cameraOrbit
, and the green cone is cameraNode
.
There's a couple of bonuses to this approach:
- You don't have to set the initial camera position in Cartesian coordinates. Just place it at whatever distance you want along the z-axis. Since
cameraNode
is a child node ofcameraOrbit
, its own position stays constant -- the camera moves due to the rotation ofcameraOrbit
. - As long as you just want the camera pointed at the center of this imaginary sphere, you don't need a look-at constraint. The camera points in the -Z direction of the space it's in -- if you move it in the +Z direction, then rotate the parent node, the camera will always point at the center of the parent node (i.e. the center of rotation).
Handling Input
Now that you've got your scene architected for camera rotation, turning input events into rotation is pretty easy. Just how easy depends on what kind of control you're after:
- Looking for arcball rotation? (It's great for direct manipulation, since you can feel like you're physically pushing a point on the 3D object.) There are some questions and answers about that already on SO -- most of them use
GLKQuaternion
. (UPDATE: GLK types are "sorta" available in Swift 1.2 / Xcode 6.3. Prior to those versions you can do your math in ObjC via a bridging header.) - For a simpler alternative, you can just map the x and y axes of your gesture to the yaw and pitch angles of your node. It's not as spiffy as arcball rotation, but it's pretty easy to implement -- all you need to do is work out a points-to-radians conversion that covers the amount of rotation you're after.
Either way, you can skip some of the gesture recognizer boilerplate and gain some handy interactive behaviors by using UIScrollView
instead. (Not that there isn't usefulness to sticking with gesture recognizers -- this is just an easily implemented alternative.)
Drop one on top of your SCNView
(without putting another view inside it to be scrolled) and set its contentSize
to a multiple of its frame size... then during scrolling you can map the contentOffset
to your eulerAngles
:
func scrollViewDidScroll(scrollView: UIScrollView) {
let scrollWidthRatio = Float(scrollView.contentOffset.x / scrollView.frame.size.width)
let scrollHeightRatio = Float(scrollView.contentOffset.y / scrollView.frame.size.height)
cameraOrbit.eulerAngles.y = Float(-2 * M_PI) * scrollWidthRatio
cameraOrbit.eulerAngles.x = Float(-M_PI) * scrollHeightRatio
}
On the one hand, you have to do a bit more work for infinite scrolling if you want to spin endlessly in one or both directions. On the other, you get nice scroll-style inertia and bounce behaviors.
Hey I ran into the problem the other day and the solution I came up with is fairly simple but works well.
First I created my camera and added it to my scene like so:
// create and add a camera to the scene
cameraNode = [SCNNode node];
cameraNode.camera = [SCNCamera camera];
cameraNode.camera.automaticallyAdjustsZRange = YES;
[scene.rootNode addChildNode:cameraNode];
// place the camera
cameraNode.position = SCNVector3Make(0, 0, 0);
cameraNode.pivot = SCNMatrix4MakeTranslation(0, 0, -15); //the -15 here will become the rotation radius
Then I made a CGPoint slideVelocity
class variable. And created a UIPanGestureRecognizer
and a and in its callback I put the following:
-(void)handlePan:(UIPanGestureRecognizer *)gestureRecognize{
slideVelocity = [gestureRecognize velocityInView:self.view];
}
Then I have this method that is called every frame. Note that I use GLKit
for quaternion math.
-(void)renderer:(id<SCNSceneRenderer>)aRenderer didRenderScene:(SCNScene *)scenie atTime:(NSTimeInterval)time {
//spin the camera according the the user's swipes
SCNQuaternion oldRot = cameraNode.rotation; //get the current rotation of the camera as a quaternion
GLKQuaternion rot = GLKQuaternionMakeWithAngleAndAxis(oldRot.w, oldRot.x, oldRot.y, oldRot.z); //make a GLKQuaternion from the SCNQuaternion
//The next function calls take these parameters: rotationAngle, xVector, yVector, zVector
//The angle is the size of the rotation (radians) and the vectors define the axis of rotation
GLKQuaternion rotX = GLKQuaternionMakeWithAngleAndAxis(-slideVelocity.x/viewSlideDivisor, 0, 1, 0); //For rotation when swiping with X we want to rotate *around* y axis, so if our vector is 0,1,0 that will be the y axis
GLKQuaternion rotY = GLKQuaternionMakeWithAngleAndAxis(-slideVelocity.y/viewSlideDivisor, 1, 0, 0); //For rotation by swiping with Y we want to rotate *around* the x axis. By the same logic, we use 1,0,0
GLKQuaternion netRot = GLKQuaternionMultiply(rotX, rotY); //To combine rotations, you multiply the quaternions. Here we are combining the x and y rotations
rot = GLKQuaternionMultiply(rot, netRot); //finally, we take the current rotation of the camera and rotate it by the new modified rotation.
//Then we have to separate the GLKQuaternion into components we can feed back into SceneKit
GLKVector3 axis = GLKQuaternionAxis(rot);
float angle = GLKQuaternionAngle(rot);
//finally we replace the current rotation of the camera with the updated rotation
cameraNode.rotation = SCNVector4Make(axis.x, axis.y, axis.z, angle);
//This specific implementation uses velocity. If you don't want that, use the rotation method above just replace slideVelocity.
//decrease the slider velocity
if (slideVelocity.x > -0.1 && slideVelocity.x < 0.1) {
slideVelocity.x = 0;
}
else {
slideVelocity.x += (slideVelocity.x > 0) ? -1 : 1;
}
if (slideVelocity.y > -0.1 && slideVelocity.y < 0.1) {
slideVelocity.y = 0;
}
else {
slideVelocity.y += (slideVelocity.y > 0) ? -1 : 1;
}
}
This code gives infinite Arcball rotation with velocity, which I believe is what you are looking for. Also, you don't need the SCNLookAtConstraint
with this method. In fact, that will probably mess it up, so don't do that.
If you want to implement rickster's answer using a gesture recognizer, you have to save state information as you'll only be given a translation relative to the beginning of the gesture. I added two vars to my class
var lastWidthRatio: Float = 0
var lastHeightRatio: Float = 0
And implemented his rotate code as follows:
func handlePanGesture(sender: UIPanGestureRecognizer) {
let translation = sender.translationInView(sender.view!)
let widthRatio = Float(translation.x) / Float(sender.view!.frame.size.width) + lastWidthRatio
let heightRatio = Float(translation.y) / Float(sender.view!.frame.size.height) + lastHeightRatio
self.cameraOrbit.eulerAngles.y = Float(-2 * M_PI) * widthRatio
self.cameraOrbit.eulerAngles.x = Float(-M_PI) * heightRatio
if (sender.state == .Ended) {
lastWidthRatio = widthRatio % 1
lastHeightRatio = heightRatio % 1
}
}
Maybe this could be useful for readers.
class GameViewController: UIViewController {
var cameraOrbit = SCNNode()
let cameraNode = SCNNode()
let camera = SCNCamera()
//HANDLE PAN CAMERA
var lastWidthRatio: Float = 0
var lastHeightRatio: Float = 0.2
var fingersNeededToPan = 1
var maxWidthRatioRight: Float = 0.2
var maxWidthRatioLeft: Float = -0.2
var maxHeightRatioXDown: Float = 0.02
var maxHeightRatioXUp: Float = 0.4
//HANDLE PINCH CAMERA
var pinchAttenuation = 20.0 //1.0: very fast ---- 100.0 very slow
var lastFingersNumber = 0
override func viewDidLoad() {
super.viewDidLoad()
// create a new scene
let scene = SCNScene(named: "art.scnassets/ship.scn")!
// create and add a light to the scene
let lightNode = SCNNode()
lightNode.light = SCNLight()
lightNode.light!.type = SCNLightTypeOmni
lightNode.position = SCNVector3(x: 0, y: 10, z: 10)
scene.rootNode.addChildNode(lightNode)
// create and add an ambient light to the scene
let ambientLightNode = SCNNode()
ambientLightNode.light = SCNLight()
ambientLightNode.light!.type = SCNLightTypeAmbient
ambientLightNode.light!.color = UIColor.darkGrayColor()
scene.rootNode.addChildNode(ambientLightNode)
//Create a camera like Rickster said
camera.usesOrthographicProjection = true
camera.orthographicScale = 9
camera.zNear = 1
camera.zFar = 100
cameraNode.position = SCNVector3(x: 0, y: 0, z: 50)
cameraNode.camera = camera
cameraOrbit = SCNNode()
cameraOrbit.addChildNode(cameraNode)
scene.rootNode.addChildNode(cameraOrbit)
//initial camera setup
self.cameraOrbit.eulerAngles.y = Float(-2 * M_PI) * lastWidthRatio
self.cameraOrbit.eulerAngles.x = Float(-M_PI) * lastHeightRatio
// retrieve the SCNView
let scnView = self.view as! SCNView
// set the scene to the view
scnView.scene = scene
//allows the user to manipulate the camera
scnView.allowsCameraControl = false //not needed
// add a tap gesture recognizer
let panGesture = UIPanGestureRecognizer(target: self, action: "handlePan:")
scnView.addGestureRecognizer(panGesture)
// add a pinch gesture recognizer
let pinchGesture = UIPinchGestureRecognizer(target: self, action: "handlePinch:")
scnView.addGestureRecognizer(pinchGesture)
}
func handlePan(gestureRecognize: UIPanGestureRecognizer) {
let numberOfTouches = gestureRecognize.numberOfTouches()
let translation = gestureRecognize.translationInView(gestureRecognize.view!)
var widthRatio = Float(translation.x) / Float(gestureRecognize.view!.frame.size.width) + lastWidthRatio
var heightRatio = Float(translation.y) / Float(gestureRecognize.view!.frame.size.height) + lastHeightRatio
if (numberOfTouches==fingersNeededToPan) {
// HEIGHT constraints
if (heightRatio >= maxHeightRatioXUp ) {
heightRatio = maxHeightRatioXUp
}
if (heightRatio <= maxHeightRatioXDown ) {
heightRatio = maxHeightRatioXDown
}
// WIDTH constraints
if(widthRatio >= maxWidthRatioRight) {
widthRatio = maxWidthRatioRight
}
if(widthRatio <= maxWidthRatioLeft) {
widthRatio = maxWidthRatioLeft
}
self.cameraOrbit.eulerAngles.y = Float(-2 * M_PI) * widthRatio
self.cameraOrbit.eulerAngles.x = Float(-M_PI) * heightRatio
print("Height: \(round(heightRatio*100))")
print("Width: \(round(widthRatio*100))")
//for final check on fingers number
lastFingersNumber = fingersNeededToPan
}
lastFingersNumber = (numberOfTouches>0 ? numberOfTouches : lastFingersNumber)
if (gestureRecognize.state == .Ended && lastFingersNumber==fingersNeededToPan) {
lastWidthRatio = widthRatio
lastHeightRatio = heightRatio
print("Pan with \(lastFingersNumber) finger\(lastFingersNumber>1 ? "s" : "")")
}
}
func handlePinch(gestureRecognize: UIPinchGestureRecognizer) {
let pinchVelocity = Double.init(gestureRecognize.velocity)
//print("PinchVelocity \(pinchVelocity)")
camera.orthographicScale -= (pinchVelocity/pinchAttenuation)
if camera.orthographicScale <= 0.5 {
camera.orthographicScale = 0.5
}
if camera.orthographicScale >= 10.0 {
camera.orthographicScale = 10.0
}
}
override func supportedInterfaceOrientations() -> UIInterfaceOrientationMask {
return .Landscape
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Release any cached data, images, etc that aren't in use.
}
}