red character 'A' without the horizontal bar Achraf Kassioui

SpriteKit Lighting

Blog / Apple Dev,

A demo scene to play with SpriteKit built-in lighting and hard shadows.

SpriteKit has built-in lighting with hard shadows. The implementation is nice and fast. It supports highlights (notice the edge highlights in the video below) and even normal maps. The shadow casters can be of any shape including concave polygons. However SpriteKit doesn't provide soft shadows out of the box, which made me build SpriteKit Soft Shadows, a custom lighting and shadowing solution using Metal.

SKLightNode demo scene running on Mac.

Below is the full code to reproduce the demo above. Copy the code in an Xcode file, then present SKLightView():

/**
 
 # SKLight
 
 A sample scene using SpriteKit built-in lighting and hard shadows.
 
 Achraf Kassioui
 Created 27 Jun 2026
 Updated 2 Jul 2026
 
 */
import SwiftUI
import SpriteKit

// MARK: View

struct SKLightView: View {
    @State var scene = SKLightScene()
    
    var body: some View {
        SpriteView(
            scene: scene,
            preferredFramesPerSecond: 120,
            options: [.ignoresSiblingOrder],
            debugOptions: [.showsFPS, .showsNodeCount, .showsDrawCount]
        )
        .ignoresSafeArea()
    }
}

#Preview {
    SKLightView()
}

// MARK: Scene

class SKLightScene: SKScene {
    
    // MARK: Properties
    
    /// Lighting
    let lightBitMask: UInt32 = 0b0001
    
    /// Dragging state
    private var draggableNodes: [SKNode] = []
    private var draggedNode: SKNode?
    private var dragOffset = CGPoint.zero
    
    // MARK: Lifecycle
    
    override func didMove(to view: SKView) {
        view.isMultipleTouchEnabled = true
        view.contentMode = .center
        
        size = view.bounds.size
        scaleMode = .resizeFill
        backgroundColor = .black
        anchorPoint = CGPoint(x: 0.5, y: 0.5)
        
        cleanup()
        
        createContent(view: view)
    }
    
    override func willMove(from view: SKView) {
        cleanup()
    }
    
    deinit {
        cleanup()
    }
    
    private func cleanup() {
        draggableNodes.removeAll()
        draggedNode = nil
        removeAllChildren()
    }
    
    // MARK: Content
    
    private func createContent(view: SKView) {
        /// Light source.
        let light = SKLightNode()
        let lightSprite = SKSpriteNode(texture: generateRadialGradientTexture(size: CGSize(width: 44, height: 44)))
        light.addChild(lightSprite)
        light.categoryBitMask = lightBitMask
        light.lightColor = .systemBlue.withAlphaComponent(0.5)
        light.falloff = 0 /// default is 1
        light.ambientColor = .systemBlue.withAlphaComponent(0.2)
        light.shadowColor = .black.withAlphaComponent(0.5) /// default is black with 0.5 alpha
        
        light.position = CGPoint(x: 0, y: 300)
        light.zPosition = 100
        addChild(light)
        makeDraggable(light)
        
        /// Background shadow receiver.
        let background = SKSpriteNode(color: .gray, size: CGSize(width: 2000, height: 2000))
        background.lightingBitMask = lightBitMask
        background.shadowedBitMask = lightBitMask
        
        background.zPosition = 10
        addChild(background)
        
        /// Shadow caster.
        /// Creating sprites from texture instead of color will make them have specular highlights
        /// on the top edges, probably from a default normal map.
        let rectangleTexture = generateRectangleTexture(view: view, cornerRadius: 7)
        let rectangle = SKSpriteNode(texture: rectangleTexture)
        rectangle.colorBlendFactor = 1
        rectangle.color = .darkGray
        rectangle.shadowCastBitMask = lightBitMask
        //obstacle.lightingBitMask = lightBit
        
        rectangle.position = CGPoint(x: 0, y: -200)
        rectangle.zPosition = 20
        addChild(rectangle)
        makeDraggable(rectangle)
        
        /// Convex shape
        let convexSprite = SKSpriteNode(texture: generateConvexTexture(view: view))
        convexSprite.colorBlendFactor = 1
        convexSprite.color = .systemBlue
        convexSprite.shadowCastBitMask = lightBitMask
        //convexSprite.lightingBitMask = lightBit
        
        convexSprite.zPosition = 30
        convexSprite.position = CGPoint(x: -200, y: 0)
        addChild(convexSprite)
        makeDraggable(convexSprite)
        
        /// Concave shape
        let concaveSprite = SKSpriteNode(texture: generateConcaveTexture(view: view))
        concaveSprite.colorBlendFactor = 1
        concaveSprite.color = .systemRed
        concaveSprite.shadowCastBitMask = lightBitMask
        //concaveSprite.lightingBitMask = lightBit
        
        concaveSprite.zPosition = 40
        concaveSprite.position = CGPoint(x: 200, y: 0)
        addChild(concaveSprite)
        makeDraggable(concaveSprite)
    }
    
    // MARK: Touch
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first else { return }
        
        let location = touch.location(in: self)
        
        guard let node = draggableNode(at: location) else { return }
        
        draggedNode = node
        dragOffset = CGPoint(
            x: node.position.x - location.x,
            y: node.position.y - location.y
        )
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first,
              let draggedNode
        else { return }
        
        let location = touch.location(in: self)
        
        draggedNode.position = CGPoint(
            x: location.x + dragOffset.x,
            y: location.y + dragOffset.y
        )
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        draggedNode = nil
    }
    
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        draggedNode = nil
    }
    
    // MARK: Dragging
    
    private func makeDraggable(_ node: SKNode) {
        draggableNodes.append(node)
    }
    
    private func draggableNode(at location: CGPoint) -> SKNode? {
        /// Return highest z position.
        draggableNodes
            .sorted { $0.zPosition > $1.zPosition }
            .first { node in
                node.contains(location)
            }
    }
    
}

// MARK: Texture Generators

/// Radial white-to-transparent texture.
private func generateRadialGradientTexture(size: CGSize) -> SKTexture {
    let renderer = UIGraphicsImageRenderer(size: size)
    
    let image = renderer.image { context in
        let center = CGPoint(x: size.width * 0.5, y: size.height * 0.5)
        let outerRadius = min(size.width, size.height) * 0.5
        
        guard let gradient = CGGradient(
            colorsSpace: CGColorSpaceCreateDeviceRGB(),
            colors: [
                UIColor.white.cgColor,
                UIColor.white.withAlphaComponent(0.0).cgColor
            ] as CFArray,
            locations: [0.0, 1.0]
        ) else { return }
        
        context.cgContext.drawRadialGradient(
            gradient,
            startCenter: center,
            startRadius: 14,
            endCenter: center,
            endRadius: outerRadius,
            options: [.drawsBeforeStartLocation]
        )
    }
    
    return SKTexture(image: image)
}

private func generateRectangleTexture(view: SKView, cornerRadius: CGFloat) -> SKTexture? {
    let rectangleShape = SKShapeNode(rectOf: CGSize(width: 75, height: 75), cornerRadius: cornerRadius)
    rectangleShape.fillColor = .white
    rectangleShape.lineWidth = 0
    
    return view.texture(from: rectangleShape)
}

private func generateConvexTexture(view: SKView) -> SKTexture? {
    let convexPath = CGMutablePath()
    
    convexPath.move(to: CGPoint(x: -80, y: -20))
    convexPath.addLine(to: CGPoint(x: -45, y: 65))
    convexPath.addLine(to: CGPoint(x: 35, y: 85))
    convexPath.addLine(to: CGPoint(x: 95, y: 25))
    convexPath.addLine(to: CGPoint(x: 70, y: -60))
    convexPath.addLine(to: CGPoint(x: -25, y: -85))
    convexPath.closeSubpath()
    
    let convexShape = SKShapeNode(path: convexPath)
    convexShape.fillColor = .white
    convexShape.lineWidth = 0
    
    return view.texture(from: convexShape)
}

private func generateConcaveTexture(view: SKView) -> SKTexture? {
    let concavePath = CGMutablePath()
    
    concavePath.move(to: CGPoint(x: -90, y: -60))
    concavePath.addLine(to: CGPoint(x: -90, y: 60))
    concavePath.addLine(to: CGPoint(x: 10, y: 60))
    concavePath.addLine(to: CGPoint(x: 10, y: 20))
    concavePath.addLine(to: CGPoint(x: 85, y: 0))
    concavePath.addLine(to: CGPoint(x: 10, y: -20))
    concavePath.addLine(to: CGPoint(x: 10, y: -60))
    concavePath.closeSubpath()
    
    let concaveShape = SKShapeNode(path: concavePath)
    concaveShape.fillColor = .white
    concaveShape.lineWidth = 0
    
    return view.texture(from: concaveShape)
}

Links