Rendering

DriftLight

Every fixed light in a scene lights the world, near or far: the lights the frame does not shade summed into an occluded volume.

Install npm install @driftengine/core @driftengine/assets @driftengine/drft

A frame shades a limited number of lights exactly: sixteen on the plain path, a few hundred with clustered shading. A gallery of seven hundred candles therefore leaves every wall past the nearest few dark, until the camera walks up to it. DriftLight sums every fixed light once into a volume of light and direction, occluded by the scene's own walls, and the shader reads that volume wherever the frame's exact choice does not reach. Near the camera lights are shaded exactly; past them the summed light stands in, and a two-metre band crossfades between the two so nothing pops.

Starts examples/driftlight in this page, on WebGPU where your browser has it.

Open on its own · Every file · Source

A field for a scene

examples/driftlight/main.ts
/** Three rows of candles along each wall, thirty centimetres apart. None of them casts. */
const candles: PointLightSource[] = [];
for (const side of [-1, 1])
  for (const y of [1, 1.6, 2.2])
    for (let x = -LENGTH + 2; x <= LENGTH - 2; x += 0.3)
      candles.push({
        x,
        y,
        z: side * (WIDTH - 0.4),
        r: 0.5,
        g: 0.27,
        b: 0.08,
        radius: 1.2,
        flicker: 0,
        shadowNear: 0.05,
        sourceRadius: 0.005,
        castsShadow: false,
      });

The lights are ordinary PointLightSource records. None of them casts a shadow here: seven hundred shadow maps are not what a candle needs, and the field brings its own occlusion.

examples/driftlight/main.ts
/** Each slab baked into a distance field, which is what stops summed light passing through it. */
const walls: GlobalFieldInstance[] = slabs.map((slab) => ({
  source: bakeObjectSdf(new MeshBuilder().addBox(slab.centre, slab.half, [1, 1, 1]).build(), 96),
  transform: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
}));

const field = renderer.createLightField(candles, { fields: walls });
field.scale = flag('driftlight', 'on') === 'on' ? 1 : 0;

createLightField(lights, options) makes the field and marks each light inLightField, which tells the selection that the light is also summed, so no pixel counts it twice. fields are the distance fields that occlude the summed light: here each slab of the gallery is baked with bakeObjectSdf from @driftengine/assets, so light from the candles beyond the partition does not reach the near face of it. A field made without them is unoccluded.

The other options:

  • spacing, a third of a metre by default: the distance between samples. A brick of samples spans a metre.
  • band, two metres by default: how wide the crossfade between exact and summed light is.
  • fadeSec, half a second by default: how long the summed light takes to fade in once the field is whole. Zero for a field baked behind a loading screen.

Baking and following

examples/driftlight/main.ts
/* Bricks until four milliseconds of this frame are spent, so the walk stays smooth on a slow
   machine too; once the field is whole it fades in over half a second. */
const until = performance.now() + 4;
while (!field.ready && performance.now() < until) field.bake(4);

const [x, y, z] = camera.position;
selectPointLights(candles, x, y, z, chosen, time, DEFAULT_POINT_LIGHT_VIEW_RANGE);
env.lightCount = chosen.count;
env.lightPositions = chosen.positions;
env.lightColors = chosen.colors;
env.lightRadii = chosen.radii;
env.lightSourceRadii = chosen.sourceRadii;
env.lightWeights = chosen.weights;
/* Where the exact choice is complete, so the shader knows where the field takes over. */
field.follow(chosen.complete, x, y, z, time - shadedAt);
shadedAt = time;

bake(bricks) bakes that many bricks and no more, and reads no clock, so the build is deterministic and pacing it is the scene's. The example bakes four at a time until four milliseconds of the frame are gone, so the walk stays smooth while the field fills, on a slow machine too; a fixed count per frame costs a fast machine nothing and stalls a slow one. progress is how much is done, for a loading screen, and ready says the field is whole. The volume is not used until every brick has landed, then fades in.

follow(complete, x, y, z, dt) tells the field where the frame's exact choice is complete. The selection's complete is a distance from its centre: a point nearer than that is reached only by lights the frame shades exactly, at full strength, so the field can leave it alone. The field shrinks its radius at once when the choice shrinks, and grows it back slowly.

A scene has one field at a time; creating another replaces it, and disposeLightField lets it go. scale dims everything the field summed at once, for dawn or a power cut.

What it gives up

  • Highlights. Summed light lights surfaces; it puts no specular highlight on them. The exact lights near the camera do.
  • Flicker past the choice. The volume holds each light's steady colour.
  • Occlusion by anything that moves. Only the fields given at construction shadow it.
  • The lights as they change. The field sums the lights as they stood when it was made, from a copy, so a scene that dims its lamps later uses scale.

A whole city

A street lamp reaches thirty metres and stands every forty, so a city is lit almost everywhere and a sparse volume of bricks buys nothing. For a world, bakeDenseField sums every fixed light offline into one dense grid: at eight metres a sample, a city two kilometres across and 128 metres tall is 256 × 16 × 256 samples, 16 MB on the GPU. The grid may be at most 256 samples along x and z and 128 along y.

examples/snippets/driftlight.ts
/** Offline, in a build script: sum every lamp over two kilometres at eight metres a sample. */
export function bakeCityLight(lamps: readonly PointLightSource[]): DenseLightVolume {
  return bakeDenseField(lamps, 'smooth', null, [-1000, 0, -1000, 1000, 128, 1000], 8);
}

/** And store it in the world's file, as one `LVOL` chunk. */
export function writeWorld(world: DrftSource, lamps: readonly PointLightSource[]): ArrayBuffer {
  return writeDrft({ ...world, lightVolume: bakeCityLight(lamps) });
}

The bake buckets lights before it sums them, so each sample considers only the lamps that can reach it, and it may take seconds, which is why it belongs in a build script. Its distance argument occludes, as the scene field's distance fields do, or is null for none. The .drft format carries the result as one LVOL chunk.

examples/snippets/driftlight.ts
/** In the game: the volume arrives with the file, and the lamps it summed are marked as summed. */
export async function openCity(
  renderer: RendererApi,
  url: string,
  lamps: PointLightSource[],
): Promise<void> {
  for (const lamp of lamps) lamp.inLightField = true;
  await streamDrft(await fetch(url), {
    onLightVolume: (volume) => renderer.createWorldLightField(volume, { fadeSec: 0 }),
  });
}

createWorldLightField(volume) hands the volume to the renderer whole. It carries no list of the lights it came from, so the scene marks them inLightField itself. The exact lights near the eye are found with the light grid from Lights, which makes the same choice as the full scan over twenty thousand lamps, measured 20 to 30% faster.

At eight metres a lamp's pool is a few samples across, which is why the volume only stands in past the exact lights, where a pool is a few dozen pixels on screen.

This page's source, on GitHub