Builtin Library
- ..
- BakedLighting.component
- LightingScenario.component
- SkyResponse.component
- VolumeProbe.component
- bakeScene.module
- bakeState.module
- bakeViz.module
- baking.module
- documentation
- lightmap.module
- lightmap_bake.computeShader
- lightmap_blur.computeShader
- lightmap_resolve.computeShader
- package.yaml25 B
- probe_bake.computeShader
- readme.md13.8 KB
- scenarios.module
- skyResponse.module
- uv_rasterizer.module
- volumeProbe.module
readme.md
globalIllumination system
Production-grade baked global illumination for the Zero engine. Two complementary techniques share one baking pipeline:
-
Lightmaps — bake full irradiance (direct + multi-bounce indirect) into a per-entity texture sampled in UV space. For static surfaces.
-
Volume probes — path-trace incoming radiance at points in a 3D grid, encode as spherical harmonics (SH L2), and publish the field into the renderer's irradiance-volume set: every standard-PBR fragment inside the volume samples the nearest probes by world-space position. For moving entities and any static surface without lightmap UVs.
Both bake the same lighting model from the same scene: every sample is
path-traced through the scene BVH for up to bounces surface
interactions. Each path vertex contributes its emissive plus its albedo
times the shadowed direct light there, so the bake captures indirect
light, color bleed between surfaces, emissive surfaces acting as area
lights, and sky occlusion — the transport that is too expensive to
evaluate at runtime.
Mobility decides who bakes
Model.mobility ("auto" | "static" | "movable") is the single
selection axis for the whole system:
- Static geometry receives lightmaps and occludes / bounces baked
light.
Lightmap.bakeAll()bakes every static receiver — no per-entity setup. - Movable geometry (and every player / skinned character) samples the published probe fields — renderer-level, no per-entity setup. It never bakes: a moving mesh baked as an occluder would freeze its shadows into the world and walk away from them.
"auto" derives the answer per entity: a non-static Physics body, a
SkinnedModel, a ClipPlayer, a Mover/FreeMover, or a non-world
runtime participation (players, editor viz, prototypes) makes it
movable; everything else is static. An explicit "static"/"movable"
on the field overrides the derivation — that is the exclusion switch
when the derivation gets an entity wrong.
Files
globalIllumination.package/
VolumeProbe.component/ -- define a probe volume (bounds + resolution)
BakedLighting.component/ -- keep the bake in step with the scene
lightmap.module/ -- Lightmap.bake / .bakeAll / .applyFromAsset / .clear
volumeProbe.module/ -- VolumeProbe.bake / .restore / .retire
scenarios.module/ -- Scenarios.capture / .blend / .mix
skyResponse.module/ -- SkyResponse.calibrate / .apply / .combine
bakeState.module/ -- BakeState.affects / .invalidate / .rebake
bakeViz.module/ -- the two debug overlays that make a bake visible
baking.module/ -- Shared utilities: light gather, surface
-- attributes, static mesh-instance gather
lightmap_bake.computeShader/ -- Compute shader: per-texel path-traced bake
probe_bake.computeShader/ -- Compute shader: per-probe path-traced bake
A sky that moves without a re-bake
A probe bake follows each path until it escapes, and what escapes carries the sky's colour — so an ordinary bake fixes the sky the moment it finishes.
The sky enters as throughput * sky where a ray escapes, and throughput does
not depend on the sky, so the field splits exactly, per channel:
field(sky) = lights + sky * aperture
local skyResponse = require("@builtin::systems.globalIllumination.skyResponse")
skyResponse.calibrate(volId) -- two bakes, once
skyResponse.apply(volId, { 0.3, 0.6, 0.9 }) -- no bake
SkyResponse.component is the authored form: calibrate once, then move
skyR / skyG / skyB.
Both terms come from bakes at the same settings, and the bake seeds each path from the probe and sample index alone — so the two runs trace the same paths and the subtraction that isolates the aperture carries no sampling residual.
Lighting scenarios
A volume holds one bake, so a scene lit for noon cannot become a scene lit for dusk without baking again — which takes far longer than a transition is allowed to. Capture each bake under a name and blend the captures:
local scenarios = require("@builtin::systems.globalIllumination.scenarios")
-- light the scene for noon, bake, then:
scenarios.capture(volId, "noon")
-- light it for dusk, bake again, then:
scenarios.capture(volId, "dusk")
scenarios.blend(volId, { noon = 0.25, dusk = 0.75 })
LightingScenario.component is the authored form of the two-state case: name a
from and a to and drive blend from 0 to 1.
Irradiance adds, so blending the SH coefficients is the same as blending the light that produced them. Each coefficient's fourth lane carries the probe's per-axis visibility reach rather than light, and is taken whole from the heaviest-weighted scenario — the mean of two distances describes no wall that exists.
A bake stays true to its scene
A bake is a photograph of a moment. Move a baked wall and its lightmap goes on describing where the wall used to be — the shadow it cast stays painted on the floor it no longer touches.
So a bake maintains itself. BakedLighting watches the engine's
authored-change feed (layers.onEntityChanged) and reacts the moment an
edit reaches the bake: the artifacts that described the old scene are
dropped, the withheld lights come back, and the scene re-bakes once the
edits settle. The bake attaches that component itself, so a scene that
has been baked is a scene that maintains its bake — there is nothing to
remember to run.
Dropping the artifacts (rather than flagging them) is what makes the staleness visible: the viewport goes from baked to honest realtime light the moment the edit lands.
Static geometry and every light the bake consumes reach it. Movable geometry
and dynamic lights never entered one, so editing them reaches nothing.
BakeState.affects is the answer, measured against a signature of what
the bake actually reads — which is also why the bake writing its own
bookkeeping back onto the entities it just baked cannot re-trigger it.
Turn off automatic re-baking (invalidation stays) with:
entity.find("scene").component.add("BakedLighting", { autoBake = false })
Seeing the bake
Baked lighting is the one thing in a scene with no silhouette — texels on surfaces and coefficients in a grid — so "did this actually capture the room?" is otherwise a question you answer by reasoning rather than by looking. Two overlays answer it directly, in the editor's Visualization panel:
- Baked Probes — every baked probe drawn where it sits, tinted by the light it captured, inside its volume's bounds. Light falling off through the volume becomes visible and a dead probe reads as a black dot. Displayed on a gamma curve: indirect light spans orders of magnitude, so a linear scale against the brightest probe would draw most of a room as black and claim a good bake captured nothing.
- Lightmapped Surfaces — a box around every surface currently drawing baked texels, so lightmapped and probe-lit geometry are told apart at a glance.
Both appear in the panel only while the scene holds what they draw. An
unbaked scene lists neither; baking makes them appear. That comes from
debugViz.registerProvider(name, fn, { label, default, available }) — a
package contributes an overlay and declares when it has something to
show, without the debug driver needing to know it exists.
How a bake works
Baking.gatherMeshInstancescollects every static mesh-bearing entity as{ guid, transform, attributes }— the attributes carry the surface's linear albedo (base_color × average base-color texture color) and emissive, read through the material's own asset behavior.compute.buildBvhbuilds the scene BVH engine-side and uploads the node + triangle buffers; each triangle carries its instance's attributes, so the kernels know how every surface reflects and emits.- The bake kernel runs one thread per texel (lightmaps) or probe
(volumes). Each thread traces
samplespaths of up tobouncessurface interactions, with shadowed next-event estimation at every vertex and sky on escape. - The work is planned, not dispatched whole.
Baking.planChunkssplits it into dispatches that each stay inside a per-frame ray budget (Baking.RAY_BUDGET_PER_FRAME, 2^20 estimated ray traversals), sized from a cost model (Baking.lightmapRays,Baking.probeRays: a lightmap texel spends17 + 64 * direct + 64rays on direct light and contact occlusion andbounces * (1 + lights)per chain; a probe spendsbounces * (1 + lights)per sample). Each dispatch carries its stage and range in the kernel'sparamsuniform, and the running sums live in the bake buffer between dispatches, so how the work was split does not change the result. - A
Baking.newGpuSchedulerissues the dispatches, one per pass, a frame's worth at a time. It waits for the kernels to compile first, ends each frame with a fence (a read of a one-vec4 buffer the batch records after the frame's dispatches), keeps at mostBaking.MAX_FRAMES_IN_FLIGHTfenced frames queued, and checks the render device on every wait. A device that was lost or replaced raises[Bake] the render device was lost during the bakewith what the device reported (renderer.deviceErrors), and releases what the pass made. The budget bounds each frame, not the bake: a bake of billions of rays is hundreds of frames of work, which is what keeps the device alive on an integrated GPU and in a browser. - A receiver lights up when its texels are read back with the resolve's
completion flag set. Its bake buffers (144 bytes a texel) are freed once the
texels are persisted, and a
bakeAllstarts a receiver only when what it holds fits half the device's memory budget (Baking.bakeLimits).
What a device bakes
renderer.deviceLimits() states the largest buffer, the largest storage
binding, the workgroups per dispatch and the memory budget the device affords.
The largest lightmap a bake makes is the largest power of two within the atlas
page whose bake output (64 bytes a texel) fits one storage binding (a baseline
WebGPU device binds 128 MiB, which holds a 1024 lightmap and a 2048 one needs
more) and whose bake (144 bytes a texel) fits the bake memory budget. A derived
resolution is cut there with a warning naming the limit; a pinned resolution
past it raises.
When a bake fails
A bake raises with its cause: the stage, a lost device, a dispatch the engine
refused (a buffer past the storage binding limit is refused by name before it
can invalidate the frame's command buffer), an error the device reported. It
leaves nothing applied. BakeState.rebake drops the artifacts its stages left
and posts bake.rebake_failed instead of "baked lighting is current again";
baking.all raises naming each stage that failed; VolumeProbe.autoPlace raises naming
the volumes that failed when any did, and marks no light baked. A failed automatic re-bake holds
automatic re-baking until an explicit bake succeeds.
Quick start
Lightmap
local Lightmap = require("@builtin::systems.globalIllumination.lightmap")
-- Bake every static receiver in the world. Without `resolution` each surface
-- derives its own from its area at 32 texels per metre, rounded up to a power
-- of two and capped by what the device can bake.
Lightmap.bakeAll({ samples = 32 })
-- Pin a resolution (a power of two from 32 up to what the device bakes) and, when
-- a scene wants a different pace, the per-frame ray budget: a larger budget
-- finishes in fewer frames, a smaller one leaves more of each frame to the
-- editor.
Lightmap.bakeAll({ resolution = 128, samples = 32, rayBudget = 2 ^ 21 })
-- Bake a single static entity
Lightmap.bake(floorId, { resolution = 256, samples = 64 })
-- Tear down (frees the bake resources + restores the original material)
Lightmap.clear(floorId)
Volume probes
local VolumeProbe = require("@builtin::systems.globalIllumination.volumeProbe")
-- Author a probe volume that covers a room
local vol = entity.spawn("level_probes")
vol.localPosition = { 0, 0, 0 }
vol.component.add("VolumeProbe", {
sizeX = 20, sizeY = 6, sizeZ = 20,
resX = 8, resY = 4, resZ = 8,
})
-- Bake: publishes the field renderer-wide and persists it into the
-- scene's baked-lighting container. Every entity inside the bounds
-- receives it — no per-entity binding.
VolumeProbe.bake(vol.id, { samples = 256 })
What's static vs dynamic
| Quantity | Static at bake time | Dynamic per frame |
|---|---|---|
| Geometry positions / rotations | yes | no |
| Lights (positions, colour, intensity) | yes | no |
| Sky / ambient values | yes | no |
| Material base colour | no | yes (modulated against lightmap) |
| Object position (inside a probe volume) | n/a | yes (samples nearest probes each frame) |
Re-bake when any of the "static" inputs change. Lighting changes that are too dynamic to bake (e.g. flickering torch) should be added on top of the baked term as additional realtime lights.
Engine integration
This package is Luau / WGSL over generic engine capabilities: compute
shader dispatch, compute.buildBvh (scene BVH with per-instance
attributes), component lifecycle, the renderer's irradiance-volume set
(lighting.setProbeVolume), and the baked-lighting container asset
(lightmapData).
See also
man shaders— the WGSL bind-group layout these shaders extendman compute— compute dispatch + buffer managementman /Light— the Light component baked into the resultman /Material— material registry that lightmaps and receivers extend