Lightmap
Baked lighting for geometry that never moves: lecodes lightmap bake traces the sun, the sky, the
lamps and their bounces onto every static surface once, at development time; at runtime the statics
take their light from those atlases, at no cost per frame and with shadows sharp at any distance.
Light and shadows computed every frame are Light; a baked level's vegetation is
foliage.
- bake the lighting of a static level
- add lightmap UVs to GLB models before a bake
- load a bake in a scene file or in a hand-built scene
- choose which objects are baked and which stay real-time
- light moving characters and props in a baked level
- make baked lighting cheaper on a weak or integrated GPU
- debug a bake: show the light alone, find the texel under the cursor
- tune bake quality, atlas size and texel density
Platforms: desktop. Not on iOS (a real bake's BC6H light pages abort the app at
Lightmap.load); Android and the web player only on a GPU / browser with BC6H (BPTC) textures —
most phones lack them, and the app then aborts the same way; on the web (Web Lite) and in headless
tests Lightmap.load resolves null and the level renders unbaked. The bake itself runs only in
lecodes-bake, the bake tool (Windows / Linux), on a GPU with hardware ray tracing: OptiX on an
NVIDIA card, Vulkan ray query on any other (Radeon RX 6000+, Arc) — --tracer picks, the driver
alone is needed.
Bake a scene file's level: env.lightmap
A level that is a scene file needs one block and no code:
export default defineScene({
env: {
skybox: { texture: asset('../assets/sky.ktx') },
lightmap: {
data: asset('../assets/lightmap/lightmap.bake'),
light: asset('../assets/lightmap/lightmap-light.ktx2'),
aux: asset('../assets/lightmap/lightmap-aux.ktx2'),
probes: asset('../assets/lightmap/lightmap-probes.ktx2'), // optional: reflection probes
volume: asset('../assets/lightmap/lightmap.lgrid'), // optional: light grid for movers
uv1: asset('../assets/lightmap/lightmap-uv1.bin'), // the packed lightmap uvs the bake wrote
},
},
nodes: {
ground: { mesh: { kind: 'plane', normal: [0, 1, 0] }, scale: 40 },
barrel: { model: asset('../assets/props/barrel.glb'), aspects: [use(Shape), use(Physics)] },
},
})With env.lightmap set, the statics are derived, not declared: every model / mesh node
is a lightmap static — a receiver and an occluder in the bake, no real-time shadow casting once
the bake applies — unless something moves it: a Physics aspect (dynamic, Physics' default, or
kinematic) or a CharacterController. Children of a mover are movers too. A physics-less
decoration is static; the barrel above keeps its real-time shadow. lightmap: true | false on a
node overrides the rule for the exceptions (a code-animated prop without physics → false, a
kinematic platform that never actually moves → true); prefab subtrees inherit the wrapper's
verdict; make() subtrees are code and register themselves with Lightmap.add. lightmap: 'cube'
is a static whose meshes bake as AMBIENT CUBES instead of atlas texels — still an occluder, its own
light baked where it stands, the way the bake treats small and thin props by itself — for a pipe run,
a fence or a prop whose lightmap seams show; env.lightmap.cube (and the bake's --cube) does the
same by name, regular expressions over the bake's keys (cube: ['SM_pipe', 'SM_fence']). A static
terrain: node is built with the baked terrain material and bakes as one rect. Keys in the bake
are node paths (perimeter/metalfence1), so a rename or a move means a rebake. The bake applies
when load() has built every node — and that is exactly where lecodes lightmap bake runs it. The
scene editor renders real-time shadows (edit mode ignores the block) and shows the verdict as a
"Baked lighting" switch on model/mesh nodes.
Bake a level step by step: lightmap UVs, lecodes lightmap bake
- Lightmap UVs come with the bake. Before it bakes,
lecodes lightmap bakeunwraps every GLB the app references that has noTEXCOORD_1: xatlas cuts its meshes into charts, and the set is written into the file itself (a raw import bakes as it is;--no-unwrapskips the step). A file that carriesTEXCOORD_1is never touched, and skinned meshes are left alone — a rigged character never stays in one pose.lecodes assets unwrap <glb | dir …>does the same by hand (--forceunwraps a file again), and is the way to bake a level in ONE GLB coarser where nobody walks:--zone(with--groups) gives the meshes far from the player's boxes a coarser texel. Run it before the bake; the bake then leaves the file as it is. The unwrap only CUTS charts: the bake packs them itself, per mesh, and hands the runtime the packed set through<name>-uv1.bin(env.lightmap.uv1), so a mesh's atlas rect is its own whatever file it came from, and a level in one GLB spans pages. - Mark the level —
env.lightmapin the scene file (above), orLightmap.add+Model.load { lightmap: true }in code (below). - Bake:
lecodes lightmap bake. The app runs inlecodes-bake, the bake tool (the windowless host with the bake built in); when the level finishes loading, creator-bake traces the scene on the GPU and the command writes intoassets/lightmap/(--out, file stem--name, defaultlightmap):lightmap.bake(the layout, JSON),lightmap-light.ktx2(irradiance in lux, BC6H),lightmap-aux.ktx2(sky visibility, the directional share and direction of the light),lightmap-probes.ktx2(reflection probes) andlightmap.lgrid(the light grid for movers). A bake that needs more than one atlas page writes-light_1.ktx2,-aux_1.ktx2, … — list every page in order. The host writes the KTX2 files itself: no KTX-Software needed. It creates placeholder files first, so the very first compile already has something to stage. The bake needslecodes-bake— the CLI downloads it on first use;LECODES_BAKE_EXEpoints at a local build (hosts/desktopbuild.ps1 -Bake) — and a card with hardware ray tracing;--denoiserpicks the AI denoiser (OptiX's on NVIDIA, Intel Open Image Denoise elsewhere: its libraries ship besidelecodes-bake). The bake tool has no physics, 2D, audio, net or navmesh: a level'sPhysicsaspects do nothing there (the statics are whatLightmap.addregisters). A hand-built app skips its menus whileLightmap.bakingis true — a title screen never reaches the scene. - Run the app: the bake applies, or quietly does nothing where there is no bake yet or the host has no lightmap support (the platform line above).
Load a bake in a hand-built scene: Lightmap.add / Lightmap.load
const scene = new Scene()
scene.add(Light.sun())
scene.open()
const main = async () => {
const crate = await Model.load(asset('./assets/props/crate.glb'), { lightmap: true })
const ground = Mesh.plane({ material: Material.lit({ color: '#9a9488' }), normal: [0, 1, 0], scale: 40 })
const holder = new Node()
holder.add(crate)
scene.add(holder, ground)
Lightmap.add(holder) // static: a receiver AND an occluder
Lightmap.add(ground) // Mesh.plane / Mesh.box work too
const info = await Lightmap.load(scene, { // after the static scene is built
data: asset('./assets/lightmap/lightmap.bake'),
light: asset('./assets/lightmap/lightmap-light.ktx2'),
aux: asset('./assets/lightmap/lightmap-aux.ktx2'),
probes: asset('./assets/lightmap/lightmap-probes.ktx2'), // optional
volume: asset('./assets/lightmap/lightmap.lgrid'), // optional
})
if (info) console.log(`baked: ${info.applied} of ${info.total} statics, ${info.pages} page(s)`)
}
main()Lightmap.add(node: Node, key?: string, { cube? }?): Node // a Model, a Mesh, or any node holding them; cube = ambient cubes, no texels
Lightmap.track(node: Node, on = true) // a MOVER spawned by code takes the light grid
Lightmap.load(scene, files, options?): Promise<LightmapInfo | null>
Lightmap.baking: boolean // true under `lecodes lightmap bake`
Lightmap.moverShadows: boolean // movers' sun shadows on the statics; default true
Lightmap.clear() // forget every registration (scene rebuild)
files: {
data: string // <stem>.bake
light: string | string[] // <stem>-light[_n].ktx2, every page in order
aux: string | string[] // <stem>-aux[_n].ktx2, pages like `light`
probes?: string // <stem>-probes.ktx2 — absent: statics reflect the sky's SH alone
volume?: string // <stem>.lgrid — absent: movers keep the scene's IBL
direct?, indirect? // DEBUG: the pages of a `--split` bake
}
options: {
lightScale?: number // DEBUG multiplier on the atlas' lux (default 1)
emissiveNits?: number // FOR THE BAKE: cd/m² of emission 1.0 (--emissive-nits wins)
emissiveBake?: boolean // false = emissive surfaces light nothing (default true)
transmit?: Record<string, number | { through?, diffuse? }> // FOR THE BAKE: materials light passes through
cube?: string[] // FOR THE BAKE: keys (regex) baked as ambient cubes; --cube adds its own
}
LightmapInfo = { size, texel, pages, applied, total, missing: string[] } // null = nothing appliedA scene file takes the same keys in env.lightmap (files and options in one object).
Stable names for baked nodes — key
Each registered static needs a stable name in the bake: by default the node's name plus a running
number in registration order (container#3); pass key when names are not stable. A node holding
several models gets key/0, key/1, … load reports keys present on one side only — a layout
change means a rebake. Clones inherit { lightmap: true }, so one Model.load + clone() per prop
type is enough. A node that holds no Model or Mesh is warned about and skipped.
When Lightmap.load returns null
load resolves null — and the level renders unbaked — when there is no bake yet (the placeholder
files), the files cannot be read (a warning names them), the host has no lightmap support (warned
once) or the app runs under lecodes lightmap bake, where this call is what starts the bake.
What is baked and what stays real-time
light = the texel's irradiance in lux — the sun, the sky, the lamps and their bounces (soft
penumbrae come from the sun's angular size and the lamps' radius, not a blur); aux = the sky
visibility, the directional share and the direction of the light. A static gets nothing from the
real-time sun or lamps (the engine keeps those off baked renderables through light channels);
movers, glass and anything loaded without lightmap: true stay real-time, and the real-time shadow
map holds only them — which makes their shadows crisper too. A lamp that must light statics live
too (Light.point({ baked: false })) is kept out of the bake and put on the statics' channel; a
baked lamp is held dark in real time while the light grid is loaded — see Light.
The light is baked — the sun's direction, colour and intensity, the sky, the lamps and the layout. Change any of them → rebake. Exposure stays live.
Light moving objects in a baked level: Lightmap.track
Characters, enemies and props that move are not baked: they take their ambient light from the
light grid (volume) at the place they are at, whatever their shader: a scene file marks every node a Physics aspect or a CharacterController moves, code
marks what it spawns with Lightmap.track (safe before the bake has loaded; on = false hands the
subtree back to the scene's IBL).
const scene = new Scene()
scene.add(Light.sun())
scene.open()
const main = async () => {
const enemy = await Model.load(asset('./assets/enemy.glb')) // no `lightmap: true`: it moves
enemy.position = [3, 0, -2]
scene.add(enemy)
Lightmap.track(enemy) // its ambient light comes from the bake's light grid where it stands
}
main()Movers' shadows on the statics. A bake that wrote the sun map lets a mover darken the baked
ground under it (the lightmap shader reads the real-time shadow map there). Lightmap.moverShadows = false turns that off, live — the cheapest lighting tier: a baked surface then takes no real-time
shadow at all, while a mover still shades itself and the other movers.
Materials and meshes a bake accepts
Lightmapped GLB primitives run a lit PBR shader that reads the glTF base colour, metal-rough,
occlusion, emissive and normal maps on UV0 with their factors; alpha-MASK materials take its masked
twin. A material that needs more — a map on UV1, texture transforms, BLEND (glass),
KHR_materials_unlit, spec-gloss, clearcoat, sheen, transmission, volume, specular — is declined
with a [lightmap] … log line naming what it lacks, and renders with the standard shader, lit
real-time, unbaked — and it keeps casting a real-time shadow on top of the one the atlas holds.
Mesh receivers sample the atlas through Geometry.uv1: Mesh.box lays its six faces out in
proportion to their areas, Mesh.cylinder unwraps its side and caps, a plane's own uv already is
a chart; a custom Geometry can set uv1 itself (absent = uv, so a UV-scaled or tiled mesh needs
one). A Mesh receiver's lit material becomes a lightmap material with the same colour, map,
roughness and metallic when the bake applies.
Cheaper baked-light shader for a weak GPU: Material.lightmapShading
Material.lightmapShading = 'lite' // 'full' (default) | 'lite' — before the level loadsThe engine-wide choice of the lightmap shader. 'full' is the target's own (on desktop: the
bicubic atlas fetch, eight probe corners, the sun's highlight on metal); 'lite' is the shader the
phones ship (bilinear fetch, one probe corner, no sun highlight; the movers' shadows on the statics
stay) — some 30 % less per pixel on an integrated GPU, the picture a phone shows. It is read when a
lightmap material is first made, which Model.load({ lightmap }) does once per world: set it at
boot, like Texture.maxSize — a later change takes the next level. A host without the lite
material (a phone: its own is that shader already) warns once and takes the full one.
Debug a bake: data views, the texel under the cursor — Lightmap.debug
Lightmap.debug(mode: LightmapDebugMode | number, params?: { luxRange?: [lo, hi] }) // 'off' = back
Lightmap.debugModes: readonly LightmapDebugMode[] // the names in order — for a knob that cycles them
Lightmap.probe(screenX, screenY): LightmapProbe | null // the baked texel under a screen point (logical px)
Lightmap.debugProbes(on: boolean, radius = 0.2): number // a mirror ball at every reflection probedebug switches every lightmapped surface to a DATA view, level-wide: irradiance (the lighting
alone on a white material), albedo, normal, shadingNormal, share, skyVis, atlas (a
texel checker per instance), direction, lux (false colour over luxRange, log10, default
[0, 5]), relief, probe, probeMap, skyPath, probePath; direct / indirect bind the
pages of a --split bake listed in files. probe answers { key, entity, node, material, page, texel, uv1, world, distance } — the texel to look up with lecodes lightmap inspect; null when
nothing baked is under the point. debugProbes returns how many balls it placed.
Three ways a static takes its light: atlas texels, ambient cubes, the light grid
Every mesh of a static is one RECORD of the bake (<node key>@<index> in the .bake), and each
record is lit one of three ways:
- Atlas texels — the default: the mesh's charts get a rect of the atlas at the level's texel, the shader reads its light per pixel. Right for anything a shadow or a gradient can read on: walls, floors, furniture.
- Ambient cubes — no rect: the record stays an occluder (its shadow and bounce are baked onto
its neighbours) and takes its own light from six sides measured where it stands, blended by the
normal; a long one gets a cube per
--object-slabmetres along its length (default 1 m, 16 at most) blended along it. Smooth, seamless, cheaper per pixel than the atlas, but without the object's own shadow on itself. The bake chooses it by itself for the SMALL (--min-texels 12: under that many texels along every axis — cartridges, boxes, elbows) and the THIN (--thin-texels 4: no chart wider than that many texels across — pipes, rails, cables, blinds, gun barrels: a strip of 1–3 texels around a tube cannot hold the light's change around it and its uv seam shows as a step). Mark more by hand:--cube <regex>/env.lightmap.cube: [...]over the record keys ('SM_pipe','SM_fence','@1$'for one mesh), orlightmap: 'cube'on a node.--dry-runlists what each rule took, kind by kind. - The light grid — movers (
lightmap: false, physics) readlightmap.lgridat their position every frame (Lightmap.track); the real-time sun and lamps stay on them. Not for statics.
Bake quality, atlas size and texel density: lecodes lightmap bake options
--out assets/lightmap · --name lightmap file stem (one per baked level) · --entry main.ts the
file that calls Lightmap.load · --size 4096 atlas page edge · --texel 0.05 metres per texel
and --pages 4 pages to fill before the bake coarsens the texel (ask finer than you need with the
pages capped — --texel 0.04 --pages 1 — and the bake settles on the finest texel that fits; the
log says asked 4.00 cm texels, … fit 4.58 cm) · --detail-texels 2000 a surface under that many
texels is laid out finer, 3x at most · --sun-rays 64 / --sky-rays 2048 / --light-rays 64 /
--bounce-rays 128 / --bounces 3 / --patch-samples 64 quality · --denoise no-sun which layers
the AI denoiser filters (none|indirect|no-sun|all) and --denoiser auto which one (optix on an
NVIDIA card, oidn = Intel Open Image Denoise anywhere) · --tracer auto which GPU API traces
(optix on NVIDIA, vulkan ray query on any card with hardware ray tracing: Radeon RX 6000+, Arc,
RTX — the driver alone) · --sun-angle 0.53 the sun's diameter in degrees (bigger = softer edges) ·
--min-texels 12 / --thin-texels 4 / --object-slab 1 / --cube <regex> what bakes as ambient
cubes (above) · --probe-spacing 4 reflection probes (0 = none) · --volume 1 light grid cell in
metres (0 = none) · --split also writes the direct and the indirect light as page sets of their
own (the direct / indirect views) · --dry-run the atlas layout only (pages, texel, fill, what
leaves the atlas — seconds, nothing written) · --frames 900 load headroom. The full list with every
flag's reasoning: lecodes lightmap bake --help; lecodes lightmap stats | diff | inspect read a
finished bake.
The bake runs only in lecodes-bake (it is a development tool; the desktop host the game ships on
carries no bake). A host without lightmap calls (the web, headless tests) warns once
(Lightmap: this host has no lightmap support) and draws the level unbaked.
See also
- foliage.md — the family's vegetation tier: the ground's baked light on cards that
sway, bend and fade (
Model.load { foliage: true }) - terrain.md — the terrain as the bake's receiver
- light.md —
baked/bakeArealamps, shadows on a baked level - scene-files.md —
env.lightmap,lightmap: true | falseon a node