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
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- arcade.ts
- atmosphere.ts
- feedback.ts
- signal.ts
- 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 }) };