Builtin Library
README.md
lightmap
Bakes full global illumination (shadowed direct light plus multi-bounce
indirect gathered by path tracing through the scene BVH) into per-entity
lightmaps: the bake output is read back, dilated across UV seams, and
copied into the receiver's region of ONE shared atlas that the PBR module
samples through the receiver's per-instance lanes — the receiver keeps the
material it was authored with. Each texel traces
samples cosine-weighted chains of up to bounces surface interactions,
so color bleed, emissive surfaces, and sky occlusion all land in the map.
Receiver selection is the Model.mobility axis: only static-mobility
world entities bake. Movable geometry samples the published probe fields
instead. Set Model.mobility = "static" to override a wrong derivation.
Every bake persists: the dilated texels land in the scene's
baked-lighting container (lightmapData asset) keyed by the entity, and
the receiver's Model.lightmapData ref points at it. A fresh boot
restores the lightmap from the asset (applyFromAsset, called by the
Model component's awake) with zero re-baking.
Exports
Lightmap.bake(entityId: string, opts: BakeOpts?) -> BakeReport— bake one static entity's lightmap into the shared atlas and point the receiver at it. Yields while the GPU bake + read-back complete, over many frames by design; raises with the cause when the bake cannot finish.Lightmap.bakeAll(opts: BakeOpts?) -> { BakeAllEntry }— bake every static receiver in the world, reusing a single light + BVH cache across them. The result carriesgpu(what the scheduler issued for the pass) andpeakHeldBytes.Lightmap.progress() -> table— how far the running bake has got, withgpu(frames, rays per frame, dispatches per frame) while it runs.Lightmap.checkOptions(opts, limits)— raise naming the first bake option the bake cannot honour.Lightmap.deriveResolution(worldArea, texelsPerMeter, cap?) -> (resolution, capped, effective)— the resolution a receiver gets, cut atcap(the largest the device bakes) or the atlas page.Lightmap.applyFromAsset(entityId: string) -> (boolean, string?)— restore a receiver's baked lightmap from its persisted container entry (the boot path; no re-bake).Lightmap.clear(entityId: string)— destroy the bake buffer, the runtime lightmap texture, and the per-entity material, restore the entity's original material, and remove the container entry.Lightmap.bufferName(entityId: string) -> string— the name the receiver's bake output is made under ("gi_lightmap_<sanitized id>"). The buffer lives until the receiver's texels are persisted.
Types:
ColorRGB = { r: number, g: number, b: number }BakeOpts = { resolution?, texelsPerMeter?, samples?, bounces?, intensity?, rayMax?, shadowSoftness?, sky?, rayBudget?, maxFramesInFlight?, memoryBudget?, ... }BakeReport = { ok, resolution, texelCount, validTexels, ms, textureGuid, atlasLayer, atlasX, atlasY, gpu }BakeAllEntry = { id: string, report: BakeReport }
How the GPU work is bounded
A bake traces rays through a software BVH, and a dispatch that runs past the
platform's watchdog loses the render device, so no dispatch is allowed to be
that big. Baking.planChunks splits a receiver into a direct stage (stage 0,
chunks of texels) and indirect passes (stage 1, chunks of texels times a range
of the samples chains), each sized from the cost model
(Baking.lightmapRays) to carry at most rayBudget estimated rays
(Baking.RAY_BUDGET_PER_FRAME, 2^20). Each dispatch reads its stage and range
from the params uniform of lightmap_bake; a texel's result does not depend
on how the work was split.
A Baking.newGpuScheduler issues the dispatches, one per pass (a bakeAll, or a
lone bake), so the budget is per frame and not per receiver. It waits for the
three kernels to compile first. A frame that would pass the budget ends with a
fence (a read of a one-vec4 buffer the batch records after the frame's
dispatches), waits until at most maxFramesInFlight fenced frames are queued,
and lets a frame draw. It checks the render device on every wait: a lost or
replaced device raises [Bake] the render device was lost during the bake, with
what the device reported, and posts the bake.device_lost notice once.
A receiver lights up only after 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. A bakeAll starts a receiver only when what it holds and
what the receiver needs fit memoryBudget (half the device's memory budget for
GPU content, between 64 MiB and 1 GiB, from Baking.bakeLimits); otherwise the
oldest receiver finishes first.
Options the bake checks
samples is a whole number from 0, bounces a whole number from 1, a pinned
resolution a power of two from 32 up to what the device bakes
(Baking.bakeLimits().maxLightmapResolution: the bake output, 64 bytes a texel,
must fit one storage binding, which a baseline WebGPU device limits to 128 MiB,
and a receiver's whole bake must fit the memory budget), and rayBudget,
memoryBudget numbers from 1. Anything else raises naming the option and the
range it allows. A derived resolution above the device's limit is cut, with a
warning that names the limit.
Usage
local Lightmap = require("@builtin::systems.globalIllumination.lightmap")
local report = Lightmap.bake(entityId, { resolution = 64, samples = 16, bounces = 3 })
print(report.ms, "ms")
-- Bake every static receiver in the world:
local reports = Lightmap.bakeAll({ samples = 32 })
-- Tear down:
Lightmap.clear(entityId)
Notes
bakerefuses non-static receivers with an error naming the mobility rule — the published probe fields are the movable-geometry counterpart.bakeAllbuilds a single BVH and gathers lights once, threading the caches through every per-entity bake. Add/move lights between invocations to invalidate.- A pass that raises releases its receivers' buffers and the shared BVH and clears the atlas lanes of the receivers it had applied; nothing from a lost device is reported as applied.
- The lightmap texture is a runtime GPU resource under a stable
per-entity guid (
BakeReport.textureGuid); re-bakes update it in place, so the material keeps rendering the latest bake with no re-binding. - Texel coverage rides in the texture's alpha; the upload dilates covered texels over uncovered neighbours so bilinear filtering never bleeds black across UV island seams.
clearis a no-op when the entity is missing or never baked.