Examples

A room lit by a lightmap

A room lit by a bake: a page read at each surface's second coordinates, and a shadow in it.

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

Open on its own · Source

Its comments explain it as it goes, starting from the top of main.ts.

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

Source

main.ts

examples/lightmaps/main.ts
/**
 * A room lit by a lightmap: light baked once, here on the CPU from a warm lamp the scene does not
 * otherwise have, with the shadow of a box on the floor that no shadow map draws.
 *
 * A bake from another tool arrives as the same two images a page holds: the irradiance at each
 * texel, and the direction it arrives from as a first-order spherical harmonic. Every surface reads
 * its own region of the page at its second set of coordinates. The switch turns the page off, which
 * leaves the room to its faint ambient: what is left is what the bake was carrying.
 */
import { MeshBuilder, createEnvironment, lightmapModel } from '@driftengine/core';
import type {
  LightmapPage,
  LightmapRegion,
  MeshData,
  SurfaceMaterial,
  SurfaceTextureHandle,
  Vec3,
} from '@driftengine/core';
import { controls, flag, openStage } from '../common/stage';

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

/** Texels a side of each of the page's three regions: the floor, the back wall, the left wall. */
const SIDE = 64;
/** The lamp the bake was made from, and its intensity: warm, with nothing in the scene at it. */
const LAMP: Vec3 = [1.6, 3.6, -0.8];
const POWER: Vec3 = [7, 5, 3.2];
/** The box on the floor, whose shadow is in the bake. */
const BOX: Vec3 = [-0.6, 0.6, -1.2];
const HALF = 0.6;

/** Where each region's texel is in the world, from its own coordinates. */
const SURFACES: readonly ((u: number, v: number) => Vec3)[] = [
  (u, v) => [-4 + 8 * u, 0, -4 + 8 * v],
  (u, v) => [-4 + 8 * u, 5 * v, -4],
  (u, v) => [-4, 5 * v, -4 + 8 * u],
];

/** Whether the segment from `p` to the lamp passes through the box: a slab test. */
function blocked(p: Vec3): boolean {
  let near = 0;
  let far = 1;
  for (let axis = 0; axis < 3; axis += 1) {
    const from = p[axis] as number;
    const along = (LAMP[axis] as number) - from;
    const lo = (BOX[axis] as number) - HALF;
    const hi = (BOX[axis] as number) + HALF;
    if (Math.abs(along) < 1e-9) {
      if (from < lo || from > hi) return false;
      continue;
    }
    const t0 = (lo - from) / along;
    const t1 = (hi - from) / along;
    near = Math.max(near, Math.min(t0, t1));
    far = Math.min(far, Math.max(t0, t1));
    if (near > far) return false;
  }
  return true;
}

// #region bake
/** One byte of a direction component, stored as `v * 0.5 + 0.5`. */
const byte = (v: number): number => Math.round((v * 0.5 + 0.5) * 255);

/**
 * The page: the lamp's light at each texel with the direction it comes from, and a faint bounce
 * from every side, carried by the harmonic's constant, so a shadow is not black.
 */
function bake(): LightmapPage {
  const width = SIDE * 3;
  const irradiance = new Float32Array(width * SIDE * 3);
  const direction = new Uint8Array(width * SIDE * 4);
  for (let region = 0; region < 3; region += 1) {
    const surface = SURFACES[region] as (u: number, v: number) => Vec3;
    for (let y = 0; y < SIDE; y += 1) {
      for (let x = 0; x < SIDE; x += 1) {
        const p = surface((x + 0.5) / SIDE, (y + 0.5) / SIDE);
        const dx = LAMP[0] - p[0];
        const dy = LAMP[1] - p[1];
        const dz = LAMP[2] - p[2];
        const d2 = dx * dx + dy * dy + dz * dz;
        const d = Math.sqrt(d2);
        const lit = blocked(p) ? 0 : 1;
        const t = y * width + region * SIDE + x;
        for (let c = 0; c < 3; c += 1)
          irradiance[t * 3 + c] = ((POWER[c] as number) * lit) / d2 + 0.04;
        const share = lit === 0 ? 0 : 1 - 0.04 / (irradiance[t * 3] as number);
        direction.set(
          [byte((dx / d) * share), byte((dy / d) * share), byte((dz / d) * share), byte(1 - share)],
          t * 4,
        );
      }
    }
  }
  return { width, height: SIDE, irradiance, direction };
}
// #endregion

/** A surface's mesh, its second coordinates read back off its positions. */
function surface(
  build: (b: MeshBuilder) => MeshBuilder,
  uv: (p: Vec3) => [number, number],
): MeshData {
  const data = build(new MeshBuilder()).build();
  const lightmapUvs = new Float32Array((data.positions.length / 3) * 2);
  for (let i = 0; i < lightmapUvs.length / 2; i += 1) {
    const p: Vec3 = [
      data.positions[i * 3] as number,
      data.positions[i * 3 + 1] as number,
      data.positions[i * 3 + 2] as number,
    ];
    lightmapUvs.set(uv(p), i * 2);
  }
  return { ...data, lightmapUvs };
}

const PALE: Vec3 = [0.78, 0.76, 0.72];
const floor = renderer.createMesh(
  surface(
    (b) => b.addGroundQuad([-4, 0, -4], [4, 0, -4], [4, 0, 4], [-4, 0, 4], PALE),
    (p) => [(p[0] + 4) / 8, (p[2] + 4) / 8],
  ),
);
const back = renderer.createMesh(
  surface(
    (b) => b.addWallQuad([-4, 0, -4], [4, 0, -4], [4, 5, -4], [-4, 5, -4], [0, 2, -10], PALE),
    (p) => [(p[0] + 4) / 8, p[1] / 5],
  ),
);
const left = renderer.createMesh(
  surface(
    (b) =>
      b.addWallQuad(
        [-4, 0, -4],
        [-4, 0, 4],
        [-4, 5, 4],
        [-4, 5, -4],
        [-10, 2, 0],
        [0.6, 0.66, 0.74],
      ),
    (p) => [(p[2] + 4) / 8, p[1] / 5],
  ),
);
const box = renderer.createMesh(
  new MeshBuilder().addBox(BOX, [HALF, HALF, HALF], [0.5, 0.35, 0.25]).build(),
);

// #region material
const page = renderer.createLightmap(bake());
/** Region `k` of the page's three, as `[scaleU, scaleV, biasU, biasV]`. */
const third = (k: number): LightmapRegion => [1 / 3, 1, k / 3, 0];
const baked = (k: number): SurfaceMaterial<SurfaceTextureHandle> => ({
  model: lightmapModel({ region: third(k) }),
  modelMap: page,
});
// #endregion
const materials = [baked(0), baked(1), baked(2)];

const env = createEnvironment({
  directionalDir: [0.3, 1, 0.4],
  directionalColor: [0.05, 0.05, 0.06],
  ambient: [0.15, 0.15, 0.15],
  ambientGround: [0.15, 0.15, 0.15],
  fogDensity: 0,
});
camera.fovYDeg = 50;
camera.position[0] = 1.2;
camera.position[1] = 3.4;
camera.position[2] = 8;
camera.lookAt(-0.6, 1.4, -1.5);

let lit = flag('page', 'on') === 'on';
controls([
  {
    key: 'page',
    label: 'baked light',
    value: lit ? 'on' : 'off',
    options: [
      { text: 'on', value: 'on' },
      { text: 'off', value: 'off' },
    ],
    change: (value) => {
      lit = value === 'on';
    },
  },
]);

const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const walls = [floor, back, left];
stage.run({
  render() {
    renderer.beginFrame([0.02, 0.022, 0.026]);
    renderer.bindMeshPass(camera, env);
    for (let k = 0; k < walls.length; k += 1) {
      renderer.setMaterial(lit ? (materials[k] ?? null) : null);
      renderer.drawMesh(walls[k] as (typeof walls)[number], IDENTITY);
    }
    renderer.setMaterial(null);
    renderer.drawMesh(box, IDENTITY);
    renderer.endFrame();
  },
});