PluginBench
Skill
Pass
Audit score 90

scenekit

dpearson2699/swift-ios-skills

Maintain existing SceneKit 3D scenes and visualizations in Swift iOS apps.

What is scenekit?

SceneKit is Apple's 3D graphics framework for iOS and macOS. Use it to maintain, debug, and extend existing SceneKit scenes—including nodes, geometry, materials, lighting, cameras, animation, physics, and particle systems. SceneKit is soft-deprecated as of WWDC 2025; route new projects and USD/USDZ pipelines to RealityKit instead.

  • Work with SCNView, SCNScene, and SCNNode scene graphs
  • Create and modify geometry, materials, lights, and cameras
  • Animate nodes using SCNAction, SCNTransaction, and Core Animation
  • Implement physics bodies, collisions, and constraints
  • Load and manage .scn, .dae, and .abc model assets
  • Integrate SceneKit with SwiftUI via SceneView

How to install scenekit

npx skills add https://github.com/dpearson2699/swift-ios-skills --skill scenekit
Prerequisites
  • Xcode with iOS or macOS SDK
  • Basic Swift knowledge
  • Familiarity with 3D graphics concepts (nodes, transforms, materials)
Claude Code
Cursor
Windsurf
Cline

How to use scenekit

  1. 1.Import SceneKit in your Swift file
  2. 2.Create or load an SCNScene (from .scn asset or programmatically)
  3. 3.Add SCNView to your UIViewController or use SwiftUI SceneView
  4. 4.Create and configure nodes with geometry, materials, and transforms
  5. 5.Attach lights and cameras to define the scene environment
  6. 6.Apply animations using SCNAction or Core Animation
  7. 7.Test physics and collisions if needed
  8. 8.Review the checklist for common mistakes before shipping

Use cases

Good for
  • Debug and fix rendering issues in existing 3D apps
  • Add or modify animations and visual effects in live scenes
  • Implement physics-based interactions and collisions
  • Adjust lighting, materials, and camera positioning
  • Load external 3D models and integrate them into scenes
Who it's for
  • iOS/macOS developers maintaining existing SceneKit apps
  • Game developers working with legacy SceneKit projects
  • 3D graphics engineers debugging scene rendering
  • Developers extending or refactoring 3D visualizations

scenekit FAQ

Should I use SceneKit for new projects?

No. Apple deprecated SceneKit at WWDC 2025. Use RealityKit for new apps, significant updates, and USD/USDZ pipelines. SceneKit is in maintenance mode only.

What file formats does SceneKit support?

SceneKit loads .scn (native), .dae (Collada), and .abc (Alembic) files. For new work, prefer USD/USDZ with RealityKit.

How do I animate nodes?

Use SCNAction for declarative, composable animations; SCNTransaction for implicit animations; or Core Animation (CABasicAnimation) for fine-grained control.

Can I use SceneKit with SwiftUI?

Yes, via SwiftUI's SceneView. For new SwiftUI projects, RealityKit is the preferred choice.

What is the maximum number of lights per node?

SceneKit renders a maximum of 8 lights per node. Use attenuation and category bit masks to optimize performance.

Full instructions (SKILL.md)

Source of truth, from dpearson2699/swift-ios-skills.


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

SCNView in UIKit

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

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.

let parentNode = SCNNode()
scene.rootNode.addChildNode(parentNode)

let childNode = SCNNode()
childNode.position = SCNVector3(0, 1, 0)  // 1 unit above parent
parentNode.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 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).

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, _ 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

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)

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

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

// 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:

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

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

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.

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                        // 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.

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)

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)

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

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:

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

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

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

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 = 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.

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 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 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.

Common Mistakes

Not adding a camera or lights

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

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

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