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:

TypeScript
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

  1. Lightmap UVs come with the bake. Before it bakes, lecodes lightmap bake unwraps every GLB the app references that has no TEXCOORD_1: xatlas cuts its meshes into charts, and the set is written into the file itself (a raw import bakes as it is; --no-unwrap skips the step). A file that carries TEXCOORD_1 is 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 (--force unwraps 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.
  2. Mark the level — env.lightmap in the scene file (above), or Lightmap.add + Model.load { lightmap: true } in code (below).
  3. Bake: lecodes lightmap bake. The app runs in lecodes-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 into assets/lightmap/ (--out, file stem --name, default lightmap): 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) and lightmap.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 needs lecodes-bake — the CLI downloads it on first use; LECODES_BAKE_EXE points at a local build (hosts/desktop build.ps1 -Bake) — and a card with hardware ray tracing; --denoiser picks the AI denoiser (OptiX's on NVIDIA, Intel Open Image Denoise elsewhere: its libraries ship beside lecodes-bake). The bake tool has no physics, 2D, audio, net or navmesh: a level's Physics aspects do nothing there (the statics are what Lightmap.add registers). A hand-built app skips its menus while Lightmap.baking is true — a title screen never reaches the scene.
  4. 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

TypeScript
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()
TypeScript
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 applied

A 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).

TypeScript
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

TypeScript
Material.lightmapShading = 'lite'    // 'full' (default) | 'lite' — before the level loads

The 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

TypeScript
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 probe

debug 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-slab metres 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), or lightmap: 'cube' on a node. --dry-run lists what each rule took, kind by kind.
  • The light grid — movers (lightmap: false, physics) read lightmap.lgrid at 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 / bakeArea lamps, shadows on a baked level
  • scene-files.md — env.lightmap, lightmap: true | false on a node