How to adapt Dynamic Lights to the size of a Volume

We can scale the intensity and attenuation values for lights relative to a root entity.

The Problem:

If you use Dynamic Lights like PointLight or Spotlight inside a volume, you may notice that things don’t look right. When the user resizes the volume, the lighting in the scene changes. The issue is that the dynamic light settings don’t scale with the rest of the scene. For example, if a user scales the volume down to a small size, light has a shorter distance to travel for a given intensity.

Solution

We can adjust the intensity and attenuationRadius values based on the scale of a root entity. We need to scale the intensity by scaler². This keeps the apparent brightness (power) consistent as the distance it has to travel changes. We can also adjust the attenuation radius so the light has the same relative reach.

Note: I’m using this technique to scale my content when the volume resizes.

// Assumes a uniform scale for the volume
let scaler = volumeRootEntity.transform.scale.x
light.intensity = 1500 * scaler * scaler
light.attenuationRadius = 6 * scaler

Video Demo

Scalable Light Component & System

We can use RealityKit ECS to create a component and system that will adapt our lights. We can add this new component to any entity with a SpotLight or PointLight component. The values we enter will override any values from the original light component.

guard let lightSource = subject.findEntity(named: "PointLight") else { return }
let slc = ScalableLightComponent(
    baseIntensity: 1200,
    baseRadius: 10,
    scaleSourceName: "VolumeRoot", // the name of a top-level entity that will be scaled with the volume.
    referenceScale: 1.0,
    epsilon: 0.02,
    nonUniformMode: .average
)
lightSource.components.set(slc)

ScalableLightComponent.swift

import RealityKit

struct ScalableLightComponent: Component, Codable, Hashable {
    /// Base intensity at referenceScale = 1.0
    /// Will by multiplied by × scale²
    var baseIntensity: Float = 1500

    /// Base radius at referenceScale = 1.0
    /// Will by multiplied by × scale²
    var baseRadius: Float = 6

    /// Name of the entity whose (uniform) scale we watch (e.g. your volume root)
    var scaleSourceName: String

    /// Reference scale at which base* values were authored
    var referenceScale: Float = 1.0

    /// Minimum relative change in scale before recalculating light values.
    /// This avoids constant updates from tiny scale fluctuations (floating-point jitter or micro resize events).
    /// For example, epsilon = 0.02 means the light only updates when the scale changes more than ~2% compared to the last applied value.
    var epsilon: Float = 0.02

    /// How to collapse non-uniform scale into one value
    enum NonUniformMode: String, Codable { case average, max }
    var nonUniformMode: NonUniformMode = .average

    // cache the last used scale
    var lastAppliedScale: Float = .nan
}

ScalableLightSystem.swift

import RealityKit

struct ScalableLightSystem: System {
    static let query = EntityQuery(where: .has(ScalableLightComponent.self))

    init(scene: Scene) {}

    func update(context: SceneUpdateContext) {
        for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
            guard var comp = entity.components[ScalableLightComponent.self] else { continue }
            guard let source = context.scene.findEntity(named: comp.scaleSourceName) else { continue }

            // derive a single scale 's' from the source
            let t = source.transform.scale
            let sRaw: Float = switch comp.nonUniformMode {
            case .average: (t.x + t.y + t.z) / 3
            case .max: max(t.x, max(t.y, t.z))
            }

            // normalize against the reference authoring scale
            let s = max(0.0001, sRaw / max(0.0001, comp.referenceScale))

            // threshold check
            if !comp.lastAppliedScale.isNaN {
                let delta = abs(s - comp.lastAppliedScale)
                if delta <= (abs(s) * comp.epsilon) { continue }
            }

            // Apply to Point/Spot lights if present
            var touched = false

            if var point = entity.components[PointLightComponent.self] {
                point.intensity = comp.baseIntensity * s * s
                point.attenuationRadius = comp.baseRadius * s
                // other properties remain as-authored
                entity.components.set(point); touched = true
            }

            if var spot = entity.components[SpotLightComponent.self] {
                spot.intensity = comp.baseIntensity * s * s
                spot.attenuationRadius = comp.baseRadius * s
                // other properties remain as-authored
                entity.components.set(spot); touched = true
            }

            if touched {
                comp.lastAppliedScale = s
                entity.components.set(comp)
            }
        }
    }
}

Download the Xcode project from this repo. Look for the project named ScaledLightSystem

Support our work so we can continue to bring you new examples and articles.

Questions or feedback?