Log inGet started

Lighting and global illumination

Updated October 16, 2026

Lighting

The lighting toolbox is the scene's light environment — sun, ambient, sky, and point/spot lights, each with modify-or-spawn semantics. Reach for its tools when you are setting a scene up; a component or runtime script that adjusts lighting each frame uses the modules.api.engine.lighting capability directly, with the same options.

  • setSun — aims the sun and sets its colour and intensity.
  • setAmbient — the ambient fill.
  • setSky — a preset word, a material, or "none".
  • addLight / setLight / removeLight — a named point or spot light, by position and intensity. addLight modifies one that already exists under that name rather than stacking a second.
  • setup — sun, ambient, sky and clear colour in one call.
  • get — the full lighting and sky state, in the shape setup takes back.

A component or runtime script that adjusts lighting each frame reaches the same light environment through the modules.api.engine.lighting capability, with no tool call. Setting and reading are separate calls there: a setter takes the toolbox's opts and answers with the entity id the light resolved to, and a reader takes no arguments and answers from the resolved lighting state. Both find the sun and the ambient through the light the renderer resolved, so a per-frame call is priced by the scene's lights rather than by everything else standing in it.

local lighting = require("@builtin::modules.api.engine.lighting")

-- writes — the same opts the tools above take:
lighting.setSun({ direction = { -0.5, -1, -0.3 }, intensity = 3, color = { 1, 0.95, 0.8 } })
lighting.setAmbient({ intensity = 0.2 })
-- sun, ambient, sky and clearColor in one call — only the sections passed change:
lighting.applySetup({ sun = { intensity = 3 }, ambient = { intensity = 0.2 }, clearColor = { 0.02, 0.02, 0.04 } })

-- reads — what the frame is shaded by:
local s = lighting.sun()        -- direction, color, intensity, castsShadows, entityId
local a = lighting.ambient()    -- color, intensity, entityId
local rows = lighting.lightRows()   -- every punctual light the renderer resolved

lighting.sun() reads and lighting.setSun(opts) writes, so an opts table handed to the reader is refused with the name of the call that applies it. The name-based lookup, the sky presets and the sky-material orchestration stay in the toolbox — the capability resolves each light by its component.

Lighting is real: with every source removed the scene goes dark. Lights also exist as a component (entity.component.add("Light", { intensity = 5 })) for ones that move with an entity — the lighting surface is the scene-environment one, the Light component is the per-entity one.

Point and spot lights share one intensity scale. They take a row of the same light buffer and are shaded by the same term — color * intensity * falloff — with the spot's cone as one further factor that is 1 inside its inner angle. So the same number at the same radius puts the same light on a surface either kind faces from the same place, and addLight hands intensity to both components unchanged. What the cone decides is how much of the room the light reaches: a spot lights the solid angle it opens on and leaves the rest of the room to whatever else is lit, so a frame-wide or room-wide average of a spot reads far below the same average of a point light carrying the same number. Balance a mixed rig by the brightness of the surfaces each light is aimed at, and read a spot on the surface its axis lands on. A spot's angle and innerAngle are HALF-angles from that axis, so angle = 30 opens a 60-degree cone; widening the cone or raising range spreads the same light over more of the room, where raising intensity makes the spot's own pool brighter. The sun, a directional light and a distant light read on a scale of their own: a parallel light arrives from the same direction at every point in the world and no distance attenuates it, so the same number lands far more light on a surface than a point or spot carrying it across a room does. Balance those against the sun and against each other.

The sun is a single field: one direction, one colour, and the sun's shadow. Setting it aims that light, so a second Light { kind = "directional" } moves the sun rather than joining it. A scene that wants more than one — a binary star, a moon opposite the sun, a cold fill from the far side of a set — adds DirectionalLight components, which are rows of the scene's light buffer beside its point, spot and area lights:

entity(moon).component.add("DirectionalLight", {
    direction = { 0.3, -0.5, 0.4 },   -- the direction the light TRAVELS
    color = { 0.7, 0.8, 1.0 },
    intensity = 0.6,
})

They shade through the same BRDF the sun does, so one at the sun's direction, colour and intensity renders the frame the sun would have. The sun's shadow, the sky and the image-based lighting all follow the sun, so a distant light reaches every surface facing it. A GI bake gathers one the way it gathers the sun — its mobility field says how much of it to bake. lighting.lightRows() reports each of them with kind = "distant", and the lighting toolbox's get tool lists them beside the scene's other lights.

The sky is an explicit component on an entity — ProceduralSky for the procedural day/night look, or Skybox for a material sky or kind = "none" to turn it off. A scene with no sky entity renders the engine's fallback sky and carries a missing-sky warning; the lighting toolbox's setSky tool creates (or reconfigures) the scene's sky entity and clears the warning.

Global illumination — baked lightmaps & light probes

The lights above are direct light. Indirect light (bounce, color bleed, soft ambient) comes from the @builtin::systems.globalIllumination system, which path-traces global illumination offline so scenes get realistic multi-bounce lighting at no per-frame cost. The Model.mobility field ("auto" | "static" | "movable") decides how each entity participates: static geometry receives lightmaps and occludes baked light, movable geometry (and every player) samples probe volumes. "auto" derives the answer from the entity's components (physics bodies, skinned meshes, animation, and movers all make it movable); set the field explicitly to override.

Lights carry their own mobility ("auto" | "static" | "mixed" | "dynamic") answering a different question — how much of the light the bake takes. "mixed" is what "auto" picks: the bounce bakes, the direct light and shadows stay live, so a light still shines on whatever walks under it. "static" bakes the light whole and stops rendering it, which is the per-frame saving and the promise that nothing will move there.

Baking is a toolbox, so it answers on every tool surface. From the shell, zero baking lists the verbs and zero baking <verb> --help gives one verb's options:

zero baking all        # lightmaps + auto-placed probe volumes + reflections
zero baking lightmaps  # just the static-surface lightmaps
zero baking probes     # just the irradiance probe volumes
zero baking detect     # report what is baked vs stale vs missing
zero baking clear      # remove baked lighting across a scope

The same verbs are the baking toolbox's tools, all among them, each taking an args table for its options (scope, resolution, samples, ...). Reach for them from code when what you are writing needs to bake, such as a panel offering a Bake button to a person.

Movable entities pick up the baked probes automatically: each baked volume publishes into the renderer's irradiance-volume set, and every standard-PBR fragment inside its bounds samples it — no per-entity setup. The full system (bake settings, UV requirements, mobility, staying current, the overlays) has its own guide: guides { path = "systems/globalIllumination/documentation/baked-lighting" }. ReflectionProbe (per-entity specular reflections) is a sibling component.

Baked light in your own shader

A bake writes nothing to the material. Every receiver keeps the material it was authored with; the bake puts its texels in a region of one shared atlas and records where in the receiver's per-instance shader-data lanes. That means a surface() shader, or any shader that #include "@builtin::shaderModules.pbr_shading", already supports baked lightmaps — you write nothing. The engine resolves the lanes and folds the irradiance into indirect light for you, and two instances of the SAME material resolve independently, so baked and unbaked geometry can share a material.

You only do this by hand in a self-shading fragment() shader that computes its own lighting (no PBR module). Read the two lanes and sample the atlas:

// lane 0 = (scale.u, scale.v, offset.u, offset.v) — this instance's rect in the page
// lane 1 = (layer, enabled, -, -)
let params = input.shader_data[1];
var baked = vec3<f32>(0.0);
if params.y > 0.5 {
    let rect = input.shader_data[0];
    let uv = input.lightmap_uv * rect.xy + rect.zw;
    baked = zero_feature_texture(uv, u32(params.x)).rgb;
}

input.lightmap_uv is the mesh's unique, atlas-packed uv1 — the bake's own UV set, not the texturing uv. The sample is irradiance (incoming diffuse light), so a surface turns it into outgoing light by multiplying by its own diffuse albedo: baked * albedo. Add that where your shader would otherwise apply ambient — a baked value REPLACES flat ambient and probe-volume irradiance rather than adding to them, since all three approximate the same quantity and summing double-counts the bake.

Gate on params.y and never on "is the texture bound": the atlas is always bound and always valid (black until something bakes), and the enable flag is what varies per instance.

  • documentation
  • guide
  • rendering