Examples

Cinematic

A buggy and a jump, cut by a scripted director, a cinematic as data, and a keyed path.

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

Open on its own · Source

Cameras and cinematics is the chapter that walks through it.

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

Source

main.ts

examples/cinematic/main.ts
/**
 * A buggy lapping a track with a jump, filmed three ways.
 *
 * Directed: `director.drs` watches the buggy and picks a shot, a low wide one as it takes off, an
 * orbit round the landing, the tower framed as it passes, and the page cuts a `CinematicCamera` to
 * it. A cut is instant and the motion inside a shot is smoothed. Timeline: a cinematic written as
 * data, shots and the lines shown under them, played by a `CinematicPlayer`. Path: a camera keyed
 * in world space and flown through on a curve. The time switch re-times the jump with a curve that
 * slows it in the air and pays the time back after, so a lap still takes as long.
 */
import {
  CinematicCamera,
  CinematicPlayer,
  ColliderSet,
  MeshBuilder,
  boxCollider,
  computeLightMatrix,
  createCameraPath,
  createCameraPathSample,
  createEnvironment,
  curveOffsetSec,
  defineCinematic,
  sampleCameraPath,
  timeCurve,
} from '@driftengine/core';
import type { ShotParams, Vec3 } from '@driftengine/core';
import { patchModule } from 'driftscript';
import { createReadout } from '../common/readout';
import { exported, hostScript } from '../common/script';
import { controls, flag, openStage } from '../common/stage';
import * as directorScript from './director.drs';

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

/* The track: an oval, a jump on its left side, and a tower in the middle. A lap is sixteen seconds,
   quicker on the straights. */
const LAP = 16;
const JUMP: readonly [number, number] = [Math.PI - 0.35, Math.PI + 0.35];
const RAMP = 0.15;
const TOWER: Vec3 = [0, 5, 0];
const angleAt = (lapSec: number): number =>
  ((Math.PI * 2 * lapSec) / LAP + 0.12 * Math.sin((Math.PI * 4 * lapSec) / LAP)) % (Math.PI * 2);
const pointAt = (angle: number, out: Vec3): Vec3 => {
  out[0] = 15 * Math.cos(angle);
  out[2] = 9 * Math.sin(angle);
  out[1] = heightAt(angle);
  return out;
};
function heightAt(angle: number): number {
  const [from, to] = JUMP;
  if (angle > from - RAMP && angle < from) return (0.9 * (angle - (from - RAMP))) / RAMP;
  if (angle >= from && angle <= to) {
    const u = (angle - from) / (to - from);
    return 0.9 + 2.6 * 4 * u * (1 - u);
  }
  if (angle > to && angle < to + RAMP) return 0.9 * (1 - (angle - to) / RAMP);
  return 0;
}

// #region time
/* The stretch of a lap the buggy spends over the jump, found once, and a curve that re-times it:
   slower in the air, quicker after, the same length in all. */
function lapSecAt(angle: number): number {
  let low = 0;
  let high = LAP / 2 + 2;
  for (let step = 0; step < 40; step += 1) {
    const mid = (low + high) / 2;
    if (angleAt(mid) < angle) low = mid;
    else high = mid;
  }
  return low;
}
const flightStart = lapSecAt(JUMP[0] - RAMP);
const flightEnd = lapSecAt(JUMP[1] + RAMP);
const slowMotion = timeCurve('ramp', flightEnd - flightStart, { amount: 0.75 });
let retimed = flag('time', 'real') === 'slow';
/** Where in its lap the buggy is, from the lap's clock, through the curve where it applies. */
function sourceSec(lapClock: number): number {
  if (!retimed || lapClock < flightStart || lapClock > flightEnd) return lapClock;
  return flightStart + curveOffsetSec(slowMotion, lapClock - flightStart);
}
// #endregion

// #region shots
/* The shots, as records the page owns, under the names of the director script's `Shot` variants. */
type ShotName = 'Chase' | 'LowWide' | 'Orbit' | 'Flyby' | 'Overhead' | 'LookAt';
const SHOTS: Readonly<Record<ShotName, ShotParams>> = {
  Chase: { kind: 'chase', distance: 7, height: 2.2, fovDeg: 62 },
  LowWide: { kind: 'lowWide', distance: 9, height: 0.7, fovDeg: 48 },
  Orbit: { kind: 'orbit', distance: 8, height: 3, fovDeg: 55, orbitRate: 0.7 },
  Flyby: { kind: 'flyby', distance: 10, height: 1.6, fovDeg: 45 },
  Overhead: { kind: 'overhead', distance: 14, height: 16, fovDeg: 50 },
  LookAt: { kind: 'lookAt', distance: 11, height: 3, fovDeg: 42, anchor: TOWER },
};
/* The rig keeps its boom out of the rocks, so it is given them. */
const ROCKS: Vec3[] = [
  [6, 0.8, 3],
  [-5, 0.8, -4],
  [9, 0.8, -12],
];
const rocks = new ColliderSet(ROCKS.map(([x, y, z]) => boxCollider(x, y, z, 1, 0.8, 1)));
const rig = new CinematicCamera(rocks);
/* The rig's own clock, which a cut is stamped with: a shot's age, and an orbit's angle, count
   from it. */
let rigClock = 0;
// #endregion

// #region director
const script = hostScript(directorScript);
/* A variant reaches the page as `{ tag }`, and an option as `{ tag: 'some', value }` or `none`. */
interface Director {
  shot: { tag: ShotName };
  cuts: number;
}
const director = exported<() => Director>(script, 'createDirector')();
type Direct = (
  director: Director,
  camera: CinematicCamera,
  airborne: boolean,
  nearTower: boolean,
) => { tag: 'none' } | { tag: 'some'; value: { tag: ShotName } };
if (import.meta.hot) {
  import.meta.hot.accept('./director.drs', (next) => {
    if (next !== undefined) {
      patchModule(script, next as Record<string, unknown>, { Director: [director] });
    }
  });
}
// #endregion

// #region timeline
/* A cinematic written as data: when the camera cuts, a shot that moves while it holds, and the
   lines shown under the picture. Checked when it is defined, so a malformed one fails on load. */
const TRAILER = defineCinematic('trailer', {
  durationSec: 16,
  shots: [
    { atSec: 0, camera: { kind: 'overhead', distance: 16, height: 18, fovDeg: 48 } },
    { atSec: 3.5, camera: { kind: 'chase', distance: 6, height: 1.8, fovDeg: 64 } },
    {
      atSec: 7,
      camera: [
        {
          atSec: 0,
          camera: { kind: 'lookAt', distance: 14, height: 2, fovDeg: 40, anchor: TOWER },
        },
        { atSec: 4, camera: { kind: 'lookAt', distance: 8, height: 5, fovDeg: 50, anchor: TOWER } },
      ],
    },
    { atSec: 11.5, camera: { kind: 'flyby', distance: 9, height: 1.2, fovDeg: 44 } },
  ],
  lines: [
    { atSec: 0.5, holdSec: 2.5, text: 'ONE TRACK', look: 'title' },
    { atSec: 4, holdSec: 2.5, text: 'ONE JUMP', look: 'title' },
    { atSec: 8, holdSec: 3, text: 'EVERY LAP THE SAME LENGTH', look: 'title' },
    { atSec: 12, holdSec: 3, text: 'CUT ON THE MOMENT', look: 'title' },
  ],
});
const player = new CinematicPlayer(TRAILER, 'trailer');
// #endregion

// #region path
/* A move through the world with no subject: an eye and a point it looks at, keyed in time and
   passed through smoothly, looping round the track. */
const flight = createCameraPath(
  [
    { atSec: 0, eye: [22, 6, 16], target: [0, 1, 0], fovDeg: 50 },
    { atSec: 5, eye: [-8, 3, 14], target: [-15, 1, 0], fovDeg: 45 },
    { atSec: 10, eye: [-24, 9, -6], target: [-14, 1, 2], fovDeg: 40 },
    { atSec: 15, eye: [0, 20, -22], target: [0, 0, 0], fovDeg: 55 },
  ],
  { loop: true, periodSec: 20 },
);
const along = createCameraPathSample();
// #endregion

let mode = flag('camera', 'director');
controls([
  {
    key: 'camera',
    label: 'camera',
    value: mode,
    options: ['director', 'timeline', 'path'].map((m) => ({ text: m, value: m })),
    change: (value) => {
      mode = value;
      player.reset();
      rig.cut(SHOTS[director.shot.tag], rigClock);
    },
  },
  {
    key: 'time',
    label: 'jump',
    value: retimed ? 'slow' : 'real',
    options: ['real', 'slow'].map((t) => ({ text: t, value: t })),
    change: (value) => {
      retimed = value === 'slow';
    },
  },
]);

/* The scene: ground, the track as a ribbon, the ramps, rocks, the tower, and the buggy. */
const ground = new MeshBuilder().addBox([0, -0.1, 0], [40, 0.1, 40], [0.33, 0.4, 0.27]);
const at: Vec3 = [0, 0, 0];
const ahead: Vec3 = [0, 0, 0];
for (let k = 0; k < 120; k += 1) {
  const angle = (k / 120) * Math.PI * 2;
  if (angle > JUMP[0] - RAMP && angle < JUMP[1] + RAMP) continue;
  pointAt(angle, at);
  pointAt(angle + 0.06, ahead);
  ground.addOrientedBox(
    [at[0], 0.01, at[2]],
    [1.6, 0.01, 0.42],
    [ahead[0] - at[0], 0, ahead[2] - at[2]],
    [0.2, 0.19, 0.18],
  );
}
for (const [x, y, z] of ROCKS) ground.addBox([x, y, z], [1, 0.8, 1], [0.45, 0.43, 0.4]);
ground.addBox([TOWER[0], 2.5, TOWER[2]], [0.9, 2.5, 0.9], [0.55, 0.5, 0.45]);
ground.addBox([TOWER[0], 5.3, TOWER[2]], [1.2, 0.3, 1.2], [0.6, 0.25, 0.18]);
/* The take-off ramp and the landing, each a wedge across the track. */
for (const [edge, rising] of [
  [JUMP[0], true],
  [JUMP[1], false],
] as const) {
  const low = pointAt(rising ? edge - RAMP : edge + RAMP, [0, 0, 0]);
  const high = pointAt(edge, [0, 0, 0]);
  const half = 1.6;
  /* Each face both ways, since a wedge is seen from every side as the cameras move round it. */
  const face = (a: Vec3, b: Vec3, c: Vec3, d: Vec3, colour: Vec3): void => {
    ground.addQuad(a, b, c, d, colour);
    ground.addQuad(d, c, b, a, colour);
  };
  face(
    [low[0] - half, 0.01, low[2]],
    [low[0] + half, 0.01, low[2]],
    [high[0] + half, 0.9, high[2]],
    [high[0] - half, 0.9, high[2]],
    [0.62, 0.45, 0.28],
  );
  face(
    [high[0] - half, 0.9, high[2]],
    [high[0] + half, 0.9, high[2]],
    [high[0] + half, 0, high[2]],
    [high[0] - half, 0, high[2]],
    [0.5, 0.36, 0.22],
  );
  for (const side of [-half, half]) {
    /* A triangle, as a quad whose fourth corner is halfway along its bottom edge. */
    face(
      [low[0] + side, 0.01, low[2]],
      [high[0] + side, 0.9, high[2]],
      [high[0] + side, 0, high[2]],
      [(low[0] + high[0]) / 2 + side, 0.005, (low[2] + high[2]) / 2],
      [0.5, 0.36, 0.22],
    );
  }
}
const groundMesh = renderer.createMesh(ground.build());
const buggy = renderer.createMesh(
  new MeshBuilder()
    .addBox([0, 0.55, 0], [0.55, 0.25, 0.95], [0.85, 0.3, 0.12])
    .addBox([0, 0.9, 0.15], [0.45, 0.15, 0.45], [0.15, 0.17, 0.2])
    .addCylinder([0.6, 0.35, -0.6], 0.35, 0.12, 'x', [0.1, 0.1, 0.1], 0, 14)
    .addCylinder([-0.6, 0.35, -0.6], 0.35, 0.12, 'x', [0.1, 0.1, 0.1], 0, 14)
    .addCylinder([0.6, 0.35, 0.6], 0.35, 0.12, 'x', [0.1, 0.1, 0.1], 0, 14)
    .addCylinder([-0.6, 0.35, 0.6], 0.35, 0.12, 'x', [0.1, 0.1, 0.1], 0, 14)
    .build(),
);

const env = createEnvironment({
  directionalDir: [0.45, 0.75, 0.35],
  directionalColor: [1.7, 1.6, 1.45],
  ambient: [0.42, 0.46, 0.55],
  ambientGround: [0.16, 0.15, 0.12],
});
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.65;
env.shadowDepthSpan = computeLightMatrix(
  env.directionalDir,
  0,
  1,
  0,
  26,
  renderer.shadowMapSize,
  lightMatrix,
);
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const model = new Float32Array(16);
const readout = createReadout(renderer, 3);
const position: Vec3 = [0, 0, 0];
const next: Vec3 = [0, 0, 0];
let lapClock = 0;
let time = 0;
let yaw = 0;
let pathSec = 0;
let caption = '';

stage.run({
  simulate(dt) {
    time += dt;
    lapClock = (lapClock + dt) % LAP;
    const angle = angleAt(sourceSec(lapClock));
    pointAt(angle, position);
    pointAt(angle + 0.02, next);
    yaw = Math.atan2(next[0] - position[0], -(next[2] - position[2]));
    const airborne = position[1] > 1;
    const nearTower =
      Math.abs(angle - Math.PI / 2) < 0.35 || Math.abs(angle - 1.5 * Math.PI) < 0.35;

    // #region frame
    caption = '';
    if (mode === 'director') {
      const shot = exported<Direct>(script, 'direct')(director, rig, airborne, nearTower);
      if (shot.tag === 'some') rig.cut(SHOTS[shot.value.tag], rigClock);
    } else if (mode === 'timeline') {
      player.update(dt);
      if (player.shotChanged && player.shot !== null) rig.cut(player.shot, rigClock);
      caption = player.line?.text ?? '';
      if (player.finished) player.reset();
    }
    if (mode === 'path') {
      pathSec += dt;
      sampleCameraPath(flight, pathSec, along);
      camera.position[0] = along.eye[0];
      camera.position[1] = along.eye[1];
      camera.position[2] = along.eye[2];
      camera.lookAt(along.target[0], along.target[1], along.target[2]);
      camera.fovYDeg = along.fovDeg;
    } else {
      rigClock += dt;
      rig.update(dt, position[0], position[1], position[2], yaw);
      camera.position[0] = rig.camera.position[0] ?? 0;
      camera.position[1] = rig.camera.position[1] ?? 0;
      camera.position[2] = rig.camera.position[2] ?? 0;
      camera.yaw = rig.camera.yaw;
      camera.pitch = rig.camera.pitch;
      camera.fovYDeg = rig.camera.fovYDeg;
    }
    // #endregion
  },
  render() {
    const c = Math.cos(yaw);
    const s = Math.sin(yaw);
    model.set([c, 0, s, 0, 0, 1, 0, 0, -s, 0, c, 0, position[0], position[1], position[2], 1]);
    renderer.beginShadowPass(lightMatrix, 'static');
    renderer.drawShadowCasters((sink) => {
      sink.mesh(groundMesh, IDENTITY);
      sink.mesh(buggy, model);
    });
    renderer.endShadowPass();
    renderer.beginFrame([0.55, 0.66, 0.8]);
    renderer.bindMeshPass(camera, env);
    renderer.drawMesh(groundMesh, IDENTITY);
    renderer.drawMesh(buggy, model);
    const shotName = mode === 'path' ? 'A KEYED PATH' : shotLabel().toUpperCase();
    readout.set(0, `${mode.toUpperCase()}  ${shotName}  CUTS ${director.cuts}`);
    readout.set(
      1,
      mode === 'timeline'
        ? `TRAILER ${player.timeSec.toFixed(1)} / ${TRAILER.durationSec} S`
        : `LAP ${lapClock.toFixed(1)} S  JUMP ${retimed ? 'SLOWED, PAID BACK AFTER' : 'IN REAL TIME'}`,
    );
    readout.set(2, caption);
    readout.draw(time);
    renderer.endFrame();
  },
});

/** The shot the rig is in, by its kind. */
function shotLabel(): string {
  const shot = mode === 'timeline' ? player.shot : SHOTS[director.shot.tag];
  return shot === null || shot === undefined ? '' : `${shot.kind} ${rig.shotAgeSec.toFixed(1)} S`;
}

director.drs

examples/cinematic/director.drs
// The director: which shot to cut to, from what the buggy is doing. The page holds each shot's
// framing and makes the cut, because a script cannot build a shot's record yet, so this names the
// shot and the page looks it up.
//
// Under `npm run examples`, change a rule and save: the next cut follows it. Try holding shots
// longer, an orbit on every landing, or a director who never leaves the chase.

import { shotAge } from "drift/camera"

// The shots the page holds, one variant each.
enum Shot {
    Chase
    LowWide
    Orbit
    Flyby
    Overhead
    LookAt
}

data Director {
    shot: Shot = Shot.Chase
    cuts: u32 = 0
    // Whether the buggy was in the air last tick, so a take-off and a landing are each one moment.
    flying: bool = false
    // The least a shot holds, so the edit breathes, and the most before it moves on.
    least: f32 = 2
    most: f32 = 5
}

// #region direct
// A take-off cuts at once, across the line of travel and low, the shot that sells a gap; a landing
// cuts to an orbit round it. Otherwise a shot holds its time, the tower is framed when the buggy
// passes it, and a long shot gives way to the next in turn. Answers the shot to cut to, or `none`
// to hold the one on screen.
fn direct(director: mut Director, cam: Camera, airborne: bool, nearTower: bool) -> Shot? {
    let age = camera.shotAge(cam)
    var want = director.shot
    if airborne && !director.flying {
        want = Shot.LowWide
    } else if !airborne && director.flying {
        want = Shot.Orbit
    } else if age < director.least {
        want = director.shot
    } else if nearTower && director.shot != Shot.LookAt {
        want = Shot.LookAt
    } else if age > director.most {
        want = next(director.shot)
    }
    director.flying = airborne
    if want == director.shot {
        return none
    }
    director.shot = want
    director.cuts += 1
    return some(want)
}
// #endregion

// The running order when nothing else calls for a cut.
fn next(shot: Shot) -> Shot {
    return match shot {
        Chase => Shot.Flyby
        Flyby => Shot.Overhead
        _ => Shot.Chase
    }
}