Bake real shadow/lighting lightmaps from a world's own lights and inject
them into an already-compiled .World00p in place — no recompile. The
engine's shaders\rigid\Solid\diffuse_spec_lightmap.fx shader supports
lightmaps; this tool bakes and wires them up.
Lightmap Studio — live perspective viewport (fly + FPS-walk camera), realtime light editing with 3D gizmos, per-surface bake selection, and a one-click bake.
![]() |
![]() |
![]() |
Lightmap Studio in action — viewport, light gizmos, and bake panel.
![]() |
![]() |
![]() |
Baked soft shadows + ambient occlusion in-engine.
Baked multi-page lightmap atlas — each tile is one surface's soft-shadow + ambient-occlusion lighting, packed proportional to its world area.
- Reads the world's render geometry + its light rig (
LightPoint/Area/ Directional/Spot) + GI/light blocker polys. Sky/cloud and engine shadow-volume surfaces are excluded from the occluder set (a finite skybox above the level would otherwise cast a giant fake shadow over the interior). - Unwraps each surface into non-overlapping lightmap charts with
xatlas(worlds often lump a whole multi-directional structure into one surface, which a single-plane projection can't unwrap without overlap). Vertices are duplicated at chart seams. - Bakes a multi-page lightmap atlas with embree ray-traced soft shadows + ambient occlusion, using each light's own colour/intensity/radius.
- Generates lightmap-variant
.Mat00materials (diffuse on UV1, atlas on the emissive UV2) and rebuilds the lit surfaces into the 64B lightmap vertex layout (expanded chart-cut verts + reindexed triangles, render BSP tree kept valid), splicing the new render section back into the world. - Stages the world + atlas pages + materials into the game tree.
--src (the world to lightmap) and --base-tree (the loose game data dir
containing Materials\..., used to read each material's base texture) are
required.
# quick test bake (~35s)
python build_lightmap_bake.py --src World.World00p --base-tree path\to\Game \
--name MyMap --fast
# high-quality bake (~3-4 min) — the tuned settings
python build_lightmap_bake.py --src World.World00p --base-tree path\to\Game \
--name MyMap --quality
# a preset, with an individual override
python build_lightmap_bake.py --src World.World00p --base-tree path\to\Game \
--name MyMap --quality --pages 8--fast— quick iteration: 2 pages, low samples, 1024px max tile.--quality— the tuned high-quality settings: 6 pages, 16 shadow samples, 24 AO samples, sharp sun (0.35° disk) for crisp shadows, 2048px max tile for floor detail, low ambient + light denoise for shadow contrast.
Both presets also set --spec-intensity 0 (kills lightmap-unmodulated specular
glare). SurfaceFlags are left as the base material's value.
Any individual flag (--pages, --ao-samples, --ambient, …) overrides the
preset. With no preset, the built-in BakeOpts defaults apply (a medium
quality). Notable extra flags: --bias (shadow-ray offset; raise to kill
self-shadow acne), --spec-intensity, --dir-transform (debug: remap the
directional emit axis to A/B-test the engine's convention).
Output is staged locally under ./output/, mirroring the game tree:
output/Worlds/<World>.World00p
output/Data_Bg/Lightmap/<name>/LM_*.dds (atlas pages)
output/Data_Bg/Lightmap/<name>/LM_*/...Mat00 (lightmap materials)
Copy the contents of output/ into the game root when you're happy with it
(override the location with --stage). Run python build_lightmap_bake.py -h
for every setting (atlas size, pages, ambient, exposure, sun/point boost, AO,
soft-shadow samples, sun angle, denoise, dilate, workers, paths).
GI/light blockers in the target world are used automatically as bake occluders (no flag needed) — they seal light leaks. If the world has none (0 blockers), there's nothing to seal.
A PySide6 + moderngl front-end for everything the CLI does, modelled on
tools/skin_edit. Live perspective viewport with WASD + RMB fly camera,
realtime light preview (sun + point/spot Lambert with the same windowed
attenuation the baker uses), project hierarchy (lights grouped by kind, plus
surfaces with material + LM flag), per-light property pane, a full bake panel
mapped to every CLI flag, one-click bake (runs in a QProcess, stdout streamed
to the log dock), and on success an auto-load of the freshly baked world with
its LM_0 atlas bound for the Atlas shading mode.
py lightmap_studio.py [--base-tree DIR] [WORLD.World00p]
# or: lightmap_studio.bat World.World00pViewport controls
| Input | Action |
|---|---|
| RMB drag | mouse-look |
| WASD | move forward / strafe (hold RMB for proper FPS feel) |
| Q / E | drop / rise (world up) |
| Shift | sprint |
| Wheel (no RMB) | adjust fly speed |
| Wheel (with RMB) | dolly along forward |
| F | frame the whole world |
| T | snap to top-down |
Shading modes (toolbar dropdown)
| Mode | What you see |
|---|---|
| Unlit (height) | render_preview.py look: blue→green→orange height gradient |
| Sun N·L | only the sun, dot-product against face normals (shadowed) |
| Realtime Lit | per-material diffuse × (ambient + sun-with-shadow-map + per-pixel point/spot Lambert) |
| Flat grey | mid-grey shaded by normal, useful for reading geometry |
| Atlas | per-material diffuse × bound LM_0.dds via UV2 (for inspecting a baked result) |
Materials: each surface's .Mat00 is read from --base-tree, its
tDiffuseMap decoded (DXT1/3/5) and bound on UV1. Missing materials fall back
to neutral grey. Megatextures are downsampled to 1024px on upload to keep VRAM
in check. Edit Base tree in the Bake panel and the world re-loads with the new
path's materials.
Sun shadow map: a 2048² ortho shadow map of the strongest directional light
is re-baked when the sun (or geometry) changes; the main pass samples it with
3×3 PCF for soft edges. Toggle via the Shadows checkbox.
Light editing (preview only — does not save back to the .World00p)
Select a light in the project tree; the right panel becomes editable spinboxes
for position, color, intensity, radius, engine euler X/Y/Z (X = pitch,
where 90° = straight down — same convention as tools/world00p/fear_sun_rotation.py
and tools/blender_addon/format/coord.py; the quaternion is stored as truth
and direction + right + up are re-derived via quat_rotate) and FOV. Edits
mutate the light in memory and re-render gizmos + shadow map + lighting live.
The + Light button drops a new directional/point/spot/area at the world
center; − Light removes the selected one. These edits affect what you SEE
but the bake still runs against the on-disk world — there is no round-trip
writer yet.
Translate gizmo: when a light is selected, three colored axis arrows (X red / Y green / Z blue) appear at its position, sized to a constant on-screen length. Left-click + drag an arrow to slide the light along that world axis; the editor spinboxes update live. The arrows render on top (depth test off) so they stay visible through geometry.
WorldModel placement: render geometry whose surfaces belong to a movable
WorldModel slot (cranes, trains, sky cubes, animated props…) is stored in
LOCAL space and placed at runtime by its owning object's Pos + Rotation.
The studio replicates tools/blender_addon's import logic: builds a slot
name table from world_models.bsp_name_entries, votes each surface to its
owning slot by vertex-position lookup, then applies the owner's quat + pos
per vertex. Without this fix, movable models cluster at the origin.
Live bake preview (WYSIWYG): ambient / exposure / sun boost / point boost in the Bake panel are the single source of truth — editing them updates the realtime viewport instantly, so what you see is what the bake will produce. The toolbar Ambient/Exposure sliders mirror the panel.
Walk mode (FPS): toolbar Walk Mode (or F1) drops a player into the world.
WASD walks, Shift sprints, Space jumps. Gravity + a downward ray-cast against
world geometry (via trimesh/embreex) keeps the player on the floor.
The Character tab in the right dock tunes height, move/jump speed,
run multiplier, gravity, FOV, step-up height and feet offset; values persist
to tools/lightmapbaker/studio_settings.ini so they survive restarts.
Light gizmos (always on, toggle via the "Gizmos" checkbox)
- Directional / sun — long colored arrow across the world along its emit direction + a fainter ground-projection arrow + ring glyph at the source.
- Point — sphere at Pos + three orthogonal
LightRadiusrings. - Spot — wedge from Pos to a
FovX×FovYrectangle at LightRadius. - Area — rectangle from local right/up.
- Selecting a light in the project tree highlights it in bright yellow (double-click to focus the camera on it).
-
_probe_lights.py <world>— list a world's light rig. -
_probe_render_surfaces.py <world>— dump render-section layout; confirm lit surfaces are 56B (promotable) and whether it's already lightmapped. -
_verify_lm.py <staged_world.World00p>— integrity check + atlas-page PNG. -
build_lightmap_test.py— inject a flat solid-colour test atlas (plumbing check, no bake). -
render_preview.py— pre-bake preview PNGs of the world geometry with every light overlaid (sun arrows with direction + euler, point lights with their falloff radius rings, spot cones drawn to FovX/FovY at LightRadius, area rectangles). Top-down + two side views, painter's-sorted, height-shaded.python render_preview.py --src World.World00p --out images/preview --res 1400 # or pick views: top, side_x, side_z (comma list) python render_preview.py --src World.World00p --views top,side_z -
lm_inspect.py— inspect/debug a baked world's lightmap (decodes the DXT1 atlas, no engine needed). Three modes:# plan view of the baked floor + sun/shadow-cast direction arrow # (sanity-check shadow direction) python lm_inspect.py topdown \ --world output/Worlds/MyMap.World00p \ --lmdir output/Data_Bg/Lightmap/MyMap \ --out images/td.png # dump one surface's atlas tile (autocontrast) to inspect its baked lighting python lm_inspect.py tile \ --world output/Worlds/MyMap.World00p \ --lmdir output/Data_Bg/Lightmap/MyMap \ --surface 5 --out images/surf5.png # per-surface lit-check: mean normal, N·(toward-sun) (lit if >0), mean baked # luminance — flags surfaces that face the sun but baked dark, or vice versa python lm_inspect.py faces \ --world output/Worlds/MyMap.World00p \ --lmdir output/Data_Bg/Lightmap/MyMap
| File | Role |
|---|---|
build_lightmap_bake.py |
main driver: bake → materials → inject → stage |
lightmap_baker.py |
xatlas unwrap + embree bake (pack, raster, soft shadows, AO, denoise, multiprocessing) |
inject_lightmaps.py |
rebuild surfaces to 64B layout (chart-cut verts + reindex) + splice render section |
lightmap_assets.py |
LTMI .Mat00 read/write + DXT1 encode |
world_lights.py |
parse the light rig from the object section (+ quat→euler) |
lm_inspect.py |
inspect/debug a baked lightmap (topdown / tile / faces) |
render_preview.py |
pre-bake top/side PNG previews with light gizmos |
lightmap_studio.py |
GUI front-end (PySide6 + moderngl). Subpackage in studio/. |
studio/ |
GUI subpackage: main_window, viewport, scene, fly_camera, gl_shaders, math3d, textures, bake_runner. |
worldparser/ |
vendored copy of the world00p binary parser (format/model/sections/roundtrip). Source of truth is tools/world00p/. |
Install everything with:
pip install -r requirements.txtThat pulls numpy trimesh embreex xatlas pillow (core baker) plus
PySide6 moderngl (the GUI) — embreex is the fast raycaster, xatlas does
the non-overlapping lightmap unwrap, pillow is for the verify/preview/inspect
PNGs. (If xatlas is missing the baker falls back to a single-plane unwrap,
which only works for genuinely planar surfaces.) If you only use the CLI you can
skip the PySide6/moderngl lines.
The whole bake comes out dark / flat / no sun, but the realtime preview looks
bright. Almost always the world's GI/light-blocker box is sealing out the
sky. Those invisible blocker brushes seal light leaks indoors, but most maps
have one big box over the level that walls off the sun → the whole map bakes to
flat ambient. It's off by default now; only turn it on (--use-blockers,
or the studio's "Use GI blocker box" checkbox) for genuinely sealed indoor
maps. (The realtime preview looks bright because its shadow map is too coarse to
capture the box — it's the bake that's telling the truth.)
Other dark-bake causes, in rough order:
| Symptom | Cause | Fix |
|---|---|---|
| Flat/dark everywhere, ~no sun | GI blocker box (above) | leave --use-blockers OFF |
| Whole map dim but shaded | low --ambient (e.g. --fast uses 0.15) |
use --quality or raise --ambient |
| Only part of the map lit | low sun blocked by terrain/rim | raise the sun's elevation (edit the light's pitch in the studio) |
| Trees cast no shadow | canopy too transparent | lower Foliage transmit (0=solid crisp tree shadows, default) |
| Under trees too dark | canopy fully solid | raise Foliage transmit toward 1 for softer/dappled |
| One specific thing over-shadows | a big occluder | add its material to Exclude occluders (e.g. cliff_5) |
| A few objects render fully black in-game (but fine in the studio) | their base shader had no matching *_lightmap.fx in the engine, so the generated material failed to load |
fixed: variants are now mapped by capability to a shader that actually ships (specular_bump→diffuse_spec_normalmap_lightmap, diffuse_specular→diffuse_spec_lightmap, …). Re-bake any world baked before this fix |
| Colours look wrong/blue-grey | n/a | ambient now auto-tints to the lights — a red light gives a red ambient |
The bake log prints a surface-selection report (what's promoted / skipped / kept-unbaked and why) and an occluder report — check those first when a result surprises you.
- GI/light blocker polys are off by default as occluders (they usually seal
out the sun); enable with
--use-blockersfor sealed indoor maps. The baker warns if an enabled blocker volume looks like a sky-sealing box. - Sky/cloud (
...\Sky\...,Sky_Day*,CloudPlane*) and engineshadowvolumesurfaces are auto-excluded from the occluder set — a finite skybox sits above the level and would otherwise cast a huge fake shadow over the interior (e.g. a central tower baking fully dark). - Each surface is unwrapped into non-overlapping charts (xatlas); vertices are duplicated at chart seams and triangle indices rewritten in place (same order, so the render BSP tree stays valid). A single surface's chart-cut vertex count must stay under 65536 (uint16 indices); the injector errors clearly if not.
- Lightmap atlas binds to the material's
tEmissiveMap(UV2); the base texture stays ontDiffuseMap(UV1). The shader multiplies them, so a black atlas = full shadow. - Each base material is converted to an existing
*_lightmap.fxshader family, chosen by capability (normalmap→diffuse_spec_normalmap_lightmap, env→diffuse_spec_env_lightmap, specular→diffuse_spec_lightmap, elsediffuse_lightmap) so normal/env/spec aren't lost. The engine only ships thediffuse_spec*/diffuse_alpha*/translucent_*lightmap variants — it has nospecular_lightmap/specular_bump_lightmap/diffuse_specular_ lightmap, so the baker never emits those (doing so rendered the surface black in-game). Self-lit / transparent blends (emissive, additive, multiply, glass, water, decal, …) have no opaque lightmap form, so they're kept un-baked (render normally, just no baked shadow).specular.fxmaps to the diffuse-spec family. The base material's properties are carried over;tEmissiveMapis added. Unknown shaders fall back to "base props + emissive". - Point and spot lights do cast shadows (occlusion ray surface→light per
sample). If point-light shadows look absent it's usually the light setup —
high intensity saturating to white, many overlapping lights filling each
other's shadows, or a large soft radius. Tune
--point-boost,--shadow-samples,--light-soft. - Point/spot attenuation uses a smooth windowed falloff
(1-(d/r)²)²(reaches most ofLightRadiusthen rolls off), not a hard(1-d/r)².FovX/FovYare degrees;FarZis a fixed projection field (constant 300 in retail), not the reach — reach isLightRadius. - Spot projection cookies: if a spot has a
Texture(projected cookie/gobo, e.g.Tex\Lights\Light_01_P.dds), the baker samples it and shapes the baked pool to that texture (a circle), so baked light + shadow stay inside the lit area instead of spilling into theFovX×FovYrectangle. Auto-detected; resolved relative to--base-tree. Spots without a texture use the rectangular frustum with a soft edge. (The engine renders the cookie's illumination dynamically but casts no shadow for it — the baker supplies the shadow.) - Hard/blocky "stair-step" light edges are lightmap resolution, not the
falloff:
--fastpacks big floors at low texel density. Use--quality(or raise--max-tile/--pages);lm_inspectcan show units-per-texel. - ~4 workers is the sweet spot (embreex is already internally multithreaded).
MIT © leftspace89







