SceneKit Tips
Pitfalls and patterns for SceneKit rendering — render thread vs MainActor, constraint closures and actor isolation, scene-clock gating, and bone manipulation.
Install in BlintOpens the Blint desktop app and adds this pattern to your project. Don't have Blint?
Render thread vs MainActor
SceneKit's render loop runs on its own thread (com.apple.scenekit.scnview-renderer). The coordinator's SCNSceneRendererDelegate methods are called on that thread, but the coordinator is @MainActor-isolated, so the delegate methods are nonisolated and dispatch to MainActor via Task { @MainActor in }.
This means anything inside that Task runs after the frame — not during it.
Code that must execute during the current frame (before constraints evaluate, before the frame renders) must run synchronously inside the nonisolated delegate method, without a Task hop.
Delegate timing
SceneKit fires delegate methods in this order each frame:
didApplyAnimationsAtTime— animations and actions have been evaluated- Constraints evaluate (e.g.
SCNTransformConstraint) willRenderScene— final chance before GPU submission
Use didApplyAnimations to write data that constraints need. Use willRenderScene for read-only work like reporting camera positions.
The bridge pattern
When data must flow from the render thread into a constraint closure (also render thread), use a plain @unchecked Sendable class as a bridge — no actor isolation, no locks. Write from didApplyAnimations, read from the constraint.
final class SomeBridge: @unchecked Sendable {
var value: SIMD3<Float> = .zero
}
// In didApplyAnimationsAtTime (synchronous, render thread):
bridge.value = renderer.pointOfView!.presentation.worldPosition
// In constraint closure (also render thread, same frame):
let pos = bridge.value
Do not write bridge values from a Task { @MainActor in } block — they will be stale by the time the constraint reads them.
Closures inherit actor isolation
A closure created inside a @MainActor method inherits MainActor isolation. SceneKit calls SCNTransformConstraint handlers on the render thread — a MainActor-isolated handler will trigger _dispatch_assert_queue_fail at runtime.
Fix: Create the constraint in a nonisolated static func so the closure has no actor isolation.
// WRONG — closure inherits @MainActor from the enclosing method
func setup() {
let c = SCNTransformConstraint(inWorldSpace: true) { node, transform in
// 💥 dispatch_assert_queue_fail on render thread
}
}
// RIGHT — nonisolated static, closure is unbound
nonisolated private static func makeConstraint() -> SCNConstraint {
SCNTransformConstraint(inWorldSpace: true) { node, transform in
// ✅ runs on render thread without assertion
}
}
This also applies to stored constants on a globally-isolated class — axis constants, bridge references — which inherit the class's isolation and are otherwise unreachable from a constraint closure. Mark them nonisolated.
Use plain nonisolated, not nonisolated(unsafe), whenever the value is a let of Sendable type. (unsafe) is an escape hatch that opts the declaration out of checking; the compiler warns when you reach for it needlessly. Reserve it for storage it genuinely cannot verify — mutable statics, or non-Sendable values.
Never gate correctness on the scene clock
SCNActions, scene-time-driven animations, and anything advanced by the
render loop only progress while the scene clock actually runs. On a paused
scene (scene.isPaused) or an on-demand renderer (rendersContinuously = false with sparse draws), a queued action sits at progress zero — while the
view may still draw frames showing the scene's current state.
Consequence: anything whose correctness depends on an action completing (visibility fades, one-shot state transitions) must not be expressed as an action unless the clock is guaranteed to be driving. A node faded in from opacity 0 by an action on a paused scene is invisible forever, drawn frame after frame.
- Prefer expressing visibility and one-shot reveals at the hosting-view layer (UIKit/SwiftUI opacity — Core Animation runs regardless of the scene clock).
- If an action is genuinely wanted (polish while actively animating), gate it on the clock being driven and take the instant path otherwise.
- Fading a multi-mesh model via node opacity is also visually wrong: every overlapping mesh becomes translucent at once and the model's own hidden surfaces (limbs behind torso, clothing layers) blend through each other. Whole-view fades avoid the sorting artifacts entirely.
Reading camera position
SCNNode.position gives the model position (what you set). SCNNode.presentation.position and presentation.worldPosition give the position as rendered — accounting for in-flight actions and orbit controller manipulation.
When you need the real camera position during the render loop, read renderer.pointOfView?.presentation.worldPosition. This is especially important during free-cam (orbit controller) and preset camera cycling (SCNAction-driven), where the model position lags or differs entirely.
Delta rotations on bones
When overriding a bone's orientation (e.g. head gaze), use a delta rotation rather than an absolute look-at orientation. The bone's coordinate system depends on the rig, the rest pose, and the root rotation — computing an absolute orientation requires knowing the exact axis conventions.
A delta rotation is rig-agnostic:
- Extract the face direction from the animated world transform:
animatedQ.act(faceAxis) - Compute the rotation from current face direction to the desired direction:
simd_quatf(from: current, to: desired) - Clamp the angle for realism
- Multiply on top of the animated rotation:
delta * animatedQ
The only rig-specific piece is faceAxis — which local axis points in the face direction (for example, on a CC5 rig whose root carries a -90° X rotation, it is +Z).