Examples

Lights

Lamps that cast, a spot with a cookie, a photometric profile and a window that is an area light.

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

Open on its own · Source

Lights is the chapter that walks through it.

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

Source

main.ts

examples/lights/main.ts
/**
 * A courtyard at night, lit by nothing but its fixtures: lamps on posts that cast, a spot that
 * throws a window frame through a cookie, a downlight shaped by a photometric profile, and a lit
 * window in the back wall that is a rectangle of light.
 *
 * The block circling the middle is the one caster that moves, so its shadows come from the live
 * maps while the pillars' come from maps baked once. Switch shadows off in the strip and every
 * fixture lights straight through the stone. Both switches are quality options, so each builds a
 * new renderer in place, and the block and the camera carry on from where they were.
 */
import {
  DEFAULT_POINT_LIGHT_VIEW_RANGE,
  MeshBuilder,
  SceneNode,
  createAreaLightBuffer,
  createEnvironment,
  createPointLightBuffer,
  selectAreaLights,
  selectPointLights,
} from '@driftengine/core';
import type {
  AreaLightSource,
  PhotometricProfile,
  PointLightSource,
  ShadowCasters,
} from '@driftengine/core';
import type { RendererApi } from '@driftengine/core';
import { controls, flag, openScene } from '../common/stage';
import type { SceneHooks } from '../common/stage';

// #region lamps
/** Two lamps on posts, one of them flickering, and a downlight aimed at the floor between pillars. */
const lamps: PointLightSource[] = [
  {
    x: -6,
    y: 2.7,
    z: 1.5,
    r: 2.4,
    g: 1.5,
    b: 0.7,
    radius: 10,
    flicker: 0,
    shadowNear: 0.2,
    sourceRadius: 0.08,
  },
  {
    x: 6,
    y: 2.7,
    z: 1.5,
    r: 2.4,
    g: 1.3,
    b: 0.55,
    radius: 10,
    flicker: 0.3,
    shadowNear: 0.2,
    sourceRadius: 0.08,
  },
  {
    x: 0,
    y: 4.5,
    z: -2,
    r: 4.5,
    g: 3.6,
    b: 2.6,
    radius: 9,
    flicker: 0,
    shadowNear: 0.2,
    sourceRadius: 0.05,
    dirX: 0,
    dirY: -1,
    dirZ: 0,
    coneInnerDeg: 70,
    coneOuterDeg: 85,
    iesProfile: 0,
  },
];
// #endregion

// #region spot
/** A spot high over the camera's shoulder, aimed down at the floor in front of the pillars. */
const SPOT = lamps.length;
lamps.push({
  x: 0,
  y: 7,
  z: 7,
  r: 4,
  g: 3.8,
  b: 3.4,
  radius: 16,
  flicker: 0,
  shadowNear: 0.2,
  sourceRadius: 0.04,
  dirX: 0,
  dirY: -1,
  dirZ: -0.55,
  coneInnerDeg: 18,
  coneOuterDeg: 24,
});
// #endregion

// #region profile
/**
 * A photometric profile built in code: intensity that falls to nothing sixty degrees off the aim.
 * The downlight asked for row 0 of the atlas with `iesProfile: 0`.
 */
function downlight(): PhotometricProfile {
  const steps = 19;
  const verticalAngles = new Float32Array(steps);
  const candela = new Float32Array(steps);
  for (let i = 0; i < steps; i += 1) {
    const degrees = (i / (steps - 1)) * 180;
    const t = Math.max(0, 1 - degrees / 60);
    verticalAngles[i] = degrees;
    candela[i] = 1000 * t * t;
  }
  return { verticalAngles, horizontalAngles: new Float32Array([0]), candela, maxCandela: 1000 };
}
// #endregion

// #region cookie
/** A cookie is any image the spot projects: here a window frame, white panes and black bars. */
function windowFrame(): HTMLCanvasElement {
  const size = 128;
  const canvas = document.createElement('canvas');
  canvas.width = size;
  canvas.height = size;
  const paint = canvas.getContext('2d');
  if (paint === null) return canvas;
  paint.fillStyle = '#000';
  paint.fillRect(0, 0, size, size);
  paint.fillStyle = '#fff';
  const pane = size / 3;
  const bar = size / 16;
  for (let row = 0; row < 2; row += 1)
    for (let column = 0; column < 2; column += 1)
      paint.fillRect(
        size / 6 + column * (pane + bar),
        size / 6 + row * (pane + bar),
        pane - bar,
        pane - bar,
      );
  return canvas;
}
// #endregion

// #region window
/** The lit window: a rectangle that emits along `right × up`, which here is into the courtyard. */
const windows: AreaLightSource[] = [
  {
    x: 0,
    y: 2.4,
    z: -5.8,
    r: 2.4,
    g: 3.2,
    b: 4.8,
    rightX: 1,
    rightY: 0,
    rightZ: 0,
    upX: 0,
    upY: 1,
    upZ: 0,
    halfWidth: 2.2,
    halfHeight: 1.1,
    castsShadow: true,
    shadowRange: 18,
    shadowNear: 0.2,
  },
];
const windowBuffer = createAreaLightBuffer();
selectAreaLights(windows, windowBuffer);
// #endregion

const env = createEnvironment({
  directionalColor: [0, 0, 0],
  ambient: [0.025, 0.028, 0.04],
  ambientGround: [0.012, 0.012, 0.016],
  nightFactor: 1,
  emissiveGain: 2,
});
const blockNode = new SceneNode();
const fixed = new SceneNode();
fixed.updateWorld();
let time = 0;
let shadedAt = 0;

/** What one renderer draws, and the fixtures it uploads. */
function build(renderer: RendererApi): SceneHooks {
  /* The profile and the cookie are assets: uploaded once per renderer. */
  renderer.setIesProfiles([downlight()]);
  renderer.setSpotCookies([windowFrame()]);

  /** Everything that never moves, as one mesh: floor, back wall, pillars, posts and fixtures. */
  const yard = new MeshBuilder();
  yard.addBox([0, -0.1, 0], [14, 0.1, 10], [0.42, 0.4, 0.38]);
  yard.addBox([0, 3, -6.2], [14, 3, 0.2], [0.5, 0.46, 0.42]);
  for (const x of [-4.5, -1.5, 1.5, 4.5])
    yard.addCylinder([x, 1.6, -3], 0.35, 1.6, 'y', [0.62, 0.58, 0.52], 0, 16);
  for (const lamp of lamps.slice(0, 2)) {
    yard.addCylinder([lamp.x, 1.25, lamp.z], 0.06, 1.25, 'y', [0.12, 0.12, 0.13], 0, 8);
    yard.addSphere([lamp.x, lamp.y, lamp.z], 0.07, [1, 0.75, 0.45], 1);
  }
  yard.addBox([0, 4.65, -2], [0.2, 0.08, 0.2], [0.85, 0.9, 1], 1);
  yard.addBox([0, 2.4, -5.98], [2.2, 1.1, 0.02], [0.75, 0.85, 1], 1);
  const courtyard = renderer.createMesh(yard.build());
  const block = renderer.createMesh(
    new MeshBuilder().addBox([0, 0.6, 0], [0.6, 0.6, 0.6], [0.7, 0.68, 0.64]).build(),
  );

  // #region casters
  /** The two caster lists: baked once and kept, and drawn again every frame. */
  const still: ShadowCasters = (sink) => sink.mesh(courtyard, fixed.worldMatrix);
  const moving: ShadowCasters = (sink) => sink.mesh(block, blockNode.worldMatrix);

  /** Size the shadow array once, for every lamp and every rectangle that casts. */
  renderer.prepareStaticPointShadows(lamps, windows);
  // #endregion

  // #region select
  /** Sized from the renderer, so the shader shades the nearest lights and never an arbitrary few. */
  const chosen = createPointLightBuffer(renderer.shadedLights);

  function chooseLights(focusX: number, focusZ: number): void {
    const eye = scene.camera.position;
    selectPointLights(
      lamps,
      eye[0],
      eye[1],
      eye[2],
      chosen,
      time,
      DEFAULT_POINT_LIGHT_VIEW_RANGE,
      focusX,
      0.6,
      focusZ,
    );
    env.lightCount = chosen.count;
    env.lightPositions = chosen.positions;
    env.lightColors = chosen.colors;
    env.lightRadii = chosen.radii;
    env.lightSourceRadii = chosen.sourceRadii;
    env.lightWeights = chosen.weights;
    env.lightDirections = chosen.directions;
    env.lightConeCos = chosen.coneCos;
    env.lightIesProfiles = chosen.iesProfiles;
    env.lightFalloffExponents = chosen.falloffExponents;
    env.activeLightWorldIndices = chosen.sourceIndex;
    for (let slot = 0; slot < chosen.count; slot += 1) {
      const spot = chosen.sourceIndex[slot] === SPOT;
      /* The cookie's tile, and which way is up in it: a cookie with no axis is not projected. */
      env.lightCookies[slot] = spot ? 0 : -1;
      env.lightIesAxes[slot * 3] = spot ? 1 : 0;
    }
    env.areaLights = windowBuffer;
  }
  // #endregion

  return {
    simulate(dt) {
      time += dt;
    },
    render() {
      const { camera } = scene;
      const blockX = Math.cos(time * 0.4) * 3.2;
      const blockZ = 0.5 + Math.sin(time * 0.4) * 2.2;
      blockNode.setPosition(blockX, 0, blockZ);
      blockNode.setRotationAxisAngle(0, 1, 0, time * 0.6);
      blockNode.updateWorld();

      camera.fovYDeg = 55;
      camera.position[0] = Math.sin(time * 0.08) * 4;
      camera.position[1] = 5.5;
      camera.position[2] = 13;
      camera.lookAt(0, 1.2, -1.5);

      // #region shadows
      chooseLights(blockX, blockZ);
      renderer.updatePointShadows(
        lamps,
        chosen.sourceIndex,
        chosen.count,
        blockX,
        0.6,
        blockZ,
        time - shadedAt,
        still,
        moving,
        chosen.shadowIndex,
        chosen.shadowCount,
        windows,
      );
      shadedAt = time;
      // #endregion

      renderer.beginFrame([0, 0, 0]);
      renderer.bindMeshPass(camera, env);
      renderer.drawMesh(courtyard, fixed.worldMatrix);
      renderer.drawMesh(block, blockNode.worldMatrix);
      renderer.endFrame();
    },
  };
}

// #region quality
/** Point shadows and the falloff curve both change what the lit shader is built with. */
const quality = (shadows: string, falloff: string) => {
  windows[0].castsShadow = shadows === 'on';
  return {
    pointShadows: shadows === 'on',
    directionalShadows: false,
    pointLightFalloff:
      falloff === 'inverseSquare' ? ('inverseSquare' as const) : ('smooth' as const),
    outputTransform: 'aces' as const,
    sceneSamples: 4,
  };
};
const settings = { shadows: flag('shadows', 'on'), falloff: flag('falloff', 'smooth') };
const scene = await openScene(quality(settings.shadows, settings.falloff), build);

controls([
  {
    key: 'shadows',
    label: 'pointShadows',
    value: settings.shadows,
    options: [
      { text: 'on', value: 'on' },
      { text: 'off', value: 'off' },
    ],
    change: (value) => {
      settings.shadows = value;
      return scene.rebuild(quality(settings.shadows, settings.falloff));
    },
  },
  {
    key: 'falloff',
    label: 'pointLightFalloff',
    value: settings.falloff,
    options: [
      { text: 'smooth', value: 'smooth' },
      { text: 'inverseSquare', value: 'inverseSquare' },
    ],
    change: (value) => {
      settings.falloff = value;
      return scene.rebuild(quality(settings.shadows, settings.falloff));
    },
  },
]);
// #endregion