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

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. depth.tsDisplays depth as greyscale eye distance from view, with black for unwritten pixels.
  3. lattice.tsDraws a rotating lattice into scene passes.
  4. motion.tsDisplays motion as colour.
  5. 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' });