Afterimage

A bronze Foucault pendulum swings over a ring of pegs in a brutalist concrete hall, under a coffered barrel vault. The low sun streams through the piers and the slots above them. Low-resolution rendering, bloom and an ordered colour dither, which the sun shafts share, give the picture a sixth-gen texture. The sun's bounce and the sky's occlusion are baked into the hall's vertex colours, as games of that era lit their worlds. Optional image feedback adds a brief trail. Compare it with the clean render. The hall and its pendulum were modelled in Blender for the kit and are CC0.

Files, in reading order

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. arcade.ts
  3. atmosphere.ts
  4. feedback.ts
  5. signal.ts
  6. assets.ts

Kit modules bloom, cameras, environment, finish, instances, lighting, memo, meshPool, owner, passes, pipelines, shading, targets, tone. Also @typegpu/color, @typegpu/noise, typegpu.

scene.ts

import {  bloom,  cameras,  environment,  finish,  lighting,  meshPool,  owner,  passes,  pipelines,  targets,  tone,  type Frame,  type Surface,} from '@typegpu/kit';import { d, std, type TgpuRoot } from 'typegpu';import type { Settings } from '../settings.ts';import { createArcade, ROOM, type Part } from './arcade.ts';import { createAtmosphere } from './atmosphere.ts';import { createFeedback } from './feedback.ts';import { createSignal } from './signal.ts';/** A warm haze low down, cooling to blue overhead, for `environment.background`. */const sky = (look: d.v3f) => {  'use gpu';  const up = std.saturate(look.y * 2);  return std.mix(d.vec3f(0.82, 0.67, 0.47), d.vec3f(0.29, 0.38, 0.43), up) * 1.6;};const defaults = {  compare: 'processed' as 'processed' | 'clean',  lines: 320,  feedback: false,  persistence: 0.05,  bloom: true,  shafts: true,  quantize: true,  swing: true,};export interface SceneOptions {  /** The hall and the pendulum, as `gltf.read` returns their parts. */  readonly model: Readonly<Record<Part, meshPool.ModelData>>;  /** Receives compilation progress as a fraction after each part finishes. */  readonly onProgress?: (fraction: number) => void;}/** How close the eye comes to the hall's walls, beams and floor, in metres. */const MARGIN = 0.45;const LOOK = { exposure: 0.9, vignette: 0.28, dither: 0 };/** How far a ray from `from` may go `along` an axis and stay the margin inside `low` and `high`. */const across = (from: number, along: number, [low, high]: readonly [number, number]) =>  along === 0 ? Infinity : ((along > 0 ? high - MARGIN : low + MARGIN) - from) / along;/** How far the eye may back away from `target` along a yaw and pitch and stay in the room. */const reach = (target: ArrayLike<number>, yaw: number, pitch: number) =>  Math.min(    across(target[0]!, Math.cos(pitch) * Math.sin(yaw), ROOM.x),    across(target[1]!, Math.sin(pitch), ROOM.y),    across(target[2]!, Math.cos(pitch) * Math.cos(yaw), ROOM.z),  );export async function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions) {  const own = owner();  const values = { ...defaults };  const size = targets.scaled(surface, () =>    values.compare === 'clean' ? 1 : Math.min(1, values.lines / surface.height),  );  const screen = own(    targets.create(root, size, {      hdr: { format: 'rgba16float', sampled: true },      depth: { format: 'depth24plus', sampled: true },    }),  );  const view = own(    cameras.orbit(root, {      size: surface,      target: [0, 3.4, -1],      distance: 12,      yaw: 0.3,      pitch: 0.06,      fov: Math.PI / 3.2,      pitchRange: [-0.05, 0.75],      distanceRange: [4, 14],      reach,      near: 0.1,      far: 90,    }),  );  const light = own(    lighting.create(root, {      settings: {        sunDirection: [-1, 0.6, -0.4],        sunColor: [1, 0.78, 0.5],        sunIntensity: 3.4,        skyTop: [0.31, 0.39, 0.43],        skyBottom: [0.17, 0.125, 0.075],        skyIntensity: 0.34,        skyReflection: 1.6,        fogColor: [0.14, 0.105, 0.065],        fogDensity: 0.009,        fogFalloff: 0.07,        fogStart: 3,      },      shadow: { center: [-3, 4, -2.5], extent: 44, size: 2048, filter: 4, lift: 0.5 },    }),  );  const scene = own(    passes      .create(root, screen, { hdr: passes.color, depth: passes.depth })      .pipe(view.inject())      .beneath(light.inject()),  );  const arcade = own(createArcade(root, options.model));  const drawn = [scene.add(arcade.renderer), scene.add(environment.background(sky))];  const cast = [light.shadow.add(arcade.renderer)];  const air = own(    createAtmosphere(root, view, light, {      size,      source: () => screen.textures.hdr,      depth: () => screen.textures.depth,    }),  );  const picture = () => (values.shafts ? air.output : screen.textures.hdr);  const glow = own(    bloom.create(root, { source: picture, levels: 5, settings: { threshold: 0.9, spread: 1.1 } }),  );  const echo = own(createFeedback(root, size, picture, glow));  const signal = createSignal(root, size);  const processed = own(    finish.create(root, {      format: surface.format,      source: echo,      tone: tone.aces,      filter: 'sharp',      grade: signal.grade,      settings: LOOK,    }),  );  const clean = own(    finish.create(root, {      format: surface.format,      source: () => screen.textures.hdr,      tone: tone.aces,      settings: LOOK,    }),  );  await Promise.all([    arcade.ready,    pipelines.compile([scene, light.shadow, air, glow, echo, processed, clean], options),  ]);  const settings: Settings<typeof values> = {    values,    knobs: {      compare: { options: ['processed', 'clean'] },      lines: { label: 'render height', options: [240, 320, 480] },      feedback: { label: 'image feedback' },      persistence: { label: 'trail half-life (s)', min: 0.02, max: 0.5, step: 0.01 },      bloom: {},      shafts: { label: 'sun shafts' },      quantize: { label: '5-bit colour' },      swing: { label: 'swing pendulum' },    },    write(key) {      if (key === undefined || key === 'quantize') signal.precision.write(values.quantize ? 31 : 0);      if (key !== 'persistence' && key !== 'swing') echo.reset();    },    actions: { 'clear afterimage': echo.reset },  };  let time = 0;  // The goal as the last frame left it, to tell a cut from a drag  const last = { yaw: view.goal.yaw, pitch: view.goal.pitch, distance: view.goal.distance };  const trail = { halfLife: 0, bloom: 0, vertical: 0.45 };  return {    view,    settings,    frame(frame: Frame) {      if (values.swing) time += frame.dt;      arcade.swing(Math.sin(time * 1.13 + 0.4) * 0.28);      arcade.flush();      const { goal } = view;      const cut =        Math.abs(goal.yaw - last.yaw) + Math.abs(goal.pitch - last.pitch) > 0.3 ||        Math.abs(goal.distance - last.distance) > 2;      if (cut) echo.reset();      last.yaw = goal.yaw;      last.pitch = goal.pitch;      last.distance = goal.distance;      screen.fit();      view.update(frame.dt);      light.shadow.record(frame, cast);      scene.record(frame, drawn);      if (values.compare === 'clean') {        clean.record(frame, surface.view(frame));        return;      }      if (values.shafts) air.record(frame);      if (values.bloom) glow.record(frame);      signal.fit();      trail.halfLife = values.feedback ? values.persistence : 0;      trail.bloom = values.bloom ? 0.22 : 0;      echo.record(frame, trail);      processed.record(frame, surface.view(frame));    },    destroy: own.destroy,  };}

arcade.ts

import { instances, meshPool, shading } from '@typegpu/kit';import { perlin3d } from '@typegpu/noise';import { d, std, type TgpuRoot } from 'typegpu';/** The named nodes of `models/arcade.glb`: the hall, and the pendulum about its pivot. */export const PARTS = ['arcade', 'pendulum'] as const;export type Part = (typeof PARTS)[number];/** The glTF materials, in the order the shader numbers them. */const FINISHES = ['concrete', 'floor', 'ground', 'stone', 'bronze', 'brass', 'steel'] as const;const [, FLOOR, GROUND, STONE, BRONZE, BRASS, STEEL] = FINISHES.map((_, i) => i);/** Where the pendulum hangs, in the model's metres. */const PIVOT: [number, number, number] = [0, 9.7, 0];/** * The free air inside the hall, in metres: between the piers and the east wall's pilasters, above * the floor, below the transverse beams and between the end walls. */export const ROOM = { x: [-4.95, 4.7], y: [0.3, 7.9], z: [-20, 15] } as const;/** * The sun's light bounced around the hall, baked into each vertex's colour as a share of its * brightest value. Blender baked it as 0.0514 of a unit sun, whose diffuse light carries a 1/π * the kit's leaves out; the kit's sun has intensity 3.4. */const BOUNCE = 0.0514 * Math.PI * 3.4 * 0.35;/** The sky the unbaked pendulum sees in the dark hall, as an occlusion. */const UNBAKED_SKY = 0.4;/** The warm light the bronze's edge catches from the sunlit hall around it. */const RIM = d.vec3f(1, 0.52, 0.2);/** * A line every `spacing` metres along `t`, 0 on the line and 1 off it, blurred over `blur` * metres, `t`'s change across a pixel, and fading where lines crowd closer than pixels. */const groove = (t: number, blur: number, spacing: number, width: number) => {  'use gpu';  const offset = std.abs(std.fract(t / spacing + 0.5) - 0.5) * spacing;  const fade = std.saturate(1.5 - (blur * 6) / spacing);  return 1 - (1 - std.smoothstep(width, width + blur, offset)) * fade;};export function createArcade(root: TgpuRoot, model: Readonly<Record<Part, meshPool.ModelData>>) {  const pool = meshPool.create(root, {    ...meshPool.roomFor([model.arcade, model.pendulum]),    extra: { color: d.vec4f },  });  /**   * Board-formed concrete, a polished floor, garden gravel, dark stone and bronze. The baked   * colour carries the sun's bounce in rgb and the occlusion of the sky in alpha.   */  const Hall = instances.define('Hall', {    host: { finish: d.u32, baked: d.f32 },    carry: (vertexIndex) => {      'use gpu';      return pool.extra.color(vertexIndex);    },    material: (point, hall, _base, carried) => {      'use gpu';      const p = point.position;      const n = point.normal;      // Derivatives run first, in uniform control flow      const blur = std.max(std.fwidth(p), d.vec3f(1e-4));      const finish = hall.finish;      const light = std.mix(d.vec4f(0, 0, 0, UNBAKED_SKY), carried, hall.baked);      const grazing = 1 - std.saturate(std.dot(n, point.view));      const grain = perlin3d.sample(p * 7.5);      const stain = perlin3d.sample(p * 0.43);      const upright = 1 - std.smoothstep(0.5, 0.8, std.abs(n.y));      const along = std.select(p.z, p.x, std.abs(n.x) < std.abs(n.z));      // Concrete: boards cast 30 cm high, each poured a shade apart, with lift lines and streaks      const board = std.floor(p.y / 0.3);      const pour = perlin3d.sample(d.vec3f(board * 1.7, along * 0.35, 0.5));      const streak = perlin3d.sample(d.vec3f(along * 2.3, p.y * 0.12, 0.25));      let tone = 0.9 + grain * 0.05 + stain * 0.1 + (pour * 0.05 + streak * 0.07) * upright;      tone *= std.mix(1, groove(p.y, blur.y, 0.3, 0.008) * 0.06 + 0.94, upright);      tone *= std.mix(1, groove(p.y + 0.05, blur.y, 1.35, 0.012) * 0.2 + 0.8, upright);      let color = d.vec3f(0.43, 0.405, 0.35) * tone;      let roughness = d.f32(0.88);      let metallic = d.f32(0);      if (finish === FLOOR) {        const joint = std.min(groove(p.x, blur.x, 2.5, 0.012), groove(p.z, blur.z, 2.5, 0.012));        color =          d.vec3f(0.3, 0.28, 0.235) * (0.84 + grain * 0.05 + stain * 0.12) * (0.55 + joint * 0.45);        roughness = 0.42 + stain * 0.2;      }      if (finish === GROUND) {        color = d.vec3f(0.22, 0.18, 0.13) * (0.75 + perlin3d.sample(p * 3.1) * 0.3 + stain * 0.2);        roughness = 1;      }      if (finish === STONE) {        color = d.vec3f(0.05, 0.047, 0.042) * (1 + grain * 0.15);        roughness = 0.22;      }      let rim = d.f32(0);      // Dark bronze with a little patina, and polished brass, both lit hot at their edges      if (finish === BRONZE || finish === BRASS) {        const patina =          std.smoothstep(0.05, 0.45, stain + grain * 0.18) * std.select(1, 0.2, finish === BRASS);        color = std.mix(d.vec3f(0.4, 0.2, 0.07), d.vec3f(0.08, 0.12, 0.09), patina * 0.6);        roughness = 0.2 + patina * 0.3;        metallic = 1;        if (finish === BRASS) color = std.mix(d.vec3f(0.9, 0.6, 0.24), color, patina);        rim = std.pow(grazing, 4) * (1 - patina) * 0.7;      }      if (finish === STEEL) {        color = d.vec3f(0.065, 0.06, 0.05);        metallic = 0.7;        roughness = 0.45;      }      return shading.Material({        color,        roughness,        metallic,        occlusion: light.w,        emission: color * light.xyz * (BOUNCE * (1 - metallic)) + RIM * rim,      });    },  });  const set = instances.create(root, pool, { kind: Hall, capacity: 16 });  const hall = pool.add(model.arcade);  const swinging = pool.add(model.pendulum);  const finishOf = (part: Part, i: number) =>    FINISHES.indexOf(model[part].meshes[i]!.material.name as (typeof FINISHES)[number]);  set.add(hall, (_, i) => ({ finish: finishOf('arcade', i), baked: 1 }));  const pendulum = set.add(swinging, (_, i) => ({    finish: finishOf('pendulum', i),    baked: 0,    position: PIVOT,  }));  const turn: [number, number, number, number] = [0, 0, 0, 1];  const renderer = set.renderer();  return {    renderer,    ready: Promise.all([hall.ready, swinging.ready]),    /** Turns the pendulum `radians` about its pivot, across the hall. */    swing(radians: number) {      turn[2] = Math.sin(radians / 2);      turn[3] = Math.cos(radians / 2);      set.write(pendulum, { rotation: turn });    },    flush: () => set.flush(),    destroy() {      set.destroy();      pool.destroy();    },  };}

atmosphere.ts

import {  cameras,  memo,  shading,  targets,  type CameraBase,  type Frame,  type Lighting,  type SizeSource,  type finish,} from '@typegpu/kit';import { common, d, std, tgpu, type TgpuRoot } from 'typegpu';import { ordered } from './signal.ts';const layout = tgpu.bindGroupLayout({  source: { texture: d.texture2d(d.f32) },  depth: { texture: d.textureDepth2d() },  sampler: { sampler: 'filtering' },});export function createAtmosphere(  root: TgpuRoot,  view: CameraBase,  light: Lighting,  options: { size: SizeSource; source: () => finish.FloatTexture; depth: () => targets.Depth },) {  const { size, source, depth } = options;  const screen = targets.create(root, size, { hdr: { format: 'rgba16float', sampled: true } });  const sampler = root.createSampler({ minFilter: 'linear', magFilter: 'linear' });  const fragment = tgpu.fragmentFn({    in: { uv: d.vec2f, position: d.builtin.position },    out: d.vec4f,  })(({ uv, position }) => {    'use gpu';    const dimensions = std.textureDimensions(layout.$.depth);    const pixel = std.min(d.vec2i(uv * d.vec2f(dimensions)), d.vec2i(dimensions) - 1);    const z = std.textureLoad(layout.$.depth, pixel, 0);    const camera = cameras.current.$;    const ray = cameras.rayOf(camera, uv * d.vec2f(2, -2) + d.vec2f(-1, 1));    const distance = std.min(      38,      cameras.distanceOf(camera, z) /        std.max(0.1, std.dot(ray.direction, cameras.forwardOf(camera))),    );    const stride = distance / 24;    // March from the dither's threshold, so the steps' grain repeats its pattern    const offset = ordered(d.vec2u(position.xy));    let beam = d.f32(0);    for (let step = d.u32(0); step < 24; step++) {      const world = ray.origin + ray.direction * ((d.f32(step) + offset) * stride);      const inside =        std.smoothstep(-6.6, -5.8, world.x) *        (1 - std.smoothstep(4.6, 5, world.x)) *        (1 - std.smoothstep(9.5, 10.1, world.y)) *        std.smoothstep(-20, -18.5, world.z) *        (1 - std.smoothstep(13.5, 15, world.z));      const density = 0.018 * std.exp(-std.max(0, world.y) * 0.065) * inside;      const lit = shading.sunShare(shading.pointAt(world, d.vec3f(0, 1, 0)));      beam += lit * density * stride;    }    const facing = std.pow(std.saturate(std.dot(ray.direction, shading.towardsSun())), 8);    const shafts = d.vec3f(1, 0.69, 0.36) * (beam * (1 + facing * 2.5));    const picture = std.textureSampleLevel(layout.$.source, layout.$.sampler, uv, 0).xyz;    return d.vec4f(picture + shafts, 1);  });  const pipeline = root    .pipe(view.inject())    .pipe(light.inject())    .createRenderPipeline({      vertex: common.fullScreenTriangle,      fragment,      targets: { format: 'rgba16float' },    });  const bound = memo((color: finish.FloatTexture, z: targets.Depth) =>    pipeline.with(root.createBindGroup(layout, { source: color, depth: z, sampler })),  );  const output = { colorAttachments: { view: screen.views.hdr } };  bound(source(), depth());  return {    get output() {      return screen.textures.hdr;    },    initAsync: () => pipeline.initAsync(),    record(frame: Frame) {      screen.fit();      output.colorAttachments.view = screen.views.hdr;      const pass = frame.encoder.beginRenderPass(output);      pass.setPipeline(bound(source(), depth()));      pass.draw(3);      pass.end();    },    destroy: screen.destroy,  };}

feedback.ts

import { memo, targets, type Frame, type SizeSource, type finish } from '@typegpu/kit';import { common, d, std, tgpu, type TgpuRoot } from 'typegpu';const State = d.struct({ retained: d.f32, glow: d.f32, vertical: d.f32 });const layout = tgpu.bindGroupLayout({  source: { texture: d.texture2d(d.f32) },  previous: { texture: d.texture2d(d.f32) },  glow: { texture: d.texture2d(d.f32) },  sampler: { sampler: 'filtering' },});export function createFeedback(  root: TgpuRoot,  size: SizeSource,  source: () => finish.FloatTexture,  glow: finish.Rendered,) {  const screen = targets.create(root, size, {    a: { format: 'rgba16float', sampled: true },    b: { format: 'rgba16float', sampled: true },  });  const state = { retained: 0, glow: 0, vertical: 0 };  const uniform = root.createUniform(State, state).$name('afterimage feedback');  const sampler = root.createSampler({ minFilter: 'linear', magFilter: 'linear' });  const fragment = tgpu.fragmentFn({ in: { uv: d.vec2f }, out: d.vec4f })(({ uv }) => {    'use gpu';    const current = std.textureSampleLevel(layout.$.source, layout.$.sampler, uv, 0).xyz;    const light = std.textureSampleLevel(layout.$.glow, layout.$.sampler, uv, 0).xyz;    let color = current + light * uniform.$.glow;    if (uniform.$.retained > 0) {      const texel = d.vec2f(0, 1) / d.vec2f(std.textureDimensions(layout.$.previous));      const above = std.textureSampleLevel(layout.$.previous, layout.$.sampler, uv - texel, 0).xyz;      const below = std.textureSampleLevel(layout.$.previous, layout.$.sampler, uv + texel, 0).xyz;      const previous = std.textureSampleLevel(layout.$.previous, layout.$.sampler, uv, 0).xyz;      const filtered = std.mix(        previous,        previous * 0.5 + (above + below) * 0.25,        uniform.$.vertical,      );      color = std.max(color, filtered * uniform.$.retained);    }    return d.vec4f(color, 1);  });  const pipeline = root.createRenderPipeline({    vertex: common.fullScreenTriangle,    fragment,    targets: { format: 'rgba16float' },  });  const bound = memo(    (      color: finish.FloatTexture,      light: finish.FloatTexture,      a: finish.FloatTexture,      b: finish.FloatTexture,    ) => [      pipeline.with(        root.createBindGroup(layout, { source: color, previous: b, glow: light, sampler }),      ),      pipeline.with(        root.createBindGroup(layout, { source: color, previous: a, glow: light, sampler }),      ),    ],  );  const output = { colorAttachments: { view: screen.views.a } };  bound(source(), glow.output, screen.textures.a, screen.textures.b);  let index = 0;  let valid = false;  return {    get output() {      return index === 0 ? screen.textures.a : screen.textures.b;    },    initAsync: () => pipeline.initAsync(),    reset() {      valid = false;    },    record(frame: Frame, options: { halfLife: number; bloom: number; vertical: number }) {      if (screen.fit()) valid = false;      state.retained =        valid && options.halfLife > 0 ? Math.exp((-Math.LN2 * frame.dt) / options.halfLife) : 0;      state.glow = options.bloom;      state.vertical = options.vertical;      uniform.write(state);      index = 1 - index;      output.colorAttachments.view = index === 0 ? screen.views.a : screen.views.b;      const pass = frame.encoder.beginRenderPass(output);      pass.setPipeline(bound(source(), glow.output, screen.textures.a, screen.textures.b)[index]!);      pass.draw(3);      pass.end();      valid = true;    },    destroy: screen.destroy,  };}

signal.ts

import { linearToSrgb, srgbToLinear } from '@typegpu/color';import type { SizeSource } from '@typegpu/kit';import { d, std, type TgpuRoot } from 'typegpu';/** The 4×4 ordered dither's threshold at a pixel, from 1/32 to 31/32. */export const ordered = (pixel: d.v2u) => {  'use gpu';  const x = pixel.x & 3;  const y = pixel.y & 3;  const low = ((x & 1) ^ (y & 1)) * 2 + (y & 1);  const high = (((x >>> 1) & 1) ^ ((y >>> 1) & 1)) * 2 + ((y >>> 1) & 1);  return (d.f32(low * 4 + high) + 0.5) / 16;};export function createSignal(root: TgpuRoot, size: SizeSource) {  const precision = root.createUniform(d.f32, 31);  const dimensions = [size.width, size.height] as [number, number];  const grid = root.createUniform(d.vec2f, dimensions);  return {    precision,    fit() {      if (dimensions[0] === size.width && dimensions[1] === size.height) return;      dimensions[0] = size.width;      dimensions[1] = size.height;      grid.write(dimensions);    },    grade: (color: d.v3f, uv: d.v2f) => {      'use gpu';      if (precision.$ === 0) return d.vec3f(color);      const threshold = ordered(d.vec2u(std.floor(uv * grid.$)));      const encoded = linearToSrgb(std.max(color, d.vec3f(0)));      return srgbToLinear(std.floor(encoded * precision.$ + threshold) / precision.$);    },  };}

assets.ts

import { glb } from '../assets.ts';import { PARTS } from './arcade.ts';/** The hall and its pendulum, one Draco-compressed file made in Blender for the kit. */export const assets = { model: glb('models/arcade.glb', { parts: PARTS, draco: true }) };