Night market
A potion seller's stall in a toy market at night in the rain, where a lantern, a fire and two lamps cast point and spot shadows through one lighting. Poses blend a customer's walk and run by speed and play the seller's lantern, a wave on a tap and their breath on masked and additive layers, and a tap pulls the depth of field's focus before TAA and bloom finish the frame. The buildings, props, potions, people and clips are from Kay Lousberg's KayKit packs, and the table, lamps, string lights and logs from Kenney's kits (CC0).
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- stalls.tsBuilds the market: KayKit stalls, a tavern and houses along a street, a well, crates and barrels, a potion seller's table by the eye, string lights across the street, a lantern on a cord, lamps and a fire pit.
- people.tsA potion seller behind her table and a customer who hurries up the street out of the rain, slows to a stop at the table and leaves again.
- sky.ts
- ground.tsDraws the wet paving: stone slabs in offset courses, dark grout holding water, and puddles that mirror the lights, rippled by the rain.
- fire.tsFlames licking up from the logs at
atand embers drifting up into the rain. - rain.tsRain falling through the market, streaks that glint in the lamps' and the fire's light.
- clips.tsThe grip's rotation from the chest while a hold plays.
- assets.ts
Kit modules bindings, bloom, cameras, clock, dof, environment, finish, instances, lighting, machines, materials, meshPool, owner, particles, passes, pipelines, poses, shading, taa, table, targets, tone. Also @typegpu/color, @typegpu/noise, math, typegpu, wgpu-matrix.
scene.ts
import { bindings, bloom, cameras, clock, dof, environment, finish, lighting, owner, passes, pipelines, table, taa, targets, tone, type Frame, type Surface,} from '@typegpu/kit';import { vec3 } from 'wgpu-matrix';import { d, type TgpuRoot } from 'typegpu';import type { Pointing } from '../contract.ts';import type { Settings } from '../settings.ts';import { createStalls, FIRE, type Models } from './stalls.ts';import { createPeople, type PeopleOptions } from './people.ts';import { sky } from './sky.ts';import { createGround } from './ground.ts';import { createFire } from './fire.ts';import { createRain } from './rain.ts';type Vec3 = [number, number, number];/** A blue moonlight over the rooftops, the sky's blue in the shade, and a blue haze down the street. */const MOON = { sunDirection: [-0.45, 0.75, -0.5], sunColor: [0.5, 0.65, 1], sunIntensity: 0.25, skyTop: [0.03, 0.06, 0.16], skyBottom: [0.02, 0.016, 0.014], skyIntensity: 1, skyReflection: 1, fogColor: [0.03, 0.06, 0.13], fogDensity: 0.015, fogFalloff: 0.12,} as const;/** The lights that cast shadows: the hung lantern, the fire, and the lamps over the banner and the table. */const LANTERN = { color: [1, 0.55, 0.22] as Vec3, intensity: 8, range: 11 };const FIRE_LIGHT = { color: [1, 0.44, 0.14] as Vec3, intensity: 9, range: 12 };const SIGN_LAMP = { color: [1, 0.84, 0.6] as Vec3, intensity: 1.6, range: 5, cone: lighting.cone(0.3, 0.6),};const COUNTER_LAMP = { color: [1, 0.78, 0.5] as Vec3, intensity: 3, range: 6, cone: lighting.cone(0.45, 0.8),};/** Metres from the top of the fire's barrel to the middle of its light. */const FLAME_HEIGHT = 0.35;/** Lowest eye height over the street, so the camera never dips under the paving. */const EYE_ABOVE_STREET_METRES = 0.3;/** Every lit point visits each lamp: the four that cast, then the awning spots and small lamps. */const LAMPS = 18;const LOOK = { exposure: 1.2, glowIntensity: 0.2, vignette: 0.35 };const BLOOM = { threshold: 1.2 };const SIZES = [256, 512, 1024] as const;/** How fast the focus follows a tap, per second. */const PULL = 5;/** The largest blur radius, as a fraction of the picture height, wide enough for discs. */const LIMIT = 0.03;/** The lantern's swing: gravity over the cord's length in metres, and damping per second. */const SWING = { gravity: 9.8, cordMetres: 1.2, damping: 0.35 };const defaults = { /** Draws the point and spot shadows; without them the lights shine through every crate. */ shadows: true, /** Sets each shadow layer's size in texels. */ map: 512 as (typeof SIZES)[number], /** How hard the wind swings the lantern. */ wind: 0.6, /** How bright the fire burns. */ fire: 1, /** Smooths edges over several frames. */ taa: true, /** Blurs what is nearer or farther than the focus, which a tap moves. */ dof: true, /** The lens opening, in metres. */ aperture: 0.12,};/** * How the lantern swings in the wind: a pendulum on its cord, pushed by gusts. `angle` holds its * tilt about x and z in radians. */function pendulum() { const angle = [0.12, 0.05]; const speed = [0, 0]; let seconds = 0; const swingAxis = (k: number, push: number, h: number) => { const pull = -(SWING.gravity / SWING.cordMetres) * angle[k]!; speed[k]! += (pull - SWING.damping * speed[k]! + push) * h; angle[k]! += speed[k]! * h; }; return { angle: angle as readonly number[], /** Swings on for a step of `h` seconds in `wind`. */ step(h: number, wind: number) { const t = seconds; const gust = wind * (0.55 * Math.sin(t * 0.7) + 0.3 * Math.sin(t * 1.9 + 1.2) + 0.15); swingAxis(0, gust * 0.9, h); swingAxis(1, gust * 0.35 * Math.sin(t * 0.43), h); seconds += h; }, };}/** How brightly the fire burns at `seconds`, around 0.82 of full. */const flickerAt = (seconds: number) => 0.82 + 0.1 * Math.sin(seconds * 13) + 0.06 * Math.sin(seconds * 29 + 1) + 0.05 * Math.sin(seconds * 7.3 + 2);export interface SceneOptions { /** Each part of the market's file. */ readonly models: Models; /** The seller, the customer and their clips. */ readonly people: Pick<PeopleOptions, 'mage' | 'rogue' | 'clips'>; /** Receives compilation progress as a fraction after each part finishes. */ readonly onProgress?: (fraction: number) => void;}/** * Lights a toy market at night: a lantern swinging from a string of lights, a cooking fire, a lamp * on a banner and a street lamp over a potion seller's table, all casting point and spot shadows * onto wet cobbles. TAA smooths the frame with the walkers' and the lantern's own motion, then * depth of field keeps the potions sharp and opens the lights behind them into discs before the * bloom. */export async function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions) { const values = { ...defaults }; const own = owner(); const screen = own( targets.create(root, surface, { hdr: { format: 'rgba16float', sampled: true }, motion: { format: 'rg16float', sampled: true }, depth: { format: 'depth24plus', sampled: true }, }), ); const view = cameras.orbit(root, { size: surface, target: [-0.6, 1.5, 2], distance: 7.5, yaw: 0.2, pitch: 0.2, pitchRange: [-0.2, 1.2], distanceRange: [2, 24], floor: () => EYE_ABOVE_STREET_METRES, near: 0.1, far: 150, }); const sim = clock.fixedStep({ step: 1 / 60 }); const lamps = own( table.create(root, lighting.SpotLight, { capacity: LAMPS, defaults: { ...lighting.WHITE_SPOT, range: 8 }, }), ); const light = own( lighting.create(root, { settings: MOON, points: { table: lamps }, shadow: { eye: view, cascades: 2, far: 40, size: 1024 }, pointShadow: { count: 4, filter: 9, enabled: () => values.shadows }, }), ); const shared = bindings.share(root, { shadow: light.shadow.part, pointShadow: light.pointShadow.part, }); const aa = own( taa.create(root, { source: () => screen.textures.hdr, motion: () => screen.textures.motion, depth: () => screen.textures.depth, camera: view, }), ); // Beneath a drawing's own fills, so the ground shades with the market's lighting const scene = passes .create( root, screen, { hdr: passes.color, motion: taa.motion, depth: passes.depth }, { jitter: aa.jitter, shared }, ) .pipe(view.inject()) .beneath(light.inject()); const time = root.createUniform(d.f32); const market = own(createStalls(root, options.models)); const people = own( createPeople(root, { ...options.people, lantern: options.models.lantern, seller: market.seller, customer: market.customer, }), ); const rain = own(createRain(root, view, sim.step)); const fire = own(createFire(root, view, sim.step, FIRE)); // The lights that cast take the first slots, the lantern and fire first, then the sign lamps const swing = pendulum(); const lantern = lamps.add({ position: [...market.swing(0, 0)], ...LANTERN }); const flame: Vec3 = [FIRE[0], FIRE[1] + FLAME_HEIGHT, FIRE[2]]; const blaze = lamps.add({ position: flame, ...FIRE_LIGHT }); const { sign, counter } = market.spots; const signs = [ lamps.add({ position: sign.lamp, aim: sign.aim, ...SIGN_LAMP }), lamps.add({ position: counter.lamp, aim: counter.aim, ...COUNTER_LAMP }), ]; for (const { position, aim, cone, color, intensity, range } of market.bulbs) { const spot = aim && cone ? { aim, cone: lighting.cone(cone[0], cone[1]) } : {}; lamps.add({ position, color, intensity, range, ...spot }); } light.pointShadow.cast([lantern, blaze, ...signs]); // The sky fills what the market leaves, before the fire and rain blend over it const drawn = [ scene.add(createGround(time)), scene.add(market.solid), scene.add(market.glowing), scene.add(people.renderer), scene.add(environment.background(sky)), scene.add(fire.drawing), scene.add(rain.drawing), ]; const moonCast = [light.shadow.add(market.solid), light.shadow.add(people.renderer)]; const lampCast = [light.pointShadow.add(market.solid), light.pointShadow.add(people.renderer)]; // The potions are in focus at the start, and a tap pulls the focus to what it lands on const eye = cameras.eyeOf(view.goal, vec3.create()); const ahead = vec3.normalize(vec3.subtract(view.goal.target, eye)); const potions = vec3.dot(vec3.subtract(market.potions, eye), ahead); const blur = own( dof.create(root, view, { source: aa, depth: () => screen.textures.depth, pull: PULL, settings: { focus: potions, aperture: values.aperture, limit: LIMIT }, }), ); // Depth of field reads TAA's output, and bloom and the final pass read the blur while it is on const picture = () => (values.dof ? blur.output : aa.output); const glow = own(bloom.create(root, { source: picture, settings: BLOOM })); const final = own( finish.create(root, { format: surface.format, source: picture, tone: tone.aces, glow, settings: LOOK, }), ); const parts = [people, scene, light.shadow, light.pointShadow, aa, blur, glow, final]; await Promise.all([market.ready, pipelines.compile(parts, options)]); const settings: Settings<typeof values> = { values, knobs: { shadows: { label: 'point and spot shadows' }, map: { label: 'shadow size (texels)', options: SIZES }, wind: { min: 0, max: 2, step: 0.05 }, fire: { min: 0, max: 2, step: 0.05 }, taa: { label: 'TAA' }, dof: { label: 'depth of field' }, aperture: { label: 'aperture (m)', min: 0.02, max: 0.6, step: 0.01 }, }, write(key) { if (key === undefined || key === 'map') light.pointShadow.resize(values.map); if (key === undefined || key === 'taa') aa.enable(values.taa); if (key === undefined || key === 'aperture') blur.settings.patch({ aperture: values.aperture }); }, }; const ray = { origin: new Float32Array(3), direction: new Float32Array(3) }; const pointing = { press(x, y) { view.rayAt(x, y, ray); people.tap(ray); blur.aim(x, y); }, } satisfies Pointing; let seconds = 0; /** Moves the lantern's light with the lantern and flickers the fire. */ const sway = () => { const at = market.swing(swing.angle[0]!, swing.angle[1]!); lamps.fields.position.set(lantern, at[0]!, at[1]!, at[2]!); const flicker = flickerAt(seconds); lamps.fields.intensity.set(blaze, FIRE_LIGHT.intensity * values.fire * flicker); lamps.fields.position.set( blaze, flame[0] + Math.sin(seconds * 11) * 0.03, flame[1] + Math.sin(seconds * 17) * 0.02, flame[2] + Math.cos(seconds * 9) * 0.03, ); }; return { view, settings, pointing, frame(frame: Frame) { seconds += frame.dt; view.update(frame.dt); const steps = sim.advance(frame.dt); for (let step = 0; step < steps; step++) swing.step(sim.step, values.wind); sway(); time.write(seconds); fire.burn(values.fire); people.step(seconds, frame.dt); market.flush(frame.encoder); people.flush(frame.encoder); lamps.flush(); const compute = frame.encoder.beginComputePass(); people.animate(compute, seconds); fire.system.spawn(compute); rain.system.spawn(compute); for (let step = 0; step < steps; step++) { fire.system.step(compute); rain.system.step(compute); } fire.system.compact(compute, sim.ahead); rain.system.compact(compute, sim.ahead); compute.end(); screen.fit(); light.shadow.record(frame, moonCast); light.pointShadow.record(frame, lampCast); scene.record(frame, drawn); aa.record(frame); if (values.dof) blur.record(frame); glow.record(frame); final.record(frame, surface.view(frame)); }, destroy: own.destroy, };}stalls.ts
import { instances, meshPool } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import type { TgpuCommandEncoder, TgpuRoot } from 'typegpu';import { quat, vec3, type Quat } from 'wgpu-matrix';import type { Model } from '../models.ts';type Vec3 = [number, number, number];/** * The parts of `models/market.glb`. Buildings and props are from Kay Lousberg's KayKit Medieval * Hexagon Pack, potions, the mug and the book from KayKit Adventurers, and the table, banners, * string lights, lamps, candles and logs from Kenney's Fantasy Town, Holiday, Graveyard and * Survival kits. Lamp glass, bulbs, flames and window panes are split into materials named * `glow`. */export const MODELS = [ 'market-red', 'market-blue', 'market-yellow', 'tavern', 'well', 'home-a', 'home-b', 'barrel', 'bucket', 'crate-a', 'crate-open', 'crate-fruit', 'crate-goods', 'sack', 'flag-red', 'flag-blue', 'tree', 'potion-huge-blue', 'potion-large-red', 'potion-medium-green', 'potion-small-orange', 'potion-large-green', 'potion-medium-red', 'potion-small-blue', 'stall-red', 'post', 'table', 'banner-green', 'lights', 'street-lamp', 'wall-lamp', 'lantern', 'candles', 'logs',] as const;export type ModelName = (typeof MODELS)[number];type Part = Pick<Model, 'meshes' | 'images'>;export type Models = Readonly<Record<ModelName, Part>>;/** KayKit's hexagon buildings and props stand this many times their size beside the characters. */const HEX = 4.5;/** Kenney's town pieces stand this many times their size. */const TOWN = 2.6;/** The potion table, by the eye: where its middle stands, and how it is scaled. */const TABLE = { at: [0.9, 0, 3.4] as Vec3, scale: 2.6 };/** Half the table's length along the street's width, and half its depth, once scaled. */const HALF_LENGTH = 0.5 * TABLE.scale;const HALF_DEPTH = 0.325 * TABLE.scale;/** Where the fire burns, on top of a barrel in the street. */export const FIRE: Vec3 = [4.6, 1.1, -4.6];/** * The rows of string lights across the street: where each crosses and how high its wire is tied * to a pole at either side. The lantern hangs from the second row's wire. */const ROWS = [ { z: 5.2, high: 4.4 }, { z: 0.6, high: 4.7 }, { z: -5.4, high: 4.4 }, { z: -9.0, high: 4.7 }, { z: -14.8, high: 4.4 },] as const;const LANTERN_ROW = 1;/** The poles stand this far from the street's middle, this thick, and rise this far over the tie. */const POLE_X = 4.3;const POLE = 1.6;const POLE_OVER = 0.25;/** Half the post model's width, before it is scaled. */const POST_HALF_WIDTH = 0.08;/** Swags of lights on each wire, and how far the wire sags between its poles. */const SWAGS = 5;const SAG = 0.35;/** The wires the lights hang from, and the lantern's cord. */const WIRE = { color: [0.02, 0.02, 0.02] as Vec3, roughness: 0.8 };const CORD = 1.2;/** How much the lantern on the cord is scaled. */const HUNG_LANTERN = 2.2;/** Where a wall lamp's bulb sits in the lamp's own space. */const WALL_LAMP_BULB: Vec3 = [0, -0.45, 0.42];/** How bright each glowing part is, as a factor on its texture's colour or, for panes, alone. */const WINDOW: Vec3 = [5, 2.4, 0.7];const LAMP_GLASS: Vec3 = [5, 4, 3];const BULBS: Vec3 = [5, 3.2, 1.2];const RED_BULBS: Vec3 = [7, 1.2, 0.35];const CANDLE: Vec3 = [5, 4, 3];/** The embers' glow in the burning logs. */const EMBERS: Vec3 = [0.5, 0.12, 0.02];/** The faint light of each brew in its bottle. */const BREW = { red: [0.035, 0.003, 0.003] as Vec3, blue: [0.003, 0.015, 0.05] as Vec3, green: [0.006, 0.035, 0.009] as Vec3, orange: [0.04, 0.015, 0.0015] as Vec3,};/** A light that casts no shadow. */interface Bulb { readonly position: Vec3; readonly aim?: Vec3; /** Inner and outer half-angles in radians. */ readonly cone?: readonly [number, number]; readonly color: Vec3; readonly intensity: number; readonly range: number;}/** The spots under the markets' awnings, the candles on the table and the standing lamps. */const AWNING_SPOT = { cone: [0.55, 1.1], color: [1, 0.7, 0.4], intensity: 3, range: 4.5,} satisfies Omit<Bulb, 'position' | 'aim'>;const CANDLE_LIGHT = { color: [1, 0.62, 0.3], intensity: 0.5, range: 2.4 } satisfies Partial<Bulb>;const LANTERN_LIGHT = { color: [1, 0.66, 0.32], intensity: 1.2, range: 3.5,} satisfies Partial<Bulb>;const STREET_LIGHT = { color: [1, 0.72, 0.4], intensity: 2.5, range: 4.5 } satisfies Partial<Bulb>;const UP = [0, 1, 0] as const;const X_AXIS = [1, 0, 0] as const;const Z_AXIS = [0, 0, 1] as const;const DOWN = [0, -1, 0] as const;/** Whether a mesh is a glowing part: lamp glass, a bulb, a flame or a window pane. */export const isGlow = (mesh: Part['meshes'][number]) => (mesh.material.name ?? '').endsWith('/glow');/** The lowest and highest point of a model, before it is scaled. */const extent = (model: Pick<Model, 'meshes'>) => { const low: Vec3 = [Infinity, Infinity, Infinity]; const high: Vec3 = [-Infinity, -Infinity, -Infinity]; for (const { positions } of model.meshes) { for (let i = 0; i < positions.length; i += 3) { for (let k = 0; k < 3; k++) { low[k] = Math.min(low[k]!, positions[i + k]!); high[k] = Math.max(high[k]!, positions[i + k]!); } } } return { low, high };};interface Placing { readonly at: Vec3; readonly yaw?: number; /** Turns the model this way instead of by `yaw`. */ readonly rotation?: Quat; readonly scale?: number; /** Stands the model's lowest point at `at[1]` rather than its origin. */ readonly stand?: boolean; readonly roughness?: number; /** The glowing part's emission factor. */ readonly glow?: Vec3; /** The body's own emission. */ readonly emission?: Vec3; /** Draws the body without casting shadows, as lamps do. */ readonly lamp?: boolean;}/** * The market's pool and instances, and what places each model in them. Solid things cast shadows; * lamps, their glass and the glowing panes don't. */function createBuilder(root: TgpuRoot, models: Models) { const cord = meshes.cylinder({ radialSegments: 8 }); const pool = meshPool.create( root, meshPool.roomFor([...MODELS.map((name) => models[name]), cord]), ); // Each model is its body and, where it has one, its glowing part, which sets its own emission const bodies = new Map<ModelName, meshPool.Model>(); const glows = new Map<ModelName, meshPool.Model>(); for (const name of MODELS) { const { meshes: all, images } = models[name]; const glow = all.filter(isGlow); bodies.set(name, pool.add({ meshes: all.filter((mesh) => !isGlow(mesh)), images })); if (glow.length > 0) glows.set(name, pool.add({ meshes: glow, images })); } const rope = pool.add(cord); const ready = Promise.all([...bodies.values(), ...glows.values()].map((model) => model.ready)); const solid = instances.create(root, pool, { capacity: 512 }); // The lantern and its cord swing, so they keep last frame's rows for TAA's motion const glowing = instances.create(root, pool, { capacity: 256, history: true }); const extents = new Map(MODELS.map((name) => [name, extent(models[name])])); /** How far `placing` raises a model so its lowest point stands at `at[1]`. */ const liftOf = (name: ModelName, { scale = HEX, stand = false }: Placing) => stand ? -extents.get(name)!.low[1] * scale : 0; return { pool, solid, glowing, rope, ready, /** The lights that cast no shadow, in the order they are placed. */ bulbs: [] as Bulb[], bodyOf: (name: ModelName) => bodies.get(name)!, glowOf: (name: ModelName) => glows.get(name)!, extentOf: (name: ModelName) => extents.get(name)!, topOf: (name: ModelName, scale: number) => extents.get(name)!.high[1] * scale, /** Places a model's body and glowing part. */ place(name: ModelName, placing: Placing) { const { at, yaw = 0, scale = HEX, roughness, glow, emission, lamp = false } = placing; const position: Vec3 = [at[0], at[1] + liftOf(name, placing), at[2]]; const rotation = placing.rotation ?? quat.fromAxisAngle(UP, yaw); const fields = { position, rotation, scale, ...(roughness === undefined ? {} : { roughness }), }; (lamp ? glowing : solid).add(bodies.get(name)!, { ...fields, ...(emission ? { emission } : {}), }); const lit = glows.get(name); if (lit) glowing.add(lit, { ...fields, ...(glow ? { emission: glow } : {}) }); }, /** Where a point given in a model's own space lands once placed. */ onto(name: ModelName, placing: Placing, local: Vec3): Vec3 { const { at, yaw = 0, scale = HEX } = placing; const turned = vec3.transformQuat(vec3.scale(local, scale), quat.fromAxisAngle(UP, yaw)); return [at[0] + turned[0]!, at[1] + liftOf(name, placing) + turned[1]!, at[2] + turned[2]!]; }, };}type Builder = ReturnType<typeof createBuilder>;/** The street: stalls down both sides, the tavern closing its end, houses, awnings and goods. */function buildStreet({ place, bulbs }: Builder) { place('market-red', { at: [-7.2, 0, -0.5], yaw: Math.PI / 2 }); place('market-blue', { at: [7.6, 0, -5.5], yaw: -Math.PI / 2 }); place('market-yellow', { at: [-7.2, 0, -10.5], yaw: Math.PI / 2 }); place('tavern', { at: [1.5, 0, -21], glow: WINDOW }); place('home-b', { at: [8.5, 0, -14.5], yaw: -Math.PI / 2, glow: WINDOW }); place('home-a', { at: [-7.5, 0, -18.5], yaw: Math.PI / 2, glow: WINDOW }); place('well', { at: [-1.6, 0, -8.5], yaw: 0.4 }); place('tree', { at: [-12, 0, -6], yaw: 0.7 }); place('tree', { at: [12.5, 0, -10], yaw: 2.1, scale: 5 }); place('stall-red', { at: [5.6, 0, -11.5], yaw: -Math.PI / 2, scale: TOWN }); place('flag-red', { at: [-5.2, 0, 3.6], yaw: 0.6 }); place('flag-blue', { at: [5.6, 0, -9.5], yaw: -0.4 }); // Awning bulbs over each market's goods, spots looking down at them for (const [x, z, facing] of [ [-4.6, -2.5, 1], [-4.6, 1.5, 1], [5.0, -7.5, -1], [5.0, -3.5, -1], [-4.6, -12.5, 1], [-4.6, -8.5, 1], ] as const) { bulbs.push({ position: [x, 2.6, z], aim: [facing * 0.5, -1, 0], ...AWNING_SPOT }); } // The crates the fire throws towards the eye, and goods by the stalls const crates: [ModelName, number, number, number][] = [ ['crate-a', 4.3, -2.8, 0.3], ['crate-a', 5.15, -3.0, -0.45], ['crate-open', 5.5, -2.3, 0.9], ['barrel', 3.7, -2.2, 0.4], ['sack', 5.1, -1.2, 0.8], ['crate-goods', -4.5, -10, 1.5], ['crate-a', -0.4, -13.5, 0.4], ['barrel', 0.4, -14.2, 0], ['sack', -0.9, -14.6, 1.1], ['barrel', -4.3, -3.6, 0], ['barrel', -4.4, -4.6, 0.5], ['crate-fruit', -3.6, 2.6, 1.4], ['crate-goods', -3.8, -0.4, 1.7], ['sack', -3.2, 1.2, 0.3], ['bucket', -0.6, -8.0, 0], ['barrel', 3.4, -15.5, 0.2], ['crate-a', 4.3, -16.2, 0.5], ]; for (const [name, x, z, yaw] of crates) place(name, { at: [x, 0, z], yaw });}/** * The potion seller's table by the eye, its goods in a row along the front and candles behind. * Returns the height of its top and where along the street its front row stands. */function buildTable({ place, onto, topOf, bulbs }: Builder) { place('table', { at: TABLE.at, yaw: Math.PI / 2, scale: TABLE.scale }); const top = topOf('table', TABLE.scale); const front = TABLE.at[2] + HALF_DEPTH - 0.35; const potions: [ModelName, number, number, Vec3][] = [ ['potion-medium-red', -1.0, 0.1, BREW.red], ['potion-small-orange', -0.62, 0.12, BREW.orange], ['potion-small-blue', -0.32, 0.1, BREW.blue], ['potion-large-green', 0.05, -0.05, BREW.green], ['potion-huge-blue', 0.46, -0.1, BREW.blue], ['potion-large-red', 0.84, -0.08, BREW.red], ['potion-medium-green', 1.16, 0.06, BREW.green], ]; for (const [name, along, yaw, emission] of potions) { place(name, { at: [TABLE.at[0] + along, top, front + Math.sin(along * 3) * 0.08], yaw, scale: 1, stand: true, roughness: 0.15, emission, }); } const candles: Placing = { at: [TABLE.at[0] - 0.75, top, TABLE.at[2] - 0.3], scale: 2, stand: true, glow: CANDLE, }; place('candles', candles); bulbs.push({ position: onto('candles', candles, [0, 0.25, 0]), ...CANDLE_LIGHT }); return { top, front };}/** * Lanterns standing on crates and barrels, the seller's lamp on a post at the table's end, the * street lamps and the banner with a lamp over it. Returns the bulbs of the lamps over the table * and the banner. */function buildLamps({ place, onto, solid, bodyOf, bulbs }: Builder) { for (const [x, y, z] of [ [-3.6, 0.95, 2.6], [-4.3, 0.95, -3.6], [3.7, 1.15, -2.2], [3.4, 0.95, -15.5], ] as const) { const standing: Placing = { at: [x, y, z], scale: 1.4, lamp: true, glow: LAMP_GLASS }; place('lantern', standing); bulbs.push({ position: onto('lantern', standing, [0, 0.2, 0]), ...LANTERN_LIGHT }); } // The seller's lamp, whose spot falls along the potions const corner: Vec3 = [TABLE.at[0] + HALF_LENGTH + 0.12, 0, TABLE.at[2] + 0.2]; solid.add(bodyOf('post'), { position: corner, scale: [1.8, 2.9, 1.8] }); const hook: Placing = { at: [corner[0] - POST_HALF_WIDTH * 1.8, 2.4, corner[2]], yaw: -Math.PI / 2, scale: 1.5, lamp: true, glow: LAMP_GLASS, }; place('wall-lamp', hook); const counter = onto('wall-lamp', hook, WALL_LAMP_BULB); for (const at of [ [-4.2, 0, -6.2], [3.6, 0, -12.6], ] as const) { const lamp: Placing = { at: [...at], scale: 1.7, glow: LAMP_GLASS }; place('street-lamp', lamp); bulbs.push({ position: onto('street-lamp', lamp, [0, 1.5, 0]), ...STREET_LIGHT }); } // The red stall's banner on a post, with a lamp on an arm over it const bannerPost: Vec3 = [-4.2, 0, 2.6]; const bannerPostScale = 3.8; const postFace = bannerPost[2] + POST_HALF_WIDTH * bannerPostScale; place('post', { at: bannerPost, scale: bannerPostScale }); place('banner-green', { at: [bannerPost[0], 0.9, postFace - 0.38 * TOWN], yaw: -Math.PI / 2, scale: TOWN, }); const arm: Placing = { at: [bannerPost[0], 3.75, postFace], scale: 1.6, lamp: true, glow: LAMP_GLASS, }; place('wall-lamp', arm); return { counter, sign: onto('wall-lamp', arm, WALL_LAMP_BULB) };}/** * String lights across the street: a wire tied to a pole at each side sags between them in a * parabola, and swags of lights hang from it, each tilted along the wire. Returns the points where * each row's swags meet. */function buildStringLights({ place, solid, glowing, rope, bodyOf, extentOf }: Builder) { const tie = POLE_X - POST_HALF_WIDTH * POLE; const sagAt = (x: number) => SAG * (1 - (x / tie) ** 2); const swag = extentOf('lights'); const width = swag.high[0] - swag.low[0]; return ROWS.map(({ z, high }, r) => { for (const x of [-POLE_X, POLE_X]) { solid.add(bodyOf('post'), { position: [x, 0, z], scale: [POLE, high + POLE_OVER, POLE], }); } const points: Vec3[] = []; for (let k = 0; k <= SWAGS; k++) { const x = -tie + ((2 * tie) / SWAGS) * k; points.push([x, high - sagAt(x), z]); } for (let k = 0; k < SWAGS; k++) { const [ax, ay] = points[k]!; const [bx, by] = points[k + 1]!; const length = Math.hypot(bx - ax, by - ay); const slope = Math.atan2(by - ay, bx - ax); const roll = quat.fromAxisAngle(Z_AXIS, slope); const scale = length / width; // The swag's wire ends at its top corners, so its middle hangs below the chord's const below = vec3.transformQuat([0, swag.high[1] * scale, 0], roll); const at: Vec3 = [(ax + bx) / 2 - below[0]!, (ay + by) / 2 - below[1]!, z]; place('lights', { at, rotation: roll, scale, lamp: true, glow: r % 2 === 0 ? BULBS : RED_BULBS, }); // The wire, straight between two swags' ends glowing.add(rope, { position: [(ax + bx) / 2, (ay + by) / 2, z], rotation: quat.fromAxisAngle(Z_AXIS, Math.atan2(-(bx - ax), by - ay)), scale: [0.025, length, 0.025], ...WIRE, }); } return points; });}/** * Hangs a lantern on a cord from `anchor`. Returns what swings it by `x` and `z` radians and * returns where its light is, in a vector it reuses. */function hangLantern({ glowing, rope, bodyOf, glowOf, topOf }: Builder, anchor: Vec3) { const cord = glowing.add(rope, WIRE); const body = glowing.add(bodyOf('lantern'), { scale: HUNG_LANTERN }); const glass = glowing.add(glowOf('lantern'), { scale: HUNG_LANTERN, emission: LAMP_GLASS }); const height = topOf('lantern', HUNG_LANTERN); const tilt = quat.create(); const across = quat.create(); const down = vec3.create(); const light = vec3.create(); const hung = { position: vec3.create(), rotation: tilt, scale: [0.03, CORD, 0.03] as Vec3 }; const held = { position: vec3.create(), rotation: tilt }; return (x: number, z: number) => { quat.fromAxisAngle(X_AXIS, x, across); quat.multiply(across, quat.fromAxisAngle(Z_AXIS, z, tilt), tilt); vec3.transformQuat(DOWN, tilt, down); vec3.addScaled(anchor, down, CORD / 2, hung.position); glowing.write(cord, hung); vec3.addScaled(anchor, down, CORD + height, held.position); glowing.write(body, held); glowing.write(glass, held); return vec3.addScaled(anchor, down, CORD + height * 0.45, light) as Readonly<Float32Array>; };}/** * Builds the market: KayKit stalls, a tavern and houses along a street, a well, crates and barrels, * a potion seller's table by the eye, string lights across the street, a lantern on a cord, lamps * and a fire pit. */export function createStalls(root: TgpuRoot, models: Models) { const builder = createBuilder(root, models); const { place, solid, glowing, pool } = builder; buildStreet(builder); const { top, front } = buildTable(builder); const lamps = buildLamps(builder); const wires = buildStringLights(builder); const swing = hangLantern(builder, wires[LANTERN_ROW]![3]!); swing(0, 0); place('barrel', { at: [FIRE[0], 0, FIRE[2]], scale: 5.5, roughness: 0.9 }); place('logs', { at: [FIRE[0], FIRE[1] - 0.12, FIRE[2]], scale: 3.2, lamp: true, emission: EMBERS, }); const { counter, sign } = lamps; return { solid: solid.renderer(), glowing: glowing.renderer(), ready: builder.ready, /** The awning spots, the candles, the standing lanterns and the street lamps. */ bulbs: builder.bulbs as readonly Bulb[], /** The lamp over the table and the lamp over the banner, and what each looks at. */ spots: { counter: { lamp: counter, aim: [TABLE.at[0] - counter[0], top - counter[1], front - counter[2]] as Vec3, }, sign: { lamp: sign, aim: [0, -1, -0.35] as Vec3 }, }, /** Where the seller stands behind her table. */ seller: [TABLE.at[0] + 0.2, 0, TABLE.at[2] - HALF_DEPTH - 0.45] as Vec3, /** Where a customer stops, at the table's left end. */ customer: [TABLE.at[0] - HALF_LENGTH - 0.45, 0, TABLE.at[2] - 0.8] as Vec3, /** Where the potions stand, the focus at the start. */ potions: [TABLE.at[0], top + 0.3, front] as Vec3, swing, flush(encoder: TgpuCommandEncoder) { solid.flush(); glowing.flush(encoder); }, destroy() { solid.destroy(); glowing.destroy(); pool.destroy(); }, };}people.ts
import { instances, machines, meshPool, poses } from '@typegpu/kit';import { clamp } from 'math';import { mat4n, quat, quatn, vec3n, type QuatArg } from 'wgpu-matrix';import { d, type TgpuCommandEncoder, type TgpuComputePass, type TgpuRoot } from 'typegpu';import type { Model } from '../models.ts';import { breathing, gripIn, opposite, raised } from './clips.ts';import { isGlow } from './stalls.ts';type Vec3 = [number, number, number];type Part = Pick<Model, 'meshes' | 'images'>;/** A ray from the eye, as `view.rayAt` writes it. */interface Ray { readonly origin: Float32Array; readonly direction: Float32Array;}/** Clip names in the KayKit animation file. */const CLIPS = { stand: 'Idle_A', idle: 'Idle_B', walk: 'Walking_A', run: 'Running_A', hold: 'Holding_A', wave: 'Waving',} as const;/** Seconds into the run where its left foot lands, as the walk's does at its start. */const RUN_LANDS = 0.08;/** Ground speed in metres per second at mix 1, the walk, and mix 2, the run. */const WALK_SPEED = 0.8;const RUN_SPEED = 3;/** How the customer hurries in and strolls back: top speeds in m/s, braking and speeding up in m/s². */const HURRY = 2.4;const BRAKE = 0.45;const ACCEL = 1.6;const STROLL = 0.95;/** Metres a second the customer still walks as they reach the end of the street. */const CREEP = 0.1;/** Metres from either end of the route that count as there. */const ARRIVED_METRES = 0.01;/** How fast the customer and the seller turn to face their way, as a share a second. */const TURNING = 5;const SELLER_TURNING = 2;/** Seconds the customer stays at the table, and waits down the street before coming back. */const STAY = 9;const AWAY = 3;/** The customer comes up the street from the tavern, round the table's end to `customer`. */const ROUTE: readonly (readonly [number, number])[] = [ [-3, -18], [-3.2, -9.3], [-2.9, -4], [-2.1, 0.4], [-1.6, 2.2],];/** How the seller's arm moves: fades in seconds, and how far she turns to a customer. */const RAISE_FADE = 0.6;const LOWER_FADE = 0.8;const LEAN_IN = 0.4;/** Seconds a wave takes to lift the arm, and to lower it again. */const WAVE_IN = 0.35;const WAVE_OUT = 0.5;/** Where a tap finds a person: a standing cylinder this wide and tall around each. */const REACH = 0.5;const HEIGHT = 2.3;/** How much the lantern the seller holds is scaled, and how bright its glass is. */const LANTERN_SCALE = 1.1;const GLASS: Vec3 = [5, 4, 3];/** KayKit items face backwards in the hand slots; half a turn about the grip faces them forward. */const GRIP = mat4n.rotationY(Math.PI);const UP = [0, 1, 0] as const;const Person = instances.define('Person', { host: { pose: d.u32 }, pose: (row) => { 'use gpu'; return row.pose; },});/** Wraps `angle` in radians to -π through π. */const wrapped = (angle: number) => angle - Math.PI * 2 * Math.round(angle / (Math.PI * 2));/** Ground speed to a mix of the line: standing at 0, walking at 1 and running at 2. */const mixFor = (speed: number) => speed <= WALK_SPEED ? speed / WALK_SPEED : 1 + (speed - WALK_SPEED) / (RUN_SPEED - WALK_SPEED);/** * The offset that hangs `lantern` upright, its ring at the grip, from a hand slot that turns as * `grip` does: the `GRIP` half turn, the turn upright, the scale and the drop from its top. */function hanging(lantern: Part, grip: QuatArg) { const up = vec3n.transformQuat(UP, quatn.conjugate(grip)); // Up in the item's space, before the half turn about y that GRIP adds const upright = quatn.rotationTo(UP, [-up[0], up[1], -up[2]]); let top = -Infinity; for (const { positions } of lantern.meshes) { for (let i = 1; i < positions.length; i += 3) top = Math.max(top, positions[i]!); } const offset = mat4n.multiply(GRIP, mat4n.fromQuat(upright)); mat4n.uniformScale(offset, LANTERN_SCALE, offset); return mat4n.translate(offset, [0, -top, 0], offset);}export interface PeopleOptions { readonly mage: Model; readonly rogue: Model; readonly clips: { readonly clips: readonly poses.ClipData[] }; /** The lantern the seller holds, from the market's file. */ readonly lantern: Part; /** Where the seller stands behind her table. */ readonly seller: Vec3; /** Where the customer stops at the table. */ readonly customer: Vec3;}/** * A potion seller behind her table and a customer who hurries up the street out of the rain, * slows to a stop at the table and leaves again. Both breathe on an additive layer. The seller * holds a lantern on a layer masked to her right arm, and raises it while a customer is there. * A tap on either plays a wave on a layer masked to the spine, before the lantern's layer, so * the seller waves her free left hand and keeps the lantern. */export function createPeople(root: TgpuRoot, options: PeopleOptions) { const skin = options.mage.skin!; const clip = (name: string) => poses.clip(options.clips, name); const hold = clip(CLIPS.hold); const stand = clip(CLIPS.stand); // KayKit's wave leans the body away from the hand from the hips; masked to the spine, the // spine takes that lean const wave = poses.lean(clip(CLIPS.wave), skin, 'spine', { from: stand }); const figures = poses.create( root, skin, { moving: [stand, clip(CLIPS.walk), poses.shift(clip(CLIPS.run), RUN_LANDS)], idle: clip(CLIPS.idle), hold, raise: raised(hold, skin), wave, waveLeft: poses.mirror(wave, opposite), breathe: breathing(skin), }, { capacity: 2, layers: { wave: { joints: ['spine'] }, lantern: { joints: ['upperarm.r'] }, breath: { joints: ['spine'], add: true }, }, }, ); // The bodies, and the lantern hanging from the seller's right grip const mage = poses.rebind(options.mage, skin, skin); const rogue = poses.rebind(options.rogue, options.rogue.skin!, skin); const { lantern } = options; const offset = hanging(lantern, gripIn(hold, skin)); const carried = (meshes: Part['meshes']) => poses.attach({ meshes, images: lantern.images }, skin, 'handslot.r', { offset }); const iron = carried(lantern.meshes.filter((mesh) => !isGlow(mesh))); const glass = carried(lantern.meshes.filter(isGlow)); const room = meshPool.roomFor([mage, rogue, iron, glass]); const pool = meshPool.create(root, room); const bodies = { mage: pool.add(mage), rogue: pool.add(rogue), iron: pool.add(iron), glass: pool.add(glass), }; // Each body once, a row for each of its meshes // They walk, so they keep last frame's rows for TAA's motion const people = instances.create(root, pool, { kind: Person, capacity: room.capacity, history: true, }); let now = 0; const seller = { pose: figures.add('idle', 0, { from: 0.7 }), at: options.seller, yaw: 0, id: undefined as unknown as instances.Id<'Person'>, /** The clip a tap waves, with her free left hand. */ waveClip: 'waveLeft' as const, waving: -Infinity, }; figures.play(seller.pose, 'hold', 0, { layer: 'lantern' }); figures.play(seller.pose, 'breathe', 0, { layer: 'breath', from: 1.3 }); // One figure: the lantern's glass glows const dressed = [...bodies.mage.meshes, ...bodies.iron.meshes, ...bodies.glass.meshes]; seller.id = people.add({ meshes: dressed }, (mesh) => bodies.glass.meshes.includes(mesh) ? { pose: seller.pose, emission: GLASS } : { pose: seller.pose }, ); // The customer's route by length, so a distance along it gives a place and a heading const route = [...ROUTE, [options.customer[0], options.customer[2]] as const]; const lengths = [0]; for (let k = 1; k < route.length; k++) { const [ax, az] = route[k - 1]!; const [bx, bz] = route[k]!; lengths.push(lengths[k - 1]! + Math.hypot(bx - ax, bz - az)); } const length = lengths.at(-1)!; /** Where the customer is, and which way the route heads there, written by `along`. */ const spot = { x: 0, z: 0, heading: 0 }; const along = (metres: number) => { let k = 1; while (k < route.length - 1 && lengths[k]! < metres) k++; const a = route[k - 1]!; const b = route[k]!; const t = clamp((metres - lengths[k - 1]!) / (lengths[k]! - lengths[k - 1]!), 0, 1); spot.x = a[0] + (b[0] - a[0]) * t; spot.z = a[1] + (b[1] - a[1]) * t; spot.heading = Math.atan2(b[0] - a[0], b[1] - a[1]); return spot; }; const customer = { pose: figures.add('moving', 0, { mix: 0 }), id: undefined as unknown as instances.Id<'Person'>, metres: 0, speed: 0, yaw: 0, waveClip: 'wave' as const, waving: -Infinity, }; figures.play(customer.pose, 'breathe', 0, { layer: 'breath', from: 2.1 }); figures.weigh(customer.pose, 0.7, { layer: 'breath' }); customer.id = people.add(bodies.rogue, { pose: customer.pose }); const { position, rotation } = people.fields; const turned = quat.create(); const place = (id: instances.Id<'Person'>, x: number, y: number, z: number, yaw: number) => { position.set(id, x, y, z); rotation.write(id, quat.fromAxisAngle(UP, yaw, turned)); }; const halt = () => { customer.speed = 0; }; /** * The customer's visit: waiting down the street, hurrying up it, staying at the table and * strolling back. The seller raises her lantern while they stay. */ const Visit = machines.define('Visit', { states: { away: { enter: halt, next: [{ to: 'coming', after: AWAY }] }, coming: { next: [{ to: 'staying', when: () => length - customer.metres < ARRIVED_METRES }] }, staying: { enter: () => { halt(); figures.play(seller.pose, 'raise', now, { layer: 'lantern', fade: RAISE_FADE }); }, exit: () => { figures.play(seller.pose, 'hold', now, { layer: 'lantern', fade: LOWER_FADE }); }, next: [{ to: 'going', after: STAY }], }, going: { next: [{ to: 'away', when: () => customer.metres <= ARRIVED_METRES }] }, }, }); const visit = machines.run(Visit); const waveOut = { layer: 'wave', fade: WAVE_OUT } as const; const endWave = (person: { pose: poses.Id; waving: number }, time: number) => { if (person.waving === -Infinity || time <= person.waving) return; figures.stop(person.pose, time, waveOut); person.waving = -Infinity; }; /** Moves the customer through the visit, turns both to face their way and ends waves. */ const step = (time: number, dt: number) => { now = time; visit.step(dt); const state = visit.state(); const c = customer; if (state === Visit.coming || state === Visit.going) { const coming = state === Visit.coming; const goal = coming ? Math.min(HURRY, Math.sqrt(2 * BRAKE * (length - c.metres))) : Math.min(STROLL, Math.sqrt(2 * BRAKE * c.metres) + CREEP); c.speed += clamp(goal - c.speed, -BRAKE * 2 * dt, ACCEL * dt); c.metres = clamp(c.metres + c.speed * (coming ? dt : -dt), 0, length); } along(c.metres); const staying = state === Visit.staying; const facing = staying ? Math.atan2(seller.at[0] - spot.x, seller.at[2] - spot.z) : spot.heading + (state === Visit.going ? Math.PI : 0); c.yaw = wrapped(c.yaw + wrapped(facing - c.yaw) * Math.min(TURNING * dt, 1)); place(c.id, spot.x, 0, spot.z, c.yaw); figures.mix(c.pose, mixFor(c.speed), time); // The seller turns a little towards a customer at the table const toward = Math.atan2(spot.x - seller.at[0], spot.z - seller.at[2]); const lean = staying ? wrapped(toward) * LEAN_IN : 0; seller.yaw += (lean - seller.yaw) * Math.min(SELLER_TURNING * dt, 1); place(seller.id, seller.at[0], seller.at[1], seller.at[2], seller.yaw); // A wave ends by handing the arm back to the base endWave(seller, time); endWave(c, time); }; /** The nearest of the two along `ray` within a standing cylinder, if any. */ const hit = (ray: Ray) => { along(customer.metres); const standing = [ { who: 'seller' as const, x: seller.at[0], z: seller.at[2] }, { who: 'customer' as const, x: spot.x, z: spot.z }, ]; let best: { who: 'seller' | 'customer'; t: number } | undefined; const [ox, oy, oz] = ray.origin; const [dx, dy, dz] = ray.direction; for (const { who, x, z } of standing) { // Solve |o + t·d - c| = REACH in the ground plane, then check the height there const px = ox! - x; const pz = oz! - z; const a = dx! * dx! + dz! * dz!; const b = px * dx! + pz * dz!; const c = px * px + pz * pz - REACH * REACH; const disc = b * b - a * c; if (disc < 0 || a === 0) continue; const t = (-b - Math.sqrt(disc)) / a; const y = oy! + t * dy!; if (t > 0 && y > 0 && y < HEIGHT && (!best || t < best.t)) best = { who, t }; } return best?.who; }; return { renderer: people.renderer().pipe(figures.inject()), step, /** Waves the arm of whoever `ray` passes through first. */ tap(ray: Ray) { const who = hit(ray); if (!who) return; const person = who === 'seller' ? seller : customer; figures.play(person.pose, person.waveClip, now, { layer: 'wave', loop: false, fade: WAVE_IN, }); person.waving = now + wave.duration; }, initAsync: () => Promise.all([figures.initAsync(), ...Object.values(bodies).map((b) => b.ready)]), flush(encoder: TgpuCommandEncoder) { people.flush(encoder); }, animate(pass: TgpuComputePass, time: number) { figures.animate(pass, time); }, destroy() { figures.destroy(); people.destroy(); pool.destroy(); }, };}sky.ts
import { perlin2d } from '@typegpu/noise';import { d, std } from 'typegpu';/** A deep blue overhead, lighter and greener near the rooftops, as the sky an hour after dusk. */const ZENITH = d.vec3f(0.004, 0.009, 0.045);const HORIZON = d.vec3f(0.03, 0.06, 0.13);const GROUND = d.vec3f(0.01, 0.015, 0.03);/** The market's lamps warm the haze over the rooftops, and lit clouds drift above them. */const HAZE = d.vec3f(0.035, 0.016, 0.01);const CLOUD = d.vec3f(0.018, 0.022, 0.036);/** The night sky over the market in a direction, for `environment.background`. */export const sky = (look: d.v3f) => { 'use gpu'; const up = look.y; let above = std.mix(HORIZON, ZENITH, std.pow(std.saturate(up), 0.6)); above += HAZE * std.exp(-std.max(up, 0) * 20); // Clouds on a plane overhead, thinning towards the zenith and fading into the haze const onCloud = look.xz / (std.max(up, 0) + 0.08); const cover = perlin2d.sample(onCloud * 0.35) * 0.65 + perlin2d.sample(onCloud * 0.9 + 7) * 0.35; above += CLOUD * std.smoothstep(-0.15, 0.45, cover) * std.smoothstep(0, 0.12, up); const below = std.mix(HORIZON, GROUND, std.saturate(-up * 6)); return std.select(below, above, up > 0);};ground.ts
import { cameras, materials, passes, shading } from '@typegpu/kit';import { srgbToLinear } from '@typegpu/color';import { perlin2d, randf } from '@typegpu/noise';import { d, std, tgpu, type TgpuUniform } from 'typegpu';/** Metres across the paved street, and how far down the street its middle lies. */const SIDE = 80;const MIDDLE = -6;/** Metres along a paving slab, which run in courses offset by half a slab. */const SLAB = 0.62;/** Metres across a cell that holds one raindrop's ripple. */const DROP = 0.45;/** Ripples a second each cell's drop makes. */const DROPS = 1.4;const RIPPLE = 0.2;const STONE = srgbToLinear(d.vec3f(0.2, 0.19, 0.18));const GROUT = srgbToLinear(d.vec3f(0.07, 0.065, 0.06));const Corner = d.struct({ position: d.vec4f, world: d.vec3f });const Seen = d.struct({ color: d.vec4f });const corners = tgpu.const( d.arrayOf(d.vec2f, 6), [ [-1, -1], [1, 1], [1, -1], [-1, -1], [-1, 1], [1, 1], ].map(([x, z]) => d.vec2f(x!, z!)),);/** How deep the water stands, 0 on dry stone to 1 in a puddle. */const waterAt = (place: d.v2f) => { 'use gpu'; const pools = perlin2d.sample(place * 0.32) + perlin2d.sample(place * 1.1 + 7.3) * 0.3; return std.smoothstep(-0.05, 0.15, pools);};/** A ring spreading from a drop that falls somewhere in each cell, as a tilt of the water. */const rippleAt = (place: d.v2f, seconds: number) => { 'use gpu'; const cell = std.floor(place / DROP); randf.seed2(cell); const center = (cell + 0.25 + d.vec2f(randf.sample(), randf.sample()) * 0.5) * DROP; const age = std.fract(seconds * DROPS + randf.sample()); const away = place - center; const distance = std.length(away); const off = (distance - age * RIPPLE) / 0.012; const ring = std.exp(-off * off) * (1 - age); return (away / std.max(distance, 1e-4)) * (ring * std.sin(off * 2) * 0.35);};/** * Draws the wet paving: stone slabs in offset courses, dark grout holding water, and puddles that * mirror the lights, rippled by the rain. */export function createGround(time: TgpuUniform<d.F32>) { const drawing: passes.Drawing<typeof Corner, d.Infer<typeof Seen>> = { label: 'ground', Vertex: Corner, at: (vertexIndex: number, _instanceIndex: number) => { 'use gpu'; const corner = corners.$[vertexIndex]! * (SIDE / 2); const world = d.vec3f(corner.x, 0, corner.y + MIDDLE); return Corner({ position: cameras.current.$.viewProjection * d.vec4f(world, 1), world }); }, surfaceOf: (corner) => { 'use gpu'; const place = corner.world.xz; const course = std.floor(place.y / SLAB); const along = place.x / SLAB + course * 0.5; const slab = d.vec2f(std.floor(along), course); const inside = d.vec2f(std.fract(along), std.fract(place.y / SLAB)); const edge = std.min(std.min(inside.x, 1 - inside.x), std.min(inside.y, 1 - inside.y)); const grout = 1 - std.smoothstep(0.015, 0.045, edge); const tone = perlin2d.sample(slab * 0.83 + 0.5); const grain = perlin2d.sample(place * 9); const water = std.max(waterAt(place), grout * 0.35); const stone = STONE * (0.85 + tone * 0.22 + grain * 0.1); // Water darkens what it wets and smooths it towards a mirror const color = std.mix(stone, GROUT, grout) * std.mix(1, 0.4, water); const roughness = std.mix(0.55 - grain * 0.1, 0.05, water); // Slabs bevel at their edges, and drops ripple where the water stands const bevel = (inside - 0.5) * (grout * 0.4) * (1 - water); const ripple = rippleAt(place, time.$) * water; const normal = std.normalize(d.vec3f(bevel.x + ripple.x, 1, bevel.y + ripple.y)); const lit = shading.shade( materials.glossy(color, roughness), shading.pointAt(corner.world, normal), ); return Seen({ color: d.vec4f(lit, 1) }); }, color: passes.shaded, draw: (pass) => pass.draw(6), }; return drawing;}fire.ts
import { cameras, Particle, particles } from '@typegpu/kit';import { d, std, type TgpuRoot } from 'typegpu';type Vec3 = readonly [number, number, number];/** Flames a second, and embers a second among them. */const FLAMES = 70;const EMBERS = 9;/** Where the flames are hottest, white-yellow, then orange, then a dull red as they fade. */const CORE = d.vec3f(5, 3.2, 1.2);const BODY = d.vec3f(3.2, 0.9, 0.15);const SMOKE = d.vec3f(0.5, 0.06, 0.01);const SPARK = d.vec3f(7, 2.6, 0.6);/** Metres across a flame particle and an ember. Anything smaller than `EMBER_BELOW` is an ember. */const FLAME_SIZE = 0.3;const EMBER_SIZE = 0.025;const EMBER_BELOW = 0.05;const rise = (flame: Particle) => { 'use gpu'; let next = Particle(flame); const ember = flame.size < EMBER_BELOW; const flutter = flame.age * 9 + flame.seed * 40; if (ember) { next.velocity += d.vec3f(std.sin(flutter) * 0.06, 0.02, std.cos(flutter) * 0.06); } else { // Flames draw in towards the middle as they climb next.velocity = d.vec3f(next.velocity.x * 0.9, next.velocity.y + 0.035, next.velocity.z * 0.9); } return next;};const burn = (flame: Particle, local: d.v2f) => { 'use gpu'; const age = flame.age / flame.life; const edge = std.saturate(1 - std.length(local)); if (flame.size < EMBER_BELOW) { const glow = edge * edge * (1 - age) * (0.6 + 0.4 * std.sin(flame.age * 30 + flame.seed * 20)); return d.vec4f(SPARK * glow, 0); } const heat = std.mix(CORE, BODY, std.smoothstep(0, 0.45, age)); const color = std.mix(heat, SMOKE, std.smoothstep(0.45, 1, age)); const fade = edge * edge * std.smoothstep(0, 0.1, age) * (1 - age); return d.vec4f(color * (fade * 0.5), 0);};const shrink = (flame: Particle, corner: d.v2f) => { 'use gpu'; const across = cameras.current.$.right * corner.x + cameras.current.$.up * corner.y; const size = flame.size * (1 - (flame.age / flame.life) * 0.6); return flame.position + across * (size * 0.5);};/** Flames licking up from the logs at `at` and embers drifting up into the rain. */export function createFire(root: TgpuRoot, view: cameras.CameraBase, step: number, at: Vec3) { const fire = particles.create(root, view, { capacity: 512, step, update: rise, place: shrink, shade: burn, label: 'fire', settings: { gravity: [0, 0, 0], drag: 0.6 }, }); const flames = { position: [at[0], at[1] + 0.12, at[2]] as const, area: [0.18, 0.04, 0.18] as const, velocity: [0, 0.75, 0] as const, scatter: 0.12, size: FLAME_SIZE, life: 0.75, variance: 0.4, }; const flaming = fire.stream(FLAMES, flames); const sparking = fire.stream(EMBERS, { position: [at[0], at[1] + 0.3, at[2]], area: [0.15, 0.05, 0.15], velocity: [0, 1.6, 0], scatter: 0.5, size: EMBER_SIZE, life: 2.6, variance: 0.5, }); return { drawing: fire.drawing, system: fire, /** Burns at `strength`, 1 for the full fire: more and larger flames, and more embers. */ burn(strength: number) { flaming.rate = FLAMES * strength; sparking.rate = EMBERS * strength; flames.size = FLAME_SIZE * Math.sqrt(strength); }, destroy: () => fire.destroy(), };}rain.ts
import { cameras, Particle, particles, shading } from '@typegpu/kit';import { d, std, type TgpuRoot } from 'typegpu';const DROPS = 7000;/** The box the rain falls through, which a drop leaves at the bottom and enters at the top. */const LOW = [-6, 0, -8] as const;const HIGH = [6, 7, 8] as const;const FALL = d.vec3f(0.7, -7.5, 0.35);/** Metres a streak spans along its fall, and across. */const STREAK = 0.28;const WIDTH = 0.01;/** The faint grey a drop shows over the moon and sky light it glints. */const SKY = d.vec3f(0.0105, 0.012, 0.0145);/** The share of the light arriving at a drop that its streak glints. */const GLINT = 0.012;const fall = (drop: Particle) => { 'use gpu'; let next = Particle(drop); next.velocity = FALL * (0.85 + drop.seed * 0.3); if (next.position.y < LOW[1]) next.position.y += HIGH[1] - LOW[1]; if (next.position.x > HIGH[0]) next.position.x -= HIGH[0] - LOW[0]; if (next.position.z > HIGH[2]) next.position.z -= HIGH[2] - LOW[2]; return next;};const streak = (drop: Particle, corner: d.v2f) => { 'use gpu'; const down = std.normalize(drop.velocity); const toEye = std.normalize(cameras.current.$.position - drop.position); const across = std.normalize(std.cross(down, toEye)); return drop.position + across * (corner.x * drop.size * 0.5) + down * (corner.y * STREAK * 0.5);};/** A streak glints in the light arriving at its drop, from the lighting the scene's pass fills. */const shine = (drop: Particle, local: d.v2f) => { 'use gpu'; const thin = 1 - std.abs(local.x); const along = 1 - local.y * local.y; const alpha = thin * along * drop.color.w; const lit = SKY + shading.incoming(drop.position) * GLINT; return d.vec4f(lit * alpha, 0);};/** Rain falling through the market, streaks that glint in the lamps' and the fire's light. */export function createRain(root: TgpuRoot, view: cameras.CameraBase, step: number) { const rain = particles.create(root, view, { capacity: DROPS, step, update: fall, place: streak, shade: shine, label: 'rain', settings: { gravity: [0, 0, 0], drag: 0 }, }); rain.burst({ count: DROPS, position: [(LOW[0] + HIGH[0]) / 2, (LOW[1] + HIGH[1]) / 2, (LOW[2] + HIGH[2]) / 2], area: [(HIGH[0] - LOW[0]) / 2, (HIGH[1] - LOW[1]) / 2, (HIGH[2] - LOW[2]) / 2], color: [1, 1, 1, 0.5], size: WIDTH, life: 1e9, variance: 0.5, }); return { drawing: rain.drawing, system: rain, destroy: rain.destroy, };}clips.ts
import { poses } from '@typegpu/kit';import { quatn, vec3n } from 'wgpu-matrix';type Vec3 = readonly [number, number, number];/** The joint of `skin` named `name`, which must exist. */const jointOf = (skin: poses.SkinData, name: string) => { const joint = skin.joints.find((each) => each.name === name); if (!joint) throw new Error(`The skin has no joint named '${name}'.`); return joint;};/** A channel that turns or scales `joint` linearly through `values` at `times`. */const linear = ( joint: string, path: 'rotation' | 'scale', times: Float32Array, values: ArrayLike<number>,): poses.ChannelData => ({ joint, path, interpolation: 'linear', times, values: Float32Array.from(values),});/** The rotation of joint `name` at `time` in `clip`, or its rest rotation without a channel. */function rotationIn(clip: poses.ClipData, skin: poses.SkinData, name: string, time: number) { const channel = clip.channels.find((c) => c.joint === name && c.path === 'rotation'); return channel ? poses.sample(channel, time) : [...jointOf(skin, name).rotation];}/** The right arm's joints, from the shoulder to the hand slot. */const ARM = ['upperarm.r', 'lowerarm.r', 'wrist.r', 'hand.r', 'handslot.r'] as const;/** When in the hold clip the grip is read. */const GRIP_AT = 0.5;/** The grip's rotation from the chest while a hold plays. Lanterns hang upright in it. */export function gripIn(hold: poses.ClipData, skin: poses.SkinData) { return ARM.reduce( (turned, name) => quatn.multiply(turned, rotationIn(hold, skin, name, GRIP_AT)), quatn.identity(), );}/** Directions the raised arm points from the chest, out to the right and forward, swaying. */const RAISED: readonly Vec3[] = [ [-0.6, 0.75, 0.3], [-0.62, 0.77, 0.15], [-0.6, 0.75, 0.3],];const RAISE_SECONDS = 2.4;/** * Authors a right arm raised high, swaying a little. The rig ships no such clip. The wrist turns * so the grip keeps the hold's angle and a lantern in it still hangs upright. */export function raised(hold: poses.ClipData, skin: poses.SkinData): poses.ClipData { const upper = jointOf(skin, 'upperarm.r'); const lower = jointOf(skin, 'lowerarm.r'); const along = vec3n.normalize(vec3n.transformQuat(lower.position, upper.rotation)); const hand = rotationIn(hold, skin, 'hand.r', GRIP_AT); const slot = rotationIn(hold, skin, 'handslot.r', GRIP_AT); const elbow = quatn.multiply(lower.rotation, quatn.fromAxisAngle([0, 0, 1], 0.25)); const grip = quatn.multiply(gripIn(hold, skin), quatn.conjugate(quatn.multiply(hand, slot))); const keys = RAISED.map((direction) => { const toward = quatn.rotationTo(along, vec3n.normalize(direction)); const shoulder = quatn.multiply(toward, upper.rotation); const wrist = quatn.multiply(quatn.conjugate(quatn.multiply(shoulder, elbow)), grip); return [shoulder, elbow, wrist, hand, slot]; }); const times = Float32Array.from(RAISED, (_, k) => (k / (RAISED.length - 1)) * RAISE_SECONDS); return { name: 'raise', duration: RAISE_SECONDS, channels: ARM.map((joint, j) => linear( joint, 'rotation', times, keys.flatMap((key) => key[j]!), ), ), };}/** The same joint on the rig's other side, for names ending `.r` or `.l`. */export const opposite = (joint: string) => joint.replace(/\.([rl])$/, (_, side: string) => (side === 'r' ? '.l' : '.r'));const BREATH_SECONDS = 3.6;/** Radians the spine and chest lean back at the top of a breath. */const LEAN = 0.035;const SWELL = 1.04;/** * Authors a slow breath for an additive layer: the spine and chest lean back and the chest * swells, the head keeping level. Its first key is the rest pose, so it adds only the breath. */export function breathing(skin: poses.SkinData): poses.ClipData { const times = Float32Array.from([0, 0.45, 0.75, 1], (share) => share * BREATH_SECONDS); const leans = [0, 1, 0.4, 0]; const turned = (name: string, by: number) => { const rest = jointOf(skin, name).rotation; const keys = leans.flatMap((lean) => quatn.multiply(rest, quatn.fromAxisAngle([1, 0, 0], by * lean)), ); return linear(name, 'rotation', times, keys); }; const chest = jointOf(skin, 'chest').scale; const swell = leans.flatMap((lean) => { const s = 1 + (SWELL - 1) * lean; return [chest[0] * s, chest[1], chest[2] * s]; }); return { name: 'breathe', duration: BREATH_SECONDS, channels: [ turned('spine', -LEAN), turned('chest', -LEAN), turned('head', LEAN * 1.6), linear('chest', 'scale', times, swell), ], };}assets.ts
import { glb } from '../assets.ts';import { MODELS } from './stalls.ts';/** * The market's buildings, goods and lamps, each a part of one Draco-compressed file that shares * its images, and the seller, the customer and their clips. */export const assets = { models: glb('models/market.glb', { parts: MODELS, draco: true }), people: { mage: glb('models/mage.glb'), rogue: glb('models/rogue.glb'), clips: glb('models/clips.glb'), },};