CtrlK
BlogDocsLog inGet started
Tessl Logo

dpearson2699/swift-ios-skills

Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.

71

Quality

89%

Does it follow best practices?

Impact

No eval scenarios have been run

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

scenekit-patterns.mdskills/scenekit/references/

SceneKit Patterns

Advanced patterns and techniques for SceneKit development. Covers custom geometry construction, shader modifiers, node constraints, morph targets, hit testing, scene serialization, performance optimization, and SpriteKit overlay rendering.

Contents

  • Custom Geometry
  • Shader Modifiers
  • Node Constraints
  • Morph Targets
  • Hit Testing
  • Scene Serialization
  • Render Loop and Delegates
  • Performance Optimization
  • SpriteKit Overlay
  • Level of Detail
  • SCNProgram and Metal Shaders

Custom Geometry

Build geometry from vertex data using SCNGeometrySource and SCNGeometryElement.

Triangle Mesh

import SceneKit

func makeTriangle() -> SCNGeometry {
    let vertices: [SCNVector3] = [
        SCNVector3(-0.5, 0, 0),
        SCNVector3( 0.5, 0, 0),
        SCNVector3( 0,   1, 0)
    ]
    let normals: [SCNVector3] = [
        SCNVector3(0, 0, 1),
        SCNVector3(0, 0, 1),
        SCNVector3(0, 0, 1)
    ]
    let texCoords: [CGPoint] = [
        CGPoint(x: 0, y: 0),
        CGPoint(x: 1, y: 0),
        CGPoint(x: 0.5, y: 1)
    ]
    let indices: [UInt16] = [0, 1, 2]

    let vertexSource = SCNGeometrySource(vertices: vertices)
    let normalSource = SCNGeometrySource(normals: normals)
    let uvSource = SCNGeometrySource(textureCoordinates: texCoords)

    let element = SCNGeometryElement(
        indices: indices,
        primitiveType: .triangles
    )

    return SCNGeometry(sources: [vertexSource, normalSource, uvSource],
                       elements: [element])
}

Generic Data Source

For custom attributes or interleaved data, use the general-purpose initializer:

let data = Data(bytes: vertexData, count: vertexData.count * MemoryLayout<Float>.stride)

let source = SCNGeometrySource(
    data: data,
    semantic: .vertex,
    vectorCount: vertexCount,
    usesFloatComponents: true,
    componentsPerVector: 3,
    bytesPerComponent: MemoryLayout<Float>.stride,
    dataOffset: 0,
    dataStride: MemoryLayout<Float>.stride * 3
)

Updating Geometry Per Frame

For dynamic meshes, recreate geometry sources each frame. Alternatively, use Metal buffers directly for zero-copy updates:

let device = MTLCreateSystemDefaultDevice()!
let buffer = device.makeBuffer(
    bytes: vertices,
    length: vertices.count * MemoryLayout<SIMD3<Float>>.stride,
    options: .storageModeShared
)!

let source = SCNGeometrySource(
    buffer: buffer,
    vertexFormat: .float3,
    semantic: .vertex,
    vertexCount: vertices.count,
    dataOffset: 0,
    dataStride: MemoryLayout<SIMD3<Float>>.stride
)

Primitive Types

TypeDescription
.trianglesEvery 3 indices form one triangle
.triangleStripShared edges between consecutive triangles
.lineEvery 2 indices form one line segment
.pointEach index renders as a point
.polygonIndices define polygons (first value = vertex count)

Shader Modifiers

Shader modifiers inject GLSL or Metal Shading Language snippets into SceneKit's rendering pipeline at specific entry points.

Entry Points

Entry PointUse Case
.geometryModify vertex positions
.surfaceModify material surface properties
.lightingModelCustom lighting calculation
.fragmentFinal pixel color adjustment

Vertex Displacement (Geometry Modifier)

let waveShader = """
#pragma arguments
float amplitude;
float frequency;

float wave = amplitude * sin(frequency * _geometry.position.x + scn_frame.time);
_geometry.position.y += wave;
"""

material.shaderModifiers = [.geometry: waveShader]
material.setValue(Float(0.2), forKey: "amplitude")
material.setValue(Float(5.0), forKey: "frequency")

Surface Color Modifier

let desatShader = """
float gray = dot(_surface.diffuse.rgb, float3(0.299, 0.587, 0.114));
_surface.diffuse.rgb = mix(_surface.diffuse.rgb, float3(gray), 0.5);
"""

material.shaderModifiers = [.surface: desatShader]

Fragment Modifier

let scanlineShader = """
float line = step(0.5, fract(_surface.position.y * 50.0));
_output.color.rgb *= mix(0.8, 1.0, line);
"""

material.shaderModifiers = [.fragment: scanlineShader]

Passing Values to Shaders

Use setValue(_:forKey:) on the material or geometry. SceneKit matches the key name to #pragma arguments declarations:

material.setValue(NSValue(scnVector3: SCNVector3(1, 0, 0)), forKey: "customDirection")
material.setValue(Float(scn_frame.time), forKey: "elapsed")  // scn_frame.time is built-in

Built-in uniforms: scn_frame.time, scn_frame.sinTime, scn_frame.cosTime, scn_frame.inverseResolution.

Node Constraints

Constraints automatically adjust a node's transform each frame.

Look-At Constraint

let lookAt = SCNLookAtConstraint(target: targetNode)
lookAt.isGimbalLockEnabled = true  // Prevent upside-down flipping
cameraNode.constraints = [lookAt]

Distance Constraint

Keep a node within a distance range of another:

let distance = SCNDistanceConstraint(target: targetNode)
distance.minimumDistance = 3
distance.maximumDistance = 10
followerNode.constraints = [distance]

Billboard Constraint

Make a node always face the camera:

let billboard = SCNBillboardConstraint()
billboard.freeAxes = .Y  // Only rotate around Y axis
labelNode.constraints = [billboard]

Replicator Constraint

Copy position/orientation from another node:

let replicator = SCNReplicatorConstraint(target: leaderNode)
replicator.positionOffset = SCNVector3(2, 0, 0)
replicator.replicatesOrientation = true
followerNode.constraints = [replicator]

IK Constraint

Inverse kinematics for skeletal animation:

let ik = SCNIKConstraint.inverseKinematicsConstraint(chainRootNode: shoulderNode)
ik.setMaxAllowedRotationAngle(90, forJoint: elbowNode)
handNode.constraints = [ik]

// Move the target to trigger IK
ik.targetPosition = SCNVector3(1, 2, 0)

Combining Constraints

Constraints are evaluated in order. Use influenceFactor (0-1) to blend:

lookAt.influenceFactor = 0.8
distance.influenceFactor = 1.0
cameraNode.constraints = [lookAt, distance]

Morph Targets

SCNMorpher blends between base geometry and target geometries for facial expressions, shape keys, or procedural deformation.

let morpher = SCNMorpher()

// Target geometries must share the same vertex count and topology
morpher.targets = [smileGeometry, frownGeometry, blinkGeometry]

// Names for programmatic access
morpher.targets[0].name = "smile"
morpher.targets[1].name = "frown"
morpher.targets[2].name = "blink"

node.morpher = morpher

// Set blend weights (0.0 = base, 1.0 = full target shape)
node.morpher?.setWeight(0.5, forTargetAt: 0)          // 50% smile
node.morpher?.setWeight(0.0, forTargetNamed: "frown")  // No frown

Animating Morph Weights

let animation = CABasicAnimation(keyPath: "morpher.weights[0]")
animation.fromValue = 0.0
animation.toValue = 1.0
animation.duration = 0.5
animation.autoreverses = true
node.addAnimation(animation, forKey: "smile")

Morph Calculation Modes

morpher.calculationMode = .additive     // Weights add to base (default)
morpher.calculationMode = .normalized   // Weights normalized to sum to 1

Hit Testing

Determine which nodes a screen point or ray intersects.

Screen-Space Hit Testing

// From a tap gesture
let location = gesture.location(in: sceneView)
let hits = sceneView.hitTest(location, options: [
    .searchMode: SCNHitTestSearchMode.all.rawValue,
    .boundingBoxOnly: false,
    .firstFoundOnly: false
])

if let first = hits.first {
    let node = first.node
    let worldPoint = first.worldCoordinates
    let localPoint = first.localCoordinates
    let normal = first.worldNormal
    let texCoord = first.textureCoordinates(withMappingChannel: 0)
}

Ray-Based Hit Testing

let hits = scene.rootNode.hitTestWithSegment(
    from: SCNVector3(0, 10, 0),
    to: SCNVector3(0, -10, 0),
    options: [
        .searchMode: SCNHitTestSearchMode.closest.rawValue
    ]
)

Hit Test Options

OptionTypePurpose
.searchModeSCNHitTestSearchMode.closest, .all, .any
.boundingBoxOnlyBoolTest bounding box instead of geometry
.firstFoundOnlyBoolStop after first hit
.rootNodeSCNNodeLimit search to subtree
.categoryBitMaskIntFilter by node category
.ignoreHiddenNodesBoolSkip hidden nodes (default true)

Filtering Hits by Category

let interactableCategory = 1 << 2
node.categoryBitMask = interactableCategory

let hits = sceneView.hitTest(location, options: [
    .categoryBitMask: interactableCategory
])

Scene Serialization

Writing a Scene to File

let scene = SCNScene()
// ... populate scene ...

let url = FileManager.default.temporaryDirectory.appendingPathComponent("scene.scn")
let success = scene.write(to: url, options: nil, delegate: nil) { totalProgress, error, _ in
    print("Export progress: \(totalProgress)")
}

Scene Attributes

scene.setAttribute(
    ["UnitMetersPerUnit": 1.0, "UpAxis": "Y"],
    forKey: SCNScene.Attribute.unit.rawValue
)

Archiving Nodes

Individual nodes conform to NSSecureCoding:

let data = try NSKeyedArchiver.archivedData(
    withRootObject: node,
    requiringSecureCoding: true
)

let restored = try NSKeyedUnarchiver.unarchivedObject(
    ofClass: SCNNode.self,
    from: data
)

Render Loop and Delegates

SCNSceneRendererDelegate

Hook into the render loop for per-frame updates:

class GameController: NSObject, SCNSceneRendererDelegate {
    var previousTime: TimeInterval = 0

    func renderer(_ renderer: any SCNSceneRenderer, updateAtTime time: TimeInterval) {
        let deltaTime = time - previousTime
        previousTime = time
        updateGameLogic(deltaTime: deltaTime)
    }

    func renderer(_ renderer: any SCNSceneRenderer, didApplyAnimationsAtTime time: TimeInterval) {
        // After animations are applied, before physics
    }

    func renderer(_ renderer: any SCNSceneRenderer, didSimulatePhysicsAtTime time: TimeInterval) {
        // After physics simulation
    }

    func renderer(_ renderer: any SCNSceneRenderer, willRenderScene scene: SCNScene,
                  atTime time: TimeInterval) {
        // Just before rendering
    }

    func renderer(_ renderer: any SCNSceneRenderer, didRenderScene scene: SCNScene,
                  atTime time: TimeInterval) {
        // After rendering completes
    }
}

sceneView.delegate = gameController

Render Loop Order

  1. updateAtTime -- game logic
  2. Actions and animations applied
  3. didApplyAnimationsAtTime
  4. Constraints evaluated
  5. didApplyConstraintsAtTime
  6. Physics simulation
  7. didSimulatePhysicsAtTime
  8. willRenderScene
  9. GPU render
  10. didRenderScene

Frame Rate Control

sceneView.preferredFramesPerSecond = 60
sceneView.isPlaying = true          // Enable continuous rendering
sceneView.rendersContinuously = true // Render even without changes

Performance Optimization

Geometry

  • Flatten hierarchies: node.flattenedClone() merges child geometries sharing the same material into a single draw call.
  • Reduce polygon count: Use lower-poly meshes for distant or small objects.
  • Share geometry: Assign the same SCNGeometry instance to multiple nodes.
  • Level of Detail: Use SCNLevelOfDetail for automatic LOD switching.

Materials

  • Share materials: Reuse SCNMaterial instances across geometries.
  • Minimize transparent surfaces: Transparency is expensive; use writesToDepthBuffer and sort render order.
  • Use texture atlases: Reduce draw calls by combining textures.
  • Avoid unnecessary PBR: Use .lambert or .constant for objects where lighting detail is not needed.

Lighting and Shadows

  • Limit light count: SceneKit processes up to 8 lights per node.
  • Use attenuationEndDistance: Let SceneKit skip lights for distant nodes.
  • Bake static lighting: Use light maps (multiply property) for immovable environments instead of real-time lights.
  • Shadow map size: Smaller shadowMapSize improves performance at the cost of shadow quality.

Physics

  • Simplified shapes: Always use primitive shapes (box, sphere, capsule) for physics, not mesh-accurate shapes.
  • Allow resting: Keep allowsResting = true so static bodies are skipped.
  • Disable when off-screen: Set physicsBody = nil for inactive bodies.
  • Category masks: Use collision and contact masks to reduce pairwise checks.

Scene Graph

  • Minimize node count: Each node has overhead. Use flattenedClone() or combine meshes in a DCC tool when possible.
  • movabilityHint: Set .fixed on static objects so SceneKit can optimize rendering.
  • renderingOrder: Render opaque objects first (lower values), then transparent objects.
  • Culling: SceneKit frustum-culls automatically. Ensure bounding volumes are reasonable for effective culling.

Debugging

sceneView.showsStatistics = true  // FPS, draw calls, triangles
sceneView.debugOptions = [
    .showBoundingBoxes,
    .showWireframe,
    .showPhysicsShapes,
    .showLightInfluences,
    .showLightExtents,
    .renderAsWireframe
]

Statistics panel fields:

  • fps: Frames per second
  • draws: Number of draw calls (target: < 100 for 60 fps)
  • tris: Triangle count
  • verts: Vertex count

SpriteKit Overlay

Render 2D HUD content on top of the 3D scene using a SpriteKit overlay scene.

import SpriteKit

let overlay = SKScene(size: sceneView.bounds.size)
overlay.scaleMode = .resizeFill

let scoreLabel = SKLabelNode(text: "Score: 0")
scoreLabel.fontName = "Helvetica-Bold"
scoreLabel.fontSize = 24
scoreLabel.position = CGPoint(x: 100, y: overlay.size.height - 50)
overlay.addChild(scoreLabel)

sceneView.overlaySKScene = overlay

Update the overlay from the render loop:

func renderer(_ renderer: any SCNSceneRenderer, updateAtTime time: TimeInterval) {
    DispatchQueue.main.async {
        self.scoreLabel.text = "Score: \(self.score)"
    }
}

SpriteKit Inside 3D Geometry

Render a SpriteKit scene as a material texture:

let spriteScene = SKScene(size: CGSize(width: 512, height: 512))
// ... add SpriteKit nodes ...

let material = SCNMaterial()
material.diffuse.contents = spriteScene
planeNode.geometry?.firstMaterial = material

Level of Detail

SCNLevelOfDetail automatically switches geometry based on distance or screen coverage.

let highDetail = SCNSphere(radius: 1)    // 48 segments
let medDetail = SCNSphere(radius: 1)     // 24 segments
medDetail.segmentCount = 24
let lowDetail = SCNSphere(radius: 1)     // 12 segments
lowDetail.segmentCount = 12

highDetail.levelsOfDetail = [
    SCNLevelOfDetail(geometry: medDetail, screenSpaceRadius: 50),   // < 50px on screen
    SCNLevelOfDetail(geometry: lowDetail, screenSpaceRadius: 20)    // < 20px on screen
]

// Or use world-space distance
highDetail.levelsOfDetail = [
    SCNLevelOfDetail(geometry: medDetail, worldSpaceDistance: 20),
    SCNLevelOfDetail(geometry: lowDetail, worldSpaceDistance: 50)
]

SCNProgram and Metal Shaders

Replace SceneKit's entire rendering pipeline for a geometry with a custom Metal shader program.

let program = SCNProgram()
program.vertexFunctionName = "myVertex"
program.fragmentFunctionName = "myFragment"

// Map SceneKit semantics to shader inputs
program.setSemantic(.modelViewProjectionTransform, forSymbol: "mvpTransform", options: nil)
program.setSemantic(.modelViewTransform, forSymbol: "mvTransform", options: nil)
program.setSemantic(.normalTransform, forSymbol: "normalTransform", options: nil)

material.program = program

Metal shader example:

#include <metal_stdlib>
using namespace metal;
#include <SceneKit/scn_metal>

struct VertexIn {
    float3 position [[attribute(SCNVertexSemanticPosition)]];
    float3 normal   [[attribute(SCNVertexSemanticNormal)]];
    float2 texcoord [[attribute(SCNVertexSemanticTexcoord0)]];
};

struct VertexOut {
    float4 position [[position]];
    float2 texcoord;
    float3 normal;
};

vertex VertexOut myVertex(VertexIn in [[stage_in]],
                          constant SCNSceneBuffer& scn_frame [[buffer(0)]],
                          constant NodeBuffer& scn_node [[buffer(1)]]) {
    VertexOut out;
    out.position = scn_frame.viewProjectionTransform
                   * scn_node.modelTransform
                   * float4(in.position, 1.0);
    out.texcoord = in.texcoord;
    out.normal = (scn_node.normalTransform * float4(in.normal, 0.0)).xyz;
    return out;
}

fragment float4 myFragment(VertexOut in [[stage_in]]) {
    float3 lightDir = normalize(float3(1, 1, 1));
    float diffuse = max(dot(normalize(in.normal), lightDir), 0.0);
    return float4(float3(diffuse), 1.0);
}

Passing Textures to Custom Programs

material.setValue(SCNMaterialProperty(contents: UIImage(named: "texture")!),
                  forKey: "diffuseTexture")

In the shader, declare the texture parameter with a matching name:

fragment float4 myFragment(VertexOut in [[stage_in]],
                           texture2d<float> diffuseTexture [[texture(0)]]) {
    constexpr sampler s(filter::linear);
    float4 color = diffuseTexture.sample(s, in.texcoord);
    return color;
}

skills

CHANGELOG.md

README.md

tile.json