Kinetic

A mobile of thirteen pieces on four wire arms turns slowly under a sky: Calder's red, yellow, blue, black and white in lacquer, and below them mirror, brushed steel, brass and copper. Its wires are the kit's lines, lit as the pieces are, with antialiased edges. An environment bakes each sky on the GPU into a cube of reflections at six roughnesses and nine spherical harmonics of diffuse light, which light every piece and draw the backdrop. Three of Poly Haven's CC0 HDR panoramas each have their sun or main softbox taken out and given back as the kit's sun, with its shadow. The daylight sky scatters sunlight through air and ozone; move the time of day from noon to deep dusk and the sun comes round into view and sets, while the sky re-bakes fifteen times a second.

Files, in reading order

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. mobile.tsThe mobile's pieces and wires, in the order place writes them.
  3. assets.ts

Kit modules bloom, cameras, clock, environment, exposure, finish, images, instances, lighting, lines, materials, meshPool, owner, passes, pipelines, shading, targets. Also @typegpu/color, typegpu, wgpu-matrix.

scene.ts

import {  bloom,  cameras,  clock,  environment,  instances,  lighting,  lines,  materials,  meshPool,  owner,  passes,  pipelines,  finish,  shading,  targets,  images,  exposure,  type Frame,  type Mesh,  type Surface,} from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { d, tgpu, type TgpuRoot } from 'typegpu';import type { Settings } from '../settings.ts';import { disc, LOOKS, mobileOf, place, TOP, WIRE, type Shape } from './mobile.ts';const SAMPLES = 4;const HDR = 'rgba16float';/** * Whether the backdrop draws each panorama's sun disc. `environment.clip` takes each one's sun, or * the studio's stronger softbox, and the kit's sun gives that light back with a shadow. The * studio's other softbox gives as much light, but no ceiling clips one without the other, so its * clipped light is lost. */const PANORAMAS = {  meadow: { disc: 1 },  venice: { disc: 1 },  studio: { disc: 0 },} as const;/** The metered exposure's range, wide enough for deep dusk. */const EYE = { key: 0.12, lowest: 1 / 16, highest: 20 };type Panorama = keyof typeof PANORAMAS;const SKIES = ['daylight', 'meadow', 'venice', 'studio'] as const;type Sky = (typeof SKIES)[number];/** The daylight sun's intensity above the air. */const DAY_SUN = 1.4;/** The hours the time of day runs through, from noon to deep dusk, 3° below the horizon. */const HOURS = [12, 20.6] as const;/** * A midsummer day at 51° south, the sun 62° up at noon and setting just after 20:00. In the south * it crosses the sky from right to left as the first view sees it, lighting the faces of the * mobile through the afternoon. */const LATITUDE = -0.9;const MIDSUMMER = -0.41;/** Turns the compass so the sun shines over the eye's shoulder after noon and sets ahead of it. */const NORTH = 2.08;/** Re-bakes per second while the time of day or the turn moves. */const REBAKES = 15;/** Radians per second the camera drifts round the mobile. */const DRIFT = 0.02;/** The sun's disc in the backdrop, over its light on a surface. */const DISC = 40;/** The wires' lacquer, lit as the pieces are, with edges as soft as the pixels they cross. */const lacquered = (fragment: lines.Fragment) => {  'use gpu';  const point = shading.pointAt(fragment.position, fragment.normal);  const lit = shading.shade(materials.glossy(fragment.color.xyz, LOOKS.wire.roughness), point);  return d.vec4f(lit * fragment.coverage, fragment.coverage);};export interface SceneOptions {  readonly skies: Readonly<Record<Panorama, images.HdrImage>>;}/** A hanging mobile turning under a sky that an environment bakes and lights it with. */export async function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions) {  const own = owner();  const screen = own(    targets.create(root, surface, {      color: { format: HDR, samples: SAMPLES },      depth: { format: 'depth24plus', samples: SAMPLES },      hdr: { format: HDR, sampled: true },    }),  );  const view = cameras.orbit(root, {    size: surface,    target: [0.24, 1.8, -0.4],    distance: 5.1,    yaw: 0.45,    pitch: -0.22,    fov: 0.75,    pitchRange: [-1.2, 0.8],    distanceRange: [2.5, 14],  });  const sky = own(environment.create(root, { settings: { blur: 0.08 } }));  const light = own(    lighting.create(root, {      shadow: { eye: view, cascades: 2 },      shading: { ambient: sky.ambient },    }),  );  const sunShown = root.createUniform(d.f32);  const behind = (direction: d.v3f) => {    'use gpu';    return environment.backdrop(direction) + environment.sunDisc(direction, sunShown.$);  };  // Beneath a drawing's own fills, so the backdrop reads the lighting and the environment  const scene = passes    .create(      root,      screen,      { color: passes.color, depth: passes.depth },      { resolve: { color: 'hdr' } },    )    .pipe(view.inject())    .beneath(light.inject())    .beneath(sky.inject());  const pool = own(meshPool.create(root, { capacity: 64 }));  const mobile = mobileOf(TOP);  const pieces = own(instances.create(root, pool, { capacity: mobile.parts.length }));  const renderer = pieces.renderer();  // A wire is finer than a shadow map's texel, so it casts no shadow, which would smear  const wires = own(    lines.create(root, {      capacity: mobile.wires.length,      length: 2,      shade: lacquered,      label: 'wires',    }),  );  // The backdrop fills what the pieces leave, and the wires blend their edges over both  const drawn = [    scene.add(renderer),    scene.add(environment.background(behind)),    scene.add(wires.drawing),  ];  const casters = [light.shadow.add(renderer)];  const hdr = () => screen.textures.hdr;  const glow = own(bloom.create(root, { source: hdr, settings: { threshold: 4 } }));  const eye = own(exposure.create(root, { source: hdr, settings: EYE }));  const final = own(    finish.create(root, { format: surface.format, source: hdr, glow, exposure: eye }),  );  const shapes: Record<Shape, Mesh> = {    disc: pool.add(disc(96, 32)),    ball: pool.add(meshes.sphere({ radius: 1, segments: 48, rings: 24 })),  };  const ids = mobile.parts.map((part) => pieces.add(shapes[part.shape], part.look));  const { x, y, z } = LOOKS.wire.color;  const strung = { width: WIRE * 2, color: [x, y, z, 1] };  const suns = {    meadow: environment.clip(options.skies.meadow),    venice: environment.clip(options.skies.venice),    studio: environment.clip(options.skies.studio),  };  const panoramas = {    meadow: own(images.create(root, suns.meadow.image)),    venice: own(images.create(root, suns.venice.image)),    studio: own(images.create(root, suns.studio.image)),  };  const sources: Record<Sky, environment.Prepared> = {    daylight: sky.add(tgpu.fn(environment.daylight).pipe(light.inject())),    meadow: sky.add(panoramas.meadow),    venice: sky.add(panoramas.venice),    studio: sky.add(panoramas.studio),  };  await pipelines.compile([scene, light.shadow, glow, eye, final, sky]);  const values = {    sky: 'daylight' as Sky,    /** Hours from noon to deep dusk, for the daylight sky. */    hour: 14,    /** Turns the sky, and with it the sun, about the vertical. */    turn: 0,    /** How soft the backdrop is. */    blur: 0.08,    /** Drifts the camera round the mobile. */    orbit: true,  };  const rebake = clock.cadence({ hz: REBAKES });  let dirty = true;  /** Points the kit's sun as the sky has it. */  const aim = () => {    if (values.sky === 'daylight') {      const sun = environment.turn(        environment.sunAt(values.hour, LATITUDE, MIDSUMMER),        values.turn + NORTH,      );      const color = environment.sunColor(sun);      light.settings.patch({        skyTop: [0, 0, 0],        skyBottom: [0, 0, 0],        sunDirection: sun,        sunColor: color,        sunIntensity: DAY_SUN,        skyIntensity: 1,      });      sky.settings.patch({ rotation: 0 });      sunShown.write(DISC);      return;    }    const { disc: shown } = PANORAMAS[values.sky];    const { direction, color, intensity } = suns[values.sky];    light.settings.patch({      sunDirection: environment.turn(d.vec3f(...direction), values.turn),      sunColor: color,      sunIntensity: intensity,    });    sky.settings.patch({ rotation: values.turn });    sunShown.write(DISC * shown);  };  aim();  const settings: Settings<typeof values> = {    values,    knobs: {      sky: { options: SKIES },      hour: { label: 'time of day (h)', min: HOURS[0], max: HOURS[1], step: 0.05 },      turn: { label: 'turn (rad)', min: 0, max: 6.28, step: 0.01 },      blur: { label: 'backdrop blur', min: 0, max: 1, step: 0.01 },      orbit: { label: 'drift' },    },    write(key) {      if (key === undefined || key === 'blur') sky.settings.patch({ blur: values.blur });      if (key === 'blur' || key === 'orbit') return;      aim();      // A panorama turns at lookup; the daylight bakes its sun where the turn puts it      if (key === undefined || key === 'sky' || values.sky === 'daylight') dirty = true;    },  };  return {    view,    settings,    frame(frame: Frame) {      if (values.orbit) view.goal.yaw += frame.dt * DRIFT;      view.update(frame.dt);      place(TOP, mobile, frame.time);      for (let i = 0; i < ids.length; i++) pieces.write(ids[i]!, mobile.parts[i]!.placed);      for (let i = 0; i < mobile.wires.length; i++) wires.write(i, mobile.wires[i]!, strung);      if (dirty && rebake.due(frame.dt)) {        sky.bake(frame.encoder, sources[values.sky]);        dirty = false;      }      screen.fit();      pieces.flush();      light.shadow.record(frame, casters);      scene.record(frame, drawn);      glow.record(frame);      eye.record(frame);      final.record(frame, surface.view(frame));    },    destroy: own.destroy,  };}

mobile.ts

// A hanging mobile of four wire arms, each turning on its wire as a slow torsion pendulum, and the// CPU placement of its wires, discs and ballimport { materials } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { srgbToLinear } from '@typegpu/color';import { d, std } from 'typegpu';import { quat, vec3 } from 'wgpu-matrix';const paint = (r: number, g: number, b: number) => srgbToLinear(d.vec3f(r, g, b));/** Calder's colours in lacquer, and the polished, brushed and glazed pieces that mirror the sky. */export const LOOKS = {  red: materials.glossy(paint(0.8, 0.09, 0.05), 0.3),  yellow: materials.glossy(paint(0.97, 0.74, 0.08), 0.3),  blue: materials.glossy(paint(0.1, 0.3, 0.72), 0.3),  black: materials.glossy(paint(0.04, 0.04, 0.045), 0.4),  white: materials.glossy(paint(0.9, 0.89, 0.85), 0.3),  mirror: materials.metal(d.vec3f(0.95, 0.95, 0.96), 0.04),  brushed: materials.metal(d.vec3f(0.9, 0.91, 0.92), 0.3),  brass: materials.metal(d.vec3f(0.95, 0.78, 0.42), 0.1),  copper: materials.metal(d.vec3f(0.96, 0.64, 0.54), 0.14),  glaze: materials.glossy(paint(0.93, 0.92, 0.89), 0.15),  wire: materials.glossy(paint(0.05, 0.05, 0.05), 0.45),};export type Look = (typeof LOOKS)[keyof typeof LOOKS];/** Radius of the wire, in metres. */export const WIRE = 0.007;/** How far each face of a disc domes out, as a share of its radius. */const DOME = 0.14;/** * A disc of radius 1 facing +y, each face a shallow dome, so a mirror disc shows a wide piece of * the sky. `v` runs from the front's centre over the rim to the back's. */export const disc = (around: number, rings: number) =>  meshes.parametric(    {      at: (u, v) => {        'use gpu';        const r = 1 - std.abs(v * 2 - 1);        const side = std.sign(0.5 - v);        const phi = u * Math.PI * 2;        return d.vec3f(r * std.cos(phi), side * DOME * (1 - r * r), r * std.sin(phi));      },      normalAt: (u, v) => {        'use gpu';        const r = 1 - std.abs(v * 2 - 1);        const side = std.sign(0.5 - v);        const phi = u * Math.PI * 2;        const slope = 2 * DOME * r;        return std.normalize(d.vec3f(slope * std.cos(phi), side, slope * std.sin(phi)));      },    },    { cols: around, rows: rings },  );/** A disc or a ball hung on a stem from a point along its arm. */interface Piece {  /** Where along the arm, from its pivot, positive to the right. */  readonly at: number;  readonly stem: number;  readonly ball?: boolean;  readonly radius: number;  readonly look: Look;  /** How far the disc's face turns from facing across the arm, in radians. */  readonly turn?: number;}/** A wire arm hung by its pivot, turning as `base + swing·sin(pace·t + phase)` and a slower echo. */interface Arm {  readonly left: number;  readonly right: number;  /** Wire length above the pivot. */  readonly drop: number;  readonly base: number;  readonly swing: number;  readonly pace: number;  readonly phase: number;  readonly pieces: readonly Piece[];  /** The arm hung from this arm's right end. */  readonly child?: Arm;}// Each arm carries its pieces to the left of its pivot and the next arm from its right end. The// coloured arms hang each below the pieces of the one above. The polished pieces stay outside the// circle the lowest arm sweeps, so those two arms turn through each other at one height./** The mirror and brushed discs, nearest the eye, with a glazed ball hanging between. */const LOWEST: Arm = {  left: 0.6,  right: 0.6,  drop: 0.4,  base: 0.5,  swing: 0.7,  pace: -0.31,  phase: 0.4,  pieces: [    { at: -0.6, stem: 0.12, radius: 0.36, look: LOOKS.mirror, turn: 0.15 },    { at: 0.6, stem: 0.12, radius: 0.34, look: LOOKS.brushed, turn: -0.15 },    { at: 0, stem: 0.5, radius: 0.09, ball: true, look: LOOKS.glaze },  ],};/** Brass and copper, outside the reach of the arm below. */const LOW: Arm = {  left: 1.83,  right: 0.55,  drop: 0.75,  base: -0.31,  swing: 0.8,  pace: 0.26,  phase: 1.1,  pieces: [    { at: -0.75, stem: 0.1, radius: 0.28, look: LOOKS.brass, turn: 0.5 },    { at: -1.37, stem: 0.1, radius: 0.26, look: LOOKS.copper, turn: -0.4 },    { at: -1.83, stem: 0.1, radius: 0.1, look: LOOKS.yellow, turn: 0.3 },  ],  child: LOWEST,};const MIDDLE: Arm = {  left: 1.3,  right: 0.7,  drop: 0.8,  base: -0.16,  swing: 0.6,  pace: -0.19,  phase: 2.3,  pieces: [    { at: -1.3, stem: 0.1, radius: 0.24, look: LOOKS.blue, turn: -0.2 },    { at: -0.8, stem: 0.1, radius: 0.16, look: LOOKS.white, turn: 0.4 },    { at: -0.4, stem: 0.1, radius: 0.1, look: LOOKS.black, turn: 0.1 },  ],  child: LOW,};/** The top arm, hung from the ceiling, out of view. */export const TOP: Arm = {  left: 1.9,  right: 0.8,  drop: 2.2,  base: 0.35,  swing: 0.4,  pace: 0.13,  phase: 0.2,  pieces: [    { at: -1.9, stem: 0.1, radius: 0.3, look: LOOKS.red, turn: 0.2 },    { at: -1.35, stem: 0.1, radius: 0.12, look: LOOKS.white, turn: -0.3 },    { at: -0.95, stem: 0.1, radius: 0.18, look: LOOKS.yellow, turn: -0.5 },    { at: -0.45, stem: 0.15, radius: 0.1, look: LOOKS.black, turn: 0.3 },  ],  child: MIDDLE,};/** The pivot of the top arm. */export const HUNG = [0, 3.2, 0] as const;type Triple = [number, number, number];type Quat = [number, number, number, number];/** What draws a piece: a disc or a ball. */export type Shape = 'disc' | 'ball';/** Where a piece is, as the instances take it. */export interface Placed {  readonly position: Triple;  readonly rotation: Quat;  readonly scale: Triple;}export interface Part {  readonly shape: Shape;  readonly look: Look;  readonly placed: Placed;}/** * The pieces, and the two ends of each wire as the lines take them, x, y and z of each: the wires * that hang, then the arms' rods, so a rod drawn over the round ends that meet it hides them. */export interface Mobile {  readonly parts: Part[];  readonly wires: Float32Array[];}const arms = (arm: Arm): Arm[] => [arm, ...(arm.child ? arms(arm.child) : [])];/** The mobile's pieces and wires, in the order `place` writes them. */export function mobileOf(top: Arm): Mobile {  const all = arms(top);  return {    parts: all.flatMap((arm) =>      arm.pieces.map((piece) => ({        shape: piece.ball ? 'ball' : 'disc',        look: piece.look,        placed: { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] },      })),    ),    wires: [...all.flatMap((arm) => [arm, ...arm.pieces]), ...all].map(() => new Float32Array(6)),  };}const UP: Triple = [0, 1, 0];const FACING = quat.fromAxisAngle([1, 0, 0], Math.PI / 2);/** Scratch for `place`, which allocates nothing. */const above: Triple = [0, 0, 0];const pivot: Triple = [0, 0, 0];const across: Triple = [0, 0, 0];const end: Triple = [0, 0, 0];/** Writes the point `at` along the current arm into `out`. */const along = (at: number, out: Triple) =>  vec3.set(pivot[0] + across[0] * at, pivot[1], pivot[2] + across[2] * at, out);/** Writes a wire from `from` to `to`. */const wire = (ends: Float32Array, from: Triple, to: Triple) => {  ends.set(from);  ends.set(to, 3);};/** Writes a wire `length` long that hangs from `from`, and leaves its lower end in `end`. */const hang = (ends: Float32Array, from: Triple, length: number) => {  vec3.set(from[0], from[1] - length, from[2], end);  wire(ends, from, end);};/** * Writes every piece and wire of the mobile at `time` seconds. Each arm turns on its wire about * the vertical, and carries what hangs from it. */export function place(top: Arm, { parts, wires }: Mobile, time: number) {  let part = 0;  let strung = 0;  let rod = wires.length;  for (let arm: Arm | undefined = top; arm; arm = arm.child) rod--;  vec3.set(HUNG[0], HUNG[1] + top.drop, HUNG[2], above);  for (let arm: Arm | undefined = top; arm; arm = arm.child) {    hang(wires[strung++]!, above, arm.drop);    vec3.copy(end, pivot);    const angle =      arm.base +      arm.swing * Math.sin(arm.pace * time + arm.phase) +      arm.swing * 0.35 * Math.sin(arm.pace * 0.61 * time + arm.phase * 2);    vec3.set(Math.cos(angle), 0, -Math.sin(angle), across);    along(-arm.left, above);    along(arm.right, end);    wire(wires[rod++]!, above, end);    for (let i = 0; i < arm.pieces.length; i++) {      const piece = arm.pieces[i]!;      along(piece.at, above);      hang(wires[strung++]!, above, piece.stem);      const { position, rotation, scale } = parts[part++]!.placed;      const { radius } = piece;      vec3.set(end[0], end[1] - radius, end[2], position);      vec3.set(radius, radius, radius, scale);      if (piece.ball) {        quat.identity(rotation);        continue;      }      quat.fromAxisAngle(UP, angle + (piece.turn ?? 0), rotation);      quat.multiply(rotation, FACING, rotation);    }    along(arm.right, above);  }}

assets.ts

import { hdr } from '../assets.ts';/** * Three of Poly Haven's CC0 HDR panoramas at 1k: Greg Zaal's `kloofendal_48d_partly_cloudy_puresky` * with Jarod Guest's sky edits, Greg Zaal's `venice_sunset` and Sergej Majboroda's * `studio_small_09`. */export const assets = {  skies: {    meadow: hdr('textures/kloofendal.hdr'),    venice: hdr('textures/venice-sunset.hdr'),    studio: hdr('textures/studio.hdr'),  },};