Skill v1.0.0
Automated scanversion: "1.0.0" 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
Apple's high-level 3D rendering framework for maintaining existing scenes and visualizations on iOS using Swift 6.3. Provides a node-based scene graph, built-in geometry primitives, physically based materials, lighting, animation, and physics.
Deprecation notice (WWDC 2025): SceneKit is officially deprecated across all Apple platforms and is now in maintenance mode (critical bug fixes only). Existing apps continue to work. For new projects or major updates, Apple recommends RealityKit. See WWDC 2025 session 288 for migration guidance.
Contents
- Scene Setup
- Nodes and Geometry
- Materials
- Lighting
- Cameras
- Animation
- Physics
- Particle Systems
- Loading Models
- SwiftUI Integration
- Common Mistakes
- Review Checklist
- References
Scene Setup
SCNView in UIKit
import SceneKitlet sceneView = SCNView(frame: view.bounds)sceneView.scene = SCNScene()sceneView.allowsCameraControl = truesceneView.autoenablesDefaultLighting = truesceneView.backgroundColor = .blackview.addSubview(sceneView)
allowsCameraControl adds built-in orbit, pan, and zoom gestures. Typically disabled in production where custom camera control is needed.
Creating an SCNScene
let scene = SCNScene() // Emptyguard let scene = SCNScene(named: "art.scnassets/ship.scn") // .scn in .scnassetselse { 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.
let parentNode = SCNNode()scene.rootNode.addChildNode(parentNode)let childNode = SCNNode()childNode.position = SCNVector3(0, 1, 0) // 1 unit above parentparentNode.addChildNode(childNode)
Transforms
node.position = SCNVector3(x: 0, y: 2, z: -5)node.eulerAngles = SCNVector3(x: 0, y: .pi / 4, z: 0) // 45-degree Y rotationnode.scale = SCNVector3(2, 2, 2)node.simdPosition = SIMD3<Float>(0, 2, -5) // Prefer simd for performance
Built-in Primitives
SCNBox, SCNSphere, SCNCylinder, SCNCone, SCNTorus, SCNCapsule, SCNTube, SCNPlane, SCNFloor, SCNText, SCNShape (extruded Bezier path).
let node = SCNNode(geometry: SCNSphere(radius: 0.5))
Finding Nodes
let maxNode = scene.rootNode.childNode(withName: "Max", recursively: true)let enemies = scene.rootNode.childNodes { node, _ innode.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
let material = SCNMaterial()material.diffuse.contents = UIColor.systemBlue // Solid colormaterial.diffuse.contents = UIImage(named: "brick") // Texturematerial.normal.contents = UIImage(named: "brick_normal")sphere.firstMaterial = material
Physically Based Rendering (PBR)
let pbr = SCNMaterial()pbr.lightingModel = .physicallyBasedpbr.diffuse.contents = UIImage(named: "albedo")pbr.metalness.contents = 0.8 // Scalar or texturepbr.roughness.contents = 0.2 // Scalar or texturepbr.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
material.transparency = 0.5material.transparencyMode = .dualLayermaterial.isDoubleSided = true
Lighting
Attach an SCNLight to a node. The light's direction follows the node's negative Z-axis.
Light Types
// Ambient: uniform, no directionlet ambient = SCNLight()ambient.type = .ambientambient.color = UIColor(white: 0.3, alpha: 1)// Directional: parallel rays (sunlight)let directional = SCNLight()directional.type = .directionaldirectional.castsShadow = true// Omni: point light, all directionslet omni = SCNLight()omni.type = .omniomni.attenuationEndDistance = 20// Spot: cone-shapedlet spot = SCNLight()spot.type = .spotspot.spotInnerAngle = 20spot.spotOuterAngle = 60
Attach to a node:
let lightNode = SCNNode()lightNode.light = directionallightNode.eulerAngles = SCNVector3(-Float.pi / 3, 0, 0)lightNode.position = SCNVector3(0, 10, 10)scene.rootNode.addChildNode(lightNode)
Shadows
light.castsShadow = truelight.shadowMapSize = CGSize(width: 2048, height: 2048)light.shadowSampleCount = 8light.shadowRadius = 3.0light.shadowColor = UIColor(white: 0, alpha: 0.5)
Category Bit Masks
light.categoryBitMask = 1 << 1 // Category 2node.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.
let cameraNode = SCNNode()cameraNode.camera = SCNCamera()cameraNode.position = SCNVector3(0, 5, 15)cameraNode.look(at: SCNVector3Zero)scene.rootNode.addChildNode(cameraNode)sceneView.pointOfView = cameraNode
Configuration
camera.fieldOfView = 60 // Degreescamera.zNear = 0.1camera.zFar = 500camera.automaticallyAdjustsZRange = true// Orthographiccamera.usesOrthographicProjection = truecamera.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.
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]))// Sequentialnode.runAction(.sequence([.fadeOut(duration: 0.3), .removeFromParentNode()]))// Infinite looplet pulse = SCNAction.sequence([.scale(to: 1.2, duration: 0.5),.scale(to: 1.0, duration: 0.5)])node.runAction(.repeatForever(pulse))
SCNTransaction (Implicit Animation)
SCNTransaction.begin()SCNTransaction.animationDuration = 1.0node.position = SCNVector3(5, 0, 0)node.opacity = 0.5SCNTransaction.completionBlock = { print("Done") }SCNTransaction.commit()
Explicit Animations (Core Animation)
let animation = CABasicAnimation(keyPath: "rotation")animation.toValue = NSValue(scnVector4: SCNVector4(0, 1, 0, Float.pi * 2))animation.duration = 2animation.repeatCount = .infinitynode.addAnimation(animation, forKey: "spin")
Physics
Physics Bodies
node.physicsBody = SCNPhysicsBody(type: .dynamic, shape: nil) // Forces + collisionsfloor.physicsBody = SCNPhysicsBody(type: .static, shape: nil) // Immovableplatform.physicsBody = SCNPhysicsBody(type: .kinematic, shape: nil) // Code-driven
When shape is nil, SceneKit derives it from geometry. For performance, use simplified shapes:
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.0node.physicsBody?.restitution = 0.3
Applying Forces
node.physicsBody?.applyForce(SCNVector3(0, 10, 0), asImpulse: false) // Continuousnode.physicsBody?.applyForce(SCNVector3(0, 5, 0), asImpulse: true) // Instantnode.physicsBody?.applyTorque(SCNVector4(0, 1, 0, 2), asImpulse: true)
Collision Detection
struct PhysicsCategory {static let player: Int = 1 << 0static let enemy: Int = 1 << 1static let ground: Int = 1 << 2}playerNode.physicsBody?.categoryBitMask = PhysicsCategory.playerplayerNode.physicsBody?.collisionBitMask = PhysicsCategory.ground | PhysicsCategory.enemyplayerNode.physicsBody?.contactTestBitMask = PhysicsCategory.enemyscene.physicsWorld.contactDelegate = selffunc physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact) {handleCollision(between: contact.nodeA, and: contact.nodeB)}
Gravity
scene.physicsWorld.gravity = SCNVector3(0, -9.8, 0)node.physicsBody?.isAffectedByGravity = false
Particle Systems
SCNParticleSystem creates effects like fire, smoke, rain, and sparks.
let particles = SCNParticleSystem()particles.birthRate = 100particles.particleLifeSpan = 2particles.particleSize = 0.1particles.particleColor = .orangeparticles.emitterShape = SCNSphere(radius: 0.5)particles.particleVelocity = 2particles.isAffectedByGravity = trueparticles.blendMode = .additivelet 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.
guard let scene = SCNScene(named: "art.scnassets/ship.scn") else { return }let scene = try SCNScene(url: Bundle.main.url(forResource: "model", withExtension: "dae")!,options: [.checkConsistency: true])guard let modelNode = scene.rootNode.childNode(withName: "mesh", recursively: true) else { return }
Use SCNReferenceNode with .onDemand loading policy for large models. For import-time unit conversion, use SCNSceneSource.LoadingOption:
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:
import SwiftUIimport SceneKitstruct SceneKitView: View {let scene: SCNScene = {let scene = SCNScene()let sphere = SCNNode(geometry: SCNSphere(radius: 1))sphere.geometry?.firstMaterial?.lightingModel = .physicallyBasedsphere.geometry?.firstMaterial?.diffuse.contents = UIColor.systemBluesphere.geometry?.firstMaterial?.metalness.contents = 0.8scene.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.
Common Mistakes
Not adding a camera or lights
// DON'T: Scene renders blank or black -- no camera, no lightssceneView.scene = scene// DO: Add camera + lights, or use convenience flagslet cameraNode = SCNNode()cameraNode.camera = SCNCamera()cameraNode.position = SCNVector3(0, 5, 15)scene.rootNode.addChildNode(cameraNode)sceneView.pointOfView = cameraNodesceneView.autoenablesDefaultLighting = true
Using exact geometry for physics shapes
// DON'Tnode.physicsBody = SCNPhysicsBody(type: .dynamic,shape: SCNPhysicsShape(geometry: complexMesh, options: nil))// DO: Simplified primitivenode.physicsBody = SCNPhysicsBody(type: .dynamic,shape: SCNPhysicsShape(geometry: SCNBox(width: 1, height: 2, length: 1, chamferRadius: 0),options: nil))
Modifying transforms on dynamic bodies
// DON'T: Resets physics simulationdynamicNode.position = SCNVector3(5, 0, 0)// DO: Use forces/impulsesdynamicNode.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
autoenablesDefaultLightingfor prototyping) - [ ] Physics shapes use simplified geometry, not full mesh detail
- [ ]
contactTestBitMaskset for bodies that need collision callbacks - [ ]
SCNPhysicsContactDelegateassigned toscene.physicsWorld.contactDelegate - [ ] Dynamic body transforms changed via forces/impulses, not direct position
- [ ] Lights limited to 8 per node;
attenuationEndDistanceset on point/spot lights - [ ] Materials use
.physicallyBasedlighting model for realistic rendering - [ ] SceneKit assets use documented
.scn,.dae, or.abcscene-source formats - [ ] 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
- [ ]
SCNReferenceNodeused for large models to enable lazy loading - [ ] Particle
birthRateandparticleLifeSpanbalanced to control particle count - [ ]
categoryBitMaskused to scope lights and cameras to relevant nodes - [ ] SwiftUI scenes use
SceneVieworUIViewRepresentable-wrappedSCNView - [ ] Deprecation acknowledged; RealityKit evaluated for new projects
References
- See 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, SCNSceneSource, SCNView, SceneView
- SCNPhysicsShape, SCNShadable
- WWDC 2025 session 288: Bring your SceneKit project to RealityKit