Fireworks
Particle shells burst over a castle on a lake. Each burst leaves a flash that is a point light for the castle and the hills, and the smoke takes the same light from every side. The water only reflects the bursts. A second set of passes draws the scene through a mirrored camera, and the water samples that picture through its ripples. The exposure adapts like an eye: a volley darkens the picture, and the exposure rises again afterwards. The castle and pines are from Kenney's Castle Kit and Nature Kit (CC0).
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- fireworks.tsCreates the flashes that bursts leave, each a scene point light.
- show.tsSchedules salvos, pairs, fans, chains and periodic finales on the CPU.
- castle.tsStores castle and pine meshes in one pool, with instances lit by the scene's lighting.
- land.tsBakes island, lake bed and surrounding hills into a mesh once, lit by the scene's lighting.
- lake.tsCreates reflection passes for above-water geometry through a reflected scene camera.
- lowRes.tsDraws translucent matter such as smoke at reduced resolution to lower fragment cost.
- sky.tsA dark sky with twinkling stars that thin towards the horizon, behind everything.
- picture.tsReturns the bind group through which
samplereads a picture, made again only when a resized target gives a new texture. - assets.ts
Kit modules bloom, cameras, clock, environment, exposure, finish, instances, lighting, materials, memo, meshPool, owner, particles, passes, pipelines, readback, shading, targets, tone, trails. Also @typegpu/noise, math, math/random, typegpu.
scene.ts
import { bloom, cameras, clock, exposure, finish, type Frame, lighting, type meshPool, owner, passes, pipelines, readback, type Surface, targets, tone,} from '@typegpu/kit';import { perlin2d } from '@typegpu/noise';import { clamp } from 'math';import { d, std, type TgpuRoot } from 'typegpu';import type { Pointing } from '../contract.ts';import type { Settings } from '../settings.ts';import { createFireworks, createFlashes, defaults as fireworkDefaults } from './fireworks.ts';import { createShow } from './show.ts';import { createCastle, type ModelName } from './castle.ts';import { createLand, heightAt } from './land.ts';import { createLake } from './lake.ts';import { createLowRes } from './lowRes.ts';import { HORIZON, nightSky } from './sky.ts';type Vec3 = [number, number, number];const SAMPLES = 4;const HDR = 'rgba16float';const STEP_SECONDS = 1 / 60;const VIEW = { target: [0, 22, 0], distance: 175, yaw: 0.2, pitch: -0.1, fov: 0.95, near: 0.5, far: 8000, pitchRange: [-0.5, 0.9], distanceRange: [80, 600],} as const;/** * How the opening view fits the castle to narrower views: it backs away from the castle to fit the * width of a view narrower than `aspect`, no farther than `farthest`, inside the far shore. It * raises its target by up to `lift` as the view narrows to `upright`, so on a portrait screen the * show above the castle fills the height. */const FIT = { aspect: 1.1, upright: 0.5, farthest: 290, lift: 28 } as const;/** Minimum camera height above the water and the land, including during orbit. */const EYE_ABOVE_GROUND_METRES = 2.5;const NIGHT = { sunDirection: [-0.55, 0.32, -0.75], sunColor: [0.5, 0.62, 1], sunIntensity: 0.05, skyTop: [0.01, 0.014, 0.028], skyBottom: [0.002, 0.002, 0.003],} as const;const FOG_DENSITY_PER_METRE = 0.0011;const FOG_FALLOFF_PER_METRE = 0.006;const LOOK = { glowIntensity: 0.16, vignette: 0.3 };/** * Holds the night near an exposure of 1.2, dims it under a volley and opens it back up over a few * seconds, no further than `highest`, so a quiet sky stays dark. */const EYE = { key: 0.008, lowest: 0.5, highest: 1.3, rise: 0.5, fall: 2 };const BLOOM = { threshold: 0.8 };const CAPACITY = { shells: 48, stars: 6144, embers: 12288, smoke: 1024 } as const;/** Simulation warm-up in seconds, so smoke and bursts appear in the first frame. */const PREWARM_SECONDS = 7;/** Show-speed multiplier during loading. */const PREWARM_PACE = 1.6;/** Click-target plane distance behind the castle. The plane is upright and faces the camera. */const CLICK_BEHIND_METRES = 110;const CLICK_HEIGHT_METRES = [55, 170] as const;const READOUT_HZ = 4;/** Repeat period in Perlin cells for ripple and smoke noise. */const NOISE_CELLS = 64;/** Smoke resolution as a fraction of screen width and height. */const SMOKE_SCALE = 0.5;const defaults = { show: 'auto' as 'auto' | 'on click', rate: 1, ...fireworkDefaults, paused: false,};/** Sets `fit` to the opening view's distance and target height for an `aspect`-wide view. */function framing(aspect: number, fit = { distance: 0, height: 0 }) { const narrow = 1 - std.smoothstep(FIT.upright, FIT.aspect, aspect); fit.distance = Math.min(VIEW.distance * Math.max(1, FIT.aspect / aspect), FIT.farthest); fit.height = VIEW.target[1] + FIT.lift * narrow; return fit;}/** * Where a ray from the eye meets the upright plane `CLICK_BEHIND_METRES` behind the castle, * facing the eye, held between the click heights. Undefined for a ray that turns away from the * castle. */function clickedAt({ origin, direction }: { readonly origin: Vec3; readonly direction: Vec3 }) { const [ox, oy, oz] = origin; const [dx, dy, dz] = direction; const away = Math.hypot(ox, oz); const ahead = -(dx * ox + dz * oz) / Math.max(away, 1e-6); if (ahead < 0.05) return undefined; const distance = (away + CLICK_BEHIND_METRES) / ahead; const height = clamp(oy + dy * distance, ...CLICK_HEIGHT_METRES); return [ox + dx * distance, height, oz + dz * distance] satisfies Vec3;}export interface SceneOptions { /** Each model as `gltf.read` returns it. */ readonly models: Readonly<Record<ModelName, meshPool.ModelData>>; /** Receives compilation progress as a fraction after each part finishes. */ readonly onProgress?: (fraction: number) => void; /** Random-number source. Defaults to `Math.random`. */ readonly random?: () => number;}/** Runs fireworks over a castle and lake. Each burst lights the castle and the smoke. */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 aspect = () => surface.width / surface.height; const fitted = framing(aspect()); const view = cameras.orbit(root, { size: surface, ...VIEW, target: [VIEW.target[0], fitted.height, VIEW.target[2]], distance: fitted.distance, floor: (x, z) => Math.max(0, heightAt(x, z)) + EYE_ABOVE_GROUND_METRES, }); const time = root.createUniform(d.f32); const sim = clock.fixedStep({ step: STEP_SECONDS }); const noise = own(perlin2d.staticCache({ root, size: d.vec2u(NOISE_CELLS, NOISE_CELLS) })); const flashes = own(createFlashes(root, { camera: view, step: STEP_SECONDS })); const show = createShow(options.random); const light = own( lighting.create(root, { settings: { ...NIGHT, fogColor: [HORIZON.x, HORIZON.y, HORIZON.z], fogDensity: FOG_DENSITY_PER_METRE, fogFalloff: FOG_FALLOFF_PER_METRE, }, points: { count: flashes.capacity, light: flashes.light }, }), ); const fireworks = own( createFireworks(root, flashes, light, { camera: view, step: STEP_SECONDS, noise, capacity: CAPACITY, }), ); const scene = passes .create( root, screen, { color: passes.color, depth: passes.depth }, { resolve: { color: 'hdr' } }, ) .pipe(view.inject()) .beneath(light.inject()); const lake = own(createLake(root, { view, light, size: surface, time, noise })); const castle = own(createCastle(root, options.models)); const sky = nightSky(time); const land = own(createLand(root)); const lowRes = own( createLowRes(root, { view, size: surface, scale: SMOKE_SCALE, label: 'smoke' }), ); const occluding = [lowRes.occluders.add(land.renderer), lowRes.occluders.add(castle.renderer)]; const smoke = lowRes.matter.add(fireworks.smoke); // The sky fills what the solid drawings leave, before the light that blends over it const drawn = [ scene.add(land.renderer), scene.add(castle.renderer), scene.add(lake.water), scene.add(sky), scene.add(lowRes.over), ...fireworks.sprites.map((drawing) => scene.add(drawing)), ...fireworks.streaks.map((drawing) => scene.add(drawing)), ]; const reflected = [ lake.reflection.add(land.renderer), lake.reflection.add(castle.renderer), lake.reflection.add(sky), lake.reflection.add(fireworks.smoke), ...fireworks.sprites.map((drawing) => lake.reflection.add(drawing)), ...fireworks.streaks.map((drawing) => lake.reflection.add(drawing)), ]; const glow = own(bloom.create(root, { source: () => screen.textures.hdr, settings: BLOOM })); const eye = own(exposure.create(root, { source: () => screen.textures.hdr, settings: EYE })); const final = own( finish.create(root, { format: surface.format, source: () => screen.textures.hdr, tone: tone.aces, glow, exposure: eye, settings: LOOK, }), ); const lights = own( readback.create(root, flashes.particles.args, { hz: READOUT_HZ, decode: (args) => args[1] ?? 0, }), ); const values = { ...defaults }; const settings: Settings<typeof values> = { values, knobs: { show: { options: ['auto', 'on click'] }, rate: { label: 'show speed', min: 0.25, max: 2.5, step: 0.05 }, wind: { label: 'wind (m/s)', min: -4, max: 4, step: 0.1 }, light: { label: 'burst light', min: 0, max: 3, step: 0.05 }, smoke: { min: 0, max: 2.5, step: 0.05 }, paused: {}, }, write(key) { if (key === undefined || key === 'wind') fireworks.wind.write(values.wind); if (key === undefined || key === 'light') flashes.brightness.write(values.light); if (key === undefined || key === 'smoke') fireworks.opacity.write(values.smoke); if (key === undefined || key === 'paused') sim.paused = values.paused; }, readouts: { 'lights alive': () => lights.latest ?? 0 }, }; const ray = { origin: [0, 0, 0] satisfies Vec3, direction: [0, 0, 0] satisfies Vec3 }; const pointing: Pointing = { press(x, y) { view.rayAt(x, y, ray); const burst = clickedAt(ray); if (burst) show.fireAt(burst); }, }; const parts = [scene, lake.reflection, lowRes, flashes, fireworks, glow, eye, final]; await Promise.all([castle.ready, pipelines.compile(parts, options)]); // A spawn's bursts upload before its submission runs, so each launch gets a submission let prewarm = root.createCommandEncoder(); let launched = false; const launch: typeof fireworks.launch = (shell) => { fireworks.launch(shell); launched = true; }; for (let t = 0; t < PREWARM_SECONDS; t += STEP_SECONDS) { show.advance(STEP_SECONDS, values.rate * PREWARM_PACE, true, launch); const warming = prewarm.beginComputePass(); fireworks.record(warming, 1, 0); warming.end(); if (!launched) continue; prewarm.submit(); prewarm = root.createCommandEncoder(); launched = false; } prewarm.submit(); const next = { distance: 0, height: 0 }; /** Refits the opening view to a turned or resized screen, keeping how far it was zoomed. */ const refit = () => { framing(aspect(), next); if (next.distance === fitted.distance && next.height === fitted.height) return; view.goal.distance *= next.distance / fitted.distance; view.goal.target[1] += next.height - fitted.height; fitted.distance = next.distance; fitted.height = next.height; }; return { view, settings, pointing, frame(frame: Frame) { time.write(frame.time); refit(); view.update(frame.dt); const steps = sim.advance(frame.dt); if (!sim.paused) { show.advance(frame.dt, values.rate, values.show === 'auto', fireworks.launch); } castle.flush(); const compute = frame.encoder.beginComputePass(); fireworks.record(compute, steps, sim.ahead); compute.end(); lights.capture(frame); screen.fit(); const behind = lowRes.beginDepth(frame); for (const drawing of occluding) drawing.draw(behind); behind.end(); const puffs = lowRes.begin(frame); smoke.draw(puffs); puffs.end(); const reflection = lake.begin(frame); for (const drawing of reflected) drawing.draw(reflection); reflection.end(); scene.record(frame, drawn); glow.record(frame); eye.record(frame); final.record(frame, surface.view(frame)); }, destroy: own.destroy, };}fireworks.ts
import { lighting, Particle, particles, shading, trails, type CameraBase, type Inject, type Lighting,} from '@typegpu/kit';import { perlin2d, randf } from '@typegpu/noise';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';const WORKGROUP = 64;const workgroups = (span: number) => Math.ceil(span / WORKGROUP);const GRAVITY = 9.81;/** Hertz of the glitter on crackle stars and flashes. */const GLITTER_HZ = 17;/** Brightness of stars, shells, their streaks and tails, and embers. */const BRIGHTNESS = { star: 9, shell: 14, streak: 3.2, tail: 2.2, ember: 6 };/** Fraction of the shell's velocity its stars carry. */const CARRIED = 0.25;/** How far a star's speed and life stray from their pattern's, as fractions. */const SPREAD = { speed: 0.12, life: 0.3 };/** Seconds a star burns white-hot before it shows its colour. */const HOT_SECONDS = 0.12;const EMBER_RED = d.vec3f(1, 0.18, 0.04);const EMBER_GOLD = d.vec3f(1, 0.6, 0.22);const SHELL_COLOR = d.vec3f(1, 0.55, 0.2);/** Per-step probability that a rising shell sheds an ember. */const SHELL_SHEDS = 0.6;/** * Each flash slot adds a point light that every lit pixel visits. A finale bursts 26 shells in * 6 s, and flashes last 1.6 s. The slot ring therefore reuses slots after their flashes expire. */const FLASHES = 32;/** Metres a flash's light reaches, and its intensity per squared flash size. */const FLASH = { range: 320, power: 3 };const SMOKE_PUFFS = 14;const SMOKE_COLOR = d.vec3f(0.5, 0.52, 0.58);/** Smoke brightness inside a burst. Falls with distance outside the burst. */const SMOKE_GLOW = 0.3;/** Uniformly lit smoke-core radius as a fraction of flash size. */const SMOKE_CORE = 0.4;/** The share of a flash's light on a surface that lights smoke at its core's edge. */const SMOKE_SHARE = (SMOKE_GLOW * SMOKE_CORE * SMOKE_CORE) / FLASH.power;/** Burst patterns of shells, also carried by their stars and flashes. */export const Pattern = { peony: 0, chrysanthemum: 1, willow: 2, palm: 3, ring: 4, crackle: 5,} as const;export type Pattern = (typeof Pattern)[keyof typeof Pattern];const Traits = d.struct({ /** Stars a shell bursts into. */ count: d.u32, /** Base star lifetime in seconds before per-star variation. */ life: d.f32, /** Speed lost per second, as a fraction. */ drag: d.f32, /** Gravity multiplier relative to Earth. */ fall: d.f32, /** Metres across a star's glow. */ size: d.f32, /** Streak brightness relative to its star. */ streak: d.f32, /** Fixed steps between the points of its streak, so slow stars leave long ones. */ every: d.u32, /** Per-step probability of shedding an ember. */ sheds: d.f32,});/** Each pattern's traits, which its shells' stars share. */const TRAITS: Record<keyof typeof Pattern, d.InferInput<typeof Traits>> = { peony: { count: 120, life: 1.9, drag: 1.2, fall: 0.5, size: 2, streak: 0.35, every: 1, sheds: 0 }, chrysanthemum: { count: 140, life: 2.2, drag: 1.1, fall: 0.6, size: 1.6, streak: 1, every: 2, sheds: 0.05, }, willow: { count: 110, life: 4.2, drag: 1.9, fall: 0.35, size: 1.4, streak: 0.8, every: 5, sheds: 0.35, }, palm: { count: 14, life: 2.6, drag: 0.9, fall: 0.8, size: 3.2, streak: 1.4, every: 3, sheds: 0.9, }, ring: { count: 64, life: 1.8, drag: 1.2, fall: 0.5, size: 2, streak: 0.4, every: 1, sheds: 0 }, crackle: { count: 180, life: 1.6, drag: 1.6, fall: 0.6, size: 0.9, streak: 0.15, every: 1, sheds: 0, },};const names = Object.keys(Pattern) as (keyof typeof Pattern)[];/** `TRAITS` by `Pattern`. */const traitsOf = tgpu.const( d.arrayOf(Traits, names.length), names.map((name) => TRAITS[name]),);/** Values to start the knobs from: the wind, the flashes' light and the smoke's opacity. */export const defaults = { wind: 1.4, light: 1, smoke: 1 };/** A shell for `launch`. */export interface Launch { readonly position: readonly [number, number, number]; readonly velocity: readonly [number, number, number]; /** Seconds until it bursts. */ readonly fuse: number; readonly color: readonly [number, number, number]; readonly pattern: Pattern; /** Metres a second the burst throws its stars. */ readonly burstSpeed: number;}/** Shell pattern and burst speed in metres per second. */const Shell = particles.define('Shell', { pattern: d.u32, reach: d.f32 });type Shell = d.InferGPU<typeof Shell>;/** Star pattern and elapsed steps for spacing streak points. */const Star = particles.define('Star', { pattern: d.u32, steps: d.u32 });type Star = d.InferGPU<typeof Star>;const Flash = particles.define('Flash', { pattern: d.u32 });type Flash = d.InferGPU<typeof Flash>;/** Flash brightness by age. A sharp spike fades, with flicker for glitter shells. */const flashLevel = (flash: Flash) => { 'use gpu'; const glitter = std.select( d.f32(1), 1 - 0.5 * std.step(0.5, std.fract(flash.age * GLITTER_HZ + flash.seed)), flash.pattern === Pattern.crackle, ); return std.exp(-flash.age * 3.2) * glitter;};/** Star colour by age, progressing from white-hot to its colour and then dim red. */const burning = (star: Star) => { 'use gpu'; const pattern = star.pattern; const t = star.age / star.life; const hot = std.mix(d.vec3f(1), star.color.xyz, std.saturate(star.age / HOT_SECONDS)); const fading = std.mix( hot, EMBER_RED * std.max(star.color.xyz.x, 0.4), std.smoothstep(0.55, 1, t), ); const level = std.saturate((1 - t) * 3) * std.select(d.f32(1), 1 - t * 0.5, pattern === Pattern.willow); const strobe = std.select( d.f32(1), std.step(0.45, std.fract(star.age * GLITTER_HZ + star.seed * 7)) * 1.6, pattern === Pattern.crackle && star.age > star.life * 0.35, ); return fading * level * strobe;};/** Smoke shows only in the bursts' light, so moonlight leaves it dark. */const moonless = (_point: shading.Point) => { 'use gpu'; return d.f32(0);};/** Radial glow with a hot core. */const spark = (color: d.v3f, local: d.v2f) => { 'use gpu'; const r2 = std.dot(local, local); return d.vec4f(color * (std.exp(-r2 * 14) + std.exp(-r2 * 4) * 0.2), 0);};/** Flashes, each a point light for `lighting.create`'s `points.light`. */export type Flashes = ReturnType<typeof createFlashes>;/** * Creates the flashes that bursts leave, each a scene point light. Create them before the * lighting, which reads their lights, then pass both to `createFireworks`, whose shells burst * into the flashes and whose smoke the lighting lights. */export function createFlashes( root: TgpuRoot, { camera, step }: Pick<FireworksOptions, 'camera' | 'step'>,) { /** Multiplies the flashes' light. */ const brightness = root.createUniform(d.f32, defaults.light); const flashes = particles.create(root, camera, { label: 'flashes', capacity: FLASHES, particle: Flash, step, settings: { gravity: [0, 0, 0], drag: 2 }, shade: (flash: Flash, local: d.v2f) => { 'use gpu'; const r2 = std.dot(local, local); const glow = std.exp(-flash.age * 14) * 1.5; return d.vec4f(flash.color.xyz * glow * std.exp(-r2 * 8), 0); }, }); const lightOf = (slot: number) => { 'use gpu'; const flash = flashes.particle(slot); return lighting.Light({ position: flash.position, range: std.select(d.f32(0), FLASH.range, flashes.alive(slot)), color: flash.color.xyz, intensity: flashLevel(flash) * brightness.$ * FLASH.power * flash.size * flash.size, }); }; return { particles: flashes, brightness, /** Flash lights for `lighting.create`'s `points.light`. */ light: tgpu.fn(lightOf).pipe(flashes.inject()), capacity: FLASHES, initAsync: () => flashes.initAsync(), destroy: () => flashes.destroy(), };}interface FireworksOptions { readonly camera: CameraBase; /** The length of a step in seconds. */ readonly step: number; /** Cached Perlin gradients for smoke. */ readonly noise: { inject(): Inject }; /** How many of each the systems hold at once. */ readonly capacity: Readonly<Record<'shells' | 'stars' | 'embers' | 'smoke', number>>;}/** * Spawns shells that burst into stars, `flashes` and smoke, which `light` lights, and embers. * Records the flashes' steps with its own. */export function createFireworks( root: TgpuRoot, flashes: Flashes, light: Lighting, { camera, step, noise, capacity }: FireworksOptions,) { /** Metres a second the wind blows smoke, across the lake along x. */ const wind = root.createUniform(d.f32, defaults.wind); /** Multiplies the smoke's opacity. */ const opacity = root.createUniform(d.f32, defaults.smoke); const flashing = flashes.particles; const still = { gravity: [0, 0, 0] as [number, number, number], drag: 0 }; /** A flash lights smoke evenly across the burst's core. */ const throughSmoke = ( _point: shading.Point, _toLight: d.v3f, distance: number, range: number, slot: number, ) => { 'use gpu'; const core = flashing.particle(slot).size * SMOKE_CORE; return SMOKE_SHARE * shading.falloff(std.max(distance, core), range); }; const smoke = particles.create(root, camera, { label: 'smoke', capacity: capacity.smoke, step, blend: 'over', settings: still, update: tgpu .fn((puff: Particle, dt: number) => { 'use gpu'; let next = Particle(puff); const drift = d.vec3f(wind.$, 0.5, 0); next.velocity = std.mix(puff.velocity, drift, dt / 2.5); next.size = puff.size + 0.9 * dt; next.color = d.vec4f(shading.incoming(puff.position) * SMOKE_COLOR, puff.color.w); return next; }) .pipe(light.inject()) .pipe(flashing.inject()) .with(shading.attenuation, throughSmoke) .with(shading.sunShadow, moonless), shade: tgpu .fn((puff: Particle, local: d.v2f) => { 'use gpu'; const r2 = std.dot(local, local); const billow = perlin2d.sample(local * 2.2 + puff.seed * 31) * 0.5 + 0.5; const body = std.saturate(1 - r2) * std.saturate(1 - r2) * std.smoothstep(0.25, 0.75, billow) * 1.6; const life = std.saturate(puff.age / 1.5) * std.saturate((puff.life - puff.age) / (puff.life * 0.5)); const alpha = std.saturate(body * life * puff.color.w * opacity.$); return d.vec4f(puff.color.xyz * alpha, alpha); }) .pipe(noise.inject()), }); const embers = particles.create(root, camera, { label: 'embers', capacity: capacity.embers, step, settings: { gravity: [0, -2.4, 0], drag: 1.6 }, shade: (ember: Particle, local: d.v2f) => { 'use gpu'; const t = ember.age / ember.life; const twinkle = 0.55 + 0.45 * std.sin(ember.age * 40 + ember.seed * 60); return spark(ember.color.xyz * (BRIGHTNESS.ember * (1 - t) * (1 - t) * twinkle), local); }, }); /** Emits an ember behind a burning particle. */ const shed = (position: d.v3f, velocity: d.v3f, color: d.v3f) => { 'use gpu'; embers.emit( Particle({ position, age: 0, velocity: velocity * 0.15 + randf.inUnitSphere() * 1.5, life: 1.6 * (0.5 + randf.sample()), color: d.vec4f(color, 1), size: 0.45, seed: randf.sample(), }), ); }; const stars = particles.create(root, camera, { label: 'stars', capacity: capacity.stars, particle: Star, step, settings: still, update: (star: Star, dt: number) => { 'use gpu'; let next = Star(star); const traits = traitsOf.$[star.pattern]!; next.velocity = star.velocity * std.exp(-traits.drag * dt) - d.vec3f(0, GRAVITY * traits.fall * dt, 0); next.steps = star.steps + 1; if (star.position.y < 0) next.life = 0; return next; }, shade: (star: Star, local: d.v2f) => { 'use gpu'; return spark(burning(star) * BRIGHTNESS.star, local); }, }); const streaks = trails.create(root, { capacity: capacity.stars, length: 9, width: tgpu .fn((point: trails.Sample) => { 'use gpu'; const star = stars.particle(point.trail); const shown = std.select(d.f32(0), star.size * 0.3, stars.alive(point.trail)); return shown * (1 - point.along * 0.7); }) .pipe(stars.inject()), shade: tgpu .fn((point: trails.Sample, across: number) => { 'use gpu'; const star = stars.particle(point.trail); const streak = traitsOf.$[star.pattern]!.streak; const edge = 1 - std.smoothstep(0.3, 1, std.abs(across)); const fade = (1 - point.along) * (1 - point.along); return d.vec4f(burning(star) * (BRIGHTNESS.streak * streak * edge * fade), 0); }) .pipe(stars.inject()), // Keep each streak on its star, which moves on between the steps that push its points head: tgpu.fn(stars.drawnAt).pipe(stars.inject()), }); const tails = trails.create(root, { label: 'tails', capacity: capacity.shells, length: 28, width: (point: trails.Sample) => { 'use gpu'; return 0.5 * (1 - point.along); }, shade: (point: trails.Sample, across: number) => { 'use gpu'; const edge = 1 - std.smoothstep(0.2, 1, std.abs(across)); return d.vec4f(SHELL_COLOR * (BRIGHTNESS.tail * edge * (1 - point.along)), 0); }, }); const shells = particles.create(root, camera, { label: 'shells', capacity: capacity.shells, particle: Shell, step, settings: { gravity: [0, -GRAVITY, 0], drag: 0 }, // Start the tail at shell birth and end it with the shell born: tgpu .fn((shell: Shell, slot: number) => { 'use gpu'; tails.reset(slot); return Shell(shell); }) .pipe(tails.inject()), shade: (_shell: Shell, local: d.v2f) => { 'use gpu'; return spark(SHELL_COLOR * BRIGHTNESS.shell, local); }, died: tgpu .fn((shell: Shell, slot: number) => { 'use gpu'; tails.reset(slot); const pattern = shell.pattern; const traits = traitsOf.$[pattern]!; const reach = shell.reach; const color = d.vec4f(shell.color.xyz, 1); randf.seed3(shell.position + shell.seed); flashing.emit( Flash({ position: shell.position, age: 0, velocity: d.vec3f(), life: 1.6, color, size: reach * 1.4, seed: randf.sample(), pattern, }), ); // Choose a random orientation for each burst pattern, including the ring plane const axis = randf.onUnitSphere(); const side = std.normalize(std.cross(axis, d.vec3f(0.3, 1, 0.1))); const across = std.cross(axis, side); const golden = Math.PI * (3 - Math.sqrt(5)); const count = d.f32(traits.count); for (let i = d.u32(0); i < traits.count; i++) { const k = d.f32(i) + 0.5; let direction = d.vec3f(); if (pattern === Pattern.ring) { const angle = (k / count) * Math.PI * 2; direction = side * std.cos(angle) + across * std.sin(angle); } else { const height = 1 - (2 * k) / count; const round = std.sqrt(std.max(1 - height * height, 0)); const angle = k * golden; direction = axis * height + (side * std.cos(angle) + across * std.sin(angle)) * round; } if (pattern === Pattern.palm) { direction = std.normalize(direction + d.vec3f(0, 0.6, 0)); } const speed = reach * (1 + (randf.sample() - 0.5) * SPREAD.speed * 2); stars.emit( Star({ position: shell.position, age: 0, velocity: shell.velocity * CARRIED + direction * speed, life: traits.life * (1 + (randf.sample() - 0.5) * SPREAD.life), color, size: traits.size, seed: randf.sample(), pattern, steps: 0, }), ); } for (let i = 0; i < SMOKE_PUFFS; i++) { smoke.emit( Particle({ position: shell.position + randf.inUnitSphere() * reach * 0.9, age: 0, velocity: randf.inUnitSphere() * reach * 0.25, life: 22 * (0.7 + randf.sample() * 0.6), color: d.vec4f(0, 0, 0, 0.16 * (0.5 + randf.sample())), size: reach * 0.75, seed: randf.sample(), }), ); } }) .pipe(stars.inject()) .pipe(flashing.inject()) .pipe(smoke.inject()) .pipe(tails.inject()), }); const followStars = tgpu.computeFn({ workgroupSize: [WORKGROUP], in: { id: d.builtin.globalInvocationId }, })(({ id }) => { 'use gpu'; if (id.x >= stars.span) return; // Empty a dead star's streak so its points stop drawing if (!stars.alive(id.x)) { streaks.reset(id.x); return; } const star = stars.particle(id.x); const traits = traitsOf.$[star.pattern]!; // Reset the streak on birth because the slot may retain an earlier star's points if (star.steps === 1) streaks.reset(id.x); if (star.steps % traits.every === 0 || star.steps === 1) streaks.push(id.x, star.position); randf.seed3(d.vec3f(star.position.xy, star.age + star.seed)); if (randf.sample() < traits.sheds) { const gold = std.mix(EMBER_GOLD, star.color.xyz, 0.3); shed(star.position, star.velocity, gold); } }); const followShells = tgpu.computeFn({ workgroupSize: [WORKGROUP], in: { id: d.builtin.globalInvocationId }, })(({ id }) => { 'use gpu'; if (id.x >= shells.span) return; if (!shells.alive(id.x)) return; const shell = shells.particle(id.x); tails.push(id.x, shell.position); randf.seed3(d.vec3f(shell.position.xz, shell.age + shell.seed)); if (randf.sample() < SHELL_SHEDS) shed(shell.position, shell.velocity * -1, EMBER_GOLD); }); const starsFollowed = root .pipe(stars.inject()) .pipe(streaks.inject()) .pipe(embers.inject()) .createComputePipeline({ compute: followStars }); const shellsFollowed = root .pipe(shells.inject()) .pipe(tails.inject()) .pipe(embers.inject()) .createComputePipeline({ compute: followShells }); const owned = [shells, stars, embers, smoke]; return { wind, opacity, /** Smoke, which covers what is drawn before it. */ smoke: smoke.drawing, /** Embers, stars, shells and flashes, which add their glow. Draw them after `smoke`. */ sprites: [embers.drawing, stars.drawing, shells.drawing, flashing.drawing], /** The stars' streaks and the shells' tails, which add their glow. Draw them after `smoke`. */ streaks: [streaks.drawing, tails.drawing], /** Queues a shell to launch in the next `record`. */ launch(shell: Launch) { shells.burst({ count: 1, position: shell.position, velocity: shell.velocity, life: shell.fuse, color: [...shell.color, 1], size: 1.2, variance: 0, fields: { pattern: shell.pattern, reach: shell.burstSpeed }, }); }, /** * Records frame spawning, fixed steps and live lists in one compute pass, drawn `ahead` * seconds past the last step. */ record(pass: TgpuComputePass, steps: number, ahead: number) { shells.spawn(pass); for (let i = 0; i < steps; i++) { shells.step(pass); stars.step(pass); flashing.step(pass); smoke.step(pass); embers.step(pass); pass.setPipeline(starsFollowed); pass.dispatchWorkgroups(workgroups(stars.span)); pass.setPipeline(shellsFollowed); pass.dispatchWorkgroups(workgroups(shells.span)); } for (const system of owned) system.compact(pass, ahead); flashing.compact(pass, ahead); }, initAsync: () => Promise.all([ ...owned.map((system) => system.initAsync()), starsFollowed.initAsync(), shellsFollowed.initAsync(), ]), destroy() { for (const system of owned) system.destroy(); streaks.destroy(); tails.destroy(); }, };}show.ts
import { random, type RandomGenerator } from 'math/random';import { Pattern, type Launch } from './fireworks.ts';type Vec3 = [number, number, number];/** Linear colours the stars burn in. */const PALETTE: Vec3[] = [ [1, 0.55, 0.16], [1, 0.1, 0.06], [0.16, 1, 0.3], [0.18, 0.36, 1], [0.72, 0.24, 1], [1, 0.92, 0.82], [0.2, 0.9, 1],];const GOLD = PALETTE[0]!;const SILVER = PALETTE[5]!;const PATTERNS = [ Pattern.peony, Pattern.chrysanthemum, Pattern.willow, Pattern.palm, Pattern.ring, Pattern.crackle,];/** Shell launch sites on barges behind the castle in the initial view. */const SITES: Vec3[] = [ [-170, 0.5, -70], [-95, 0.5, -120], [-20, 0.5, -150], [55, 0.5, -130], [130, 0.5, -95], [-60, 0.5, -60], [80, 0.5, -55],];const GRAVITY = 9.81;/** Launch-speed multiplier for clicked shells relative to shells bursting at their apex. */const CLICKED_BOOST = 1.6;const HEIGHT_METRES = [72, 125] as const;/** Metres a second a burst throws its stars, which sets how wide it opens. */const BURST_SPEED = [38, 62] as const;/** Metres a burst may land from above its site, along x and z. */const LEAN_METRES = 14;/** Seconds between cues, divided by the rate. */const GAP_SECONDS = [0.7, 2.2] as const;/** A finale `every` seconds bursts `shells` shells over `seconds`. */const FINALE = { every: 70, shells: 26, seconds: 6 };/** Shots that can wait to launch at once. */const SHOTS = 64;/** Burst-speed multipliers that open palms wider and willows narrower than other patterns. */const OPENING: Partial<Record<Pattern, number>> = { [Pattern.palm]: 1.3, [Pattern.willow]: 0.8 };/** A shell waiting to launch `at` a time on the show's clock, or free at `Infinity`. */interface Shot { at: number; readonly shell: { -readonly [K in keyof Launch]: Launch[K] };}/** The barge nearest to the point under `burst`. */function siteUnder(burst: Vec3) { let nearest = SITES[0]!; let closest = Infinity; for (const site of SITES) { const distance = Math.hypot(site[0] - burst[0], site[2] - burst[2]); if (distance < closest) { nearest = site; closest = distance; } } return nearest;}/** * Schedules salvos, pairs, fans, chains and periodic finales on the CPU. The GPU simulates * shells after launch. */export function createShow(rng: RandomGenerator = Math.random) { const shots: Shot[] = Array.from({ length: SHOTS }, () => ({ at: Infinity, shell: { position: [0, 0, 0], velocity: [0, 0, 0], fuse: 0, color: [0, 0, 0], pattern: Pattern.peony, burstSpeed: 0, }, })); let clock = 0; let nextCue = 0; let nextFinale = FINALE.every; const pick = <T>(items: T[]) => random.choice(rng, items); const free = () => shots.find((shot) => shot.at === Infinity); const colorOf = (pattern: Pattern) => { if (pattern === Pattern.willow || pattern === Pattern.palm) return GOLD; if (pattern === Pattern.crackle) return SILVER; return pick(PALETTE); }; /** * Aims from `site` toward `burst`. Bursts at the apex, or during ascent when `boost` * increases launch speed. */ const aim = ( shot: Shot, site: Vec3, burst: Vec3, pattern: Pattern, color: Vec3, burstSpeed: number, boost = 1, ) => { const height = Math.max(burst[1] - site[1], 1); const rise = boost * Math.sqrt(2 * GRAVITY * height); const climb = (rise - Math.sqrt(Math.max(rise * rise - 2 * GRAVITY * height, 0))) / GRAVITY; const { shell } = shot; shell.position = [...site]; shell.velocity = [(burst[0] - site[0]) / climb, rise, (burst[2] - site[2]) / climb]; shell.fuse = climb; shell.color = [...color]; shell.pattern = pattern; shell.burstSpeed = burstSpeed; }; /** Launches a shell from `site` after `delay` seconds, bursting near `height` above it. */ const queue = ( delay: number, site: Vec3, pattern: Pattern, color: Vec3, height = random.float(rng, ...HEIGHT_METRES), ) => { const shot = free(); if (!shot) return; const burst: Vec3 = [ site[0] + (rng() - 0.5) * LEAN_METRES * 2, height, site[2] + (rng() - 0.5) * LEAN_METRES * 2, ]; aim( shot, site, burst, pattern, color, random.float(rng, ...BURST_SPEED) * (OPENING[pattern] ?? 1), ); shot.at = clock + delay; }; /** Queues the next cue's shells and returns the seconds until the cue after it. */ const cue = () => { if (clock >= nextFinale) { for (let i = 0; i < FINALE.shells; i++) { const pattern = pick(PATTERNS); queue(rng() * FINALE.seconds, pick(SITES), pattern, colorOf(pattern)); } nextFinale = clock + FINALE.every; return FINALE.seconds + GAP_SECONDS[1] * 2; } const pattern = pick(PATTERNS); const color = colorOf(pattern); const roll = rng(); if (roll < 0.3) { // One shell, as high as they go queue(0, pick(SITES), pattern, color, HEIGHT_METRES[1]); } else if (roll < 0.5) { // A pair from barges either side of the middle one const left = random.int(rng, 0, 1); queue(0, SITES[left]!, pattern, color); queue(0, SITES[4 - left]!, pattern, color); } else if (roll < 0.7) { // A fan across the first five barges, each a little later and higher for (let i = 0; i < 5; i++) queue(i * 0.22, SITES[i]!, pattern, color, HEIGHT_METRES[0] + i * 6); } else if (roll < 0.85) { // A chain from one barge, each shell a new colour const site = pick(SITES); for (let i = 0; i < 4; i++) { queue(i * 0.45, site, pattern, pick(PALETTE), HEIGHT_METRES[0] + i * 10); } } else { queue(0, pick(SITES), Pattern.palm, GOLD); queue(0.6, pick(SITES), Pattern.crackle, SILVER); queue(1.1, pick(SITES), Pattern.willow, GOLD); } return random.float(rng, ...GAP_SECONDS); }; return { /** * Advances by `dt` seconds and launches due shells. `rate` scales time between cues. With * `auto` off, launches only queued shells. */ advance(dt: number, rate: number, auto: boolean, launch: (shell: Launch) => void) { clock += dt; if (!auto) nextCue = Math.max(nextCue, clock); else if (rate > 0) { while (clock >= nextCue) nextCue += cue() / rate; } for (const shot of shots) { if (shot.at > clock) continue; launch(shot.shell); shot.at = Infinity; } }, /** Launches from the nearest barge to burst at `burst`. */ fireAt(burst: Vec3) { const site = siteUnder(burst); const shot = free(); if (!shot) return; const pattern = pick(PATTERNS); aim( shot, site, burst, pattern, colorOf(pattern), random.float(rng, ...BURST_SPEED), CLICKED_BOOST, ); shot.at = clock; }, };}castle.ts
import { instances, meshPool } from '@typegpu/kit';import { quat } from 'math';import type { TgpuRoot } from 'typegpu';import { heightAt, PLATEAU_METRES } from './land.ts';/** Binary glTF models from Kenney's Castle Kit and Nature Kit (CC0). */export const MODELS = [ 'wall', 'tower-hexagon-base', 'tower-hexagon-mid', 'tower-hexagon-roof', 'tower-square-base', 'tower-square-mid', 'tower-square-mid-windows', 'tower-square-top-roof-high', 'tower-square-arch', 'tower-square-top', 'pine-default', 'pine-round', 'pine-tall',] as const;export type ModelName = (typeof MODELS)[number];type Vec3 = [number, number, number];/** Metres a Castle Kit tile spans. */const TILE_METRES = 10;/** Tiles from the castle's middle to its walls. */const REACH_TILES = 2;/** Heights of the kit's pieces, in tiles. */const HEXAGON_BASE_TILES = 1.31;const HEXAGON_MID_TILES = 0.46;const SQUARE_STOREY_TILES = 1.01;const KEEP = [ 'tower-square-base', 'tower-square-mid-windows', 'tower-square-mid', 'tower-square-mid-windows', 'tower-square-top-roof-high',] as const satisfies readonly ModelName[];const GATEHOUSE = ['tower-square-arch', 'tower-square-top'] as const satisfies readonly ModelName[];/** Corner-tower middle-storey counts. Rear towers rise above the keep. */const CORNERS = [ { x: -1, z: -1, storeys: 5 }, { x: 1, z: -1, storeys: 4 }, { x: -1, z: 1, storeys: 2 }, { x: 1, z: 1, storeys: 3 },] as const;const PINES = ['pine-default', 'pine-round', 'pine-tall'] as const satisfies readonly ModelName[];const ISLAND_PINES = 14;/** Island pines stand in four rings, this far out and this far apart. */const ISLAND_PINE_RADIUS_METRES = 42;const ISLAND_PINE_RING_METRES = 4;const ISLAND_PINE_SCALE = [7, 11] as const;const HILL_PINES = 900;const HILL_PINE_RADIUS_METRES = [470, 1150] as const;const HILL_PINE_SCALE = [14, 26] as const;const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));interface Piece { readonly model: ModelName; readonly position: Vec3; /** Radians around +y. */ readonly yaw: number; readonly scale: number;}/** A place on the plateau, in tiles. */const at = (x: number, y: number, z: number): Vec3 => [ x * TILE_METRES, PLATEAU_METRES + y * TILE_METRES, z * TILE_METRES,];/** Evenly distributed scale between `low` and `high` for index `i`. */const scaleOf = (i: number, [low, high]: readonly [number, number]) => low + (high - low) * ((i * GOLDEN_ANGLE) % 1);/** The castle's walls round the plateau, its corner towers, the gatehouse and the keep. */function castlePieces(): Piece[] { const pieces: Piece[] = []; const wall = (x: number, z: number, yaw: number) => { pieces.push({ model: 'wall', position: at(x, 0, z), yaw, scale: TILE_METRES }); }; /** Stacks `models` at `x`, `z`, each piece `storey` tiles above the one before. */ const stack = ( x: number, z: number, models: readonly ModelName[], storey: (i: number) => number, ) => { let y = 0; models.forEach((model, i) => { pieces.push({ model, position: at(x, y, z), yaw: 0, scale: TILE_METRES }); y += storey(i); }); }; // The front wall leaves a gap in its middle for the gatehouse for (let i = -REACH_TILES + 1; i < REACH_TILES; i++) { if (i !== 0) wall(i, REACH_TILES, 0); wall(i, -REACH_TILES, Math.PI); wall(REACH_TILES, i, Math.PI / 2); wall(-REACH_TILES, i, -Math.PI / 2); } for (const { x, z, storeys } of CORNERS) { const mids = Array<ModelName>(storeys).fill('tower-hexagon-mid'); const tower = ['tower-hexagon-base', ...mids, 'tower-hexagon-roof'] as const; stack(x * REACH_TILES, z * REACH_TILES, tower, (i) => i === 0 ? HEXAGON_BASE_TILES : HEXAGON_MID_TILES, ); } stack(0, REACH_TILES, GATEHOUSE, () => SQUARE_STOREY_TILES); stack(0, 0, KEEP, () => SQUARE_STOREY_TILES); return pieces;}/** Pines on the island round the castle and over the hills round the lake. */function naturePieces(): Piece[] { const pieces: Piece[] = []; /** Plants pine `i` at `angle` and `radius` unless the land there lies under `lowest`. */ const pine = ( i: number, angle: number, radius: number, scales: readonly [number, number], lowest: number, ) => { const x = Math.cos(angle) * radius; const z = Math.sin(angle) * radius; const y = heightAt(x, z); if (y < lowest) return; pieces.push({ model: PINES[i % 3]!, position: [x, y, z], yaw: angle, scale: scaleOf(i, scales), }); }; for (let i = 0; i < ISLAND_PINES; i++) { const radius = ISLAND_PINE_RADIUS_METRES + (i % 4) * ISLAND_PINE_RING_METRES; pine(i, i * GOLDEN_ANGLE * 3, radius, ISLAND_PINE_SCALE, 0.5); } const [inner, outer] = HILL_PINE_RADIUS_METRES; for (let i = 0; i < HILL_PINES; i++) { const radius = inner + (outer - inner) * Math.sqrt((i + 0.5) / HILL_PINES); pine(i, i * GOLDEN_ANGLE, radius, HILL_PINE_SCALE, 1); } return pieces;}/** Stores castle and pine meshes in one pool, with instances lit by the scene's lighting. */export function createCastle( root: TgpuRoot, models: Readonly<Record<ModelName, meshPool.ModelData>>,) { const pool = meshPool.create(root, meshPool.roomFor(MODELS.map((name) => models[name]))); const loaded = new Map(MODELS.map((name) => [name, pool.add(models[name])] as const)); const pieces = [...castlePieces(), ...naturePieces()]; const scenery = instances.create(root, pool, { capacity: pieces.reduce((sum, { model }) => sum + models[model].meshes.length, 0), }); for (const { model, position, yaw, scale } of pieces) { const rotation = quat.setAxisAngle(quat.create(), [0, 1, 0], yaw); scenery.add(loaded.get(model)!, { position, rotation, scale }); } return { ready: Promise.all([...loaded.values()].map((model) => model.ready)), renderer: scenery.renderer(), flush: () => scenery.flush(), destroy() { scenery.destroy(); pool.destroy(); }, };}land.ts
import { instances, materials, meshPool } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { d, std, type TgpuRoot } from 'typegpu';import { mirrored } from './lake.ts';/** Height of the rock the castle stands on, above the water. */export const PLATEAU_METRES = 7;const PLATEAU_RADIUS_METRES = 38;const ISLAND_RADIUS_METRES = 62;const LAKE_FLOOR_METRES = -9;/** Far-shore shape around the lake. */const SHORE_METRES = 430;const SHORE_WAVES_METRES = [70, 35] as const;const HILL_RISE_METRES = 260;const HILL_METRES = 46;const HILL_WAVES_METRES = [22, 11] as const;const RANGE_METRES = 1500;const RANGE_RISE_METRES = 700;const PEAK_METRES = 190;const PEAK_WAVES_METRES = [80, 45] as const;/** Metres from the centre that the land reaches. */const LAND_METRES = 3200;const RINGS = 168;const SECTORS = 288;/** Radial-grid exponent. Concentrates rings near the island. */const RING_POWER = 2.6;const NORMAL_STEP_METRES = 0.6;const GRASS = d.vec3f(0.03, 0.05, 0.028);const ROCK = d.vec3f(0.07, 0.068, 0.07);const MUD = d.vec3f(0.045, 0.04, 0.032);const ROUGHNESS = 0.92;const CLIFF_SLOPE = 0.55;/** Height above water in metres, shared by CPU and GPU calculations. */export const heightAt = (x: number, z: number) => { 'use gpu'; const r = std.sqrt(x * x + z * z); const angle = std.atan2(z, x); const crag = std.sin(angle * 5 + 1.3) * 1.2 + std.sin(angle * 11 + r * 0.05) * 0.7; const island = LAKE_FLOOR_METRES + (PLATEAU_METRES - LAKE_FLOOR_METRES + crag) * (1 - std.smoothstep(PLATEAU_RADIUS_METRES, ISLAND_RADIUS_METRES, r + crag * 2)); const shore = SHORE_METRES + std.sin(angle * 3 + 0.4) * SHORE_WAVES_METRES[0] + std.sin(angle * 7 + 2.1) * SHORE_WAVES_METRES[1]; const hills = (HILL_METRES + std.sin(angle * 5 + 2) * HILL_WAVES_METRES[0] + std.sin(angle * 13 + r * 0.004) * HILL_WAVES_METRES[1]) * std.smoothstep(shore, shore + HILL_RISE_METRES, r); const range = (PEAK_METRES + std.sin(angle * 3 + 2.6) * PEAK_WAVES_METRES[0] + std.sin(angle * 8 + 0.4) * PEAK_WAVES_METRES[1]) * std.smoothstep(RANGE_METRES, RANGE_METRES + RANGE_RISE_METRES, r); const banks = LAKE_FLOOR_METRES * (1 - std.smoothstep(shore - 60, shore, r)); return std.max(island, banks + hills + range);};/** * Position on the polar grid at sector fraction `u` and ring fraction `v`, with rings concentrated * near the island. */const placeAt = (u: number, v: number) => { 'use gpu'; // The last sector meets the first exactly const angle = std.select(u, d.f32(0), u >= 1) * Math.PI * 2; return d.vec2f(std.cos(angle), std.sin(angle)) * (LAND_METRES * std.pow(v, RING_POWER));};/** Grass and rock, wet mud at the waterline. */const Land = instances.define('Land', { material: (point) => { 'use gpu'; // Exclude underwater geometry from the reflection if (mirrored.$ && point.position.y < 0) std.discard(); const cliff = 1 - std.smoothstep(CLIFF_SLOPE - 0.2, CLIFF_SLOPE, point.normal.y); const wet = 1 - std.smoothstep(0, 1.2, point.position.y); const color = std.mix(std.mix(GRASS, ROCK, cliff), MUD, wet); return materials.glossy(color, ROUGHNESS); },});/** Bakes island, lake bed and surrounding hills into a mesh once, lit by the scene's lighting. */export function createLand(root: TgpuRoot) { const shape = meshes.parametric( { at: (u, v) => { 'use gpu'; const place = placeAt(u, v); return d.vec3f(place.x, heightAt(place.x, place.y), place.y); }, normalAt: (u, v) => { 'use gpu'; const place = placeAt(u, v); const dx = heightAt(place.x + NORMAL_STEP_METRES, place.y) - heightAt(place.x - NORMAL_STEP_METRES, place.y); const dz = heightAt(place.x, place.y + NORMAL_STEP_METRES) - heightAt(place.x, place.y - NORMAL_STEP_METRES); return std.normalize(d.vec3f(-dx, NORMAL_STEP_METRES * 2, -dz)); }, }, { cols: SECTORS, rows: RINGS }, ); const pool = meshPool.create(root, meshPool.roomFor([shape])); const field = instances.create(root, pool, { kind: Land, capacity: 1 }); field.add(pool.add(shape)); field.flush(); return { renderer: field.renderer(), destroy() { field.destroy(); pool.destroy(); }, };}lake.ts
import { cameras, Camera, passes, targets, type CameraBase, type Frame, type Inject, type Lighting, type SizeSource,} from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { perlin2d } from '@typegpu/noise';import { d, std, tgpu, type TgpuRoot, type TgpuUniform } from 'typegpu';import { pictureGroups, sample } from './picture.ts';import { HORIZON } from './sky.ts';/** True for reflection pipelines. Excludes geometry below the water. */export const mirrored = tgpu.slot(false);/** Reflection resolution as a fraction of screen width and height. */const MIRROR_SCALE = 0.5;/** The square of water, 6.4 km across. */const plane = meshes.unindexed(meshes.plane({ width: 6400, depth: 6400 }));const DEEP = d.vec3f(0.0015, 0.0025, 0.004);/** Water reflectance at normal incidence. */const REFLECTANCE = 0.02;const RIPPLE_METRES = [9, 2.6] as const;const RIPPLE_HEIGHT = [0.5, 0.22] as const;const RIPPLE_DRIFT_METRES_PER_SECOND = [d.vec2f(0.6, 0.25), d.vec2f(-0.35, 0.5)] as const;const RIPPLE_STEP_METRES = 0.08;/** Ripple displacement in screen widths at a distance of 10 m. */const RIPPLE_SHIFT = 0.18;const RIPPLE_SHIFT_METRES = 10;/** Distance beyond which subpixel ripples become a still reflection. */const CALM_METRES = 900;const HAZE_METRES = 2600;/** Reflects `view` across water at y = 0 and flips the image horizontally. */const reflectionOf = (view: d.Infer<typeof Camera>) => { 'use gpu'; // Negating the y column reflects the world. Negating the x row flips the image and preserves // triangle winding for back-face culling const flip = d.vec4f(-1, 1, 1, 1); const m = view.viewProjection; return Camera({ viewProjection: d.mat4x4f( m.columns[0] * flip, m.columns[1] * flip * -1, m.columns[2] * flip, m.columns[3] * flip, ), position: view.position * d.vec3f(1, -1, 1), right: view.right * d.vec3f(-1, 1, -1), up: view.up * d.vec3f(1, -1, 1), });};/** Ripple height in metres from two drifting noise layers. */const ripplesAt = (place: d.v2f, time: number) => { 'use gpu'; let height = d.f32(0); for (const i of tgpu.unroll([0, 1])) { const drift = RIPPLE_DRIFT_METRES_PER_SECOND[i]! * time; height += perlin2d.sample((place + drift) / RIPPLE_METRES[i]!) * RIPPLE_HEIGHT[i]!; } return height;};const Corner = d.struct({ position: d.vec4f, world: d.vec3f, clip: d.vec4f });const Seen = d.struct({ color: d.vec4f });interface LakeOptions { /** The scene's camera, whose reflection the mirror draws. */ readonly view: CameraBase; /** The scene's lighting, which lights what the mirror draws. */ readonly light: Lighting; readonly size: SizeSource; readonly time: TgpuUniform<d.F32>; /** Cached Perlin gradients for ripples. */ readonly noise: { inject(): Inject };}/** * Creates reflection passes for above-water geometry through a reflected scene camera. The * water drawing samples the result with ripple distortion. */export function createLake(root: TgpuRoot, { view, light, size, time, noise }: LakeOptions) { const mirror = targets.create(root, size, { hdr: { format: 'rgba16float', sampled: true, scale: MIRROR_SCALE }, depth: { format: 'depth24plus', scale: MIRROR_SCALE }, }); const reflectedView = () => { 'use gpu'; return reflectionOf(view.uniform.$); }; const reflected: Inject = (cfg) => cfg.with(cameras.current, reflectedView).with(mirrored, true); const reflection = passes .create(root, mirror, { hdr: passes.color, depth: passes.depth }, { label: 'mirror' }) .pipe(reflected) .beneath(light.inject()); const groupOf = pictureGroups(root); const water: passes.Drawing<typeof Corner, d.Infer<typeof Seen>> = { label: 'water', Vertex: Corner, at: (vertexIndex: number, _instanceIndex: number) => { 'use gpu'; const world = plane.vertexAt(vertexIndex).position; const clip = cameras.current.$.viewProjection * d.vec4f(world, 1); return Corner({ position: clip, world, clip }); }, surfaceOf: (corner) => { 'use gpu'; const toEye = cameras.current.$.position - corner.world; const distance = std.length(toEye); const toward = toEye / distance; const calm = std.saturate(1 - distance / CALM_METRES); const place = corner.world.xz; const here = ripplesAt(place, time.$); const dx = ripplesAt(place + d.vec2f(RIPPLE_STEP_METRES, 0), time.$) - here; const dz = ripplesAt(place + d.vec2f(0, RIPPLE_STEP_METRES), time.$) - here; const normal = std.normalize(d.vec3f(-dx * calm, RIPPLE_STEP_METRES, -dz * calm)); const screen = (corner.clip.xy / corner.clip.w) * d.vec2f(-0.5, -0.5) + 0.5; const shift = (normal.xz * (RIPPLE_SHIFT * RIPPLE_SHIFT_METRES)) / std.max(distance, RIPPLE_SHIFT_METRES); const above = sample(screen + shift).xyz; const cosine = std.saturate(std.dot(normal, toward)); const fresnel = REFLECTANCE + (1 - REFLECTANCE) * std.pow(1 - cosine, 5); const haze = std.saturate(distance / HAZE_METRES); const color = std.mix(DEEP + above * fresnel, HORIZON, haze * haze); return Seen({ color: d.vec4f(color, 1) }); }, color: passes.shaded, inject: () => noise.inject(), draw(pass) { pass.setBindGroup(groupOf(mirror.textures.hdr)); pass.draw(plane.vertexCount); }, }; return { /** Add above-water geometry to these reflection passes. */ reflection, water, /** Starts the reflection pass. Call after fitting the scene targets. */ begin: (frame: Frame) => { mirror.fit(); return reflection.begin(frame); }, destroy: mirror.destroy, };}lowRes.ts
import { passes, pipelines, targets, type CameraBase, type Frame, type SizeSource,} from '@typegpu/kit';import { d, type TgpuRoot } from 'typegpu';import { pictureGroups, sample } from './picture.ts';const Seen = d.struct({ color: d.vec4f });const CLEARED = { clear: ['picture'] } as const;interface LowResOptions { readonly view: CameraBase; readonly size: SizeSource; /** Resolution as a fraction of screen width and height. */ readonly scale: number; readonly label: string;}/** * Draws translucent matter such as smoke at reduced resolution to lower fragment cost. Renders * foreground depth first to hide occluded matter, then composites the picture over the scene. */export function createLowRes(root: TgpuRoot, { view, size, scale, label }: LowResOptions) { const low = targets.create(root, size, { picture: { format: 'rgba16float', sampled: true, scale }, depth: { format: 'depth24plus', scale }, }); const occluders = passes .create(root, low, { depth: passes.depth }, { label: `${label} depth` }) .pipe(view.inject()); const matter = passes .create(root, low, { picture: passes.color, depth: passes.depth }, { label }) .pipe(view.inject()); const groupOf = pictureGroups(root); const over: passes.Drawing<typeof passes.ScreenVertex, d.Infer<typeof Seen>> = { ...passes.fullScreen, label: `${label} over`, surfaceOf: (corner) => { 'use gpu'; return Seen({ color: sample(corner.uv) }); }, color: passes.shaded, blend: pipelines.blend.over, depth: { depthWriteEnabled: false, depthCompare: 'always' }, draw(pass) { pass.setBindGroup(groupOf(low.textures.picture)); pass.draw(3); }, }; return { /** Add foreground occluders. Draws only their depth. */ occluders, /** Add matter with premultiplied blending. */ matter, /** Add this composite drawing at the matter's position in scene draw order. */ over, /** Begins the foreground depth pass. End it before calling `begin`. */ beginDepth(frame: Frame) { low.fit(); return occluders.begin(frame); }, /** Begins the matter pass using the foreground depth. */ begin: (frame: Frame) => matter.begin(frame, CLEARED), initAsync: () => Promise.all([occluders.initAsync(), matter.initAsync()]), destroy: low.destroy, };}sky.ts
import { environment } from '@typegpu/kit';import { d, std } from 'typegpu';const ZENITH = d.vec3f(0.0008, 0.0013, 0.0055);/** The horizon's colour, which the fog matches so far hills fade into it. */export const HORIZON = d.vec3f(0.007, 0.009, 0.024);const HORIZON_FALLOFF = 0.45;const BELOW = d.vec3f(0.004, 0.005, 0.012);/** Stars in a seventh of the cells, 0.6 pixels across at any resolution. */const starAt = environment.stars({ cells: 110, chance: 0.14, size: 0.6 });const STAR_BRIGHTNESS = 0.6;/** A power distribution gives many faint stars and a few bright ones. */const STAR_SPREAD = 9;const STAR_WARM = d.vec3f(1, 0.82, 0.62);const STAR_COOL = d.vec3f(0.7, 0.82, 1);/** Star-density falloff toward the hazy horizon. */const STAR_HAZE_HEIGHT = 0.18;const TWINKLE_HZ = 1.7;const TWINKLE_DEPTH = 0.35;/** Stars that twinkle and thin towards the horizon. */const starlight = (direction: d.v3f, time: number) => { 'use gpu'; const star = starAt(direction); const brightness = std.pow(star.seed.x, STAR_SPREAD) * STAR_BRIGHTNESS; const phase = star.seed.z * Math.PI * 2; const twinkle = 1 - TWINKLE_DEPTH * (0.5 + 0.5 * std.sin(time * TWINKLE_HZ + phase)); const haze = std.smoothstep(0, STAR_HAZE_HEIGHT, direction.y); const tint = std.mix(STAR_WARM, STAR_COOL, star.seed.y); return tint * (brightness * twinkle * haze * star.disc);};/** A dark sky with twinkling stars that thin towards the horizon, behind everything. */export function nightSky(time: { readonly $: number }) { const nightAt = (direction: d.v3f) => { 'use gpu'; const above = std.mix(HORIZON, ZENITH, std.pow(std.saturate(direction.y), HORIZON_FALLOFF)); const below = std.mix(HORIZON, BELOW, std.saturate(-direction.y * 8)); return std.select(below, above, direction.y > 0) + starlight(direction, time.$); }; return environment.background(nightAt, { label: 'night sky' });}picture.ts
import { memo } from '@typegpu/kit';import { d, std, tgpu, type TgpuRoot, type TgpuTexture } from 'typegpu';const Picture = tgpu.bindGroupLayout({ picture: { texture: d.texture2d(d.f32) }, smooth: { sampler: 'filtering' },});/** Samples the bound picture at `uv`, filtered. */export const sample = (uv: d.v2f) => { 'use gpu'; return std.textureSampleLevel(Picture.$.picture, Picture.$.smooth, uv, 0);};/** * Returns the bind group through which `sample` reads a picture, made again only when a resized * target gives a new texture. */export function pictureGroups(root: TgpuRoot) { const smooth = root.createSampler({ magFilter: 'linear', minFilter: 'linear' }); return memo((picture: TgpuTexture & { usableAsSampled: true }) => root.createBindGroup(Picture, { picture, smooth }), );}assets.ts
import { each, glb } from '../assets.ts';import { MODELS } from './castle.ts';/** The castle's pieces and the pines. */export const assets = { models: each(MODELS, (name) => glb(`models/${name}.glb`)) };