Skill v1.0.2
currentAutomated scan100/100~3 modified
version: "1.0.2" 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
- 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.
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) != nilelse { 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 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:
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 - [ ] 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
- [ ]
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