red character 'A' without the horizontal bar Achraf Kassioui

RealityKit Light & Shadow

Blog / Apple Dev,

Lighting and shadow casting in RealityKit have assumptions and behavior that are not readily documented.

Environment Lighting

By default, ARView and RealityView apply image-based lighting (IBL) to their content. IBL uses an environment map, commonly known as HDRI, to illuminate and reflect the surrounding environment on virtual objects.

RealityRenderer however does not apply any IBL by default. Non-emissive materials will appear black unless a light source is added.

In order to unify the environment lighting across all RealityKit renderers, we can use ImageBasedLightComponent and ImageBasedLightReceiverComponent. The first component holds the image. The second component specifies which entity tree is lit by the image. Example:

func setupIBL(holder: Entity, receiver: Entity) {
    Task {
        do {
            let iblResource = try await EnvironmentResource(named: "IBL007")
            var iblComponent = ImageBasedLightComponent(source: .single(iblResource))
            /// Whether the IBL inherits the rotation of the entity.
            iblComponent.inheritsRotation = true
            holder.components.set(iblComponent)
            receiver.components.set(ImageBasedLightReceiverComponent(imageBasedLight: holder))
        } catch {
            print(error)
        }
    }
}
RealityView { content in
    let anchor = AnchorEntity()
    content.add(anchor)

    setupIBL(holder: anchor, receiver: anchor)
}

ARView also has an environment property which we can use to specify IBL for that specific view:

class MyARView: ARView {
    
    required init(frame: CGRect) {
        super.init(frame: frame)
        
        Task {
            do {
                let environmentResource = try await EnvironmentResource(named: "black")
                /// ARView `environment` property
                environment.lighting.resource = environmentResource
            } catch {
                print("Could not load environment resource: \(error)")
            }
        }
        
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
}

Environment Resource

What is "IBL007"? It's the name of the HDRI image used in this example, downloaded from Leonid Altman's free pack. In order to create IBL resources for Xcode, follow the excellent Twitter thread by Yasuhito Nagatomo, and see the documentation:

To add an environment resource to your Xcode project, make a folder with a name that ends in .skybox and place a single image inside. Ensure that the image is an environment map of equirectangular projection, also known as a latitude-longitude projection. Drag the folder into the Project navigator. In the options pane, choose to create a folder reference (not a group), and add the folder to your app’s targets. At build time, Xcode compiles the image for use as an environment resource and inserts the result into the app bundle.

RealityKit supports the same input formats as Image I/O, such as .png and .jpg However, to achieve rich, vibrant lighting, use a .exr or .hdr format, which support a wide dynamic range.

Xcode project navigator would look like this:

RealityKit-Xcode-Skybox-Folder.png

When I need a scene with no environment lighting, I use a completely black environment resource.

RealityKit-black.jpg

Dynamic Lights

In addition to IBL, we can add up to eight light sources in a RealityKit scene. These lights are called dynamic lights and are of three types: point light, directional light, and spot light. Here is an example with directional light:

/// Create an entity that will hold the light
let lightEntity = Entity()
/// Choose from the available lighting components
let directionalLight = DirectionalLightComponent(
    color: .white,
    intensity: 1000
)
lightEntity.components.set(directionalLight)
/// Position the light
lightEntity.look(at: [0, 0, 0], from: [3, 5, -2], relativeTo: nil)
/// Add to the scene
content.add(lightEntity)

Shadows

Lights don't cast shadows by default. In order to cast shadows, an additional component must be added to the light entity:

/// Use the corresponding shadow component for each type of light
let shadowComponent = DirectionalLightComponent.Shadow()
lightEntity.components.set(shadowComponent)

A specific entity can be excluded from casting shadows using DynamicLightShadowComponent:

let entityDoesNotCastShadow = ModelEntity()
entityDoesNotCastShadow.components.set(DynamicLightShadowComponent(castsShadow: false))

The quality of the shadows can be tweaked with maximumDistance:

var shadowComponent = DirectionalLightComponent.Shadow()
shadowComponent.shadowProjection = .automatic(maximumDistance: 2)

Increasing the value allows more distant objects from the camera to cast shadows, but at the expense of quality and precision. If your design allows, you can dynamically change that value to match the camera distance from your main content.

Code

Below is the full scene code. Make sure you download the HDRI image and the teapot model.

/**
 
 # Light & Shadow
 
 Achraf Kassioui
 Created 6 Aug 2026
 Updated 11 Aug 2026
 
 */
import SwiftUI
import RealityKit

// MARK: View

struct LightAndShadowView: View {
    @State private var rootEntity = Entity()
    @State private var lightEntity = Entity()
    @State private var cameraEntity = Entity()
    
    private let cameraPosition: SIMD3<Float> = [0, 0.6, 1.5]
    private let cameraTarget: SIMD3<Float> = [0, 0.3, 0]
    
    @State private var environmentLightIntensity: Double = 1
    @State private var directLightIntensity: Double = 0
    @State private var directLightColor: Color = .white
    @State private var shadowsAreEnabled = false
    @State private var shadowDistance: Double = 2
    
    private let labelWidth: CGFloat = 100
    private let sliderValueWidth: CGFloat = 50
    
    init() {
        LightingRequestComponent.registerComponent()
        LightingSystem.registerSystem()
    }
    
    var body: some View {
        ZStack() {
            RealityView { content in
                content.add(rootEntity)
                
                /// Add environment lighting request for the scene using the custom ECS.
                rootEntity.components.set(LightingRequestComponent(requests: [
                    .environmentLightIntensity(Float(environmentLightIntensity))
                ]))
                
                /// Camera
                cameraEntity.components.set(PerspectiveCameraComponent())
                cameraEntity.look(at: cameraTarget, from: cameraPosition, relativeTo: nil)
                rootEntity.addChild(cameraEntity)
                
                /// Directional light
                lightEntity.look(at: .zero, from: [3, 5, 1.5], relativeTo: nil)
                lightEntity.components.set(LightingRequestComponent(requests: [
                    .directLightIntensity(Float(directLightIntensity)),
                    .lightColor(DirectionalLightComponent.Color(directLightColor)),
                    .shadowsAreEnabled(shadowsAreEnabled),
                    .shadowDistance(Float(shadowDistance))
                ]))
                rootEntity.addChild(lightEntity)
                
                /// Floor
                var floorMaterial = PhysicallyBasedMaterial()
                floorMaterial.baseColor.tint = .systemBrown
                
                let floorMesh = MeshResource.generateBox(width: 1, height: 0.05, depth: 1, cornerRadius: 0.005)
                let floor = ModelEntity(mesh: floorMesh, materials: [floorMaterial])
                floor.position = [0, -0.025, 0]
                rootEntity.addChild(floor)
                
                /// Teapot
                guard let teapot = try? await ModelEntity(named: "teapot") else {
                    print("Could not load Teapot.usd")
                    return
                }
                
                var teapotMaterial = PhysicallyBasedMaterial()
                teapotMaterial.baseColor.tint = .white
                
                /// Optionally replace every material used by the imported mesh.
                if var modelComponent = teapot.model {
                    let materialCount = max(modelComponent.materials.count, 1)
                    modelComponent.materials = Array(repeating: teapotMaterial, count: materialCount)
                    //teapot.model = modelComponent
                }
                
                teapot.position = .zero
                teapot.transform.rotation = .init(angle: .pi, axis: [0, 1, 0])
                rootEntity.addChild(teapot)
            }
            .realityViewCameraControls(.orbit)
            .ignoresSafeArea()
            .background(Color(Material.Color.white))
            
            // MARK: Controls
            
            VStack {
                
                Spacer()
                
                VStack {
                    HStack {
                        Text("HDRI")
                            .frame(width: labelWidth, alignment: .leading)
                        
                        Slider(value: $environmentLightIntensity, in: 0...4, step: 0.1)
                            .onChange(of: environmentLightIntensity) { _, intensity in
                                /// Request change through ECS.
                                var lightingRequestComponent = rootEntity.components[LightingRequestComponent.self] ?? LightingRequestComponent()
                                lightingRequestComponent.requests.append(.environmentLightIntensity(Float(intensity)))
                                rootEntity.components.set(lightingRequestComponent)
                            }
                        
                        Text(String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), environmentLightIntensity))
                            .monospacedDigit()
                            .frame(width: sliderValueWidth, alignment: .trailing)
                    }
                    
                    HStack {
                        HStack(spacing: 8) {
                            Text("Light")
                            
                            Spacer()
                            
                            ColorPicker("Light Color", selection: $directLightColor, supportsOpacity: false)
                                .labelsHidden()
                                .onChange(of: directLightColor) { _, color in
                                    /// Request change through ECS.
                                    var lightingRequestComponent = lightEntity.components[LightingRequestComponent.self] ?? LightingRequestComponent()
                                    lightingRequestComponent.requests.append(.lightColor(DirectionalLightComponent.Color(color)))
                                    lightEntity.components.set(lightingRequestComponent)
                                }
                        }
                        .frame(width: labelWidth)
                        
                        Slider(value: $directLightIntensity, in: 0...5000, step: 100)
                            .onChange(of: directLightIntensity) { _, intensity in
                                /// Request change through ECS.
                                var lightingRequestComponent = lightEntity.components[LightingRequestComponent.self] ?? LightingRequestComponent()
                                lightingRequestComponent.requests.append(.directLightIntensity(Float(intensity)))
                                lightEntity.components.set(lightingRequestComponent)
                            }
                        
                        Text("\(Int(directLightIntensity.rounded()))")
                            .monospacedDigit()
                            .frame(width: sliderValueWidth, alignment: .trailing)
                    }
                    
                    Divider()
                    
                    Toggle("Shadows", isOn: $shadowsAreEnabled)
                        .onChange(of: shadowsAreEnabled) { _, isEnabled in
                            /// Request change through ECS.
                            var lightingRequestComponent = lightEntity.components[LightingRequestComponent.self] ?? LightingRequestComponent()
                            lightingRequestComponent.requests.append(contentsOf: [
                                .shadowsAreEnabled(isEnabled),
                                .shadowDistance(Float(shadowDistance))
                            ])
                            
                            lightEntity.components.set(lightingRequestComponent)
                        }
                    
                    HStack {
                        Text("Distance")
                            .lineLimit(1)
                            .font(.system(size: 14))
                            .frame(width: labelWidth, alignment: .leading)
                        
                        Slider(value: $shadowDistance, in: 0...10, step: 0.1)
                            .onChange(of: shadowDistance) { _, distance in
                                /// Request change through ECS.
                                var lightingRequestComponent = lightEntity.components[LightingRequestComponent.self] ?? LightingRequestComponent()
                                lightingRequestComponent.requests.append(.shadowDistance(Float(distance)))
                                lightEntity.components.set(lightingRequestComponent)
                            }
                        
                        Text(String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), shadowDistance))
                            .monospacedDigit()
                            .frame(width: sliderValueWidth, alignment: .trailing)
                    }
                }
                .padding()
                .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24))
            }
            .frame(maxWidth: 380)
            .padding()
        }
    }
}

#Preview {
    LightAndShadowView()
}

// MARK: Lighting Request

enum LightingRequest {
    case environmentLightIntensity(Float)
    case directLightIntensity(Float)
    case lightColor(DirectionalLightComponent.Color)
    case shadowsAreEnabled(Bool)
    case shadowDistance(Float)
}

struct LightingRequestComponent: Component {
    var requests: [LightingRequest] = []
}

// MARK: Lighting System

@MainActor
class LightingSystem: System {
    
    private static let lightingQuery = EntityQuery(where: .has(LightingRequestComponent.self))
    
    required init(scene: RealityKit.Scene) {}
    
    // MARK: Update
    
    func update(context: SceneUpdateContext) {
        for entity in context.entities(matching: Self.lightingQuery, updatingSystemWhen: .rendering) {
            guard let lightingRequestComponent = entity.components[LightingRequestComponent.self] else { continue }
            
            for request in lightingRequestComponent.requests {
                switch request {
                case .environmentLightIntensity(let intensity):
                    updateEnvironmentLight(on: entity, intensity: intensity)
                    
                case .directLightIntensity(let intensity):
                    var directionalLight = entity.components[DirectionalLightComponent.self] ?? DirectionalLightComponent(color: .white, intensity: 0)
                    
                    directionalLight.intensity = intensity
                    entity.components.set(directionalLight)
                    
                case .lightColor(let color):
                    var directionalLight = entity.components[DirectionalLightComponent.self] ?? DirectionalLightComponent(color: .white, intensity: 0)
                    
                    directionalLight.color = color
                    entity.components.set(directionalLight)
                    
                case .shadowsAreEnabled(let isEnabled):
                    if isEnabled {
                        entity.components.set(DirectionalLightComponent.Shadow())
                    } else {
                        entity.components.remove(DirectionalLightComponent.Shadow.self)
                    }
                    
                case .shadowDistance(let maximumDistance):
                    if var shadow = entity.components[DirectionalLightComponent.Shadow.self] {
                        shadow.shadowProjection = .automatic(maximumDistance: maximumDistance)
                        entity.components.set(shadow)
                    }
                }
            }
            
            /// Remove transient component.
            entity.components.remove(LightingRequestComponent.self)
        }
    }
    
    // MARK: Update IBL
    
    private func updateEnvironmentLight(on entity: Entity, intensity: Float) {
        guard intensity > 0 else {
            /// A zero value disables image-based lighting.
            entity.components.set(ImageBasedLightComponent(source: .none))
            return
        }
        
        if var imageBasedLight = entity.components[ImageBasedLightComponent.self],
           case .single = imageBasedLight.source {
            /// RealityKit expresses IBL brightness as a base-two exponent.
            imageBasedLight.intensityExponent = log2(intensity)
            entity.components.set(imageBasedLight)
            return
        }
        
        Task {
            guard let environmentResource = try? await EnvironmentResource(named: "IBL007") else {
                print("Could not load environment resource")
                return
            }
            
            /// Apply image-based lighting to this entity and its descendants.
            entity.components.set(ImageBasedLightComponent(source: .single(environmentResource), intensityExponent: log2(intensity)))
            entity.components.set(ImageBasedLightReceiverComponent(imageBasedLight: entity))
        }
    }
    
}

Links