// Lightmap baking — a Meep example.
//
// Sections:
// §1 Tunables scene palette + default bake settings
// §2 Engine bootstrap systems, camera; no engine lights (we place our own)
// §3 Scene ground, two walls, a handful of shapes
// §4 Lights a single warm sun (realtime + baked)
// §5 Bake bake_lightmap_for_scene on the engine executor
// §6 UI settings buttons, intensity slider, HUD, fps
//
// `bake_lightmap_for_scene` path-traces INDIRECT illumination — light that has
// bounced off surfaces — into one shared lightmap atlas covering every
// ShadedGeometry in the scene. It's mixed-mode lighting: the sun keeps
// rendering in realtime, the baked map adds everything it can't do — colored
// bleed from the red wall, bounce fill in the shadows, soft contact darkening.
// The bake's background sampler is overridden to black (no sky), so every
// photon in the lightmap traces back to the sun via at least one bounce.
//
// The bake runs as a Task on the engine's shared executor, budgeted in ray
// batches so it never hitches the frame; the atlas texture is re-uploaded each
// cycle, so you can watch the lighting pour in live.
// ─── Imports ────────────────────────────────────────────────────────────────
import {
BoxGeometry,
MeshStandardMaterial,
SphereGeometry,
TorusKnotGeometry,
} from "three";
import Quaternion from "@woosh/meep-engine/src/core/geom/Quaternion.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import { Light } from "@woosh/meep-engine/src/engine/graphics/ecs/light/Light.js";
import { LightType } from "@woosh/meep-engine/src/engine/graphics/ecs/light/LightType.js";
import LightSystem from "@woosh/meep-engine/src/engine/graphics/ecs/light/LightSystem.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js";
import {
bake_lightmap_for_scene
} from "@woosh/meep-engine/src/engine/graphics/sh3/lightmap/bake_lightmap_for_scene.js";
// ─── §1 Tunables ───────────────────────────────────────────────────────────
const COLOR_GROUND = 0xa8a8a8;
const COLOR_WALL = 0xe8e8e8; // white — catches colored bounce
const COLOR_RED = 0xd8352b; // red wall — the classic Cornell bleed source
const COLOR_GREEN = 0x3bd16f;
const COLOR_BLUE = 0x4f86ff;
const COLOR_YELLOW = 0xf0c020;
// Bake settings driven by the UI. Defaults are tuned to finish in a few
// seconds; crank them up (1024² / 32 samples / 6 bounces) for a cleaner map.
const settings = {
resolution: 512, // square lightmap atlas, texels
samples: 8, // minimum hemisphere samples per texel (adaptive up to 4×)
bounces: 3, // path-tracer maximum depth
};
// ─── §2 Engine bootstrap ───────────────────────────────────────────────────
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
// Draws every entity carrying a ShadedGeometry component.
config.addSystem(new ShadedGeometrySystem(engine));
// Turns Light components into realtime three.js lights + shadow maps.
config.addSystem(new LightSystem(engine, { shadowResolution: 2048 }));
},
});
// Camera + orbital controls. No terrain (we build our own ground) and no
// engine lights (we place our own below, so the path tracer and the realtime
// renderer see exactly the same set).
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: false,
focus: new Vector3(0, 1.5, 0),
pitch: 0.7,
yaw: -2.1, // looking down-sun: walls ahead in shadow, their bounce facing us
distance: 20,
showFps: false,
});
const ecd = engine.entityManager.dataset;
// ─── §3 Scene ──────────────────────────────────────────────────────────────
/**
* Add one colored {@link ShadedGeometry} entity. Fully-rough, non-metallic
* materials make the bounced light easy to read. ShadedGeometry defaults to
* Visible + CastShadow + ReceiveShadow, so no flags to set.
*
* @param {THREE.BufferGeometry} geometry
* @param {number} color hex
* @param {Vector3} position
* @param {Quaternion} [rotation]
*/
function spawn(geometry, color, position, rotation) {
const material = new MeshStandardMaterial({ color, roughness: 1, metalness: 0 });
const transform = new Transform();
transform.position.copy(position);
if (rotation !== undefined) {
transform.rotation.copy(rotation);
}
new Entity()
.add(ShadedGeometry.from(geometry, material))
.add(transform)
.build(ecd);
}
// Ground platform.
spawn(new BoxGeometry(18, 1, 18), COLOR_GROUND, new Vector3(0, -0.5, 0));
// Two walls forming an open corner: a white one to catch colored bounce, a
// red one to produce it (watch the white box next to it pick up a pink tint
// once the bake lands).
spawn(new BoxGeometry(18, 6, 0.6), COLOR_WALL, new Vector3(0, 3, -8.7));
spawn(new BoxGeometry(0.6, 6, 18), COLOR_RED, new Vector3(-8.7, 3, 0));
// A few shapes to bounce light between.
spawn(new BoxGeometry(2.5, 2.5, 2.5), COLOR_WALL, new Vector3(-5.5, 1.25, 0.5));
spawn(new BoxGeometry(2, 4, 2), COLOR_GREEN, new Vector3(3.5, 2, 1.5));
spawn(new SphereGeometry(1.5, 32, 24), COLOR_BLUE, new Vector3(0, 1.5, 4));
spawn(
new BoxGeometry(2.5, 2.5, 2.5),
COLOR_YELLOW,
new Vector3(2.5, 1.25, -4),
Quaternion.fromEulerAngles(0, Math.PI / 5, 0)
);
spawn(new TorusKnotGeometry(1, 0.35, 64, 12), COLOR_WALL, new Vector3(-4, 1.6, -4));
// ─── §4 Lights ─────────────────────────────────────────────────────────────
/**
* Add a Light entity. The same component drives both the realtime renderer
* (via LightSystem) and the bake — the path tracer reads DIRECTION and POINT
* lights straight out of the ECS, so baked bounce always matches the realtime
* direct light.
*
* @param {number} type one of {@link LightType}
* @param {number[]} color linear [r, g, b] in 0..1
* @param {number} intensity
* @param {Object} placement
* @param {Vector3} [placement.position]
* @param {Vector3} [placement.direction]
*/
function spawn_light(type, color, intensity, { position, direction }) {
const light = new Light();
light.type.set(type);
light.color.setRGB(color[0], color[1], color[2]);
light.intensity.set(intensity);
light.castShadow.set(true);
const transform = new Transform();
if (position !== undefined) {
transform.position.copy(position);
}
if (direction !== undefined) {
transform.rotation.lookRotation(direction.clone().normalize());
}
new Entity()
.add(light)
.add(transform)
.build(ecd);
}
// A single warm sun with crisp realtime shadows — the scene's ONLY light.
// There is no ambient light and the bake's background is black, so before the
// bake finishes everything in shadow is pitch black; afterwards, everything
// you can see in the shadows is bounced sunlight from the baked map. The sun
// shines from behind the wall corner toward the camera, putting both walls'
// inner faces in shadow — lit purely by bounce off the bright floor.
spawn_light(LightType.DIRECTION, [1.0, 0.95, 0.88], 1.6, {
position: new Vector3(30, 70, 30),
direction: new Vector3(1, -1, 1),
});
// ─── §5 Bake ───────────────────────────────────────────────────────────────
const bakeStatusEl = document.getElementById("bake-status");
const atlasEl = document.getElementById("atlas");
const intensityEl = document.getElementById("intensity");
let activeGroup = null; // TaskGroup of the in-flight / latest bake
let staleTexture = null; // previous atlas, disposed once the new bake takes over
function startBake() {
// A bake is a one-shot TaskGroup; re-baking means removing the old one
// from the executor (if it's still running) and starting a fresh group.
if (activeGroup !== null) {
engine.executor.removeGroup(activeGroup);
staleTexture = activeGroup.lightmap.texture;
}
const startedAt = performance.now();
const group = bake_lightmap_for_scene({
ecd,
lightmap_resolution: settings.resolution,
samples_per_texel: settings.samples,
max_samples: settings.samples * 4,
min_bounce: 1,
max_bounce: settings.bounces,
padding: 2,
light_map_intensity: parseFloat(intensityEl.value),
// Black environment (the default is a sky gradient): rays that escape
// the scene contribute nothing, so the baked map is pure sun bounce.
background(out, out_offset) {
out[out_offset] = 0;
out[out_offset + 1] = 0;
out[out_offset + 2] = 0;
}
});
activeGroup = group;
// expose for console poking: group.lightmap = { texture, sampler, scene }
window.lightmap_bake = group;
// The engine's shared executor time-slices the bake across frames.
engine.executor.runGroup(group);
group.on.completed.add(() => {
const seconds = (performance.now() - startedAt) / 1000;
bakeStatusEl.textContent = `done in ${seconds.toFixed(1)}s`;
});
atlasEl.textContent = `${settings.resolution}²`;
// Progress readout. The first cycle runs the initializer (UV charting +
// atlas packing); once past it, the previous bake's atlas texture is no
// longer referenced by any material and can be disposed.
function poll() {
if (group !== activeGroup) {
return; // superseded by a newer bake
}
const p = group.computeProgress();
if (p > 0 && staleTexture !== null) {
staleTexture.dispose();
staleTexture = null;
}
if (p < 1) {
bakeStatusEl.textContent = `baking ${(p * 100).toFixed(0)}%`;
requestAnimationFrame(poll);
}
}
bakeStatusEl.textContent = "preparing";
requestAnimationFrame(poll);
}
// Give the ECS one frame to settle transforms, then bake with the defaults.
requestAnimationFrame(startBake);
// ─── §6 UI ─────────────────────────────────────────────────────────────────
// Option-button groups (atlas resolution / samples / bounces).
/**
* @param {string} id container element id
* @param {function(number):void} apply receives the clicked option's value
*/
function wireOptions(id, apply) {
const container = document.getElementById(id);
container.addEventListener("click", (e) => {
const btn = e.target.closest(".opt-btn");
if (btn === null) {
return;
}
for (const other of container.querySelectorAll(".opt-btn")) {
other.classList.toggle("active", other === btn);
}
apply(parseInt(btn.dataset.value, 10));
});
}
wireOptions("opt-resolution", (v) => {
settings.resolution = v;
});
wireOptions("opt-samples", (v) => {
settings.samples = v;
});
wireOptions("opt-bounces", (v) => {
settings.bounces = v;
});
document.getElementById("bake-btn").addEventListener("click", startBake);
// Indirect-intensity slider: lightMapIntensity is a per-material uniform, so
// scrubbing it is free — a live before/after comparison of the baked light.
const intensityReadout = document.getElementById("intensity-readout");
intensityEl.addEventListener("input", () => {
const v = parseFloat(intensityEl.value);
intensityReadout.textContent = v.toFixed(2);
ecd.traverseEntities([ShadedGeometry], (sg) => {
const material = sg.material;
if (material !== null && material !== undefined && material.lightMap !== null) {
material.lightMapIntensity = v;
}
});
});
// FPS counter.
const fpsEl = document.getElementById("fps");
let fpsWindow = 0;
let fpsFrames = 0;
let lastFrameMs = performance.now();
engine.graphics.on.postRender.add(() => {
const nowMs = performance.now();
const dt = (nowMs - lastFrameMs) / 1000;
lastFrameMs = nowMs;
fpsWindow += dt;
fpsFrames++;
if (fpsWindow >= 0.5) {
fpsEl.textContent = (fpsFrames / fpsWindow).toFixed(0);
fpsWindow = 0;
fpsFrames = 0;
}
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lightmap baking · Meep</title>
<meta name="robots" content="noindex">
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
width: 100%; height: 100%;
overflow: hidden;
background: #07090c;
color: #e6edf3;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.panel {
position: fixed;
z-index: 1000;
background: rgba(7, 9, 12, 0.72);
border: 1px solid #1f2731;
border-radius: 10px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 12px 32px rgba(0,0,0,0.4);
}
.hud {
top: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.82rem;
line-height: 1.7;
color: #9aa5b1;
min-width: 210px;
}
.hud .label {
color: #6b7785;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.65rem;
margin-right: 0.5rem;
}
.hud .value { color: #4ef0a8; }
.legend {
bottom: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-size: 0.82rem;
line-height: 1.55;
max-width: 400px;
color: #9aa5b1;
}
.legend strong { color: #e6edf3; }
.legend code {
font-family: ui-monospace, monospace;
font-size: 0.78em;
color: #4ef0a8;
}
.legend kbd {
font-family: ui-monospace, monospace;
font-size: 0.78em;
color: #e6edf3;
background: #11161d;
border: 1px solid #2a3441;
border-bottom-width: 2px;
border-radius: 3px;
padding: 0.05em 0.4em;
}
/* Bake settings, top-right */
.controls {
top: 1rem; right: 1rem;
padding: 0.9rem 1rem;
width: 240px;
font-size: 0.82rem;
color: #9aa5b1;
}
.controls .group { margin-bottom: 0.7rem; }
.controls .group-label {
color: #6b7785;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.65rem;
margin-bottom: 0.35rem;
}
.controls .options {
display: flex;
gap: 0.3rem;
}
.opt-btn {
flex: 1;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.75rem;
color: #cfd6df;
background: transparent;
border: 1px solid #2a3441;
border-radius: 6px;
padding: 0.35rem 0;
cursor: pointer;
transition: color 120ms, border-color 120ms, background 120ms;
}
.opt-btn:hover {
color: #e6edf3;
border-color: #4ef0a8;
background: rgba(78,240,168,0.08);
}
.opt-btn.active {
color: #07090c;
background: #4ef0a8;
border-color: #4ef0a8;
}
.controls input[type="range"] {
width: 100%;
accent-color: #4ef0a8;
cursor: pointer;
}
.bake-btn {
width: 100%;
margin-top: 0.2rem;
padding: 0.6rem 0;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.02em;
color: #03110a;
background: #4ef0a8;
border: none;
border-radius: 8px;
cursor: pointer;
}
.bake-btn:hover { background: #2dd185; }
.bake-btn:active { transform: translateY(1px); }
</style>
</head>
<body>
<div class="panel hud">
<div><span class="label">fps</span> <span class="value" id="fps">--</span></div>
<div><span class="label">atlas</span> <span class="value" id="atlas">--</span></div>
<div><span class="label">bake</span> <span class="value" id="bake-status">--</span></div>
</div>
<div class="panel controls">
<div class="group">
<div class="group-label">Atlas resolution</div>
<div class="options" id="opt-resolution">
<button class="opt-btn" data-value="256">256²</button>
<button class="opt-btn active" data-value="512">512²</button>
<button class="opt-btn" data-value="1024">1024²</button>
</div>
</div>
<div class="group">
<div class="group-label">Samples / texel</div>
<div class="options" id="opt-samples">
<button class="opt-btn active" data-value="8">8</button>
<button class="opt-btn" data-value="16">16</button>
<button class="opt-btn" data-value="32">32</button>
</div>
</div>
<div class="group">
<div class="group-label">Light bounces</div>
<div class="options" id="opt-bounces">
<button class="opt-btn" data-value="2">2</button>
<button class="opt-btn active" data-value="3">3</button>
<button class="opt-btn" data-value="6">6</button>
</div>
</div>
<div class="group">
<div class="group-label">Indirect intensity <span id="intensity-readout">1.0</span></div>
<input type="range" id="intensity" min="0" max="2" step="0.05" value="1">
</div>
<button class="bake-btn" id="bake-btn">Re-bake</button>
</div>
<div class="panel legend">
A path tracer bakes <strong>indirect illumination</strong> into one shared
<strong>lightmap atlas</strong> for every <code>ShadedGeometry</code> in the
scene, incrementally, without hitching the frame. The only light is a single
realtime sun and the environment is black, so everything you see in shadow —
the walls' inner faces, the red bleed onto the white box — is
<strong>baked sun bounce</strong>, filling in as the bake progresses.
Drag the <strong>indirect intensity</strong> slider to compare before/after.
<br>
<kbd>drag</kbd> orbit · <kbd>scroll</kbd> zoom
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"title": "Lightmap baking",
"description": "Path-traced global illumination baked into a shared lightmap atlas, live in the browser. A single realtime sun, black environment — every shadow is filled purely by baked bounce. Watch the atlas fill in, then re-bake with different quality settings.",
"category": "Rendering",
"status": "live",
"order": 5,
"tags": ["rendering", "lightmap", "gi", "global-illumination", "path-tracing", "baking", "ecs"],
"sourceHint": "examples-src/lightmap-baking/",
"demoUrl": "/examples/lightmap-baking/demo.html",
"defaultFile": "src/main.js"
}
{
"name": "@meep-examples/lightmap-baking",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Path-traced indirect lightmap baking: watch a shared GI atlas fill in live, with bake quality knobs exposed in the UI.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@woosh/meep-engine": "2.170.0",
"three": "0.136.0"
},
"devDependencies": {
"@rollup/plugin-strip": "^3.0.4",
"vite": "^8.0.13"
}
}
# lightmap-baking
Path-traced global illumination, baked into a single shared lightmap atlas —
live, in the browser, without hitching the frame. Direct light (a single warm
sun) stays realtime; `bake_lightmap_for_scene` adds everything realtime
lighting can't: bounce fill in the shadows, red bleed off the wall, soft
contact darkening.
There is deliberately **no ambient light**, and the bake's environment sampler
is overridden to **black** (the default is a sky gradient) — so every photon in
the lightmap traces back to the sun via at least one bounce. Before the bake
lands, everything in shadow is pitch black; the camera looks straight down-sun,
so the whole shadowed side of the scene fills in as the atlas bakes. Scrub the
*indirect intensity* slider afterwards for a before/after.
## Run locally
```bash
npm install
npm run dev
```
## Build
```bash
npm run build
```
Output goes to `../../public/examples/lightmap-baking/demo.html`.
## What this demonstrates
- `bake_lightmap_for_scene({ ecd, ... })` — one call charts every
`ShadedGeometry` into UV charts, packs them into one shared atlas, swaps in
re-charted geometry carrying a `uv2` channel, points every material at the
atlas texture, and path-traces the indirect light texel by texel
- Mixed-mode lighting — the same ECS `Light` component drives both the
realtime renderer (`LightSystem`) and the path tracer, so baked bounce
always matches the realtime direct light
- Overriding the bake's `background` environment sampler (black here, to
isolate pure sun bounce; the default is a sky gradient)
- The bake is a `TaskGroup` on the engine's shared `ConcurrentExecutor`,
budgeted in ray batches per cycle — the partial atlas is re-uploaded every
cycle for a live preview, and `computeProgress()` drives the HUD readout
- Re-baking with different quality settings: remove the old group from the
executor, start a fresh one, dispose the superseded atlas texture
- `material.lightMapIntensity` as a free, live indirect-light dimmer
## Bake settings exposed in the UI
| Setting | Options | Maps to |
| --- | --- | --- |
| Atlas resolution | 256² / 512² / 1024² | `lightmap_resolution` |
| Samples / texel | 8 / 16 / 32 | `samples_per_texel` (adaptive up to 4×, via `max_samples`) |
| Light bounces | 2 / 3 / 6 | `max_bounce` |
| Indirect intensity | 0–2 | `material.lightMapIntensity` |
Defaults (512² / 8 / 3) finish in a few seconds; the maximums produce a much
cleaner map if you're willing to wait.
import { defineConfig } from "vite";
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import strip from "@rollup/plugin-strip";
const __dirname = dirname(fileURLToPath(import.meta.url));
const outDir = resolve(__dirname, "../../public/examples/lightmap-baking");
// See examples-src/entity-stress-test/vite.config.js for the rationale on
// base: "./" and emptyOutDir: false.
export default defineConfig({
plugins: [
{
// Copy the committed source thumbnail + meta into the generated gallery
// folder. public/examples/<id>/ is build output (gitignored); these are
// kept in source here and copied through on every build so the gallery
// (src/data/examples.ts) can resolve them.
name: "copy-gallery-assets",
apply: "build",
closeBundle() {
mkdirSync(outDir, { recursive: true });
for (const name of ["thumbnail.png", "meta.json"]) {
const src = resolve(__dirname, name);
if (existsSync(src)) {
copyFileSync(src, resolve(outDir, name));
}
}
},
},
],
base: "./",
build: {
outDir,
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// this will remove all assert statements from the production build
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});