Examples

DriftLight

Seven hundred candles, the sixteen nearest shaded exactly and the rest summed by DriftLight.

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

Open on its own · Source

DriftLight is the chapter that walks through it.

From a checkout of the engine, npm run examples serves it at /driftlight/.

Source

main.ts

examples/driftlight/main.ts
/**
 * A long gallery lit by seven hundred candles, of which the frame shades the nearest sixteen.
 *
 * DriftLight sums every candle once into a volume of light, occluded by the gallery's own walls,
 * and the shader reads that volume wherever the frame's exact choice does not reach. Switch it off
 * in the strip and everything past the nearest candles goes dark; the partition halfway down shows
 * that summed light does not pass through stone.
 */
import { bakeObjectSdf } from '@driftengine/assets';
import {
  DEFAULT_POINT_LIGHT_VIEW_RANGE,
  MeshBuilder,
  SceneNode,
  createEnvironment,
  createPointLightBuffer,
  selectPointLights,
} from '@driftengine/core';
import type { GlobalFieldInstance, PointLightSource, Vec3 } from '@driftengine/core';
import { controls, flag, openStage } from '../common/stage';

const stage = await openStage({ outputTransform: 'aces', outputExposure: 1.4, sceneSamples: 4 });
const { renderer, camera } = stage;

controls([
  {
    key: 'driftlight',
    label: 'DriftLight',
    value: flag('driftlight', 'on'),
    options: [
      { text: 'on', value: 'on' },
      { text: 'off', value: 'off' },
    ],
    /* The field stays baked; its scale is how much of the summed light reaches the frame. */
    change: (value) => {
      field.scale = value === 'on' ? 1 : 0;
    },
  },
]);

const LENGTH = 20;
const WIDTH = 3;
const HEIGHT = 4;
const PARTITION = 4;
const DOOR = 0.6;

/** The gallery as slabs: floor, ceiling, four walls, and a partition with a doorway. */
const slabs: { centre: Vec3; half: Vec3 }[] = [
  { centre: [0, -0.1, 0], half: [LENGTH, 0.1, WIDTH] },
  { centre: [0, HEIGHT + 0.1, 0], half: [LENGTH, 0.1, WIDTH] },
  { centre: [0, HEIGHT / 2, -WIDTH - 0.1], half: [LENGTH, HEIGHT / 2, 0.1] },
  { centre: [0, HEIGHT / 2, WIDTH + 0.1], half: [LENGTH, HEIGHT / 2, 0.1] },
  {
    centre: [PARTITION, HEIGHT / 2, -(WIDTH + DOOR) / 2],
    half: [0.1, HEIGHT / 2, (WIDTH - DOOR) / 2],
  },
  {
    centre: [PARTITION, HEIGHT / 2, (WIDTH + DOOR) / 2],
    half: [0.1, HEIGHT / 2, (WIDTH - DOOR) / 2],
  },
  { centre: [PARTITION, 3.25, 0], half: [0.1, 0.75, DOOR] },
  { centre: [-LENGTH - 0.1, HEIGHT / 2, 0], half: [0.1, HEIGHT / 2, WIDTH] },
  { centre: [LENGTH + 0.1, HEIGHT / 2, 0], half: [0.1, HEIGHT / 2, WIDTH] },
];

// #region candles
/** 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,
      });
// #endregion

const stone = new MeshBuilder();
for (const slab of slabs) stone.addBox(slab.centre, slab.half, [0.62, 0.58, 0.52]);
const gallery = renderer.createMesh(stone.build());
const wax = new MeshBuilder();
for (const c of candles)
  wax.addBox([c.x, c.y - 0.08, c.z], [0.012, 0.07, 0.012], [0.93, 0.86, 0.72], 0.6);
const stands = renderer.createMesh(wax.build());

// #region field
/** 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;
// #endregion

const env = createEnvironment({
  directionalColor: [0, 0, 0],
  ambient: [0.004, 0.004, 0.005],
  ambientGround: [0.002, 0.002, 0.002],
  nightFactor: 1,
});
const chosen = createPointLightBuffer(renderer.shadedLights);
const still = new SceneNode();
still.updateWorld();
camera.fovYDeg = 62;
camera.near = 0.1;
let time = 0;
let shadedAt = 0;

stage.run({
  simulate(dt) {
    time += dt;
  },
  render() {
    /* Up and down the near half, always facing the partition. */
    const walk = -8 - Math.cos(time * 0.06) * 9;
    camera.position[0] = walk;
    camera.position[1] = 1.6;
    camera.position[2] = Math.sin(time * 0.2) * 0.6;
    camera.lookAt(walk + 8, 1.7, 0);

    // #region frame
    /* 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;
    // #endregion

    renderer.beginFrame([0, 0, 0]);
    renderer.bindMeshPass(camera, env);
    renderer.drawMesh(gallery, still.worldMatrix);
    renderer.drawMesh(stands, still.worldMatrix);
    renderer.endFrame();
  },
});