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.
Media unavailable: SpriteKit-SKLightNode.mov
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
-
Achraf Kassioui, SpriteKit Soft Shadows, 2026.
-
Apple Documentation, SKLightNode.