Rendering

Global illumination

How Meep bakes and applies indirect lighting through SH3 light probes arranged in a tetrahedral volume.

Meep’s global illumination system stores indirect light at a sparse set of world-space probes and samples them at runtime using tetrahedral interpolation. The representation is L2 spherical harmonics — 9 coefficients per colour channel (27 floats per probe) — enough to capture low-frequency diffuse irradiance from any direction. The core classes live under @woosh/meep-engine/src/engine/graphics/sh3/.

Light probe volume

LightProbeVolume is the runtime container for the probe set. Each probe carries:

  • a world-space position (x, y, z)
  • 27 SH3 RGB coefficients — irradiance colour in every direction
  • a small per-probe depth map encoded in octahedral projection (depth_map_resolution × depth_map_resolution, two channels for mean and mean-squared distance)
import { LightProbeVolume } from "@woosh/meep-engine/src/engine/graphics/sh3/lpv/LightProbeVolume.js";

const volume = new LightProbeVolume();

The depth map stores mean and mean-squared distances to scene geometry for each probe, allowing the shader to detect occlusion and weight contributions during interpolation.

Placing probes

Two methods place probe positions. For most scenes the grid builder is the right starting point:

import { AABB3 } from "@woosh/meep-engine/src/core/geom/3d/aabb/AABB3.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";

const bounds = new AABB3(-10, 0, -10, 10, 10, 10);
// place a regular grid of probes at 2-unit spacing
volume.build_grid(bounds, new Vector3(10, 5, 10));

Individual probes can also be placed by hand with volume.add_point(x, y, z) and removed with volume.remove_point(index).

After placing probes, call volume.build_mesh() to run a Delaunay tetrahedralization over the point set. The mesh is required for the tetrahedral interpolation described below.

Baking

LightProbeVolumeBaker drives the bake. It uses PathTracerProbeRenderer internally — the pure-JS path tracer (see Sky & environment) — sampling 4096 rays per probe by default:

import { LightProbeVolumeBaker } from "@woosh/meep-engine/src/engine/graphics/sh3/lpv/LightProbeVolumeBaker.js";

const baker = new LightProbeVolumeBaker();
const task = baker.bake(volume, ecd);   // returns a TaskGroup
engine.executor.runGroup(task);
await task.promise();

The bake call returns a TaskGroup with three steps: scene preparation, per-probe irradiance bake (the slow step — one path-trace pass per probe), and depth-map bake. Progress can be tracked through the task group. The helper build_probes_for_scene wraps the full workflow — grid layout, mesh build, and bake — behind one async call and optionally shows a progress widget.

A WebGLCubeProbeRenderer alternative renders each probe using the GPU via Three.js CubeCamera, producing faster bakes at lower quality (no Monte-Carlo bounces, just a single GPU render pass).

Tetrahedral interpolation

At runtime the shader needs to find, for any world-space position, which tetrahedron it is inside and what barycentric weights to apply. Two GPU resources drive this:

TextureContents
lpv_t_mesh_verticesper-tetrahedron vertex indices (4 per tet, Uint32)
lpv_t_mesh_neighboursper-tetrahedron neighbour tet indices (4 per tet, Uint32)
lpv_t_mesh_lookupa 3D texture mapping axis-aligned grid cells to a tet index — used to seed the walk
lpv_t_probe_positionsworld-space positions of all probes
lpv_t_probe_datathe SH3 coefficients (9 × vec3 per probe)
lpv_t_probe_depthoctahedral depth atlas

The 3D lookup texture is pre-filled by MaterialTransformer.update_lookup(), which walks the tetrahedral mesh at each voxel using mesh.walkToTetraContainingPoint and records the nearest tet index. The shader then walks from that seed to the containing tet and evaluates barycentric weights.

Applying GI to materials — MaterialTransformer

MaterialTransformer injects GI into any lit Three.js material without touching the original. Pass it a baked volume and optionally choose where probes are resolved:

import { MaterialTransformer, ProbeResolutionStage }
    from "@woosh/meep-engine/src/engine/graphics/sh3/gi/material/MaterialTransformer.js";

const transformer = new MaterialTransformer({
    volume,
    stage: ProbeResolutionStage.Fragment, // or ProbeResolutionStage.Vertex
});

transformer.update(); // upload textures and rebuild 3D lookup if volume changed

const giMaterial = transformer.transform(myMeshStandardMaterial);

ProbeResolutionStage.Vertex evaluates per-vertex (cheaper, fine for dense meshes). ProbeResolutionStage.Fragment evaluates per-fragment (more expensive, required for large low-polygon geometry). The transformer hooks into Three.js via onBeforeCompile, prepending the probe-lookup GLSL to the material’s shader without breaking other material features.

transformer.intensity (a number, default 1) scales the GI contribution at runtime without rebaking.

Serialization

LightProbeVolumeSerializationAdapter serializes and deserializes a baked volume. Because baking is the slow step, volumes are typically baked once offline and loaded at startup.

Limitations

The system bakes static light only. Dynamic lights (moving point lights, day-night cycles) require rebaking or a separate dynamic-light approximation. The path-tracer bake is CPU-only and runs in the main thread — large probe counts with high sample counts will block for a noticeable time; use the task system to spread the work and show progress.