| Solid viewport before bake | Preview bake | Production bake |
|---|---|---|
![]() |
![]() |
![]() |
Important GPU note: this baker is GPU-bound. Chrome and Edge must have Use graphics acceleration when available enabled, and
chrome://gpushould report WebGL/WebGL2 as hardware accelerated. Launch capture uses installed Chrome and, on Windows, prefers ANGLE D3D11 because that is the backend that selected the NVIDIA RTX GPU in testing. The app reports the renderer it actually received, and launch automation can reject captures from the wrong GPU withBAKER_EXPECT_GPU="RTX 3050" pnpm run capture:launch.
Path-traced lightmap baking with global illumination. In the browser. No Blender. No Unity. No round-trips.
Quick Start • Why This Exists • Features • Examples • Benchmarks • How It Works • Limitations • API • Roadmap
Three.js developers looking for real lightmap baking run into the same gaps:
@react-three/lightmap- built around an older hemicube workflow and no longer tracks modern R3F releases.mem1b/lightbaking- targets pre-BufferGeometry-era Three.js APIs, so it is not a drop-in option for current projects.three-gpu-pathtracer- excellent screen-space path tracing, but it does not publish baked lightmap atlases. Its lightmap-baking request has been open since 2021.- Stack Overflow answers - "just bake in Blender and export." Which is fine until your scene is procedural, user-generated, or assembled at runtime. Then you're stuck.
The result: in 2026, Three.js still lacks a maintained, browser-first lightmap baker focused on path-traced global illumination.
This repo fixes that.
The Three.js ecosystem needs lightmap baking that runs in the browser. Not "export to Blender, bake for 40 minutes, re-import" - actual in-app baking where the user clicks a button and sees the result in seconds.
I looked for a library. There wasn't one. The most complete option (lucas-jones/three-lightmap-baker) had the right architecture but stopped before bounce lighting - no color bleeding, no indirect illumination, no GI.
So I'm building it. This fork keeps the proven two-pass architecture, adds multi-bounce path-traced GI, and focuses on a maintained, documented library.
Every AI coding agent (Claude Code, Cursor, Devin, Copilot Workspace) can build and manipulate 3D scenes programmatically. What they can't do is bake lighting without spawning Blender and writing a Python script to drive it.
A JavaScript-native lightmap baker that runs in the browser today, with a planned headless/Node path, is infrastructure for the agentic wave. An AI agent that assembles a room from a furniture catalog needs to light that room without opening a DCC tool.
If you're building anything where 3D scenes are constructed programmatically - architectural configurators, AI interior design, procedural environments, digital twins - and you need those scenes to look lit, you're the target user.
If you've used Unity's Progressive Lightmapper or Unreal's Lightmass, you'll feel at home. This baker implements the same industry-standard concepts directly in the browser:
- Direct & Indirect (GI): Separate accumulation of direct light and multi-bounce global illumination.
- Bake Presets: Toggle between high-speed Draft bakes and high-fidelity Production bakes.
- Dilation & Padding: Automatic edge-bleeding prevention to eliminate black seams at UV island borders.
- Denoising: Integrated bilateral noise reduction for clean soft shadows and smooth gradients.
- Texel Density: Visualized via checkerboard overlays to ensure consistent lightmap resolution across the scene.
- Path-traced global illumination - real bounce lighting, not screen-space hacks. Red walls bleed red light onto white surfaces. The Cornell Box test passes.
- Auto UV2 unwrapping - drop in any geometry, xatlas generates non-overlapping lightmap UVs automatically. No Blender unwrap step.
- GPU-accelerated BVH ray tracing - powered by three-mesh-bvh. Millions of rays per second in WebGL.
- Multi-bounce - 1-4 configurable bounce depth. Each bounce adds indirect illumination. Energy-conserving (albedo < 1 guarantees convergence).
- Per-triangle material data - albedo and emissive packed into DataTextures, indexed by BVH triangle. Materials are respected during bounces - a red wall reflects red light because the bounce ray reads the wall's albedo.
- Progressive rendering - watch the lightmap converge in real-time. Stop early if quality is acceptable.
- Bake presets - measured on the Cornell advanced scene from Draft through Final, with RTX 3050 Ti numbers listed below.
- Gap flood / edge dilation - prevents black seams at UV island borders.
- Bilateral denoiser - smooths noise while preserving shadow edges, guided by world-position and normal textures.
- 3D Camera Objects - add cameras from the Asset Library, select them in the viewport, and snap your view to match any scene camera's perspective.
- TypeScript - strict mode, fully typed API.
Planned work is tracked in the Roadmap.
# Clone
git clone https://github.com/Ibrahim-3d/three-lightmap-baker.git
cd three-lightmap-baker
# Install
corepack enable
pnpm install
# Run the Cornell Box demo
pnpm run start # opens http://localhost:5173This repo uses pnpm via Corepack. The checked-in pnpm-lock.yaml is the
authoritative dependency lockfile.
Click Bake and watch the lightmap converge. Color bleeding should be visible on the sphere within seconds.
The npm package name is reserved for release, but it is not published yet.
# After the first npm release:
pnpm add three-lightmap-baker
# Until then, install from a generated tarball:
pnpm run build:package
pnpm pack
pnpm add ./three-lightmap-baker-1.0.0.tgzBefore publishing, run the full package gate:
pnpm run release:checkThat command runs typecheck, lint, format check, demo build, package build,
example typecheck, demo bundle budget, tarball import smoke, and
npm publish --dry-run --access public. The final publish still requires an
authenticated npm session. For the first real registry publish, use the manual
npm Publish GitHub Actions workflow after configuring npm trusted publishing
or the NPM_TOKEN repository secret; the workflow verifies the requested
version, reruns pnpm run release:check, and publishes with npm provenance.
If you're working in this repo, the classic baker lives in packages/baker-classic/.
import { LightmapBaker } from 'three-lightmap-baker';
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(512, 512);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x20262f);
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 100);
camera.position.set(2.5, 2, 3);
camera.lookAt(0, 0.5, 0);
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: 0xced4da, roughness: 0.6 }),
);
mesh.position.y = 0.5;
scene.add(mesh);
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(4, 4),
new THREE.MeshStandardMaterial({ color: 0x5c677d, roughness: 0.9 }),
);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
const light = new THREE.DirectionalLight(0xffffff, 2.5);
light.position.set(2, 4, 1);
scene.add(light);
// Bake (clean constructor style)
const baker = new LightmapBaker({
renderer,
samples: 64,
bounces: 2,
resolution: 512,
denoise: true,
});
const result = await baker.bake(scene, {
onProgress: (phase, percent) => {
console.log(`[baker] ${phase}: ${(percent * 100).toFixed(0)}%`);
},
});
// Apply baked lightmaps to scene materials
result.apply();
renderer.render(scene, camera);
// Export lightmaps as PNG files
await result.export('lightmaps/', { format: 'png' });
// Clean up GPU resources when done
result.dispose();Renderer-explicit style is also supported:
const baker = new LightmapBaker(renderer, { samples: 64, bounces: 2, resolution: 512 });
const result = await baker.bake(scene);Advanced automation can pass a renderer adapter when renderer/context setup is owned outside the baker:
import { LightmapBaker, createRendererAdapter } from 'three-lightmap-baker';
const adapter = createRendererAdapter(renderer, { label: 'playwright-chrome' });
const baker = new LightmapBaker({ rendererAdapter: adapter, samples: 64 });See examples/minimal-browser.ts for a copy-pasteable browser scene with a renderer, camera, MeshStandardMaterial geometry, direct light, and both supported constructor styles.
See examples/offscreen-browser.ts for a
compiled automation harness that creates a renderer for OffscreenCanvas when
available, falls back to a detached browser canvas, and passes the baker a
LightmapRendererAdapter. The same non-UI path is covered by
tests/e2e/adapter-runtime.spec.ts.
pnpm run test:adapter-runtimeSee examples/node-headless-status.ts for
a Node-safe capability check. It does not bake in Node yet; it reports the
current runtime, whether the current environment can bake, and the limitations
blocking true Node headless baking.
import { getLightmapRuntimeCapabilities } from 'three-lightmap-baker';
const capabilities = getLightmapRuntimeCapabilities();
console.log(capabilities.runtime, capabilities.canBake);The baker needs access to the underlying Three.js renderer and scene. In R3F, get them from useThree() and run the bake from a user action:
import { useState } from 'react';
import { useThree } from '@react-three/fiber';
import { LightmapBaker } from 'three-lightmap-baker';
export function BakeButton() {
const { gl, scene } = useThree();
const [busy, setBusy] = useState(false);
async function bake() {
setBusy(true);
try {
const baker = new LightmapBaker({
renderer: gl,
resolution: 512,
samples: 64,
bounces: 2,
denoise: true,
});
const result = await baker.bake(scene, {
onProgress: (phase, progress) => {
console.info(`[lightmap] ${phase}: ${Math.round(progress * 100)}%`);
},
});
result.apply();
} finally {
setBusy(false);
}
}
return (
<button type="button" onClick={bake} disabled={busy}>
{busy ? 'Baking...' : 'Bake'}
</button>
);
}Keep the button outside the <Canvas> if you prefer native DOM controls; the important part is that the bake runs after the scene contains real MeshStandardMaterial meshes and a renderer-backed WebGL context.
The current launch proof uses the committed cornell.advanced before/after
captures above plus the measured benchmark table below. A dedicated
interior/architectural scene is still useful as a stronger showcase, but it is
postponed until the custom room is designed. It is not the source of the current
README screenshots, benchmark numbers, or CI visual gate.
Target flow for that future showcase: start with a flat Three.js room/configurator scene, click Bake, show progressive convergence, show the generated atlas, and apply the baked result back onto the same scene.
Measured on Windows with installed Chrome, ANGLE D3D11, and an NVIDIA GeForce RTX 3050 Ti Laptop GPU. Treat these as a reproducible baseline for the cornell.advanced scene, not a universal promise for every model or driver.
Run the local capture and benchmark helper with:
pnpm run capture:launchIt writes launch-artifacts/before-solid-viewport.png, launch-artifacts/after-preview-baked-combined.png, launch-artifacts/after-production-baked-combined.png, launch-artifacts/benchmark.json, and launch-artifacts/benchmark.md. The output directory is ignored by git so you can rerun it on the real launch machine and copy only the numbers/assets you want to publish.
After capture, validate the measured launch benchmark against runtime budgets:
pnpm run budget:runtimeDefaults are conservative for the RTX 3050 Ti launch baseline. Tighten a preset
on a known target machine with BAKER_RUNTIME_BUDGET_DRAFT_MS,
BAKER_RUNTIME_BUDGET_PREVIEW_MS, BAKER_RUNTIME_BUDGET_PRODUCTION_MS, or
BAKER_RUNTIME_BUDGET_FINAL_MS.
For launch numbers, enforce the expected renderer so a dual-GPU laptop cannot accidentally publish integrated-GPU results:
BAKER_EXPECT_GPU="RTX 3050" pnpm run capture:launchOn Windows PowerShell:
$env:BAKER_EXPECT_GPU="RTX 3050"; pnpm run capture:launchThe capture helper uses installed Chrome by default. To intentionally use Playwright's bundled Chromium instead, set BAKER_CAPTURE_BROWSER_CHANNEL=bundled.
On Windows the helper tries ANGLE backends in this order when BAKER_EXPECT_GPU is set: d3d11, d3d11on12, then gl. Override that list when debugging:
$env:BAKER_CAPTURE_ANGLE="d3d11,d3d11on12,gl"; $env:BAKER_EXPECT_GPU="RTX 3050"; pnpm run capture:launch| Device | Scene | Preset | Resolution | Samples | Bounces | Denoise | Bake Time |
|---|---|---|---|---|---|---|---|
| RTX 3050 Ti Laptop GPU | cornell.advanced | Draft | 256px | 4 x 32 frames (128 spp) | 2 | Off | 4.63s |
| RTX 3050 Ti Laptop GPU | cornell.advanced | Preview | 512px | 5 x 96 frames (480 spp) | 2 | Off | 5.05s |
| RTX 3050 Ti Laptop GPU | cornell.advanced | Production | 1024px | 6 x 256 frames (1536 spp) | 2 | Off | 36.48s |
| RTX 3050 Ti Laptop GPU | cornell.advanced | Final | 2048px | 8 x 512 frames (4096 spp) | 2 | Off | 408.7s |
The script captures one solid 3D before image, then Preview and Production baked images. Use the Production image as the main after asset and keep the Preview image for visual preset comparison.
Blender is still the right tool when your scene is authored offline, your assets are stable, and you can afford a DCC round-trip. This project targets the cases where that workflow breaks down:
- Procedural scenes assembled at runtime.
- Product configurators where users change layouts, finishes, or lights in the browser.
- AI-generated or agent-authored 3D spaces.
- Web architectural visualization that needs an in-app “Bake” button.
- Pipelines where the source of truth is Three.js, not a
.blendfile.
The goal is not to replace offline production renderers. The goal is browser-native baked lighting for dynamic Three.js workflows.
Two-pass architecture. No vertex shader hacks. No fighting the Three.js renderer.
Each mesh's geometry is rendered with gl_Position = uv2 * 2.0 - 1.0, projecting triangles into their lightmap UV layout. Two textures are generated:
| Texture | Contents | Purpose |
|---|---|---|
| Position map | World-space XYZ per texel (RGB = position) | Ray origins for Pass 2 |
| Normal map | World-space normal per texel (RGB = normal) | Ray directions for Pass 2 |
These textures are a 2D lookup table: for any texel coordinate, you can read the corresponding world position and surface normal.
Position map (left) and normal map (right) for a Cornell Box - each texel encodes a world-space coordinate.
For each texel in the position/normal maps:
Read worldPos and worldNormal from textures
For each sample:
Generate cosine-weighted hemisphere direction around worldNormal
Trace ray from worldPos into scene BVH
If ray hits a surface:
Read hit surface's emissive color → add to radiance (direct)
Read hit surface's albedo color
// Bounce: trace again from hit point
Generate new direction around hit normal
Trace bounce ray into BVH
If bounce hits emissive surface:
radiance += hitAlbedo × bounceEmissive (indirect GI)
// More bounces if configured (2, 3, 4...)
Accumulate radiance into lightmap texel
Divide accumulated radiance by sample count → final lightmap
The BVH acceleration structure (from three-mesh-bvh) makes this fast - millions of ray queries per second on a mid-range GPU.
The naive approach (what I tried first and burned 4 hours debugging) is to do everything in a single shader: override gl_Position to UV space AND trace rays in the same fragment shader. This fights Three.js at every level - modelMatrix propagation breaks, render target state leaks between frames, matrixWorldNeedsUpdate doesn't fire. 12 integration bugs later, it still didn't work.
The two-pass approach treats the renderer normally. Pass 1 uses the GPU for what it's good at (rasterization). Pass 2 uses the BVH for what it's good at (ray queries). No renderer hacks. No matrix bugs. It just works.
| Project | Last Updated | GI Bounces | Status |
|---|---|---|---|
| This repo | Active | ✅ 1-4 bounces | ✅ Maintained |
| lucas-jones/three-lightmap-baker | 2023 | ❌ Direct only | Archived (this fork continues it) |
| @react-three/lightmap | 2022-era package | ❌ Hemicube workflow | Not current with modern R3F |
| mem1b/lightbaking | Older Three.js era | ✅ Has bounces | Uses legacy Three.js APIs |
| three-gpu-pathtracer #5 | Issue open since 2021 | n/a | Renderer, not a lightmap exporter |
| PlayCanvas lightmapper | Active | ❌ Direct + AO focused | Engine-integrated, not a Three.js package |
Measured on the cornell.advanced scene only:
| Preset | Samples | Bounces | Resolution | Measured Time (RTX 3050 Ti) |
|---|---|---|---|---|
| Draft | 4 x 32 frames (128 spp) | 2 | 256px | 4.63s |
| Preview | 5 x 96 frames (480 spp) | 2 | 512px | 5.05s |
| Production | 6 x 256 frames (1536 spp) | 2 | 1024px | 36.48s |
| Final | 8 x 512 frames (4096 spp) | 2 | 2048px | 408.7s |
The playground File menu can save and load a versioned Project JSON file. The current v1 format round-trips the active built-in scene preset or imported GLB/glTF payload, bake/editor options, and asset-library additions with transforms and basic material values. It also persists baked final lightmaps as raw Float32 RGBA atlas payloads, so a saved project can restore baked lighting without forcing an immediate re-bake. Export a finished scene as GLB when you need to carry baked geometry/materials out of the demo.
const baker = new LightmapBaker(renderer, options?: BakeOptions);
// or:
const baker = new LightmapBaker({ renderer, ...options });| Option | Type | Default | Description |
|---|---|---|---|
samples |
number |
64 |
Rays per texel. More = less noise, longer bake. |
bounces |
number |
2 |
Indirect light bounces. 1 = direct + one bounce. 4 = max. |
resolution |
number |
512 |
Lightmap texture size (square). |
denoise |
boolean |
true |
Apply bilateral denoiser after bake. |
dilatePx |
number |
4 |
Edge dilation passes to prevent UV seam artifacts. |
superSample |
number |
1 |
Bake at resolution × superSample, then downscale. |
const result = await baker.bake(scene, {
onProgress: (phase: string, percent: number) => void,
});Returns a BakeResult:
| Property | Type | Description |
|---|---|---|
lightmaps |
Map<Mesh, Texture> |
Per-mesh lightmap textures |
groups |
BakeGroupView[] |
Per-atlas internals (direct/indirect/ao/composite/position/normal) |
bvh |
MeshBVH |
Shared BVH built for the bake |
stats |
object |
Mesh count, texel count, rays traced, and per-phase durations |
Sets mesh.material.lightMap for each baked mesh. Three.js samples lightMap from UV2 automatically.
Exports lightmap textures as PNG (LDR) or EXR (HDR).
Releases all GPU resources (textures, render targets).
refreshAO({ intensity, exponent, enabled })applies AO look changes instantly.rebakeAO({ samples, distance, targetSamples })recomputes AO rays without re-running the full GI bake.
- Verified against the current codebase: these are real current limits, not marketing disclaimers.
- Browser/WebGL renderer required. True Node.js headless baking is planned but not shipped.
- Requires WebGL 2 and
EXT_color_buffer_floatfor HDR accumulation targets. result.export()triggers browser downloads; it does not write directly to arbitrary filesystem paths.- Large atlases, high sample counts, and many bounces can still hit browser/GPU timeout behavior on weaker hardware.
- Light/material coverage is focused on
MeshStandardMaterial-style surfaces, emissive contribution, direct light collection, AO, and GI bounces. Advanced production lighting such as IES profiles, textured area lights, and full material parity remain roadmap items. - Auto UV2 unwrapping is designed to remove the Blender unwrap step, but pathological geometry can still need cleanup or manual UVs.
- Playwright or other automated browser captures must record the actual WebGL renderer and should enforce the expected device with
BAKER_EXPECT_GPU. Chromium GPU flags improve the odds of hardware acceleration, but they do not override OS/driver GPU assignment on every machine. - The current public package is prepared for npm release but is not published until the first release is cut.
- Browser + injected renderer: implemented now.
- Renderer adapter boundary: implemented for browser/offscreen-browser test harness ownership of renderer setup and context-loss wiring.
- Offscreen-browser example: implemented in
examples/offscreen-browser.tsand covered bypnpm run typecheck:examples. - Runtime capability probe: implemented as
getLightmapRuntimeCapabilities()with a Node-safe example inexamples/node-headless-status.ts. Node currently reportscanBake: false. - Browser smoke suite: implemented as
pnpm run test:browser-smokeand wired into CI. It covers adapter runtime, Cornell visual bake, bake cancellation, Project JSON save/load, outliner selection, editor history, asset-library, and topbar/menu controls. Targeted scripts such aspnpm run test:adapter-runtime,pnpm run test:visual-cornell, andpnpm run test:bake-cancelremain available for focused runs. - PR preview artifact: pull requests build the demo and upload a static
dist/artifact for review without changing the production Pages deploy. - npm publish workflow: implemented in
.github/workflows/npm-publish.ymlas a manual workflow with version confirmation, dry-run mode, and npm provenance support. - Custom-room visual regression: postponed until the custom room/showcase scene exists.
- Node.js true headless baking: not implemented yet.
- Current pipeline depends on WebGL renderer/context, RAF-driven progressive passes, and browser-side texture export/download paths.
- Planned direction: keep renderer-injected API stable and prototype a Node-compatible runtime path.
- Three.js r161 (see
package.json) - WebGL 2 with
EXT_color_buffer_float(required for HDR lightmap accumulation) - Browser/renderer context required - this release is WebGL-first (Node headless adapter is planned, not shipped)
- GPU: any discrete GPU from the last 5 years. Intel/AMD integrated GPUs work but bake slower - the library auto-detects and warns.
This repo is a fork of lucas-jones/three-lightmap-baker, which established the two-pass architecture and proved it works. The bounce lighting, material system, denoiser, and API are new.
Built on top of:
- three-mesh-bvh by Garrett Johnson - GPU-accelerated BVH that makes browser ray tracing possible
- three-gpu-pathtracer by Garrett Johnson - reference implementation for path tracing in Three.js
- xatlas-three - browser-native UV unwrapping via xatlas WASM
- The original iq/Jaume Sanchez hemicube GI demo - proof that browser GI is possible, even on a phone
This is actively developed. Contributions welcome - especially:
- Test scenes - complex interiors, outdoor scenes, and edge cases beyond the current Cornell advanced launch proof
- Performance benchmarks - bake times across additional GPU generations
- Bug reports - screenshots + GPU info + sample count + resolution
- Lighting coverage - IES profiles, textured area lights, light probes, and material edge cases
Open an issue before starting a PR so we can coordinate. All contributions require signing the CLA via CLA Assistant.
See docs/ROADMAP.md for current milestones and
priorities.
MIT - same as the original lucas-jones repo.
Built by a designer who got tired of exporting to Blender at 2am.


