CtrlK
BlogDocsLog inGet started
Tessl Logo

thiennc-tesoglobal/ios-skills

Community-maintained Agent Skills for complete Swift and Apple-platform app delivery.

73

Quality

92%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Medium

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

core-implementation.mdskills/scenekit/references/

SceneKit Core Implementation Details

Read this reference when the task needs concrete setup, API wiring, or implementation recipes. Keep scope, workflow, non-obvious invariants, mistakes, and review gates in the parent SKILL.md.

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.

skills

.mcp.json

README.md

tile.json