Examples

Colour and motion

Exposure, a colour grade, vignette, grain, a fade, depth of field and motion blur.

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

Open on its own · Source

Colour and motion is the chapter that walks through it.

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

Source

main.ts

examples/look/main.ts
/**
 * A colonnade at dusk, a figure halfway down it, and a camera that every few seconds whips round.
 *
 * Everything here happens after the scene is drawn: the tone curve and the eye's exposure, a colour
 * grade built in code, a vignette and film grain, a fade in from black, focus on the figure, and
 * motion blur on the whip pan. The lens is a DriftScript module, `lens.drs`; the grade and the
 * film look are TypeScript, since a script reaches the render dials and not those. Every switch in
 * the strip changes the running scene.
 */
import { MeshBuilder, SceneNode, createEnvironment, identityGradeLut } from '@driftengine/core';
import type { ColourGradeLut, RendererApi } from '@driftengine/core';
import { patchModule } from 'driftscript';
import { exported, hostScript } from '../common/script';
import { controls, flag, openStage } from '../common/stage';
import * as lensScript from './lens.drs';

// #region quality
/** The ceilings: what each effect may cost. The per-frame dials below say how much of it to use. */
const stage = await openStage({
  outputTransform: 'aces',
  hdrScene: true,
  bloom: 0.35,
  bloomThreshold: 1.1,
  depthOfField: 0.02,
  cameraMotionBlur: 1,
  sceneSamples: 4,
});
// #endregion
const { renderer, camera } = stage;

// #region script
/** The lens, hosted: its record lives here, so a patched module carries on from it. */
const lens = hostScript(lensScript);
interface Lens {
  focusing: number;
  blurring: number;
}
const lensState = exported<() => Lens>(lens, 'createLens')();
type Present = (lens: Lens, renderer: RendererApi, turnRate: number, dt: number) => void;

if (import.meta.hot) {
  import.meta.hot.accept('./lens.drs', (next) => {
    if (next !== undefined)
      patchModule(lens, next as Record<string, unknown>, { Lens: [lensState] });
  });
}
// #endregion

const onOff = [
  { text: 'off', value: 'off' },
  { text: 'on', value: 'on' },
];
// #region switches
/** Each switch changes the running frame: a dial the lens reads, or a grade set once. */
controls([
  {
    key: 'grade',
    label: 'grade',
    value: flag('grade', 'warm'),
    options: ['none', 'warm', 'cool'].map((g) => ({ text: g, value: g })),
    change: (value) => applyGrade(value),
  },
  {
    key: 'focus',
    label: 'focus',
    value: flag('focus', 'on'),
    options: onOff,
    change: (value) => {
      lensState.focusing = value === 'on' ? 1 : 0;
    },
  },
  {
    key: 'blur',
    label: 'motion blur',
    value: flag('blur', 'on'),
    options: onOff,
    change: (value) => {
      lensState.blurring = value === 'on' ? 1 : 0;
    },
  },
]);
lensState.focusing = flag('focus', 'on') === 'on' ? 1 : 0;
lensState.blurring = flag('blur', 'on') === 'on' ? 1 : 0;
// #endregion

// #region grade
/**
 * A grade is a lookup table over display colours. Start from the identity and bend it: here every
 * colour leans warmer or cooler, the highlights more than the shadows.
 */
function gradeLut(look: 'warm' | 'cool'): ColourGradeLut {
  const lut = identityGradeLut(17);
  const warm = look === 'warm' ? 1 : -1;
  for (let i = 0; i < lut.data.length; i += 4) {
    const r = lut.data[i] / 255;
    const g = lut.data[i + 1] / 255;
    const b = lut.data[i + 2] / 255;
    const light = (r + g + b) / 3;
    const tilt = (0.3 + light) * 0.07 * warm;
    lut.data[i] = Math.round(Math.min(1, Math.max(0, r + tilt)) * 255);
    lut.data[i + 1] = Math.round(Math.min(1, Math.max(0, g + tilt * 0.3)) * 255);
    lut.data[i + 2] = Math.round(Math.min(1, Math.max(0, b - tilt)) * 255);
  }
  return lut;
}
const grades = { warm: gradeLut('warm'), cool: gradeLut('cool') };
function applyGrade(name: string): void {
  renderer.setColourGrade(name === 'warm' ? grades.warm : name === 'cool' ? grades.cool : null);
}
applyGrade(flag('grade', 'warm'));
// #endregion

const env = createEnvironment({
  directionalDir: [-0.6, 0.25, -0.4],
  directionalColor: [1.6, 1.1, 0.7],
  ambient: [0.14, 0.15, 0.22],
  ambientGround: [0.06, 0.05, 0.05],
  fogColor: [0.3, 0.27, 0.3],
  fogDensity: 0.01,
  nightFactor: 1,
  emissiveGain: 3,
});

/** Two rows of columns down a long floor, a lamp on each, and a figure halfway along. */
const set = new MeshBuilder();
set.addBox([0, -0.1, -30], [5, 0.1, 40], [0.4, 0.37, 0.34]);
for (let i = 0; i < 14; i += 1)
  for (const x of [-3, 3]) {
    set.addCylinder([x, 2.5, -i * 5], 0.3, 2.5, 'y', [0.62, 0.58, 0.52], 0, 14);
    set.addSphere([x * 0.85, 4.2, -i * 5], 0.12, [1, 0.7, 0.4], 1);
  }
set.addCapsule([0, 0.9, -15], 0.3, 0.6, [0.25, 0.32, 0.5]);
const setMesh = renderer.createMesh(set.build());
const still = new SceneNode();
still.updateWorld();

let time = 0;
let shownAt = 0;
let yaw = 0;
let lastYaw = 0;

stage.run({
  simulate(dt) {
    time += dt;
  },
  render() {
    const dt = time - shownAt;
    shownAt = time;

    /* Every eight seconds the camera whips aside and back over two seconds. */
    const phase = time % 8;
    const whip = phase > 6 ? Math.sin(((phase - 6) / 2) * Math.PI) : 0;
    yaw = whip * 0.7;
    const turnRate = Math.abs(yaw - lastYaw) / Math.max(dt, 1e-3);
    lastYaw = yaw;

    camera.fovYDeg = 50;
    camera.position[0] = 0.8;
    camera.position[1] = 1.6;
    camera.position[2] = 6;
    camera.lookAt(0.8 + Math.sin(yaw) * 20, 1.2, 6 - Math.cos(yaw) * 20);

    // #region dials
    /* The lens: exposure, focus on the figure, blur while turning, and the fade in. */
    exported<Present>(lens, 'present')(lensState, renderer, turnRate, dt);
    /* What a script does not reach: the eye's adaptation, the vignette and the grain. */
    renderer.setAutoExposure(0.6, dt);
    renderer.setVignette(0.35);
    renderer.setFilmGrain(0.025, Math.floor(time * 60));
    // #endregion

    renderer.beginFrame([0.3, 0.27, 0.3]);
    renderer.bindMeshPass(camera, env);
    renderer.drawMesh(setMesh, still.worldMatrix);
    renderer.endFrame();
  },
});

lens.drs

examples/look/lens.drs
// The lens over the colonnade: where it focuses, how much a turning camera blurs, and the fade in
// from black. The page owns the camera and calls `present` once a frame with how fast it turns.
//
// Under `npm run examples`, change a number in `present` and save: the page keeps running with
// the new code. Try focusing at 6 metres, on the nearest columns. The record below is state the
// page holds, so a reload of this file keeps it where it was.

import { exposure, focus, motionBlur, veil } from "drift/render"
import { clamp, max } from "std/math"

data Lens {
    // How much of the depth-of-field ceiling to take. The page's focus switch sets it.
    focusing: f32 = 1
    // How much of the motion-blur ceiling to take. The page's blur switch sets it.
    blurring: f32 = 1
    // Seconds of fade from black still to run.
    fade: f32 = 2
}

// #region present
fn present(lens: mut Lens, renderer: Renderer, turnRate: f32, dt: f32) {
    render.exposure(renderer, 1.2)
    // Focused on the figure 21 metres down the floor, with three metres sharp.
    render.focus(renderer, 21, 3, lens.focusing)
    // Blur only while the camera turns fast, in full at three radians a second: always on, it is a
    // filter nobody reads as speed.
    render.motionBlur(renderer, math.clamp(turnRate / 3, 0, 1) * lens.blurring)
    if lens.fade > 0 {
        lens.fade = math.max(lens.fade - dt, 0)
        render.veil(renderer, 0, 0, 0, lens.fade / 2)
    }
}
// #endregion