Skip to content

Instantly share code, notes, and snippets.

@AKosmachyov
Created April 5, 2025 19:02
Show Gist options
  • Save AKosmachyov/762bf81e1f6122668a234444786106b4 to your computer and use it in GitHub Desktop.
Save AKosmachyov/762bf81e1f6122668a234444786106b4 to your computer and use it in GitHub Desktop.
SceneKit Orbit Control

SCNOrbitControl

SCNOrbitControl is a lightweight and flexible alternative to SCNView.allowsCameraControl, allowing you to define a custom camera orbit behavior around any node in your SceneKit scene. Perfect for apps with multiple focusable 3D objects.

✨ Features

  • 🎯 Set your own target node — no more relying on the scene center.
  • 🎥 Compatible with SCNCameraController via defaultCameraController.
  • 🧠 Keeps your camera logic decoupled from the view.
  • 👆 Simple gesture setup for pan and zoom.

📦 Installation

Just copy the SCNOrbitControl.swift file into your project. No dependencies.

🛠️ Usage

// Create your camera node
let cameraNode = SCNNode()
let camera = SCNCamera()
camera.fieldOfView = 60
camera.zNear = 0.1
cameraNode.camera = camera

sceneView.scene?.rootNode.addChildNode(cameraNode)
sceneView.pointOfView = cameraNode

// Initialize the orbit controller with your camera
let orbit = SCNOrbitControl(camera: cameraNode)

// Add gestures to your SCNView
orbit.addGestures(sceneView: sceneView)

// Set a custom target node
orbit.targetNode = modelContainerNode
import SceneKit
import UIKit
final public class SCNOrbitControl {
private weak var camera: SCNNode?
// Target point to orbit around
public var target: SIMD3<Float> = .zero
public weak var targetNode: SCNNode? {
didSet {
setCameraForNode()
}
}
public var rotationSpeed: Float = 0.005
public var zoomSpeed: Float = 0.8
public var minDistance: Float = 0.1
public var maxDistance: Float = Float.infinity
public lazy var tapGesture = UIPanGestureRecognizer(target: self, action: #selector( handlePan))
public lazy var pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch))
private var touchOld: CGPoint = .zero
private var scaleOld: Float = 0
// MARK: - Initialization
init(camera: SCNNode) {
self.camera = camera
}
public func addGestures(sceneView: UIView) {
sceneView.addGestureRecognizer(tapGesture)
sceneView.addGestureRecognizer(pinchGesture)
}
// MARK: - Touch Handling
@objc
public func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
guard let view = gestureRecognizer.view else { return }
let tapPosition = gestureRecognizer.location(in: view)
switch gestureRecognizer.state {
case .began:
touchOld = tapPosition
case .changed:
let movementX = tapPosition.x - touchOld.x;
let movementY = tapPosition.y - touchOld.y;
let delta = SIMD3<Float>(-Float(movementX), -Float(movementY), 0)
rotate(delta: delta)
touchOld = tapPosition
case .ended:
touchOld = .zero
default:
break
}
}
@objc
public func handlePinch(_ gestureRecognizer: UIPinchGestureRecognizer) {
let scale = Float(gestureRecognizer.scale)
switch gestureRecognizer.state {
case .began:
scaleOld = scale
case .changed:
zoom(scale: Float(scale))
scaleOld = scale
case .ended, .cancelled:
scaleOld = 0
default:
break
}
}
private func rotate(delta: SIMD3<Float>) {
guard let camera else { return }
let vector = camera.simdPosition - target
var spherical = Spherical(vector)
spherical.theta += delta.x * rotationSpeed
spherical.phi += delta.y * rotationSpeed
spherical.makeSafe()
let newPosition = spherical.toVector3()
camera.simdPosition = target + newPosition
camera.simdLook(at: target, up: [0, 1, 0], localFront: [0, 0, -1])
}
private func zoom(scale: Float) {
guard let camera else { return }
let currentDistance = simd_distance(camera.simdPosition, target)
let delta = (scale - scaleOld) * zoomSpeed
var newDistance = currentDistance * (1.0 - delta)
newDistance = min(max(newDistance, minDistance), maxDistance)
let direction = simd_normalize(camera.simdPosition - target)
camera.simdPosition = target + (direction * newDistance)
}
private func setCameraForNode() {
guard let camera, let targetNode else { return }
let sphere = targetNode.boundingSphere
let centerLocal: SIMD3 = [sphere.center.x, sphere.center.y, sphere.center.z]
let centerWorld = targetNode.simdConvertPosition(centerLocal, to: nil)
let radiusWorld = simd_length(targetNode.simdConvertVector([sphere.radius, 0, 0], to: nil))
target = centerWorld
minDistance = radiusWorld * 1.8
maxDistance = radiusWorld * 5
let degToRad = Float.pi / 180
let spherical = Spherical(
radius: minDistance,
theta: 1 * degToRad,
phi: 60 * degToRad
)
camera.simdPosition = spherical.toVector3()
camera.simdLook(at: target, up: [0, 1, 0], localFront: [0, 0, -1])
}
}
private struct Spherical {
let radius: Float
// azimuthal angle in radians
var theta: Float = 0.0
// polar angle in radians (vertical)
var phi: Float = 0.0
init(radius: Float, theta: Float, phi: Float) {
self.radius = radius
self.theta = theta
self.phi = phi
}
init(_ vector: SIMD3<Float>) {
radius = sqrt(
vector.x * vector.x +
vector.y * vector.y +
vector.z * vector.z
)
if radius == 0 {
theta = 0
phi = 0
} else {
theta = atan2(vector.x, vector.z)
phi = acos(max(-1, min(1, vector.y / radius)))
}
}
/// Restricts the polar angle [page:.phi phi] to be between `0.000001` and pi - `0.000001`.
mutating func makeSafe() {
let EPS: Float = 0.000001
phi = max(EPS, min(Float.pi - EPS, phi))
}
func toVector3() -> SIMD3<Float> {
let sinPhiRadius = sin(phi) * radius
return [
sinPhiRadius * sin(theta),
cos( phi ) * radius,
sinPhiRadius * cos( theta )
]
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment