Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 131% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 123% | 0% |
| case-15 | ✗→✓ | ▲ Improved | 119% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 205% | 0% |
| case-05 | ✓→✓ | = Same ✓ | 185% | 0% |
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.
swiftimport 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.
swiftlet 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])
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.
swiftlet parentNode = SCNNode() scene.rootNode.addChildNode(parentNode) let childNode = SCNNode() childNode.position = SCNVector3(0, 1, 0) // 1 unit above parent parentNode.addChildNode(childNode)
swiftnode.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<Float>(0, 2, -5) // Prefer simd for performance
SCNBox, SCNSphere, SCNCylinder, SCNCone, SCNTorus, SCNCapsule, SCNTube, SCNPlane, SCNFloor, SCNText, SCNShape (extruded Bezier path).
swiftlet node = SCNNode(geometry: SCNSphere(radius: 0.5))
swiftlet maxNode = scene.rootNode.childNode(withName: "Max", recursively: true) let enemies = scene.rootNode.childNodes { node, _ in node.name?.hasPrefix("enemy") == true }
SCNMaterial defines surface appearance. Use firstMaterial for single-material geometries or the materials array for multi-material.
swiftlet 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
swiftlet 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")
.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.
swiftmaterial.transparency = 0.5 material.transparencyMode = .dualLayer material.isDoubleSided = true
Attach an SCNLight to a node. The light's direction follows the node's negative Z-axis.
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:
swiftlet lightNode = SCNNode() lightNode.light = directional lightNode.eulerAngles = SCNVector3(-Float.pi / 3, 0, 0) lightNode.position = SCNVector3(0, 10, 10) scene.rootNode.addChildNode(lightNode)
swiftlight.castsShadow = true light.shadowMapSize = CGSize(width: 2048, height: 2048) light.shadowSampleCount = 8 light.shadowRadius = 3.0 light.shadowColor = UIColor(white: 0, alpha: 0.5)
swiftlight.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.
Attach an SCNCamera to a node to define a viewpoint.
swiftlet cameraNode = SCNNode() cameraNode.camera = SCNCamera() cameraNode.position = SCNVector3(0, 5, 15) cameraNode.look(at: SCNVector3Zero) scene.rootNode.addChildNode(cameraNode) sceneView.pointOfView = cameraNode
swiftcamera.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.
SceneKit provides three animation approaches.
Reusable, composable animation objects attached to nodes.
swiftlet 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))
swiftSCNTransaction.begin() SCNTransaction.animationDuration = 1.0 node.position = SCNVector3(5, 0, 0) node.opacity = 0.5 SCNTransaction.completionBlock = { print("Done") } SCNTransaction.commit()
swiftlet 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")
swiftnode.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:
swiftlet 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
swiftnode.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)
swiftstruct 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) }
swiftscene.physicsWorld.gravity = SCNVector3(0, -9.8, 0) node.physicsBody?.isAffectedByGravity = false
SCNParticleSystem creates effects like fire, smoke, rain, and sparks.
swiftlet 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.
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.
swiftenum 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 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:
swiftlet 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.
SceneView embeds SceneKit in SwiftUI:
swiftimport 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.
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
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))
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)
pointOfViewautoenablesDefaultLighting for prototyping)contactTestBitMask set for bodies that need collision callbacksSCNPhysicsContactDelegate assigned to scene.physicsWorld.contactDelegateattenuationEndDistance set on point/spot lights.physicallyBased lighting model for realistic rendering.scn, .dae, or .abc scene-source formatsbefore commit
SCNScene.Attribute.unitSCNReferenceNode used for large models to enable lazy loadingbirthRate and particleLifeSpan balanced to control particle countcategoryBitMask used to scope lights and cameras to relevant nodesSceneView or UIViewRepresentable-wrapped SCNViewOther measured skills in the registry, with their headline benchmark lift.