Lattice
Thin beams turn over a sky as embers rise through them. One pass description makes every drawing write colour, depth and motion. TAA blends each frame with the last.
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- depth.tsDisplays depth as greyscale eye distance from
view, with black for unwritten pixels. - lattice.tsDraws a rotating lattice into scene passes.
- motion.tsDisplays motion as colour.
- sky.ts
Kit modules bloom, cameras, clock, environment, finish, history, memo, owner, particles, passes, pipelines, taa, targets, tone. Also typegpu.
scene.ts
import { bloom, cameras, clock, finish, owner, Particle, particles, passes, pipelines, taa, targets, tone, type Frame, type Surface,} from '@typegpu/kit';import { d, std, type TgpuRoot } from 'typegpu';import type { Settings } from '../settings.ts';import { createDepthView } from './depth.ts';import { createLattice, HALF } from './lattice.ts';import { createMotionView } from './motion.ts';import { sky } from './sky.ts';const EMBERS = 1500;/** An ember's colour, linear and bright enough to bloom. */const EMBER_COLOR: [number, number, number, number] = [5, 1.8, 0.5, 1];/** World units across an ember. */const EMBER_SIZE = 0.07;/** World units a second an ember sways aside at most. */const SWAY = 0.2;/** Minimum and maximum rise speed in world units per second. */const RISE = [0.3, 0.7] as const;/** Camera clip planes in world units, which the depth view reads depth back with. */const NEAR = 0.1;const FAR = 500;export const defaults = { /** Smooths the beams' edges over several frames. */ taa: true, /** Turns a second of the lattice. */ speed: 0.15, /** Displays colour, TAA motion vectors or depth. */ view: 'picture' as 'picture' | 'motion' | 'depth', sky: true, beams: true, embers: true, /** Chooses scene outputs for embers, or excludes their motion so underlying motion remains. */ into: 'scene' as 'scene' | "scene.without('motion')", /** * Chooses rotation history or a plain uniform. History gives last-frame vertices the last- * frame angle. A uniform gives both frames the current angle. */ turn: 'history' as 'history' | 'uniform',};/** Wraps rising embers from the top to the bottom to maintain the field. */const rise = (ember: Particle) => { 'use gpu'; let next = Particle(ember); const sway = ember.age * 1.7 + ember.seed * 40; const rising = std.mix(RISE[0], RISE[1], ember.seed); next.velocity = d.vec3f(std.sin(sway) * SWAY, rising, std.cos(sway * 0.8) * SWAY); if (next.position.y > HALF) next.position.y = next.position.y - HALF * 2; return next;};/** Flickers embers, and fades them at the top and bottom to hide wrapping. */const glowing = (ember: Particle, local: d.v2f) => { 'use gpu'; const flicker = 0.65 + std.sin(ember.age * 11 + ember.seed * 60) * 0.35; const edge = std.saturate((HALF - std.abs(ember.position.y)) * 1.5); return particles.softDisc(ember, local) * (flicker * edge);};/** * Draws rotating beams and rising embers over a sky. Passes write colour, depth and motion for * TAA to blend with the last frame. */export async function createScene(root: TgpuRoot, surface: Surface) { const own = owner(); const screen = own( targets.create(root, surface, { hdr: { format: 'rgba16float', sampled: true }, motion: { format: 'rg16float', sampled: true }, depth: { format: 'depth24plus', sampled: true }, }), ); const view = cameras.orbit(root, { size: surface, target: [0, 0, 0], distance: 11, yaw: 0.6, pitch: 0.2, distanceRange: [4, 30], near: NEAR, far: FAR, }); const sim = clock.fixedStep({ step: 1 / 60 }); const values = { ...defaults, paused: false }; const aa = own( taa.create(root, { source: () => screen.textures.hdr, motion: () => screen.textures.motion, depth: () => screen.textures.depth, }), ); const scene = passes .create( root, screen, { hdr: passes.color, motion: taa.motion, depth: passes.depth }, { jitter: aa.jitter }, ) .pipe(view.inject()); const lattice = createLattice(root); const embers = own( particles.create(root, view, { capacity: EMBERS, step: sim.step, update: rise, shade: glowing, settings: { gravity: [0, 0, 0], drag: 0 }, }), ); embers.burst({ count: EMBERS, position: [0, 0, 0], area: [HALF, HALF, HALF], color: EMBER_COLOR, size: EMBER_SIZE, life: 1e9, }); const beams = { history: scene.add(lattice.drawing), uniform: scene.add(lattice.plain) }; // The sky leaves motion unwritten, so TAA reconstructs it from the far depth const skyDrawn = scene.add(sky); const rising = { scene: scene.add(embers.drawing), "scene.without('motion')": scene.without('motion').add(embers.drawing), }; const glow = own(bloom.create(root, { source: aa })); const final = own( finish.create(root, { format: surface.format, source: aa, tone: tone.aces, glow }), ); const motion = createMotionView(root, surface.format, () => screen.textures.motion); const depth = createDepthView(root, surface.format, () => screen.textures.depth, view); const outputs = { picture: final, motion, depth }; await pipelines.compile([scene, aa, glow, final, motion, depth]); const settings: Settings<typeof values> = { values, knobs: { taa: { label: 'TAA' }, speed: { label: 'turns per second', min: 0, max: 1, step: 0.01 }, view: { options: ['picture', 'motion', 'depth'] }, paused: { label: 'embers paused' }, sky: { folder: 'passes' }, beams: { folder: 'passes' }, embers: { folder: 'passes' }, into: { label: 'ember outputs', folder: 'passes', options: ['scene', "scene.without('motion')"], }, turn: { label: 'rotation source', folder: 'passes', options: ['history', 'uniform'] }, }, write(key) { if (key === undefined || key === 'taa') aa.enable(values.taa); if (key === undefined || key === 'paused') sim.paused = values.paused; }, }; let turned = 0; return { view, settings, frame(frame: Frame) { view.update(frame.dt); screen.fit(); if (values.speed !== 0) { turned += frame.dt * values.speed; lattice.turn(turned); } const compute = frame.encoder.beginComputePass(); embers.spawn(compute); for (let steps = sim.advance(frame.dt); steps > 0; steps--) embers.step(compute); embers.compact(compute, sim.ahead); compute.end(); lattice.advance(frame); const pass = scene.begin(frame); if (values.beams) beams[values.turn].draw(pass); if (values.sky) skyDrawn.draw(pass); if (values.embers) rising[values.into].draw(pass); pass.end(); aa.record(frame); if (values.view === 'picture') glow.record(frame); outputs[values.view].record(frame, surface.view(frame)); }, destroy: own.destroy, };}depth.ts
import { cameras, memo, type CameraBase, type Frame, type targets } from '@typegpu/kit';import { common, d, std, tgpu, type TgpuRoot } from 'typegpu';const layout = tgpu.bindGroupLayout({ depth: { texture: d.textureDepth2d() } });/** Eye distance shown as white. Twice this distance is half as bright. */const BRIGHT = 4;/** Displays depth as greyscale eye distance from `view`, with black for unwritten pixels. */export function createDepthView( root: TgpuRoot, format: GPUTextureFormat, depth: () => targets.Depth, view: CameraBase,) { const fragment = tgpu.fragmentFn({ in: { position: d.builtin.position }, out: d.vec4f, })(({ position }) => { 'use gpu'; const z = std.textureLoad(layout.$.depth, d.vec2i(position.xy), 0); if (z >= 1) return d.vec4f(0, 0, 0, 1); const distance = cameras.distanceOf(cameras.current.$, z); return d.vec4f(d.vec3f(std.saturate(BRIGHT / distance)), 1); }); const pipeline = root.pipe(view.inject()).createRenderPipeline({ vertex: common.fullScreenTriangle, fragment, targets: { format }, }); // Refresh the binding when the depth target is rebuilt const bound = memo((texture: targets.Depth) => pipeline.with(root.createBindGroup(layout, { depth: texture.createView(d.textureDepth2d()) })), ); return { initAsync: () => pipeline.initAsync(), /** Draws into a pass of its own on `view`, such as `surface.view(frame)`. */ record(frame: Frame, into: GPUTextureView) { const pass = frame.encoder.beginRenderPass({ colorAttachments: { view: into } }); pass.setPipeline(bound(depth())); pass.draw(3); pass.end(); }, };}lattice.ts
import { cameras, history, passes } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { d, std, tgpu, type TgpuRoot } from 'typegpu';/** Nodes along each side of the lattice, a unit apart. */const SIDE = 6;/** Half-extents measured from the centre to the outer faces. */export const HALF = SIDE / 2;/** Beams along each axis. */const ROW = SIDE * SIDE * (SIDE - 1);const BEAMS = 3 * ROW;/** Beam width falls below one pixel after a few nodes, causing shimmer without TAA. */const THICKNESS = 0.035;/** Radians the lattice tips towards the camera, so its top shows. */const TILT = 0.35;const SUN = std.normalize(d.vec3f(0.5, 0.8, 0.35));// Steel and ember-glow colours are in linear lightconst STEEL = d.vec3f(0.55, 0.6, 0.7);const EMBER = d.vec3f(2.4, 0.7, 0.15);const beam = meshes.unindexed(meshes.box());const Corner = d.struct({ position: d.vec4f, world: d.vec3f, normal: d.vec3f });const Seen = d.struct({ world: d.vec3f, color: d.vec4f });type Point = d.Infer<typeof Corner>;type Lit = d.Infer<typeof Seen>;/** Rotation about y in radians. History provides the last-frame value. */const turn = tgpu.accessor(d.f32);const placed = (point: d.v3f) => { 'use gpu'; const tilted = d.vec3f( point.x, point.y * std.cos(TILT) - point.z * std.sin(TILT), point.y * std.sin(TILT) + point.z * std.cos(TILT), ); const c = std.cos(turn.$); const s = std.sin(turn.$); return d.vec3f(c * tilted.x + s * tilted.z, tilted.y, c * tilted.z - s * tilted.x);};const at = (vertexIndex: number, instanceIndex: number) => { 'use gpu'; const axis = std.intdiv(instanceIndex, d.u32(ROW)); const k = instanceIndex % ROW; const along = d.f32(k % (SIDE - 1)) + 0.5; const a = d.f32(std.intdiv(k, d.u32(SIDE - 1)) % SIDE); const b = d.f32(std.intdiv(k, d.u32((SIDE - 1) * SIDE))); let middle = d.vec3f(along, a, b); let size = d.vec3f(1 + THICKNESS, THICKNESS, THICKNESS); if (axis === 1) { middle = d.vec3f(a, along, b); size = d.vec3f(THICKNESS, 1 + THICKNESS, THICKNESS); } if (axis === 2) { middle = d.vec3f(a, b, along); size = d.vec3f(THICKNESS, THICKNESS, 1 + THICKNESS); } const corner = beam.vertexAt(vertexIndex); const world = placed(middle - (SIDE - 1) / 2 + corner.position * size); return Corner({ position: cameras.current.$.viewProjection * d.vec4f(world, 1), world, normal: placed(corner.normal), });};const surfaceOf = (corner: Point) => { 'use gpu'; const light = std.max(std.dot(std.normalize(corner.normal), SUN), 0); const glow = std.exp((-HALF - corner.world.y) * 0.9); return Seen({ world: corner.world, color: d.vec4f(STEEL * (0.06 + light) + EMBER * glow, 1) });};/** * Draws a rotating lattice into scene passes. Rotation uses history so last-frame vertex * evaluation reads the last-frame angle. */export function createLattice(root: TgpuRoot) { const turned = history.create(root, d.f32, { usage: 'uniform' }); const drawing: passes.Drawing<typeof Corner, Lit> = { Vertex: Corner, at, surfaceOf, color: passes.shaded, primitive: { cullMode: 'back' }, inject: () => (cfg) => cfg.with(turn, turned.reading), draw: (pass) => pass.draw(beam.vertexCount, BEAMS), }; const plain: passes.Drawing<typeof Corner, Lit> = { ...drawing, inject: () => (cfg) => cfg.with(turn, turned.current.as('uniform')), }; return { drawing, /** * The same beams using a plain rotation uniform, so motion vectors contain only camera * motion. */ plain, /** Queues `angle` for the next `advance`. */ turn: (angle: number) => turned.write(angle), /** * Saves the current rotation as history and applies the queued angle. Call once per frame * before drawing. */ advance: turned.advance, };}motion.ts
import { memo, taa, type Frame } from '@typegpu/kit';import { common, d, std, tgpu, type TgpuRoot, type TgpuTexture } from 'typegpu';const layout = tgpu.bindGroupLayout({ motion: { texture: d.texture2d(d.f32) } });/** Motion scale in pixels per frame. The lattice moves much less than one pixel. */const SCALE = 1;/** * Displays motion as colour. Grey means still, red and cyan mean horizontal motion, and green * and magenta mean vertical motion. Black marks unwritten pixels. */export function createMotionView( root: TgpuRoot, format: GPUTextureFormat, motion: () => TgpuTexture & { usableAsSampled: true },) { const fragment = tgpu.fragmentFn({ in: { position: d.builtin.position }, out: d.vec4f, })(({ position }) => { 'use gpu'; const moved = std.textureLoad(layout.$.motion, d.vec2i(position.xy), 0).xy; if (!taa.written(moved)) return d.vec4f(0, 0, 0, 1); const pixels = moved * d.vec2f(std.textureDimensions(layout.$.motion)); const shade = std.saturate(pixels / (SCALE * 2) + 0.5); return d.vec4f(shade.x, shade.y, 1 - shade.x, 1); }); const pipeline = root.createRenderPipeline({ vertex: common.fullScreenTriangle, fragment, targets: { format }, }); // Refresh the binding when the motion target is rebuilt const bound = memo((texture: TgpuTexture & { usableAsSampled: true }) => pipeline.with(root.createBindGroup(layout, { motion: texture })), ); return { initAsync: () => pipeline.initAsync(), /** Draws into a pass of its own on `view`, such as `surface.view(frame)`. */ record(frame: Frame, view: GPUTextureView) { const pass = frame.encoder.beginRenderPass({ colorAttachments: { view } }); pass.setPipeline(bound(motion())); pass.draw(3); pass.end(); }, };}sky.ts
import { environment } from '@typegpu/kit';import { d, std } from 'typegpu';const ZENITH = d.vec3f(0.004, 0.006, 0.03);const HORIZON = d.vec3f(0.22, 0.07, 0.04);const GROUND = d.vec3f(0.012, 0.006, 0.006);const duskAt = (direction: d.v3f) => { 'use gpu'; const above = std.mix(HORIZON, ZENITH, std.pow(std.saturate(direction.y), 0.45)); const below = std.mix(HORIZON, GROUND, std.saturate(-direction.y * 6)); return std.select(below, above, direction.y > 0);};/** A dusk gradient behind everything, drawn after the beams. */export const sky = environment.background(duskAt, { label: 'sky' });