scenekit

$npx mdskill add dpearson2699/swift-ios-skills/scenekit

Maintain and extend existing SceneKit 3D scenes and visualizations.

  • Supports maintaining legacy SceneKit apps and scenes.
  • Depends on SceneKit framework, SCNView, SCNScene, and related APIs.
  • Routes new projects and major updates to RealityKit instead.
  • Provides code examples and best practices for SceneKit maintenance.

SKILL.md

.github/skills/scenekitView on GitHub ↗
---
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<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).

```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/)

More from dpearson2699/swift-ios-skills

SkillDescription
accessorysetupkitDiscover and configure Bluetooth and Wi-Fi accessories using AccessorySetupKit. Use when presenting a privacy-preserving accessory picker, defining discovery descriptors for BLE or Wi-Fi devices, handling accessory session events, migrating from CoreBluetooth permission-based scanning, or setting up accessories without requiring broad Bluetooth permissions.
activitykitImplement, review, or improve Live Activities and Dynamic Island experiences in iOS apps using ActivityKit. Use when building real-time updating widgets for the Lock Screen and Dynamic Island — delivery tracking, sports scores, ride-sharing status, workout timers, media playback, or any time-sensitive information that updates in real time. Also use when working with ActivityKit, ActivityAttributes, Activity lifecycle (request/update/end), Dynamic Island layouts (compact/minimal/expanded), push-to-update Live Activities, or Lock Screen live widgets.
adattributionkitMeasure ad effectiveness with privacy-preserving attribution using AdAttributionKit. Use when registering ad impressions, handling attribution postbacks, updating conversion values, implementing re-engagement attribution, configuring publisher or advertiser apps, or replacing SKAdNetwork with AdAttributionKit for ad measurement.
alarmkitImplement AlarmKit alarms and countdown timers for iOS and iPadOS with Lock Screen, Dynamic Island, StandBy, and paired Apple Watch system UI. Covers AlarmManager scheduling, AlarmAttributes and AlarmPresentation, system Stop and AlarmButton secondary actions, authorization, state observation, countdown widget-extension handoff, and Live Activity integration. Use when building wake-up alarms, countdown timers, or alarm-style alerts that need Apple's system alarm experience.
app-clipsBuild iOS App Clips with invocation URLs, App Clip Codes, NFC, QR codes, Safari banners, Maps, Messages, target setup, App Store Connect experiences, size/capability constraints, NSUserActivity routing, SKOverlay promotion, App Group/keychain handoff, ephemeral notifications, location confirmation, and full-app migration. Use when creating App Clips or wiring App Clip invocation, experience configuration, or full-app handoff.
app-intentsImplement App Intents for Siri, Shortcuts, Spotlight, widgets, Control Center, and Apple Intelligence on iOS. Covers AppIntent actions, AppEntity and EntityQuery models, AppShortcutsProvider phrases, IndexedEntity Spotlight indexing, WidgetConfigurationIntent, SnippetIntent, and assistant schemas. Use when exposing app actions or entities to system surfaces.
app-store-reviewAudits App Store submission readiness and rejection risk across current review guidelines, PrivacyInfo.xcprivacy and required-reason APIs, privacy labels, ATT, StoreKit payments, metadata, entitlements, widgets, and Live Activities. Use when preparing a submission, responding to rejection, reconciling privacy evidence, or separating upload blockers from cleanup.
apple-on-device-aiBuild private, on-device AI features on iPhone, iPad, and Mac with Foundation Models, Core ML, MLX Swift, or llama.cpp. Use when choosing an Apple-local model runtime, building an Apple Intelligence chatbot or tool-calling feature, running an LLM on Apple Silicon, converting or compressing a Python model for Core ML, or comparing on-device inference backends. For Swift Core ML loading and prediction code, use the coreml skill.
appmigrationkitTransfer app data to or from other platforms using AppMigrationKit. Use when implementing system-orchestrated one-time migration between iOS and Android or another platform, building an AppMigrationExtension, packaging transportable resources with ResourcesArchiver, importing resources on the destination device, reporting import progress, handling migration errors and app group cleanup, checking MigrationStatus, or testing migration code with AppMigrationTester.
audioaccessorykitSupport automatic audio switching for paired third-party Bluetooth headphones or earbuds with AudioAccessoryKit. Use when a companion app registers an audio accessory, an app extension reports worn/removed placement or connected source-device changes, or AccessoryControlDevice capabilities and errors need handling. Do not use for general AVAudioSession routing, Bluetooth transport, or initial accessory pairing.