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.
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill 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.
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.
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])
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)
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
SCNBox, SCNSphere, SCNCylinder, SCNCone, SCNTorus, SCNCapsule,
SCNTube, SCNPlane, SCNFloor, SCNText, SCNShape (extruded Bezier path).
let node = SCNNode(geometry: SCNSphere(radius: 0.5))
let 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.
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
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")
.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.
material.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.
// 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)
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)
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.
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
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.
SceneKit provides three animation approaches.
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.begin()
SCNTransaction.animationDuration = 1.0
node.position = SCNVector3(5, 0, 0)
node.opacity = 0.5
SCNTransaction.completionBlock = { print("Done") }
SCNTransaction.commit()
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")
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
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)
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)
}
scene.physicsWorld.gravity = SCNVector3(0, -9.8, 0)
node.physicsBody?.isAffectedByGravity = false
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.
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.
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.
// 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
// 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))
// 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 SCNViewNon-animation creative direction for HyperFrames videos. Use for design spec (frame.md / design.md) handling, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns, and brand / style decisions. For atomic motion patterns and scene blueprints, use `hyperframes-animation`.
Brainstorm and write high-retention short-form video and carousel content for TikTok, Reels, and YouTube Shorts. Use whenever someone wants viral hook ideas, a video script or outline, content concepts for a product or topic, or wants to critique and improve a draft hook or script. Works for any storytime, listicle, carousel, or meme. Produces several diverse hook options from proven patterns, structures scripts for retention (hook, escalation, payoff, CTA), and adapts to each platform. Pattern-based guidance grounded in how short-form tends to perform; it improves the odds, it does not guarantee virality."
Generate short-form video ideas at volume and stop the blank-page problem for good. Use whenever someone says they're stuck for ideas, asks for 20 TikTok ideas or Reels ideas or YouTube Shorts ideas for their niche, wants a content brainstorm, needs help building a content pillar system or content matrix, wants to turn one idea into 5 angles, asks what to post this week, or wants a real idea generator workflow instead of staring at a notes app. Runs the systems prolific creators actually use: pillars, mining (comments, Reddit, search autocomplete, competitor outliers), repurposing, evergreen vs trend balance. Pattern-based guidance grounded in how short-form ideation tends to work; never run out of ideas is the goal, virality is not promised.
| This skill encodes Emil Kowalski's philosophy on UI polish, component design, animation decisions, and the invisible details that make software feel great.
Create and publish concise Rivet launch or changelog posts through an approval-gated workflow, including release research, a required user-selected hero variation, local HTML-rendered social and technical images, an R2 hero upload, MDX authoring, a reviewed draft GitHub PR for manual merge, and a scheduled and verified Buffer thread for @rivet_dev. Use when asked to launch, announce, ship, or prepare and schedule a Rivet feature or release announcement.
This skill encodes Emil Kowalski's philosophy on UI polish, component design, animation decisions, and the invisible details that make software feel great.
Generate a daily digest of today's policy-authorized meetings and voice memos — key decisions, action items, and themes across available recordings. Use when the user asks "recap my day", "what happened in my meetings today", "daily summary", "what did I discuss today", "any action items from today", or wants a consolidated view of the day's conversations.
Generate a daily digest of today's policy-authorized meetings and voice memos — key decisions, action items, and themes across available recordings. Use when the user asks "recap my day", "what happened in my meetings today", "daily summary", "what did I discuss today", "any action items from today", or wants a consolidated view of the day's conversations.
Take dpearson2699/scenekit from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.