Rendering

Global illumination

Shade's indirect lighting - the IBL default, the Brick4 sparse volumetric lightmap and its bake, and the VolumetricLightMap component that carries one.

Shade always applies an indirect term. There is no “GI off” switch: the deferred resolve and the forward transparency pass both ask for indirect lighting every frame, and the only question is where the answer comes from. That is one setting on the renderer.

import { ShadeIndirectLightingMode } from "@woosh/meep-engine/src/shade/renderer/ShadeIndirectLightingMode.js";

engine.graphics.renderer.indirect_lighting_mode = ShadeIndirectLightingMode.Brick4;

engine.graphics is the GraphicsEngine facade; .renderer is the escape hatch to Shade’s Renderer itself, and it is null before Engine.start() and again after stop(). Set the mode once the engine is running.

ModeValueWhat produces the indirect lightWhat you author
IBL0the environment map, convolved into irradiance and roughness mips. The default.an environment map - see Sky & environment
Brick41a baked sparse volumetric lightmap, sampled in world spacerun the bake, ship the bytes, carry them on a VolumetricLightMap
LPV2a tetrahedral light probe volume, refreshed on the GPU each framenothing supported - see below

The enum is a named export and the values above are the literal ones, so indirect_lighting_mode is safe to serialize as a number.


A scene with no environment renders unlit

This catches people before any of the modes matter. GraphicsEngine.set_scene fills in an environment when the scene arrives without one:

if (scene.lights.environment === undefined) {
    scene.lights.environment = make_default_environment();
}

The comment beside it states the reason plainly: Shade lights with indirect by default, and a scene without an environment renders unlit - turning global illumination off is the thing that takes a decision, not turning it on. make_default_environment() (@woosh/meep-engine/src/engine/graphics3/make_default_environment.js) generates a small 128px octahedral map of a plausible outdoor sky, deliberately not an atmosphere solve.

A scene that arrives with an environment keeps it - set_scene only fills in what is missing. And the environment is not only the IBL source: the background pass samples it in every mode, so a Brick4 or LPV scene still needs one for the sky behind the geometry.


Brick4 - the sparse volumetric lightmap

Brick4 is the technique meep 3 actually ships for baked indirect light. It is a tree of nodes over the scene’s bounds, each node carrying a 4x4x4 grid of probes (BRICK4_PROBE_RESOLUTION), branching 3 per axis (BRICK4_BRANCH_FACTOR, so 27 children per node). Probes hold second-order spherical harmonic colour packed into 28 bytes each, and adjacent nodes share the probes on their shared faces.

What makes it sparse is the expansion rule: a child cell only becomes a candidate for subdivision if it overlaps scene geometry, tested against a BVH over the scene. Empty air is never refined. Expansion runs shallowest-first and stops on whichever comes first - probe spacing that would fall below the requested cell_size, or an estimated GPU footprint that would exceed the memory budget. Density therefore follows surfaces, and the budget is a real ceiling rather than a hint.

brick4_bake_for_scenelightmap.svlm downloadedship the bytes with the levelVolumetricLightMap.data = bytesVolumetricLightMapSystem uploads itindirect_lighting_modeindirect lighting reads the mapthe map is never readBrick4IBL or LPV

Baking

import { brick4_bake_for_scene }
    from "@woosh/meep-engine/src/shade/renderer/global_illumination/brick4/cpu/brick4_bake_for_scene.js";
import { MEGABYTE } from "@woosh/meep-engine/src/core/science/units/memory/MEGABYTE.js";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";

const { tree, binary } = await brick4_bake_for_scene({
    scene: EngineHarness.shadeScene(engine),
    renderer: engine.graphics.renderer,
    cell_size: 0.5,
    max_memory_usage_bytes: 16 * MEGABYTE
});
ParameterDefaultMeaning
scenerequiredthe Shade Scene, not the ECS dataset. EngineHarness.shadeScene(engine) is the one the harness’s systems draw into.
rendererrequiredShade’s Renderer - engine.graphics.renderer
cell_size0.5smallest cell in world units. Capped, never coarsened: the value used is min(cell_size, largest_scene_dimension / 32), so a small scene silently gets a finer grid than asked for.
max_memory_usage_bytes16 * MEGABYTEceiling on the estimated GPU footprint of the whole structure, probe data included

It resolves to { tree, binary } - the intermediate CPU tree, and the ArrayBuffer in brick4’s GPU format. Four things about the call are worth knowing before you wire it into anything:

  • It is asynchronous and needs a live device. The tree is built on the CPU; the probes are baked on the GPU. Bake after Engine.start(), never before.
  • It downloads a file, unconditionally. The last thing it does before uploading is downloadAsFile(binary, "lightmap.svlm"). There is no flag to suppress it. This is an authoring tool and belongs in a tools build or a dev-only route, not anywhere a player can reach.
  • Nothing loads .svlm for you. That extension appears exactly once in the engine - in the download call above. There is no asset loader for it and no GameAssetType. Reading the bytes back is the application’s job; an ArrayBufferLoader fetch (GameAssetType.ArrayBuffer) or a plain fetch(...).arrayBuffer() is all it takes.
  • It uploads the result itself. Before returning, it pushes binary straight into the scene’s lightmap buffer, so the picture changes the moment the bake finishes, with no component involved. VolumetricLightMapSystem will not undo that upload until a claim on the scene actually changes.

The JSDoc is candid about cost: “Requires a fairly beefy GPU to run, confirmed to work on RTX 4090.” Treat it as an offline step run on a workstation, not something a game does at load time.

Carrying a lightmap at runtime

A scene has exactly one lightmap, and the VolumetricLightMap component is a claim on it. That is what gives a lightmap the lifetime an entity already has: it arrives when the level’s entity is built, it is saved with it, and it goes away when the entity does.

import { VolumetricLightMap } from "@woosh/meep-engine/src/engine/graphics3/VolumetricLightMap.js";
import { VolumetricLightMapSystem } from "@woosh/meep-engine/src/engine/graphics3/VolumetricLightMapSystem.js";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";

await em.addSystem(new VolumetricLightMapSystem(
    engine.graphics,
    EngineHarness.shadeScene(engine)
));

const map = new VolumetricLightMap();

map.data = await (await fetch("/levels/keep/lightmap.svlm")).arrayBuffer();

const entity = ecd.createEntity();

ecd.addComponentToEntity(entity, map);

Both the component and the system are named exports. The system takes (graphics, scene) - the GraphicsEngine and the Shade Scene - and declares dependencies = [VolumetricLightMap].

MemberNotes
dataArrayBuffer | null. Asserted to be an ArrayBuffer - a Uint8Array will not do. null is a normal state, not a broken one: it means the bytes are still loading, and lights the scene with nothing.
versionbumped on every assignment to data
toJSON() / fromJSON()base64. Correct but expensive; the binary VolumetricLightMapSerializationAdapter (registered by populateEngineSerializationRegistry) is the cheap path.
hash()the byte length, deliberately not the bytes
equals(other)a real byte comparison
typeName"VolumetricLightMap"

The contract around it has several edges that will otherwise cost you an afternoon:

  • First claim wins; a second waits. system.active is the first component to link and stays so until it unlinks, at which point the next in line takes over. Last-writer-wins would make a level’s lighting depend on entity construction order. Swapping one map for another is therefore: remove the old component, then add the new one.
  • The payload is opaque. It is brick4’s own GPU structure - the format brick4_to_gpu_structure writes and the indirect-lighting shaders read. The component neither validates it nor knows how to make one.
  • The entity’s transform is ignored. A brick4 structure begins with the world-space bounds it was baked against, so the map already knows where it is. Moving the entity that carries it moves nothing, and the component does not need a Transform64 at all.
  • Mutating the buffer in place is invisible. The system watches version, and only assignment moves it. Publish an in-place edit by assigning data again, even to the same buffer.
  • Unlinking uploads an empty map rather than leaving the last one in place. So do a null payload and a zero-length buffer. There is no way to un-upload - in Brick4 mode the shader reads the buffer unconditionally - so a component that removed itself and left the scene lit exactly as before would make the lifetime a fiction.
  • The upload happens in update(), not link(). A lightmap can arrive before the renderer has a device, and a restart hands out a fresh context whose buffer is empty again. Both are the same question asked once a frame, so a link is eventually consistent by at most one frame.
  • Uploading a lightmap does not turn Brick4 on. Which technique the frame uses is renderer.indirect_lighting_mode; a scene in IBL or LPV mode never reads this buffer no matter what is in it. The upload is necessary and not sufficient.

fused_indirect and screen-space reflections

renderer.fused_indirect (default true) collapses the brick4 diffuse pass, the brick4 specular pass and the indirect resolve into a single shader. It reads the G-buffer once, drops the intermediate rgba16float diffuse and specular targets, and shares one brick4 probe pair between the diffuse and specular evaluations - less bandwidth for slightly more cache pressure inside the pass.

It applies only when all three of these hold: fused_indirect is true, the mode is Brick4, and feature_ssr_enabled is false.

Screen-space reflections work in Brick4 mode. Enabling feature_ssr_enabled selects the split path, and SSR uses the baked Brick4 volume for fallback specular lighting where screen-space information is insufficient. This costs the separate reflection and filtering passes; keep SSR off when the fused pass alone gives the result you need. Frame settings covers reprojection and filtering controls.

One neighbouring setting matters here: renderer.feature_ssao_enabled is true by default, and GTAO’s bent normals are what the indirect diffuse is evaluated against in every mode. Switch it off and the indirect passes fall back to the plain G-buffer shading normal. Frame settings catalogues that switch and the rest of them.


LPV

ShadeIndirectLightingMode.LPV is a real, wired code path and there is no supported way to author for it. Both halves of that sentence are true, so the honest advice is to use IBL or Brick4 for anything you intend to ship.

What exists: every Scene carries a light_probe_volume (a LightProbeVolume, named export from shade/renderer/global_illumination/lpv/LightProbeVolume.js) holding probe positions, SH3 coefficients and a Delaunay TetrahedralMesh over them, with add_point, remove_point, build_grid(bounds, resolution), build_mesh() and a version the GPU side watches. In LPV mode Renderer.render_to_target calls the public renderer.update_lpv(scene) every frame, which records a progressive GPU probe update of roughly 100,000 rays - this is a continuous refresh, not an offline bake.

What does not exist: anything that places probes for you. There is no ECS component, no serialization, no bake step, and no supported entry point that turns a level into a probe set - the probe-placement code under lpv/placement/ is not wired to anything public, and renderer.getProbeRendererForScene() is marked internal and debug use only. You can drive scene.light_probe_volume by hand, and you are on your own if you do.