返回 Skill 列表
extension
分类: 开发与工程无需 API Key

ios-scenekit

在使用SceneKit 3D场景、将SceneKit迁移到RealityKit或维护旧版SceneKit代码时使用。涵盖场景图、材质、物理、动画、SwiftUI桥接、迁移决策树等内容。

person作者: jakexiaohubgithub

SceneKit Development Guide

Purpose: Maintain SceneKit safely and plan migration to RealityKit iOS Version: iOS 8+ (SceneKit), deprecated iOS 26+ Xcode: Xcode 15+

Use When

Use when:

  • Maintaining existing SceneKit code
  • Building a SceneKit prototype (with awareness of deprecation)
  • Planning migration from SceneKit to RealityKit
  • Debugging SceneKit rendering, physics, or animation issues
  • Integrating SceneKit content with SwiftUI
  • Loading 3D models via Model I/O or SCNSceneSource

Do not use for:

  • New 3D projects (use ios-realitykit)
  • AR experiences (use ios-realitykit)
  • visionOS development (use ios-realitykit)
  • SpriteKit 2D games (ios-spritekit)
  • Metal shader programming

Deprecation Context

SceneKit is soft-deprecated as of iOS 26 (WWDC 2025):

  • Existing apps continue to work
  • No new features or general bug fixes
  • Only critical security patches
  • SceneView (SwiftUI) is formally deprecated in iOS 26

Forward path: RealityKit. New 3D projects should use RealityKit. SceneKit remains relevant for legacy maintenance and migration.

In RealityKit: ECS replaces scene graph. See references/migration-api-reference.md for the API mapping.

References


1. Mental Model

Scene Graph Architecture

SceneKit uses a tree of nodes (SCNNode) attached to a root node in an SCNScene. Each node has a transform (position, rotation, scale) relative to its parent.

SCNScene
└── rootNode
    ├── cameraNode (SCNCamera)
    ├── lightNode (SCNLight)
    ├── playerNode (SCNGeometry + SCNPhysicsBody)
    │   ├── weaponNode
    │   └── particleNode (SCNParticleSystem)
    └── environmentNode
        ├── groundNode
        └── wallNodes

In RealityKit: Entities replace nodes. Components replace node properties. The hierarchy concept persists, but behavior is driven by Systems rather than node callbacks.

Coordinate System

SceneKit uses a right-handed Y-up coordinate system:

     +Y (up)
      |
      |
      +──── +X (right)
     /
    /
  +Z (toward viewer)

This matches RealityKit's coordinate system, so spatial concepts transfer directly during migration.

Transform Hierarchy

Transforms cascade parent → child. A child's world transform = parent's world transform × child's local transform.

let parent = SCNNode()
parent.position = SCNVector3(10, 0, 0)

let child = SCNNode()
child.position = SCNVector3(0, 5, 0)
parent.addChildNode(child)

// child.worldPosition = (10, 5, 0)
// child.position (local) = (0, 5, 0)

In RealityKit: Same concept. entity.position is local, entity.position(relativeTo: nil) gives world position.


2. Scene Setup and Rendering

SCNView (UIKit)

let sceneView = SCNView(frame: view.bounds)
sceneView.scene = SCNScene(named: "scene.scn")
sceneView.allowsCameraControl = true
sceneView.showsStatistics = true
sceneView.backgroundColor = .black
view.addSubview(sceneView)

SceneView (SwiftUI) — Deprecated iOS 26

// Still works but deprecated. Use SCNViewRepresentable for new code.
import SceneKit

SceneView(
    scene: scene,
    pointOfView: cameraNode,
    options: [.allowsCameraControl, .autoenablesDefaultLighting]
)

SCNViewRepresentable (SwiftUI replacement)

struct SceneKitView: UIViewRepresentable {
    let scene: SCNScene

    func makeUIView(context: Context) -> SCNView {
        let view = SCNView()
        view.scene = scene
        view.allowsCameraControl = true
        view.autoenablesDefaultLighting = true
        return view
    }

    func updateUIView(_ view: SCNView, context: Context) {}
}

In RealityKit: Use RealityView in SwiftUI — no UIViewRepresentable needed.


3. Geometry and Materials

Built-in Geometries

let box = SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0.1)
let sphere = SCNSphere(radius: 0.5)
let cylinder = SCNCylinder(radius: 0.3, height: 1)
let plane = SCNPlane(width: 2, height: 2)
let torus = SCNTorus(ringRadius: 1, pipeRadius: 0.3)
let capsule = SCNCapsule(capRadius: 0.3, height: 1)
let cone = SCNCone(topRadius: 0, bottomRadius: 0.5, height: 1)
let tube = SCNTube(innerRadius: 0.3, outerRadius: 0.5, height: 1)
let text = SCNText(string: "Hello", extrusionDepth: 0.2)

PBR Materials

let material = SCNMaterial()
material.lightingModel = .physicallyBased
material.diffuse.contents = UIColor.red          // or UIImage
material.metalness.contents = 0.8
material.roughness.contents = 0.2
material.normal.contents = UIImage(named: "normal_map")
material.ambientOcclusion.contents = UIImage(named: "ao_map")

let node = SCNNode(geometry: sphere)
node.geometry?.firstMaterial = material

In RealityKit: Use PhysicallyBasedMaterial with similar properties but different API surface. See references/migration-api-reference.md.

Shader Modifiers

SceneKit supports GLSL/Metal shader snippets injected at specific entry points:

// Fragment modifier — custom effect on surface
material.shaderModifiers = [
    .fragment: """
    float stripe = sin(_surface.position.x * 20.0);
    _output.color.rgb *= step(0.0, stripe);
    """
]

Entry points: .geometry, .surface, .lightingModel, .fragment

In RealityKit: Use ShaderGraphMaterial with Reality Composer Pro, or CustomMaterial with Metal functions.


4. Lighting

Light Types

| Type | Description | Shadows | |------|-------------|---------| | .omni | Point light, radiates in all directions | No | | .directional | Parallel rays (sun) | Yes | | .spot | Cone-shaped beam | Yes | | .area | Rectangle emitter (soft shadows) | Yes | | .IES | Real-world light profile | Yes | | .ambient | Uniform, no direction | No | | .probe | Environment lighting from cubemap | No |

let light = SCNLight()
light.type = .directional
light.intensity = 1000
light.castsShadow = true
light.shadowRadius = 3
light.shadowSampleCount = 8

let lightNode = SCNNode()
lightNode.light = light
lightNode.eulerAngles = SCNVector3(-Float.pi / 4, 0, 0)
scene.rootNode.addChildNode(lightNode)

In RealityKit: Use DirectionalLightComponent, PointLightComponent, SpotLightComponent as components on entities. Image-based lighting via EnvironmentResource.


5. Animation

SCNAction (Declarative)

let moveUp = SCNAction.moveBy(x: 0, y: 2, z: 0, duration: 1)
let fadeOut = SCNAction.fadeOut(duration: 0.5)
let sequence = SCNAction.sequence([moveUp, fadeOut])
let forever = SCNAction.repeatForever(moveUp.reversed())
node.runAction(sequence)

Implicit Animation (SCNTransaction)

SCNTransaction.begin()
SCNTransaction.animationDuration = 0.5
node.position = SCNVector3(0, 5, 0)
node.opacity = 0.5
SCNTransaction.commit()

Explicit Animation (CAAnimation bridge)

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")

Loading Animations from Files

let scene = SCNScene(named: "character.dae")!
let animationPlayer = scene.rootNode
    .childNode(withName: "mixamorig:Hips", recursively: true)!
    .animationPlayer(forKey: nil)!

characterNode.addAnimationPlayer(animationPlayer, forKey: "walk")
animationPlayer.play()

In RealityKit: Use entity.playAnimation() with animations loaded from USD files. Transform animations via entity.move(to:relativeTo:duration:).


6. Physics

Physics Bodies

// Dynamic — simulation controls position
node.physicsBody = SCNPhysicsBody(type: .dynamic,
    shape: SCNPhysicsShape(geometry: node.geometry!, options: nil))

// Static — immovable collision surface
ground.physicsBody = SCNPhysicsBody(type: .static, shape: nil)

// Kinematic — code controls position, participates in collisions
platform.physicsBody = SCNPhysicsBody(type: .kinematic, shape: nil)

Collision Categories

struct PhysicsCategory {
    static let player:    Int = 1 << 0   // 1
    static let enemy:     Int = 1 << 1   // 2
    static let projectile: Int = 1 << 2  // 4
    static let wall:      Int = 1 << 3   // 8
}

playerNode.physicsBody?.categoryBitMask = PhysicsCategory.player
playerNode.physicsBody?.collisionBitMask = PhysicsCategory.wall | PhysicsCategory.enemy
playerNode.physicsBody?.contactTestBitMask = PhysicsCategory.enemy | PhysicsCategory.projectile

Contact Delegate

class GameScene: SCNScene, SCNPhysicsContactDelegate {
    func setupPhysics() {
        physicsWorld.contactDelegate = self
    }

    func physicsWorld(_ world: SCNPhysicsWorld,
                      didBegin contact: SCNPhysicsContact) {
        let nodeA = contact.nodeA
        let nodeB = contact.nodeB
        // Handle collision
    }
}

In RealityKit: Use PhysicsBodyComponent, CollisionComponent, and collision event subscriptions via scene.subscribe(to: CollisionEvents.Began.self).


7. Hit Testing and Interaction

// In SCNView tap handler
let results = sceneView.hitTest(tapLocation, options: [
    .searchMode: SCNHitTestSearchMode.closest.rawValue,
    .boundingBoxOnly: false
])

if let hit = results.first {
    let tappedNode = hit.node
    let worldPosition = hit.worldCoordinates
}

In RealityKit: Use ManipulationComponent for drag/rotate/scale gestures, or collision-based hit testing.


8. Asset Pipeline

Supported Formats

| Format | Extension | Notes | |--------|-----------|-------| | USD/USDZ | .usdz, .usda, .usdc | Preferred format, works in both SceneKit and RealityKit | | Collada | .dae | Legacy, still supported | | SceneKit Archive | .scn | Xcode-specific, not portable to RealityKit | | Wavefront OBJ | .obj | Geometry only, no animations | | Alembic | .abc | Animation baking |

Loading Models

// From bundle
let scene = SCNScene(named: "model.usdz")!

// From URL
let scene = try SCNScene(url: modelURL, options: nil)

// Via Model I/O (for format conversion)
let asset = MDLAsset(url: modelURL)
let scene = SCNScene(mdlAsset: asset)

Migration tip: Convert .scn files to .usdz using xcrun scntool --convert file.scn --format usdz before migrating to RealityKit.


9. ARKit Integration (Legacy)

// ARSCNView — SceneKit + ARKit (legacy approach)
let arView = ARSCNView(frame: view.bounds)
arView.delegate = self
arView.session.run(ARWorldTrackingConfiguration())

// Adding virtual content at anchors
func renderer(_ renderer: SCNSceneRenderer,
              didAdd node: SCNNode, for anchor: ARAnchor) {
    let box = SCNBox(width: 0.1, height: 0.1, length: 0.1, chamferRadius: 0)
    node.addChildNode(SCNNode(geometry: box))
}

In RealityKit: Use RealityView with AnchorEntity types. ARSCNView is legacy — all new AR development should use RealityKit.


10. Anti-Patterns

Anti-Pattern 1: Starting New Projects in SceneKit

Time cost: Weeks of rework when you eventually must migrate

SceneKit is deprecated. New projects should use RealityKit from the start, even if the learning curve is steeper initially.

Anti-Pattern 2: Using .scn Files Without USDZ Conversion

Time cost: Hours when migration begins

.scn files are SceneKit-specific and cannot be loaded in RealityKit. Convert early:

xcrun scntool --convert model.scn --format usdz --output model.usdz

Anti-Pattern 3: Deep Shader Modifier Customization

Time cost: Complete rewrite during migration

SceneKit shader modifiers use a proprietary entry-point system. Heavy investment here has zero portability to RealityKit's ShaderGraphMaterial.

Anti-Pattern 4: Relying on SCNRenderer for Custom Pipelines

Time cost: Architecture redesign during migration

If you need custom render pipelines, build on Metal directly or use RealityRenderer (RealityKit's Metal-level API).

Anti-Pattern 5: Ignoring Deprecation Warnings

Time cost: Surprise breakage when Apple removes APIs

Track SceneView deprecation warnings and plan UIViewRepresentable fallback or RealityKit migration.

Anti-Pattern 6: Creating Hundreds of Nodes in a Loop

Time cost: 2-4 hours debugging frame drops, often misdiagnosed as GPU issue

// ❌ WRONG: Each SCNNode has overhead (transform, bounding box, hit test)
for i in 0..<500 {
    let node = SCNNode(geometry: SCNSphere(radius: 0.05))
    node.position = randomPosition()
    scene.rootNode.addChildNode(node)  // 500 nodes = terrible frame rate
}

// ✅ RIGHT: Use SCNParticleSystem for particle-like effects
let particles = SCNParticleSystem()
particles.birthRate = 500
particles.particleSize = 0.05
particles.emitterShape = SCNBox(width: 5, height: 5, length: 5, chamferRadius: 0)
particleNode.addParticleSystem(particles)

// ✅ RIGHT: Use geometry instancing for identical objects
let source = SCNGeometrySource(/* instance transforms */)
geometry.levelsOfDetail = [SCNLevelOfDetail(geometry: lowPoly, screenSpaceRadius: 20)]

Rule: If >50 identical objects, use SCNParticleSystem or flatten geometry. If different objects, use SCNNode.flattenedClone() to reduce draw calls.


11. Migration Decision Tree

Should you migrate to RealityKit?
│
├─ Is this a new project?
│   └─ YES → Use RealityKit from the start. No question.
│
├─ Does the app need AR features?
│   └─ YES → Migrate. ARSCNView is legacy, RealityKit is the only forward path.
│
├─ Does the app target visionOS?
│   └─ YES → Must migrate. SceneKit doesn't support visionOS spatial features.
│
├─ Is the codebase heavily invested in SceneKit?
│   ├─ YES, and app is stable → Maintain in SceneKit for now, plan phased migration.
│   └─ YES, but needs new features → Migrate incrementally (new features in RealityKit).
│
├─ Is performance a concern?
│   └─ YES → RealityKit is optimized for Apple Silicon with Metal-first rendering.
│
└─ Is the app in maintenance mode?
    └─ YES → Keep SceneKit until critical. Security patches will continue.

12. Pressure Scenarios

Scenario 1: "Just Use SceneKit, It Works Fine"

Pressure: Team familiarity with SceneKit, deadline to ship

Wrong approach: Start new project in SceneKit because the team knows it.

Correct approach: Invest in RealityKit learning. SceneKit will receive no new features. The longer you wait, the larger the migration debt.

Push-back template: "SceneKit is deprecated as of iOS 26. Starting new work in it creates migration debt that grows with every feature we add. RealityKit's ECS model is different but learnable — let's invest the time now."

Scenario 2: "We Don't Have Time to Learn RealityKit"

Pressure: Tight deadline, team unfamiliar with ECS

Wrong approach: Build everything in SceneKit to meet the deadline.

Correct approach: Build the prototype in SceneKit if necessary, but document every SceneKit dependency and plan the migration. Use USDZ assets from the start so they're portable.

Push-back template: "Let's use USDZ assets and keep the SceneKit layer thin. When we migrate, the assets transfer directly and only the code layer changes."

Scenario 3: "Port Everything At Once"

Pressure: Desire for a clean migration

Wrong approach: Attempt to rewrite the entire SceneKit codebase in RealityKit at once.

Correct approach: Migrate incrementally. New features in RealityKit. Existing SceneKit code stays until it needs changes. Modularize with Swift packages (per Apple's migration guide).

Push-back template: "Apple's own migration guide recommends modularizing into Swift packages and migrating system by system. A big-bang rewrite risks introducing new bugs across the entire app."


Code Review Checklist

  • [ ] No new SceneKit code in projects targeting iOS 26+ without migration plan
  • [ ] Assets in USDZ format (not .scn) for portability
  • [ ] No deep shader modifier customization without RealityKit equivalent identified
  • [ ] SCNTransaction used for implicit animations (not direct property changes without animation context)
  • [ ] Physics categoryBitMask explicitly set (not relying on defaults)
  • [ ] Contact delegate set and protocol conformance added
  • [ ] [weak self] in completion handlers and closures
  • [ ] Debug overlays enabled during development (showsStatistics = true)

Resources

WWDC: 2014-609, 2014-610, 2017-604, 2019-612

Docs: /scenekit, /scenekit/scnscene, /scenekit/scnnode, /scenekit/scnmaterial, /scenekit/scnphysicsbody

References: references/migration-api-reference.md, ../ios-realitykit/references/api-reference.md Skills: ios-realitykit