--- name: scenekit description: "Maintain and extend existing SceneKit 3D scenes and visualizations. Use when working with SCNView, SCNScene, SCNNode scene graphs, SceneKit geometry/materials/lights/cameras, SCNAction animation, SCNPhysicsBody physics, SCNParticleSystem effects, .scn/.dae/.abc SceneKit assets, shader modifiers, or SwiftUI SceneView. SceneKit is soft-deprecated and in maintenance mode; route new apps, significant updates, USD/USDZ pipelines, and migration planning toward RealityKit." --- # SceneKit Maintain existing SceneKit scenes only. Apple deprecated SceneKit at WWDC 2025 and limits it to maintenance; route new projects, major modernization, and USD/USDZ pipelines to RealityKit. Existing apps continue to work. ## Contents - [Scene Setup](#scene-setup) - [Nodes and Geometry](#nodes-and-geometry) - [Materials](#materials) - [Lighting](#lighting) - [Cameras](#cameras) - [Animation](#animation) - [Physics](#physics) - [Particle Systems](#particle-systems) - [Loading Models](#loading-models) - [SwiftUI Integration](#swiftui-integration) - [Common Mistakes](#common-mistakes) - [Review Checklist](#review-checklist) - [References](#references) ## Scene Setup ### SCNView in UIKit ```swift import SceneKit let sceneView = SCNView(frame: view.bounds) sceneView.scene = SCNScene() sceneView.allowsCameraControl = true sceneView.autoenablesDefaultLighting = true sceneView.backgroundColor = .black view.addSubview(sceneView) ``` `allowsCameraControl` adds built-in orbit, pan, and zoom gestures. Typically disabled in production where custom camera control is needed. ### Creating an SCNScene ```swift let scene = SCNScene() // Empty guard let scene = SCNScene(named: "art.scnassets/ship.scn") // .scn in .scnassets else { fatalError("Missing scene asset") } let url = Bundle.main.url(forResource: "ship", withExtension: "dae")! let scene = try SCNScene(url: url, options: [.checkConsistency: true]) ``` ## Nodes and Geometry Every scene has a `rootNode`. All content exists as descendant nodes. Nodes define position, orientation, and scale in their parent's coordinate system. SceneKit uses a right-handed coordinate system: +X right, +Y up, +Z toward the camera. ```swift let parentNode = SCNNode() scene.rootNode.addChildNode(parentNode) let childNode = SCNNode() childNode.position = SCNVector3(0, 1, 0) // 1 unit above parent parentNode.addChildNode(childNode) ``` ### Transforms ```swift node.position = SCNVector3(x: 0, y: 2, z: -5) node.eulerAngles = SCNVector3(x: 0, y: .pi / 4, z: 0) // 45-degree Y rotation node.scale = SCNVector3(2, 2, 2) node.simdPosition = SIMD3(0, 2, -5) // Prefer simd for performance ``` ### Built-in Primitives `SCNBox`, `SCNSphere`, `SCNCylinder`, `SCNCone`, `SCNTorus`, `SCNCapsule`, `SCNTube`, `SCNPlane`, `SCNFloor`, `SCNText`, `SCNShape` (extruded Bezier path). ```swift let node = SCNNode(geometry: SCNSphere(radius: 0.5)) ``` ### Finding Nodes ```swift let maxNode = scene.rootNode.childNode(withName: "Max", recursively: true) let enemies = scene.rootNode.childNodes { node, _ in node.name?.hasPrefix("enemy") == true } ``` ## Materials `SCNMaterial` defines surface appearance. Use `firstMaterial` for single-material geometries or the `materials` array for multi-material. ### Color and Texture ```swift let material = SCNMaterial() material.diffuse.contents = UIColor.systemBlue // Solid color material.diffuse.contents = UIImage(named: "brick") // Texture material.normal.contents = UIImage(named: "brick_normal") sphere.firstMaterial = material ``` ### Physically Based Rendering (PBR) ```swift let pbr = SCNMaterial() pbr.lightingModel = .physicallyBased pbr.diffuse.contents = UIImage(named: "albedo") pbr.metalness.contents = 0.8 // Scalar or texture pbr.roughness.contents = 0.2 // Scalar or texture pbr.normal.contents = UIImage(named: "normal") pbr.ambientOcclusion.contents = UIImage(named: "ao") ``` ### Lighting Models `.physicallyBased` (metalness/roughness), `.blinn` (default), `.phong`, `.lambert` (diffuse-only), `.constant` (unlit), `.shadowOnly`. Each material property is an `SCNMaterialProperty` accepting `UIColor`, `UIImage`, `CGFloat` scalar, `SKTexture`, `CALayer`, or `AVPlayer`. ### Transparency ```swift material.transparency = 0.5 material.transparencyMode = .dualLayer material.isDoubleSided = true ``` ## Lighting Attach an `SCNLight` to a node. The light's direction follows the node's negative Z-axis. ### Light Types ```swift // Ambient: uniform, no direction let ambient = SCNLight() ambient.type = .ambient ambient.color = UIColor(white: 0.3, alpha: 1) // Directional: parallel rays (sunlight) let directional = SCNLight() directional.type = .directional directional.castsShadow = true // Omni: point light, all directions let omni = SCNLight() omni.type = .omni omni.attenuationEndDistance = 20 // Spot: cone-shaped let spot = SCNLight() spot.type = .spot spot.spotInnerAngle = 20 spot.spotOuterAngle = 60 ``` Attach to a node: ```swift let lightNode = SCNNode() lightNode.light = directional lightNode.eulerAngles = SCNVector3(-Float.pi / 3, 0, 0) lightNode.position = SCNVector3(0, 10, 10) scene.rootNode.addChildNode(lightNode) ``` ### Shadows ```swift light.castsShadow = true light.shadowMapSize = CGSize(width: 2048, height: 2048) light.shadowSampleCount = 8 light.shadowRadius = 3.0 light.shadowColor = UIColor(white: 0, alpha: 0.5) ``` ### Category Bit Masks ```swift light.categoryBitMask = 1 << 1 // Category 2 node.categoryBitMask = 1 << 1 // Only lit by category-2 lights ``` SceneKit renders a maximum of 8 lights per node. Use `attenuationEndDistance` on point/spot lights so SceneKit skips them for distant nodes. ## Cameras Attach an `SCNCamera` to a node to define a viewpoint. ```swift let cameraNode = SCNNode() cameraNode.camera = SCNCamera() cameraNode.position = SCNVector3(0, 5, 15) cameraNode.look(at: SCNVector3Zero) scene.rootNode.addChildNode(cameraNode) sceneView.pointOfView = cameraNode ``` ### Configuration ```swift camera.fieldOfView = 60 // Degrees camera.zNear = 0.1 camera.zFar = 500 camera.automaticallyAdjustsZRange = true // Orthographic camera.usesOrthographicProjection = true camera.orthographicScale = 10 ``` Depth-of-field (`wantsDepthOfField`, `focusDistance`, `fStop`) and HDR effects (`wantsHDR`, `bloomIntensity`, `bloomThreshold`, `screenSpaceAmbientOcclusionIntensity`) are configured directly on `SCNCamera`. ## Animation SceneKit provides three animation approaches. ### SCNAction (Declarative, Game-Oriented) Reusable, composable animation objects attached to nodes. ```swift let move = SCNAction.move(by: SCNVector3(0, 2, 0), duration: 1) let rotate = SCNAction.rotateBy(x: 0, y: .pi, z: 0, duration: 1) node.runAction(.group([move, rotate])) // Sequential node.runAction(.sequence([.fadeOut(duration: 0.3), .removeFromParentNode()])) // Infinite loop let pulse = SCNAction.sequence([ .scale(to: 1.2, duration: 0.5), .scale(to: 1.0, duration: 0.5) ]) node.runAction(.repeatForever(pulse)) ``` ### SCNTransaction (Implicit Animation) ```swift SCNTransaction.begin() SCNTransaction.animationDuration = 1.0 node.position = SCNVector3(5, 0, 0) node.opacity = 0.5 SCNTransaction.completionBlock = { print("Done") } SCNTransaction.commit() ``` ### Explicit Animations (Core Animation) ```swift let animation = CABasicAnimation(keyPath: "rotation") animation.toValue = NSValue(scnVector4: SCNVector4(0, 1, 0, Float.pi * 2)) animation.duration = 2 animation.repeatCount = .infinity node.addAnimation(animation, forKey: "spin") ``` ## Physics ### Physics Bodies ```swift node.physicsBody = SCNPhysicsBody(type: .dynamic, shape: nil) // Forces + collisions floor.physicsBody = SCNPhysicsBody(type: .static, shape: nil) // Immovable platform.physicsBody = SCNPhysicsBody(type: .kinematic, shape: nil) // Code-driven ``` When `shape` is `nil`, SceneKit derives it from geometry. For performance, use simplified shapes: ```swift let shape = SCNPhysicsShape( geometry: SCNBox(width: 1, height: 2, length: 1, chamferRadius: 0), options: nil ) node.physicsBody = SCNPhysicsBody(type: .dynamic, shape: shape) node.physicsBody?.mass = 2.0 node.physicsBody?.restitution = 0.3 ``` ### Applying Forces ```swift node.physicsBody?.applyForce(SCNVector3(0, 10, 0), asImpulse: false) // Continuous node.physicsBody?.applyForce(SCNVector3(0, 5, 0), asImpulse: true) // Instant node.physicsBody?.applyTorque(SCNVector4(0, 1, 0, 2), asImpulse: true) ``` ### Collision Detection ```swift struct PhysicsCategory { static let player: Int = 1 << 0 static let enemy: Int = 1 << 1 static let ground: Int = 1 << 2 } playerNode.physicsBody?.categoryBitMask = PhysicsCategory.player playerNode.physicsBody?.collisionBitMask = PhysicsCategory.ground | PhysicsCategory.enemy playerNode.physicsBody?.contactTestBitMask = PhysicsCategory.enemy scene.physicsWorld.contactDelegate = self func physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact) { handleCollision(between: contact.nodeA, and: contact.nodeB) } ``` ### Gravity ```swift scene.physicsWorld.gravity = SCNVector3(0, -9.8, 0) node.physicsBody?.isAffectedByGravity = false ``` ## Particle Systems `SCNParticleSystem` creates effects like fire, smoke, rain, and sparks. ```swift let particles = SCNParticleSystem() particles.birthRate = 100 particles.particleLifeSpan = 2 particles.particleSize = 0.1 particles.particleColor = .orange particles.emitterShape = SCNSphere(radius: 0.5) particles.particleVelocity = 2 particles.isAffectedByGravity = true particles.blendMode = .additive let emitterNode = SCNNode() emitterNode.addParticleSystem(particles) scene.rootNode.addChildNode(emitterNode) ``` Load from Xcode particle editor with `SCNParticleSystem(named: "fire.scnp", inDirectory: nil)`. Particles can collide with geometry via `colliderNodes`. ## Loading Models SceneKit's documented scene-source formats are `.scn`, `.dae`, and `.abc`. For bundled assets, place scene files in a `.scnassets` folder and texture images in asset catalogs so Xcode can optimize them for target devices. USD/USDZ is the RealityKit migration path, not the default SceneKit loading path. For new projects, significant updates, or SCN-to-USD asset conversion, handoff to the RealityKit skill. ```swift enum SceneAssetError: Error { case missingResource, missingNode(String) } func loadCheckedScene() throws -> SCNScene { guard let url = Bundle.main.url(forResource: "model", withExtension: "dae") else { throw SceneAssetError.missingResource } let scene = try SCNScene(url: url, options: [.checkConsistency: true]) guard scene.rootNode.childNode(withName: "mesh", recursively: true) != nil else { throw SceneAssetError.missingNode("mesh") } return scene } ``` Use this as an authoring/import gate: stop on a consistency or required-node failure, fix the source asset or import options, then repeat the same check. For generated `.scn` files, load [Scene Serialization](references/scenekit-patterns.md#scene-serialization) and require both export success and a checked reload before commit. Use `SCNReferenceNode` with `.onDemand` loading policy for large models. For import-time unit conversion, use `SCNSceneSource.LoadingOption`: ```swift let source = SCNSceneSource(url: url, options: nil)! let scene = try source.scene(options: [.convertUnitsToMeters: 1.0]) ``` Do not use `SCNScene.Attribute.unit` or `UnitMetersPerUnit`. `SCNScene.Attribute` is metadata only: `.startTime`, `.endTime`, `.frameRate`, and `.upAxis`. ## SwiftUI Integration `SceneView` embeds SceneKit in SwiftUI: ```swift import SwiftUI import SceneKit struct SceneKitView: View { let scene: SCNScene = { let scene = SCNScene() let sphere = SCNNode(geometry: SCNSphere(radius: 1)) sphere.geometry?.firstMaterial?.lightingModel = .physicallyBased sphere.geometry?.firstMaterial?.diffuse.contents = UIColor.systemBlue sphere.geometry?.firstMaterial?.metalness.contents = 0.8 scene.rootNode.addChildNode(sphere) return scene }() var body: some View { SceneView(scene: scene, options: [.allowsCameraControl, .autoenablesDefaultLighting]) } } ``` Options: `.allowsCameraControl`, `.autoenablesDefaultLighting`, `.jitteringEnabled`, `.temporalAntialiasingEnabled`. For render loop control, wrap `SCNView` in `UIViewRepresentable` with an `SCNSceneRendererDelegate` coordinator. See [references/scenekit-patterns.md](references/scenekit-patterns.md). ## Common Mistakes ### Not adding a camera or lights ```swift // DON'T: Scene renders blank or black -- no camera, no lights sceneView.scene = scene // DO: Add camera + lights, or use convenience flags let cameraNode = SCNNode() cameraNode.camera = SCNCamera() cameraNode.position = SCNVector3(0, 5, 15) scene.rootNode.addChildNode(cameraNode) sceneView.pointOfView = cameraNode sceneView.autoenablesDefaultLighting = true ``` ### Using exact geometry for physics shapes ```swift // DON'T node.physicsBody = SCNPhysicsBody(type: .dynamic, shape: SCNPhysicsShape(geometry: complexMesh, options: nil)) // DO: Simplified primitive node.physicsBody = SCNPhysicsBody(type: .dynamic, shape: SCNPhysicsShape( geometry: SCNBox(width: 1, height: 2, length: 1, chamferRadius: 0), options: nil)) ``` ### Modifying transforms on dynamic bodies ```swift // DON'T: Resets physics simulation dynamicNode.position = SCNVector3(5, 0, 0) // DO: Use forces/impulses dynamicNode.physicsBody?.applyForce(SCNVector3(10, 0, 0), asImpulse: true) ``` ## Review Checklist - [ ] Scene has at least one camera node set as `pointOfView` - [ ] Scene has appropriate lighting (or `autoenablesDefaultLighting` for prototyping) - [ ] Physics shapes use simplified geometry, not full mesh detail - [ ] `contactTestBitMask` set for bodies that need collision callbacks - [ ] `SCNPhysicsContactDelegate` assigned to `scene.physicsWorld.contactDelegate` - [ ] Dynamic body transforms changed via forces/impulses, not direct position - [ ] Lights limited to 8 per node; `attenuationEndDistance` set on point/spot lights - [ ] Materials use `.physicallyBased` lighting model for realistic rendering - [ ] SceneKit assets use documented `.scn`, `.dae`, or `.abc` scene-source formats - [ ] Imported and exported assets pass consistency and required-node checks before commit - [ ] Bundled SceneKit textures/images use asset catalogs or Xcode-optimized resources - [ ] Scene metadata/import options use documented API; no invented `SCNScene.Attribute.unit` - [ ] New USD/USDZ pipelines or significant updates are routed to RealityKit - [ ] Game Center authentication, leaderboards, achievements, or multiplayer are handed off to GameKit - [ ] `SCNReferenceNode` used for large models to enable lazy loading - [ ] Particle `birthRate` and `particleLifeSpan` balanced to control particle count - [ ] `categoryBitMask` used to scope lights and cameras to relevant nodes - [ ] SwiftUI scenes use `SceneView` or `UIViewRepresentable`-wrapped `SCNView` - [ ] Deprecation acknowledged; RealityKit evaluated for new projects ## References - See [references/scenekit-patterns.md](references/scenekit-patterns.md) for custom geometry, shader modifiers, constraints, morph targets, hit testing, scene serialization, render loop delegates, performance, SpriteKit overlay, LOD, and Metal shaders. - [SceneKit documentation](https://sosumi.ai/documentation/scenekit), [SCNSceneSource](https://sosumi.ai/documentation/scenekit/scnscenesource), [SCNView](https://sosumi.ai/documentation/scenekit/scnview), [SceneView](https://sosumi.ai/documentation/scenekit/sceneview) - [SCNPhysicsShape](https://sosumi.ai/documentation/scenekit/scnphysicsshape), [SCNShadable](https://sosumi.ai/documentation/scenekit/scnshadable) - [WWDC 2025 session 288: Bring your SceneKit project to RealityKit](https://sosumi.ai/videos/play/wwdc2025/288/)