Survey model
Contour rings of a noise island rise off a slate plinth into a stacked survey model. A kernel traces each ring by bisecting the height along rays from the peak and writes its points into one set of lines, which draws every ring as a lit enamel tube in one draw, every fifth ring bold, dashing the soundings below the shoreline. A graticule of hairlines a point wide is printed on the plinth, beside a north arrow and a scale bar, and the rings cast a soft shadow under the sun. TAA and bloom finish the picture. Point at a ring to pick it out.
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- contours.tsTraces the island's contour rings into one set of lines and shades them as satin enamel tubes, lit by the lighting of the scene they draw in.
- terrain.ts
Kit modules bloom, cameras, finish, instances, lighting, lines, materials, meshPool, owner, passes, pipelines, shading, shadows, taa, targets, tone. Also @typegpu/color, @typegpu/noise, typegpu.
scene.ts
import { bloom, cameras, finish, instances, lighting, lines, materials, meshPool, owner, passes, pipelines, shading, shadows, taa, targets, tone, type Frame, type Surface,} from '@typegpu/kit';import { caps, meshes } from '@typegpu/kit/geometry';import { srgbToLinear } from '@typegpu/color';import { d, std, type TgpuRoot } from 'typegpu';import type { Pointing } from '../contract.ts';import type { Settings } from '../settings.ts';import { createContours } from './contours.ts';const BACKDROP = [0.022, 0.024, 0.028] as const;/** Seconds the view takes to tip from above the model to its working angle. */const TIP = 4;/** Seconds the rings take to rise, from `RISE[0]` to `RISE[1]`. */const RISE = [0.4, 3] as const;/** The view's pitch above the model, and the angle it settles at. */const PITCH = [1.42, 0.74] as const;/** The plinth's half width and thickness, its top at 0. */const PLINTH = { half: 5, thickness: 0.3 };const paint = (r: number, g: number, b: number) => srgbToLinear(d.vec3f(r, g, b));const linear = (rgb: readonly [number, number, number]) => { const { x, y, z } = paint(...rgb); return [x, y, z, 1];};const SLATE = materials.glossy(paint(0.065, 0.072, 0.085), 0.55);const BASE = materials.glossy(paint(0.08, 0.085, 0.09), 0.5);const BRASS = [0.78, 0.6, 0.32] as const;const BONE = [0.86, 0.83, 0.76] as const;/** The graticule's spacing and half width in world units, and its ink's opacity. */const GRID = { spacing: 1, half: 4.75, ink: 0.07 };/** World units the graticule's ink lies over the plinth's top. */const PRINT = 0.004;/** Share of the sun's light that reaches into a shadow. */const SHADE = 0.42;/** * Where the scale bar lies on the plinth, its `east` and its `south` end, with the north arrow * west of it, and the bar's length in world units and its parts. */const LEGEND = { east: 4.3, south: -2.2, bar: 2, parts: 4 };/** World units the legend's inlay stands over the plinth's top, above the graticule's ink. */const INLAY = 0.008;/** The north arrow's head: from its centre to its tip, and the turn pointing its tip north, to -z. */const HEAD = { radius: 0.24, rotation: [0, Math.SQRT1_2, 0, Math.SQRT1_2] as const };/** Shades the graticule as faint ink printed flat on the plinth. */const ink = (fragment: lines.Fragment) => { 'use gpu'; const lit = shading.shade( materials.matte(fragment.color.xyz), shading.pointAt(fragment.position, d.vec3f(0, 1, 0)), ); const alpha = fragment.coverage * GRID.ink; return d.vec4f(lit * alpha, alpha);};/** Lets part of the sun's light into shadows, so they stay lighter than the rings. */const softened = (point: shading.Point) => { 'use gpu'; return std.mix(SHADE, 1, shadows.filtered(point));};/** A unit vector towards the sun from the south-west, `height` its rise. */const sunAt = (height: number): [number, number, number] => [-0.82, height, 0.5];/** The graticule's lines across the plinth at every `GRID.spacing`, and its border. */const graticule = () => { const { spacing, half } = GRID; const count = Math.floor(half / spacing); const rows: number[][] = []; for (let k = -count; k <= count; k++) { rows.push([k * spacing, PRINT, -half, k * spacing, PRINT, half]); rows.push([-half, PRINT, k * spacing, half, PRINT, k * spacing]); } for (const side of [-half, half]) { rows.push([side, PRINT, -half, side, PRINT, half]); rows.push([-half, PRINT, side, half, PRINT, side]); } return rows;};export const defaults = { /** Smooths the rings' edges over several frames. */ taa: true, /** Dashes the soundings below the shoreline. */ dashed: true, /** Index contours' tube width in world units. */ width: 0.03, /** How high the sun stands. */ sun: 0.86,};/** * Draws a noise island as contour rings that rise off a slate plinth. One set of lines draws * every ring, its points written by a kernel. Another prints a graticule a point wide on the * plinth, a north arrow and a scale bar lie inlaid in it, and the rings cast the sun's softened * shadow. */export async function createScene( root: TgpuRoot, surface: Surface, options: Partial<typeof defaults> = {},) { const values = { ...defaults, ...options }; const own = owner(); const screen = own( targets.create(root, surface, { hdr: { format: 'rgba16float', sampled: true }, depth: { format: 'depth24plus', sampled: true }, }), ); const view = cameras.orbit(root, { size: surface, target: [0.5, 0.15, -0.45], distance: 8.1, yaw: 0.75, pitch: PITCH[0], pitchRange: [0.12, 1.45], distanceRange: [4, 22], }); // TAA reprojects the last frame by the camera's motion and the scene's depth const aa = own( taa.create(root, { source: () => screen.textures.hdr, depth: () => screen.textures.depth, camera: view, }), ); const light = own( lighting.create(root, { settings: { sunDirection: sunAt(values.sun), sunColor: [1, 0.78, 0.55], sunIntensity: 2.8, skyTop: [0.16, 0.2, 0.28], skyBottom: [0.04, 0.035, 0.03], skyIntensity: 0.8, }, shadow: { eye: view, cascades: 2, far: 26, filter: 9 }, shading: { sunShadow: softened }, }), ); // The lines' shading reads the lighting, which fills beneath their own fills const scene = passes .create( root, screen, { hdr: passes.color, depth: passes.depth }, { jitter: aa.jitter, clear: [...BACKDROP, 1] }, ) .pipe(view.inject()) .beneath(light.inject()); const pool = own(meshPool.create(root, { capacity: 64 })); const slab = pool.add( meshes.roundedBox({ width: PLINTH.half * 2, height: PLINTH.thickness, depth: PLINTH.half * 2, radius: 0.06, }), ); const strip = pool.add(meshes.box({ width: 1, height: INLAY * 2, depth: 1 })); // A cylinder with three sides is a triangular prism, with one corner along +x const head = pool.add(meshes.cylinder({ radius: 1, height: INLAY * 2, radialSegments: 3 })); const { east, south, bar, parts } = LEGEND; const things = own(instances.create(root, pool, { capacity: 4 + parts })); things.add(slab, { position: [0, -PLINTH.thickness / 2, 0], ...SLATE }); things.add(slab, { position: [0, -PLINTH.thickness * 1.5, 0], scale: [1.03, 1, 1.03], ...BASE, }); // A brass north arrow and a scale bar of brass and bone, inlaid in the plinth's near corner const brass = materials.glossy(paint(...BRASS), 0.3); const bone = materials.glossy(paint(...BONE), 0.3); const tip = south - bar - 0.2; const tail = south + 0.2; const neck = tip + HEAD.radius; things.add(strip, { position: [east - 0.5, 0, (tail + neck) / 2], scale: [0.12, 1, tail - neck], ...brass, }); things.add(head, { position: [east - 0.5, 0, neck], rotation: HEAD.rotation, scale: [HEAD.radius, 1, HEAD.radius], ...brass, }); for (let k = 0; k < parts; k++) { things.add(strip, { position: [east, 0, south - ((k + 0.5) * bar) / parts], scale: [0.07, 1, bar / parts], ...(k % 2 ? bone : brass), }); } const rows = graticule(); const grid = own( lines.create(root, { capacity: rows.length, length: 2, units: 'points', startCap: caps.butt, endCap: caps.butt, shade: ink, label: 'graticule', }), ); for (const [k, row] of rows.entries()) grid.write(k, row, { color: linear(BONE) }); const contours = own(createContours(root, values)); const plinth = things.renderer(); const drawn = [scene.add(plinth), scene.add(grid.drawing), scene.add(contours.drawing)]; const casters = [light.shadow.add(plinth), light.shadow.add(contours.drawing)]; const glow = own(bloom.create(root, { source: aa, settings: { threshold: 1.6 } })); const final = own( finish.create(root, { format: surface.format, source: aa, tone: tone.aces, glow }), ); await pipelines.compile([scene, light.shadow, aa, glow, final, contours]); // Trace the rings once to read their radii for picking const tracing = root.createCommandEncoder(); contours.trace(tracing); tracing.submit(); await contours.readRadii(); const settings: Settings<typeof values> = { values, knobs: { taa: { label: 'TAA' }, dashed: { label: 'dashed soundings' }, width: { label: 'ring width', min: 0.005, max: 0.08, step: 0.005 }, sun: { label: 'sun height', min: 0.1, max: 1.5, step: 0.02 }, }, write(key) { if (key === undefined || key === 'taa') aa.enable(values.taa); if (key === undefined || key === 'dashed') contours.dashed = values.dashed; if (key === undefined || key === 'width') contours.width = values.width; if (key === undefined || key === 'sun') { light.settings.patch({ sunDirection: sunAt(values.sun) }); } }, }; // The pointer picks a ring each frame, as the view and the rings move under it const pointer = { x: 0, y: 0, over: false }; const ray = { origin: [0, 0, 0], direction: [0, 0, 0] }; const pointing: Pointing = { move(x, y) { pointer.x = x; pointer.y = y; pointer.over = true; }, leave() { pointer.over = false; contours.hover(); }, }; return { view, settings, pointing, frame(frame: Frame) { // Tip the view down to the model, and raise the rings, as the scene opens if (frame.time < TIP) { view.goal.pitch = std.mix(PITCH[0], PITCH[1], std.smoothstep(0, TIP, frame.time)); } contours.lift(std.smoothstep(RISE[0], RISE[1], frame.time)); view.update(frame.dt); if (pointer.over) { view.rayAt(pointer.x, pointer.y, ray); contours.hover(ray); } contours.trace(frame.encoder); screen.fit(); things.flush(); light.shadow.record(frame, casters); scene.record(frame, drawn); aa.record(frame); glow.record(frame); final.record(frame, surface.view(frame)); }, destroy: own.destroy, };}contours.ts
import { cameras, lines, materials, shading } from '@typegpu/kit';import { d, std, tgpu, type TgpuCommandEncoder, type TgpuRoot } from 'typegpu';import { heightAt, isIndex, LEVELS, levelOf, liftedOf, REACH, SHORE, tints } from './terrain.ts';/** Points round each ring. */const POINTS = 192;const TOTAL = LEVELS * POINTS;const WORKGROUP = 64;/** Steps inward from `REACH` that find a ring's last crossing, and halvings that refine it. */const MARCH = 48;const HALVINGS = 10;/** Dash and gap along the soundings, in world units. */const DASH = 0.14;const GAP = 0.09;/** The cyan of the ring under the pointer, linear and bright enough to bloom. */const CYAN = d.vec3f(0.25, 1.6, 2.2);/** World units outside the outermost ring that still pick it. */const NEAR_RING = 0.06;/** Rays this near level pick no ring. */const LEVEL = { grazing: 1e-4 };/** Width of an intermediate contour, as a share of an index contour's. */const THIN = 0.6;const Shape = d.struct({ /** Rings rise from the plinth to their heights as this goes from 0 to 1. */ lift: d.f32, /** Index contours' tube width in world units. */ width: d.f32, /** The ring under the pointer, or `LEVELS` for none. */ hovered: d.u32, /** 1 to dash the soundings. */ dashed: d.u32,});const radiusAt = (direction: d.v2f, level: number) => { 'use gpu'; const stride = REACH / MARCH; let inner = d.f32(0); let outer = d.f32(0); for (let step = 0; step < MARCH; step++) { const r = REACH - d.f32(step) * stride; if (heightAt(direction * r) >= level) { inner = r; outer = r + stride; break; } } for (let k = 0; k < HALVINGS; k++) { const middle = (inner + outer) * 0.5; if (heightAt(direction * middle) >= level) inner = middle; else outer = middle; } return std.min((inner + outer) * 0.5, REACH);};type Ray = Parameters<typeof cameras.hitHeight>[0];/** Ring `ring`'s radius in `radii` towards `point`, between the two ring points either side. */const radiusOf = (radii: Float32Array, ring: number, point: readonly number[]) => { const turn = ((Math.atan2(point[2]!, point[0]!) / (Math.PI * 2) + 1) % 1) * POINTS; const i = Math.floor(turn); const r0 = radii[ring * POINTS + (i % POINTS)]!; const r1 = radii[ring * POINTS + ((i + 1) % POINTS)]!; return r0 + (r1 - r0) * (turn - i);};export interface ContoursOptions { readonly width: number; readonly dashed: boolean;}/** * Traces the island's contour rings into one set of lines and shades them as satin enamel tubes, * lit by the lighting of the scene they draw in. A kernel finds each ring point's radius by * marching in from the edge to the level's last crossing and halving the step, then a second runs * along each ring for its dashes. */export function createContours(root: TgpuRoot, options: ContoursOptions) { const shape = root.createUniform(Shape, { lift: 0, width: options.width, hovered: LEVELS, dashed: options.dashed ? 1 : 0, }); const enamel = (fragment: lines.Fragment) => { 'use gpu'; const ring = fragment.line; const tint = tints.$[ring]!; let material = materials.glossy(tint, 0.38); if (ring === shape.$.hovered) { material = materials.glossy(CYAN * 0.1, 0.3); material.emission = CYAN; } const lit = shading.shade(material, shading.pointAt(fragment.position, fragment.normal)); let alpha = fragment.coverage; if (ring < SHORE && shape.$.dashed === 1) alpha *= lines.dash(fragment, DASH, GAP); return d.vec4f(lit * alpha, alpha); }; const rings = lines.create(root, { capacity: LEVELS, length: POINTS, closed: true, blend: 'opaque', shade: enamel, label: 'contours', }); const trace = tgpu.computeFn({ workgroupSize: [WORKGROUP], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; if (gid.x >= TOTAL) return; const ring = std.intdiv(gid.x, d.u32(POINTS)); const i = gid.x % POINTS; const angle = (d.f32(i) / POINTS) * Math.PI * 2; const direction = d.vec2f(std.cos(angle), std.sin(angle)); const r = radiusAt(direction, levelOf(ring)); rings.setPoint( ring, i, lines.Point({ position: d.vec3f(direction.x * r, liftedOf(ring, shape.$.lift), direction.y * r), width: std.select(shape.$.width * THIN, shape.$.width, isIndex(ring)), color: d.vec4f(1), along: 0, }), ); if (i === 0) rings.setCount(ring, POINTS); }); /** Measures each ring's length from its first point, one ring a thread. */ const measure = tgpu.computeFn({ workgroupSize: [WORKGROUP], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; if (gid.x >= LEVELS) return; let along = d.f32(0); let before = rings.pointAt(gid.x, 0).position.xz; for (let i = d.u32(1); i < POINTS; i++) { const point = lines.Point(rings.pointAt(gid.x, i)); along += std.distance(point.position.xz, before); before = point.position.xz; point.along = along; rings.setPoint(gid.x, i, point); } }); const tracing = root.pipe(rings.inject()).createComputePipeline({ compute: trace }); const measuring = root.pipe(rings.inject()).createComputePipeline({ compute: measure }); /** Each ring's radius at each point, read back once for picking. */ let radii: Float32Array | undefined; const values = { lift: 0, width: options.width }; let hovered = LEVELS; let changed = true; const hit = [0, 0, 0]; /** * The ring under `ray`, or `LEVELS` for none. The rings read as the terraces of a solid model, * so the ray lands on the highest terrace whose ring encloses it, and picks the nearer of that * terrace's edges. A ring the island hides is never picked. */ const ringUnder = (ray: Ray, measured: Float32Array) => { for (let ring = LEVELS - 1; ring >= 0; ring--) { if (cameras.hitHeight(ray, liftedOf(ring, values.lift), hit, LEVEL) === undefined) continue; const out = Math.hypot(hit[0], hit[2]); const edge = radiusOf(measured, ring, hit); if (out > edge + (ring === 0 ? NEAR_RING : 0)) continue; if (ring === LEVELS - 1) return ring; // Higher rings are nested inside lower ones round the one peak return out - radiusOf(measured, ring + 1, hit) < edge - out ? ring + 1 : ring; } return LEVELS; }; return { drawing: rings.drawing, initAsync: () => Promise.all([tracing.initAsync(), measuring.initAsync()]), /** Reads back the rings' radii, which stay as they rise. */ async readRadii() { const points = await rings.points!.read(); radii = Float32Array.from(points, ({ position }) => Math.hypot(position.x, position.z)); }, /** Sets how far the rings have risen, from 0 to 1. */ lift(to: number) { if (to === values.lift) return; values.lift = to; changed = true; }, set width(width: number) { if (width === values.width) return; values.width = width; changed = true; }, set dashed(dashed: boolean) { shape.patch({ dashed: dashed ? 1 : 0 }); }, /** Records a compute pass that traces the rings, when their height or width changed. */ trace(encoder: TgpuCommandEncoder) { if (!changed) return; changed = false; shape.patch({ lift: values.lift, width: values.width }); const pass = encoder.beginComputePass(); pass.setPipeline(tracing); pass.dispatchWorkgroups(Math.ceil(TOTAL / WORKGROUP)); pass.setPipeline(measuring); pass.dispatchWorkgroups(1); pass.end(); }, /** * Picks out a ring under `ray`: of the highest terrace whose ring encloses where the ray lands, * the nearer of its two edges. Picks none without a ray. */ hover(ray?: Ray) { const picked = ray && radii ? ringUnder(ray, radii) : LEVELS; if (picked === hovered) return; hovered = picked; shape.patch({ hovered }); }, destroy() { rings.destroy(); }, };}terrain.ts
import { hexToRgb, srgbToLinear } from '@typegpu/color';import { perlin2d } from '@typegpu/noise';import { d, std, tgpu } from 'typegpu';/** Contour levels, the deepest first: ten soundings, the shoreline, then the land. */export const LEVELS = 41;/** The shoreline's level index; those below it are soundings. */export const SHORE = 10;/** Every `INDEX`th level from the shoreline is an index contour, drawn bold. */const INDEX = 5;/** Height between levels, in the terrain's units and in world units once lifted. */const STEP = 0.04;/** World units the lowest ring sits over the plinth's top. */const BASE = 0.05;/** World units from the peak to the farthest a ring reaches. */export const REACH = 4.3;/** The terrain height at level `ring`. */export const levelOf = (ring: number) => { 'use gpu'; return (d.f32(ring) - SHORE) * STEP;};/** World height of ring `ring` lifted by `lift`, from resting on the plinth at 0 to its level at 1. */export const liftedOf = (ring: number, lift: number) => { 'use gpu'; return BASE + d.f32(ring) * STEP * lift;};/** * The island's height at `p` on the plinth: one peak, its slopes roughened by noise that fades * near the summit so every level closes round it, falling away before `REACH` so every sounding * closes inside it. */export const heightAt = (p: d.v2f) => { 'use gpu'; const r = std.length(p); const peak = 2.1 * std.exp(-(r * r) / 5.2) - 0.55; const rough = perlin2d.sample(p * 0.42 + 3.1) * 0.75 + perlin2d.sample(p * 1.05 - 7.4) * 0.3; return peak + rough * std.smoothstep(0.4, 2.2, r) - std.smoothstep(2.4, REACH, r) * 0.75;};/** Hypsometric stops in sRGB: the deep soundings, the shallow ones, then the land up to the peak. */const DEEP = [0x1d4e89, 0x4f92c4].map((hex) => hexToRgb(hex));const LAND = [ 0x3f8f5a, 0x67a65b, 0x93b95b, 0xbcc566, 0xd9c36c, 0xd5a159, 0xbf7d4d, 0xa4664a, 0x8c5446,].map((hex) => hexToRgb(hex));/** The shoreline's pale sea glass. */const GLASS = hexToRgb(0xd6ece0);/** The slate that intermediate contours lean towards. */const MUTE = hexToRgb(0x3a4048);/** The colour at `t` from 0 to 1 along `stops`. */const along = (stops: d.v3f[], t: number) => { const at = t * (stops.length - 1); const k = Math.min(Math.floor(at), stops.length - 2); return std.mix(stops[k]!, stops[k + 1]!, at - k);};/** Whether level `ring` is an index contour. */export const isIndex = (ring: number) => { 'use gpu'; return (ring + LEVELS * INDEX - SHORE) % INDEX === 0;};/** Level `ring`'s tint in sRGB. */const tintOf = (ring: number) => { if (ring === SHORE) return GLASS; const srgb = ring < SHORE ? along(DEEP, ring / (SHORE - 1)) : along(LAND, (ring - SHORE - 1) / (LEVELS - SHORE - 2)); return isIndex(ring) ? srgb : std.mix(srgb, MUTE, 0.45);};/** * Hypsometric tints, the deepest sounding first. The shoreline is pale sea glass, and the * intermediate contours between index contours lean towards slate. */export const tints = tgpu.const( d.arrayOf(d.vec3f, LEVELS), Array.from({ length: LEVELS }, (_, ring) => srgbToLinear(tintOf(ring))),);