Jelly world
A port of TypeGPU's Jelly Slider, after Voicu Apostol's design, where a jelly bends on its slotted tray until a critter pulls the lever and a candy lawn grows around it with seventeen more. Drag the jelly to stretch it, carry a critter or pull the lever yourself, and turn the view with the secondary button or two fingers and zoom with the wheel or a pinch.
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- slider.tsA Verlet spine on the CPU, drawn as quadratic Bézier segments that a kernel bakes into a distance field the march samples.
- jelly.tsThe jelly, its slotted floor and the label under it, marched per pixel.
- label.tsThe slider's value as text, drawn into a texture only when it changes.
- land.tsThe jelly world on the tray: finding and shading its props and critters, and the lawn they stand on, as the jelly's scenery.
- world.tsThe jelly world's trees and houses, grown by a front sweeping along x.
- critters.tsCritters walking the jelly world, sliding down the jelly and carried by the pointer.
- camera.tsThe orbit camera over the tray.
- tray.ts
- backdrop.ts
- candy.ts
- body.tsCritter
istanding on the tray: the herald at its spot, the others anywhere off the slot. - decals.ts
- figures.ts
- props.ts
- moves.tsClimbs the jelly or slides down it, carried as it moves, until it goes off an end, falls off where it overhangs or is thrown.
- poses.tsSteps a foot towards where it belongs under the hips once the other is planted.
- spine.ts
- walking.tsOne step of a critter on the ground.
- steering.tsThe way towards the goal: past the slot's nearer end, halfway in towards it, when the goal lies across it; out to the goal's side first when in line with the slot past an end and the goal lies along it; and back from the front.
- assets.ts
Kit modules baker, cameras, clock, dynamicResolution, finish, font, history, owner, passes, pipelines, profiler, taa, table, targets, text. Also @typegpu/noise, @typegpu/sdf, typegpu, wgpu-matrix.
scene.ts
import { cameras, clock, owner, pipelines, profiler, dynamicResolution, targets, type Frame, type Surface, font, finish, taa, type Baked,} from '@typegpu/kit';import { d, std, type TgpuRoot } from 'typegpu';import type { Pointing } from '../contract.ts';import type { Settings } from '../settings.ts';import { createSlider, RANGE, STEP } from './slider.ts';import { createJelly, Look, Moment, MOTION } from './jelly.ts';import { createLabel } from './label.ts';import { createLand } from './land.ts';import { createWorld } from './world.ts';import { createCritters } from './critters.ts';import { createCamera } from './camera.ts';import type { V3 } from './tray.ts';export interface SceneOptions { /** The font of the value under the jelly, as `font.create` takes it. */ readonly font: Baked;}/** Jelly colours in display values. */const FLAVOURS = { orange: [1, 0.45, 0.075], lime: [0.55, 0.9, 0.12], berry: [0.9, 0.16, 0.42], blue: [0.15, 0.55, 1],} as const;type Flavour = keyof typeof FLAVOURS;/** How fast the jelly's colour eases to a new flavour, as a rate per second. */const FLAVOUR_EASE = 8;/** World x of the jelly world's front before and after the lever reveals it, past the view. */const FRONT = [-2.7, 2.7] as const;/** How fast the front sweeps to where it is going, as a rate per second. */const SWEEP = 1.4;/** Where the lever sends the jelly's end, as progress: from the upper half, then from the lower. */const SENDS = [0.05, 1] as const;/** The light's direction at either end of the `light` setting. */const LIGHT = [d.vec3f(0.19, -0.24, 0.75), d.vec3f(-0.5, -0.14, -0.8)] as const;/** * The share of the display's frame interval the frame's GPU time keeps under while `auto` sets * the detail, and the detail's range. */const DETAIL = { share: 0.75, range: [0.3, 1] } as const;/** How near a ray must pass the jelly's spine for a stroke to drag the jelly. */const REACH = 0.12;/** Pointer rays nearer level than this find no point on the planes below. */const LEVEL = { grazing: 1e-4 };/** Height over the tray that critters look at under the pointer. */const WATCH_HEIGHT = 0.08;/** Height of the plane the pointer drags the jelly's end along. */const DRAG_PLANE = 0.05;/** How fast the jelly's end eases to where it is going, as a rate per second. */const FOLLOW = 5;/** The idle sway of the jelly's end: its rate in radians a second, and its ends as progress. */const SWAY = { speed: 0.6, low: 0.2, high: 0.95 };/** Where the jelly's end sits in world x at `progress` from 0 to 1. */const endAt = (progress: number) => RANGE[0] + (RANGE[1] - RANGE[0]) * progress;/** Where the idle sway puts the jelly's end, `swing` from 0 to 1 between its ends. */const swayed = (swing: number) => endAt(SWAY.low + (SWAY.high - SWAY.low) * swing);/** A jelly slider marched per pixel over a tray, whose lever grows a jelly world of critters. */export async function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions) { const own = owner(); const gpu = own(profiler.create(root)); const marchTime = gpu.timer('march'); const outputTime = gpu.timer('output'); const resolution = dynamicResolution.create(gpu.frame, { budget: (interval) => DETAIL.share * interval, range: DETAIL.range, }); const values = { /** Where the jelly's end sits from 0 to 1. Setting it stops the idle sway. */ progress: 1, flavour: 'orange' as Flavour, /** Swings the light from behind the jelly to in front of it. */ light: 0, /** Blurs the floor seen through the jelly. */ frosted: false, /** Fraction of each side of the screen marched, where `auto` starts. */ detail: 1, /** * Sets `detail` to keep the frame's GPU time under three quarters of the display's frame * interval. Needs `timestamp-query`. */ auto: gpu.available, }; const camera = createCamera(root, surface); const view = camera.view; const screen = own( targets.create( root, surface, { color: { format: 'rgba16float', sampled: true }, motion: { format: MOTION, sampled: true }, depth: { format: 'depth24plus', sampled: true }, }, { resolution: () => resolution.scale }, ), ); const region = screen.regions.color; const aa = own( taa.create(root, { source: () => screen.textures.color, motion: () => screen.textures.motion, depth: () => screen.textures.depth, region, camera: view, }), ); const final = own( finish.create(root, { format: surface.format, source: aa, tone: (color) => { 'use gpu'; return std.tanh(color * 1.3); }, encode: false, settings: { vignette: 0 }, }), ); const sim = clock.fixedStep({ step: STEP }); /** Where the jelly's end is going and where it is, starting in the middle of the sway. */ let goal = swayed(0.5); let at = goal; const slider = own(createSlider(root, at)); const sans = own(font.create(root, options.font)); const label = own(createLabel(root, sans)); const look = root.createUniform(Look); const world = own(createWorld(root)); const now = root.createUniform(Moment); const critters = own(createCritters(root, world)); const land = own(createLand(root, { camera: view, world, critters, now, look })); const jelly = own( createJelly(root, { camera: view, look, field: slider.field, end: slider.end, moved: slider.moved, label: label.view, offset: aa.offset, now, scenery: land.scenery, }), ); await pipelines.compile([slider, land, jelly, label, aa, final]); /** The jelly's colour, easing to the flavour's. */ const colour = d.vec4f(...FLAVOURS.orange, 1); const moment = { front: 0, time: 0, seed: d.vec2f() }; const ray: { origin: V3; direction: V3 } = { origin: [0, 0, 0], direction: [0, 0, 1] }; const grip: V3 = [0, 0, 0]; const eye: V3 = [0, 0, 0]; const point: V3 = [0, 0, 0]; /** Whether the jelly's end has been set by hand, which stops the idle sway. */ let touched = false; let time = 0; /** Whether the spine's field is still to be baked for the first frame. */ let unbaked = true; /** Whether the floor's baked shadows match the light. */ let lit = false; let revealed = false; /** Where the jelly world's front is in world x, sweeping towards `FRONT`'s end. */ let front: number = FRONT[0]; /** Whether the stroke started away from the jelly, the lever and the critters, which it ignores. */ let idle = false; const settings: Settings<typeof values> = { values, knobs: { progress: { min: 0, max: 1, step: 0.01 }, flavour: { options: Object.keys(FLAVOURS) as Flavour[] }, light: { min: 0, max: 1, step: 0.01 }, frosted: {}, detail: { min: DETAIL.range[0], max: DETAIL.range[1], step: 0.05, live: true }, auto: { live: true, ...(gpu.available ? {} : { disabled: 'needs timestamp-query' }) }, }, write(key) { // Moving the detail by hand turns `auto` off; either restarts its timing from the detail if (key === 'detail') values.auto = false; if (key === undefined || key === 'detail' || key === 'auto') { resolution.scale = values.detail; } if (key === undefined || key === 'light') { look.patch({ light: std.normalize(std.mix(LIGHT[0], LIGHT[1], values.light)) }); lit = false; } if (key === undefined || key === 'frosted') look.patch({ blur: values.frosted ? 1 : 0 }); if (key === undefined) look.patch({ jelly: colour }); if (key === 'progress') { touched = true; goal = endAt(values.progress); } }, }; const pointing: Pointing = { move(x, y) { view.rayAt(x, y, ray); if (cameras.hitHeight(ray, WATCH_HEIGHT, point, LEVEL) !== undefined) critters.watch(point); }, leave() { critters.watch(undefined); }, drag(x, y, start) { if (start) idle = false; if (idle) return; view.rayAt(x, y, ray); const { origin, direction } = ray; if (start && world.grab(origin, direction)) return; if (world.holding) { world.pull(origin, direction); return; } if (start && critters.pick(origin, direction)) return; if (critters.holding) { critters.hold(origin, direction); return; } if (start && !slider.passes(origin, direction, REACH)) { idle = true; return; } if (cameras.hitHeight(ray, DRAG_PLANE, grip, LEVEL) === undefined) return; touched = true; goal = grip[0]; }, /** Lets go of the lever or a critter the pointer carries. */ end() { world.release(); critters.drop(); }, }; /** Sets the detail from GPU time while `auto`. */ const pace = (frame: Frame) => { if (!values.auto) return; resolution.update(frame); values.detail = resolution.scale; }; /** Eases the jelly's colour towards its flavour's, writing it while it moves. */ const easeFlavour = (dt: number) => { const flavour = FLAVOURS[values.flavour]; const blend = clock.ease(dt, FLAVOUR_EASE); let easing = false; for (let i = 0; i < 3; i++) { const left = flavour[i]! - colour[i]!; colour[i]! += left * blend; easing ||= Math.abs(left) > 1e-4; } if (easing) look.patch({ jelly: colour }); }; /** * Sweeps the front and swings the lever. Its first pull reveals the jelly world, and later pulls * send the jelly's end, now at `progress`, to the other side of the tray. */ const reveal = (dt: number, progress: number) => { front += (FRONT[revealed ? 1 : 0] - front) * clock.ease(dt, SWEEP); if (!world.update(dt, front)) return; if (revealed) { touched = true; goal = endAt(progress > 0.5 ? SENDS[0] : SENDS[1]); } revealed = true; critters.hop(); }; return { view, settings, pointing, frame(frame: Frame) { const dt = Math.min(frame.dt, 0.1); time += dt; view.update(dt); pace(frame); if (screen.fit()) aa.reset(); if (!touched) goal = swayed(0.5 + 0.5 * Math.sin(time * SWAY.speed)); at += (goal - at) * clock.ease(dt, FOLLOW); slider.drag(at); easeFlavour(dt); let stepped = unbaked; unbaked = false; for (let steps = sim.advance(dt); steps > 0; steps--) { slider.step(); critters.feel(slider.spine); stepped = true; } const progress = std.saturate((slider.target - RANGE[0]) / (RANGE[1] - RANGE[0])); reveal(dt, progress); camera.follow(dt, revealed); moment.front = front; moment.time = time; moment.seed.x = Math.random() * 2 - 1; moment.seed.y = Math.random() * 2 - 1; now.write(moment); label.show(frame, Math.round(progress * 100)); cameras.eyeOf(view.pose, eye); critters.update(dt, time, front, slider.spine, eye); critters.weigh(slider.loads, slider.spine); slider.track(); const shading = !lit || world.moving; lit = true; const compute = frame.encoder.beginComputePass(); if (stepped) slider.bake(compute); if (shading) land.bake(compute); land.cull(compute, region.width, region.height); compute.end(); const march = frame.encoder.beginRenderPass({ colorAttachments: [{ view: screen.views.color }, { view: screen.views.motion }], depthStencilAttachment: { view: screen.views.depth, depthClearValue: 1, depthLoadOp: 'clear', depthStoreOp: 'store', }, timestampWrites: marchTime.writes(), }); march.setViewport(0, 0, region.width, region.height, 0, 1); jelly.draw(march); march.end(); aa.record(frame); final.record(frame, surface.view(frame), { timestampWrites: outputTime.writes() }); gpu.capture(frame); }, destroy: own.destroy, };}slider.ts
import { sdBezier } from '@typegpu/sdf';import { baker } from '@typegpu/kit';import { d, std, type TgpuComputePass, type TgpuRoot } from 'typegpu';import { vec3n } from 'wgpu-matrix';import type { V3 } from './tray.ts';const offset: V3 = [0, 0, 0];/** How far a ray from `origin` along unit `direction` passes from `point`. */export const missOf = (origin: V3, direction: V3, point: V3) => { vec3n.sub(point, origin, offset); return vec3n.length(vec3n.addScaled(offset, direction, -vec3n.dot(offset, direction), offset));};/** Points along the jelly's spine, from the pinned anchor to the dragged end. */export const POINTS = 17;/** The anchor and the rest position of the dragged end, in world units on the slider plane. */const START = [-1, 0] as const;const END_X = 0.9;/** Lifts the spine off the floor. The dragged end rides a little higher than the rest. */const LIFT = -0.03;const BASE = START[1] + LIFT;const END_Y = 0.08 + LIFT;const LENGTH = END_X - START[0];const REST = LENGTH / (POINTS - 1);/** Where the dragged end may go, in world x. */export const RANGE = [-0.33, END_X] as const;/** Half the jelly's thickness around its spine. */export const SKIN = 0.024;/** Physics step in seconds and the substeps and constraint passes within it. */export const STEP = 1 / 120;const SUBSTEPS = 3;const ITERATIONS = 16;/** Velocity lost per substep. */const DAMPING = 0.01;/** Stiffness of the spine against stretching, per constraint pass. */const STRETCHING = 0.1;/** Stiffness of the spine against bending, strongest at its ends. */const BENDING = { strength: 0.1, exponent: 1.2 };/** Upward push in the middle of a compressed spine, which makes it arch. */const ARCH = { strength: 2, deadzone: 0.01 };/** Downward pull on the spine per rider. */const LOAD = 0.6;/** Pulls the points next to each end towards the floor. */const FLAT_ENDS = 0.05;/** * The box the jelly lies in, in world units, with half its depth across the slider plane in `z`. * The baked field covers its x and y, each texel holding the distance to the spine, the progress * along it, and the distance gradient. */export const BOX = { left: START[0] - LENGTH * 0.02, right: END_X + LENGTH * 0.1, bottom: -0.3, top: 0.65, z: 0.25,};const BAKE = { width: 256, height: 128 };/** Finite-difference step for the baked gradient, in world units. */const GRADIENT_STEP = 0.03;/** Steps the spine takes before the first frame, so it starts at rest. */const SETTLE = 120;/** * Lays the spine out at rest, with its dragged end at `end` in world x, as an arc of its length * bulging up between its ends, so it starts untangled. */function layArc(position: Float32Array, previous: Float32Array, end: number) { const run = [end - START[0], END_Y - BASE] as const; const span = Math.hypot(run[0], run[1]); // The arc's half-angle, where sin(half) / half = span / LENGTH let [low, high] = [0, Math.PI]; for (let i = 0; i < 40; i++) { const half = (low + high) / 2; if (Math.sin(half) / half > span / LENGTH) low = half; else high = half; } const half = (low + high) / 2; const turn = Math.atan2(run[1], run[0]); const radius = LENGTH / (2 * half); const centre = [ START[0] + run[0] / 2 + Math.sin(turn) * radius * Math.cos(half), BASE + run[1] / 2 - Math.cos(turn) * radius * Math.cos(half), ]; for (let i = 0; i < POINTS; i++) { const angle = turn + Math.PI / 2 + half - 2 * half * (i / (POINTS - 1)); position[i * 2] = previous[i * 2] = centre[0]! + radius * Math.cos(angle); position[i * 2 + 1] = previous[i * 2 + 1] = Math.max( BASE, centre[1]! + radius * Math.sin(angle), ); }}/** * A Verlet spine on the CPU, drawn as quadratic Bézier segments that a kernel bakes into a * distance field the march samples. Its dragged end starts at `start` in world x. */export function createSlider(root: TgpuRoot, start: number = END_X) { const position = new Float32Array(POINTS * 2); const previous = new Float32Array(POINTS * 2); const normal = new Float32Array(POINTS * 2); const control = new Float32Array((POINTS - 1) * 2); const cap = new Float32Array(4); const loads = new Float32Array(POINTS); const point: V3 = [0, 0, 0]; let target = std.clamp(start, RANGE[0], RANGE[1]); layArc(position, previous, target); const points = root.createReadonly(d.arrayOf(d.vec2f, POINTS)); /** Where each point was when `track` was last called, and how far back that is from it now. */ const drawn = new Float32Array(POINTS * 2); const back = new Float32Array(POINTS * 2); const moved = root.createReadonly(d.arrayOf(d.vec2f, POINTS)); const controls = root.createReadonly(d.arrayOf(d.vec2f, POINTS - 1)); /** The last segment, as its start in xy and its end in zw, for the rounded cap. */ const end = root.createUniform(d.vec4f); const texture = root .createTexture({ size: [BAKE.width, BAKE.height], format: 'rgba16float' }) .$usage('sampled', 'storage'); const baking = baker.create(root, texture, { texel: (uv) => { 'use gpu'; const at = d.vec2f( BOX.left + uv.x * (BOX.right - BOX.left), BOX.top - uv.y * (BOX.top - BOX.bottom), ); const dx = d.vec2f(GRADIENT_STEP, 0); const dy = d.vec2f(0, GRADIENT_STEP); let nearest = d.f32(1e10); let along = d.f32(0); let right = d.f32(1e10); let left = d.f32(1e10); let up = d.f32(1e10); let down = d.f32(1e10); for (let i = 0; i < POINTS - 1; i++) { const a = points.$[i]!; const b = points.$[i + 1]!; const c = controls.$[i]!; const distance = sdBezier(at, a, c, b); if (distance < nearest) { nearest = distance; // Progress within the segment, projected onto its chord const chord = b - a; const t = std.saturate(std.dot(at - a, chord) / std.max(std.dot(chord, chord), 1e-12)); along = (d.f32(i) + t) / (POINTS - 1); } right = std.min(right, sdBezier(at + dx, a, c, b)); left = std.min(left, sdBezier(at - dx, a, c, b)); up = std.min(up, sdBezier(at + dy, a, c, b)); down = std.min(down, sdBezier(at - dy, a, c, b)); } const gradient = d.vec2f(right - left, up - down) / (2 * GRADIENT_STEP); return d.vec4f(nearest, along, gradient); }, }); const pin = () => { position[0] = previous[0] = START[0]; position[1] = previous[1] = BASE; const last = (POINTS - 1) * 2; position[last] = previous[last] = target; position[last + 1] = previous[last + 1] = END_Y; }; const project = (i: number, j: number, rest: number, stiffness: number) => { const dx = position[j * 2]! - position[i * 2]!; const dy = position[j * 2 + 1]! - position[i * 2 + 1]!; const length = Math.hypot(dx, dy); if (length < 1e-8) return; // Pinned ends have no inverse mass and take none of the correction const wi = i === 0 || i === POINTS - 1 ? 0 : 1; const wj = j === 0 || j === POINTS - 1 ? 0 : 1; if (wi + wj === 0) return; const scale = ((length - rest) / length) * stiffness; const ci = (wi / (wi + wj)) * scale; const cj = (wj / (wi + wj)) * scale; position[i * 2] += dx * ci; position[i * 2 + 1] += dy * ci; position[j * 2] -= dx * cj; position[j * 2 + 1] -= dy * cj; }; const substep = (h: number) => { const compression = Math.max(0, 1 - Math.abs(target - START[0]) / LENGTH); for (let i = 1; i < POINTS - 1; i++) { const t = i / (POINTS - 1); const edge = ARCH.deadzone; const window = std.smoothstep(edge, 1 - edge, t) * std.smoothstep(edge, 1 - edge, 1 - t); const arch = ARCH.strength * Math.sin(Math.PI * t) * window * compression; const x = position[i * 2]!; const y = position[i * 2 + 1]!; position[i * 2] = x + (x - previous[i * 2]!) * (1 - DAMPING); position[i * 2 + 1] = Math.max( BASE, y + (y - previous[i * 2 + 1]!) * (1 - DAMPING) + (arch - loads[i]! * LOAD) * h * h, ); previous[i * 2] = x; previous[i * 2 + 1] = y; } pin(); for (let it = 0; it < ITERATIONS; it++) { for (let i = 0; i < POINTS - 1; i++) project(i, i + 1, REST, STRETCHING); for (let i = 1; i < POINTS - 1; i++) { const fromMiddle = Math.abs(i / (POINTS - 1) - 0.5) * 2; const k = BENDING.strength * (0.05 + 0.95 * fromMiddle ** BENDING.exponent); project(i - 1, i + 1, 2 * REST, k); } for (const i of [1, POINTS - 2]) { position[i * 2 + 1] += (BASE - position[i * 2 + 1]!) * FLAT_ENDS; } pin(); } }; /** Sets each segment's control point where the tangents at its ends meet. */ const shape = () => { for (let i = 0; i < POINTS; i++) { const a = Math.max(0, i - 1); const b = Math.min(POINTS - 1, i + 1); const dx = position[b * 2]! - position[a * 2]!; const dy = position[b * 2 + 1]! - position[a * 2 + 1]!; const length = Math.hypot(dx, dy); if (length < 1e-6) { normal[i * 2] = i > 0 ? normal[i * 2 - 2]! : 0; normal[i * 2 + 1] = i > 0 ? normal[i * 2 - 1]! : 1; continue; } normal[i * 2] = -dy / length; normal[i * 2 + 1] = dx / length; } for (let i = 0; i < POINTS - 1; i++) { const ax = position[i * 2]!; const ay = position[i * 2 + 1]!; const bx = position[i * 2 + 2]!; const by = position[i * 2 + 3]!; control[i * 2] = (ax + bx) * 0.5; control[i * 2 + 1] = (ay + by) * 0.5; if (i === 0 || i === POINTS - 2) continue; const na = [normal[i * 2]!, normal[i * 2 + 1]!] as const; const nb = [normal[i * 2 + 2]!, normal[i * 2 + 3]!] as const; // Nearly parallel tangents meet far away or nowhere: meet them halfway along the chord // instead, which keeps the bends at the points smooth const cross = na[1] * -nb[0] - -na[0] * nb[1]; if (na[0] * nb[0] + na[1] * nb[1] > 0.99 || Math.abs(cross) <= 1e-6) { const half = Math.hypot(bx - ax, by - ay) * 0.5; control[i * 2] = (ax + na[1] * half + bx - nb[1] * half) * 0.5; control[i * 2 + 1] = (ay - na[0] * half + by + nb[0] * half) * 0.5; continue; } const t = ((bx - ax) * -nb[0] - (by - ay) * nb[1]) / cross; control[i * 2] = ax + t * na[1]; control[i * 2 + 1] = ay - t * na[0]; } const last = (POINTS - 2) * 2; cap.set(position.subarray(last, last + 4)); }; const step = () => { for (let s = 0; s < SUBSTEPS; s++) substep(STEP / SUBSTEPS); shape(); }; for (let i = 0; i < SETTLE; i++) step(); drawn.set(position); return { /** The baked field, sampled by the march. */ field: texture.createView(), end, /** How far back each point of the spine was when the last frame was drawn, in x and y. */ moved, /** Writes `moved` for the frame about to be drawn. */ track() { for (let i = 0; i < POINTS * 2; i++) back[i] = drawn[i]! - position[i]!; drawn.set(position); moved.write(back); }, /** Sets where the dragged end goes, clamped to `RANGE`. */ drag(x: number) { target = std.clamp(x, RANGE[0], RANGE[1]); }, get target() { return target; }, /** Whether a ray passes within `reach` of a point of the spine. */ passes(origin: V3, direction: V3, reach: number) { for (let i = 0; i < POINTS; i++) { point[0] = position[i * 2]!; point[1] = position[i * 2 + 1]!; if (missOf(origin, direction, point) < reach) return true; } return false; }, /** The spine's points in x and y. */ spine: position, /** Downward pull on each spine point, such as riders' weight. */ loads, step, /** Uploads the spine and bakes its field. */ bake(pass: TgpuComputePass) { points.write(position); controls.write(control); end.write(cap); baking.record(pass); }, initAsync: () => baking.initAsync(), destroy() { texture.destroy(); }, };}jelly.ts
import { cameras, history, owner, type CameraBase } from '@typegpu/kit';import { perlin2d, randf } from '@typegpu/noise';import * as sdf from '@typegpu/sdf';import { common, d, std, tgpu, type TgpuReadonly, type TgpuRenderPass, type TgpuRoot, type TgpuTextureView, type TgpuUniform,} from 'typegpu';import { fog, sky } from './backdrop.ts';import { SOFTBOX } from './candy.ts';import { BOX, POINTS, SKIN } from './slider.ts';import { GROUND, SLOT } from './tray.ts';/** How the jelly and its light look, written when a setting changes. */export const Look = d.struct({ /** Jelly colour in display values. Its alpha is unused. */ jelly: d.vec4f, /** Unit direction the light travels. */ light: d.vec3f, /** 1 blurs the floor seen through the jelly. */ blur: d.f32,});/** What changes every frame. */export const Moment = d.struct({ /** World x of the jelly world's front, and the time in seconds. */ front: d.f32, time: d.f32, /** Changes every frame, so the blur's offsets differ between the frames TAA blends. */ seed: d.vec2f,});/** The nearest thing besides the jelly a ray meets, how far along it, and which, or index -1. */export const Found = d.struct({ t: d.f32, index: d.i32 });/** A point of the floor as the tray shades it, for scenery to cover. */export const Floor = d.struct({ /** Where the ray meets the floor, and where it is shaded, shaken by the blur. */ at: d.vec3f, shaken: d.vec3f, normal: d.vec3f, direction: d.vec3f, /** The tray's colour with the label printed on it. */ color: d.vec3f, /** How much of the point the label covers. */ ink: d.f32, /** The jelly's coloured shadow, and the share of open sky the tray leaves. */ shadow: d.vec3f, open: d.f32, /** Light the jelly bounces onto the floor. */ bounce: d.vec3f, /** A pixel's width on the floor. */ soft: d.f32,});/** What the jelly's rays meet besides the jelly and its tray, as `'use gpu'` functions. */export interface Scenery { /** * The nearest thing a ray through `pixel` of the march meets before `limit`. The jelly marches * only up to it. */ readonly find: ( eye: d.v3f, direction: d.v3f, limit: number, pixel: d.v2f, ) => d.Infer<typeof Found>; /** The colour at `p` of the thing `find` found. */ readonly shade: (p: d.v3f, direction: d.v3f, index: number, light: d.v3f) => d.v3f; /** How far back the thing `find` found was when the last frame was drawn. */ readonly moved: (index: number) => d.v3f; /** * What the jelly reflects and shows through before `limit`, and how much of the ray it covers in * w. */ readonly glimpse: (eye: d.v3f, direction: d.v3f, limit: number, light: d.v3f) => d.v4f; /** What covers the floor at a point, from the tray's colour there. */ readonly ground: (point: d.Infer<typeof Floor>, light: d.v3f) => d.v3f;}/** * What a ray of the march sees: its colour, how far along the surface is, or -1 for the sky, and * how far back the surface was when the last frame was drawn. */const Seen = d.struct({ color: d.vec3f, distance: d.f32, moved: d.vec3f });/** The motion target's format, and how far along a ray the sky's motion is taken. */export const MOTION = 'rg16float';const SKY_DISTANCE = 15;const clipOf = (world: d.v3f) => { 'use gpu'; return cameras.current.$.viewProjection * d.vec4f(world, 1);};const clipBefore = history.previously(clipOf);/** A clip position in UV units, y down, as `taa.motion` takes them. */const uvOf = (clip: d.v4f) => { 'use gpu'; return (clip.xy / clip.w) * d.vec2f(0.5, -0.5);};/** Ray-march steps and reach, and the distance that counts as a hit, in world units. */const MARCH = { steps: 64, far: 10, hit: 0.001 };/** How far the jelly's reflections reach for critters. */const REFLECTED = 3;/** Steps through the jelly to the floor behind it. */const THROUGH_STEPS = 6;/** The floor's ambient light, and its highlight's sharpness and strength. */const AMBIENT = 0.36;const SPECULAR = { power: 10, intensity: 0.6 };/** The jelly's index of refraction, and how much light it scatters along a refracted ray. */const IOR = 1.42;const SCATTER = 3;/** * Absorption per unit of the jelly's missing colour, the depth light crosses, the least of it at * the jelly's start, and how much deeper it runs through the jelly at a glancing view. */const ABSORPTION = { density: 20, depth: 0.08, least: 0.07, glancing: 1 };/** * The room the jelly mirrors, its brightness below and above. Its highlights: the candy's softbox, * the cosines its edge fades between and its brightness, and the light's glint, its sharpness and * brightness. */const ROOM = { low: 0.3, high: 0.55, softbox: SOFTBOX, edge: d.vec2f(0.7, 0.97), bright: 0.4, glint: d.vec2f(60, 0.9),};/** The most the light the jelly gathers brightens the floor, and how much it takes its colour. */const CAUSTIC = { most: 0.8, tint: 0.6 };/** Ambient occlusion on the floor: its steps, reach, strength and offset from the surface. */const AO = { steps: 3, radius: 0.1, intensity: 0.5, bias: MARCH.hit * 5 };/** The jelly's rounding radius and half its depth across the slider plane. */const TUBE = { radius: SKIN, half: 0.17 };/** The floor slab's half-thickness, its top at the ground, and its edges' rounding. */const SLAB = { thickness: GROUND, rounding: 0.02 };/** Where the progress along the spine hands over from the tube to the rounded end, and over how much. */const CAP = { from: 0.94, blend: 0.02 };/** Rays that would meet the tray farther than this see the sky instead. */const HORIZON = 14;/** A pixel's width on the floor over its distance, and at least, for soft-edged marks. */const FOOTPRINT = { slope: 0.0016, least: 0.0005 };/** Floor-label centre and size in world units. */const LABEL = { center: d.vec3f(0.72, 0, 0), width: 0.38, depth: 0.33 };/** Perlin cells cached per side, after which the lawn's and the sky's noise repeats. */const NOISE_CELLS = 64;/** The jelly's distance at a point, and the progress along the spine there. */const Hit = d.struct({ distance: d.f32, t: d.f32 });/** The baked field at a point: progress along the spine, distance to it and its gradient. */const Spine = d.struct({ t: d.f32, distance: d.f32, normal: d.vec2f });/** The slot the jelly lies in, seen from above. */const slot = (p: d.v2f) => { 'use gpu'; return sdf.sdRoundedBox2d(p, d.vec2f(SLOT.x, SLOT.z), SLOT.z);};/** The jelly's body: the spine's distance, pushed out across the slider plane and rounded. */const tube = (p: d.v3f, along: d.Infer<typeof Spine>) => { 'use gpu'; return sdf.opExtrudeZ(p, along.distance, TUBE.half) - TUBE.radius;};/** The normal of a distance field, from four samples `step` apart. */const gradientOf = (distance: (p: d.v3f) => number, step: number) => { const fourTapGradient = (p: d.v3f) => { 'use gpu'; const k = d.vec3f(1, -1, 0) * step; return std.normalize( k.xyy * distance(p + k.xyy) + k.yyx * distance(p + k.yyx) + k.yxy * distance(p + k.yxy) + k.xxx * distance(p + k.xxx), ); }; return fourTapGradient;};/** The tray: a slab cut by the slot, over the slot's floor. */const floor = (p: d.v3f) => { 'use gpu'; const slab = sdf.opExtrudeY(p, -slot(p.xz), SLAB.thickness - SLAB.rounding) - SLAB.rounding; return sdf.opUnion(sdf.sdPlane(p, d.vec3f(0, 1, 0), -SLOT.floor), slab);};const slabNormal = gradientOf(floor, 0.0001);/** The tray's normal, flat away from the slot. */const floorNormal = (p: d.v3f) => { 'use gpu'; if (std.abs(p.z) > SLOT.z || std.abs(p.x) > SLOT.x) return d.vec3f(0, 1, 0); return slabNormal(p);};export interface JellyOptions { readonly camera: CameraBase; readonly look: TgpuUniform<typeof Look>; /** The slider's baked field. */ readonly field: TgpuTextureView<d.WgslTexture2d<d.F32>>; /** The slider's last segment, start in xy and end in zw. */ readonly end: TgpuUniform<d.Vec4f>; /** How far back each point of the slider's spine was when the last frame was drawn. */ readonly moved: TgpuReadonly<d.WgslArray<d.Vec2f>>; /** The floor label, white on clear. */ readonly label: TgpuTextureView<d.WgslTexture2d<d.F32>>; /** Moves each frame's rays by a fraction of a pixel, as TAA's `offset`. */ readonly offset: TgpuUniform<d.Vec2f>; readonly now: TgpuUniform<typeof Moment>; /** What else the rays meet, each carrying its own fills. */ readonly scenery: Scenery;}/** The jelly, its slotted floor and the label under it, marched per pixel. */export function createJelly(root: TgpuRoot, options: JellyOptions) { const { look, field, end, moved, label, offset, now, scenery } = options; const sampler = root.createSampler({ magFilter: 'linear', minFilter: 'linear' }); const own = owner(); const noise = own(perlin2d.staticCache({ root, size: d.vec2u(NOISE_CELLS, NOISE_CELLS) })); /** The baked field at `p` on the slider plane. */ const spine = (p: d.v2f) => { 'use gpu'; const uv = d.vec2f( (p.x - BOX.left) / (BOX.right - BOX.left), (BOX.top - p.y) / (BOX.top - BOX.bottom), ); const baked = std.textureSampleLevel(field.$, sampler.$, std.saturate(uv), 0); return Spine({ t: baked.y, distance: baked.x, normal: baked.zw }); }; /** How far back the jelly at progress `t` along its spine was when the last frame was drawn. */ const spineMoved = (t: number) => { 'use gpu'; const at = std.saturate(t) * (POINTS - 1); const i = std.min(d.u32(at), POINTS - 2); return std.mix(moved.$[i]!, moved.$[i + 1]!, at - d.f32(i)); }; /** The jelly's rounded end, around its last segment. */ const cap = (p: d.v3f) => { 'use gpu'; const from = end.$.xy; const along = std.normalize(end.$.zw - from); const turn = d.mat2x2f(along.x, -along.y, along.y, along.x); const local = p - d.vec3f(from, 0); const turned = d.vec3f(turn * local.xy, local.z); const pie = sdf.sdPie(turned.zx, d.vec2f(1, 0), TUBE.half); return sdf.opExtrudeY(turned, pie, 0.001) - TUBE.radius; }; /** The jelly's distance at a point. The floor bounds the march instead of joining it. */ const scene = (p: d.v3f) => { 'use gpu'; const along = spine(p.xy); if (along.t > CAP.from) return Hit({ distance: cap(p), t: along.t }); return Hit({ distance: tube(p, along), t: along.t }); }; /** What occludes the floor: the tray and the jelly's tube within its box, skipping the cap. */ const occluder = (p: d.v3f) => { 'use gpu'; let jelly = d.f32(1e9); if (p.x >= BOX.left && p.x <= BOX.right && p.y >= BOX.bottom && p.y <= BOX.top) { jelly = tube(p, spine(p.xy)); } return std.min(floor(p), jelly); }; const capNormal = gradientOf(cap, 0.01); /** Blends the spine's 2D gradient into the flat faces, rounding the tube without a gradient. */ const tubeNormal = (p: d.v3f, t: number) => { 'use gpu'; const along = spine(p.xy); const threshold = TUBE.half * 0.85; const intoEdge = std.max(0, ((std.abs(p.z) - threshold) * TUBE.half) / (TUBE.half - threshold)); const edge = 0.9; const blend = std.smoothstep( edge * TUBE.radius + (1 - edge) * TUBE.half, 0, intoEdge * (1 - edge) + (TUBE.radius - along.distance) * edge, ); const face = d.vec3f(0, 0, std.sign(p.z)); let normal = std.normalize(std.mix(face, d.vec3f(along.normal, 0), blend * 0.5 + 0.5)); if (t > CAP.from) { normal = std.normalize(std.mix(normal, capNormal(p), (t - CAP.from) / CAP.blend)); } return normal; }; const normalAt = (p: d.v3f, hit: d.Infer<typeof Hit>) => { 'use gpu'; if (hit.t < CAP.from + CAP.blend) return tubeNormal(p, hit.t); return capNormal(p); }; /** The jelly's coloured shadow, from the baked field along the light, and the slot's lip. */ const shadowAt = (p: d.v3f, toLight: d.v3f) => { 'use gpu'; if (p.y < -SLAB.thickness) { const lip = std.saturate(1 - (slot(p.xz) + 0.02) * 30); const gradient = std.saturate(-p.z * 4 * toLight.z + 1); return d.vec3f(lip * gradient * 0.5); } const uv = d.vec2f( (p.x - p.z * toLight.x * std.sign(toLight.z)) * 0.5 + 0.5, 1 - (-p.z / toLight.z) * 0.5 - 0.2, ); const baked = std.textureSampleLevel(field.$, sampler.$, uv, 0); // Fades into a position-based tint where a bunched-up jelly would switch it too suddenly const saturation = std.mix(0, baked.y, std.saturate(p.x * 1.5 + 1.1)); const tint = std.mix(d.vec3f(), look.$.jelly.rgb, saturation); const contrast = 20 * std.saturate(uv.y) * (0.8 + end.$.x * 0.2); const feather = std.saturate(uv.x * 10) * std.saturate((1 - uv.x) * 10) * std.saturate((1 - uv.y) * 10) * std.saturate(uv.y); const reach = std.saturate((1 - toLight.y) * 2) * feather; const shade = std.mix(tint, d.vec3f(1), std.saturate(baked.x * contrast - 0.3)); return std.mix(d.vec3f(1), shade, reach); }; const occlusion = (p: d.v3f, normal: d.v3f) => { 'use gpu'; let total = d.f32(0); let weight = d.f32(1); for (let i = 1; i <= AO.steps; i++) { const height = (AO.radius / AO.steps) * d.f32(i); total += std.max(0, height - (occluder(p + normal * height) - AO.bias)) * weight; weight *= 0.5; if (total > AO.radius / AO.intensity) break; } return std.saturate(1 - (AO.intensity * total) / AO.radius); }; const lit = (p: d.v3f, normal: d.v3f, eye: d.v3f, shadow: d.v3f) => { 'use gpu'; const toLight = look.$.light * -1; const diffuse = std.max(std.dot(normal, toLight), 0); const mirrored = std.reflect(look.$.light, normal); const specular = std.max(std.dot(std.normalize(eye - p), mirrored), 0) ** SPECULAR.power * SPECULAR.intensity; return std.saturate(d.vec3f(0.9) * (diffuse * shadow + AMBIENT) + shadow * specular); }; const labelAt = (p: d.v3f) => { 'use gpu'; const local = p - LABEL.center; const uv = d.vec2f(local.x / LABEL.width, local.z / LABEL.depth) + 0.5; if (uv.x < 0 || uv.x > 1 || uv.y < 0 || uv.y > 1) return d.f32(0); return std.textureSampleLevel(label.$, sampler.$, uv, 0).w; }; /** A bright streak where light gathers through the jelly onto the floor. */ const caustic = (p: d.v3f) => { 'use gpu'; const light = look.$.light; const shift = d.vec2f(-light.x * 0.2, 0.05 + light.z * 0.2); const at = p.xz + shift; if (std.abs(at.x) >= 1 || std.abs(at.y) >= 0.2) return d.f32(0); const centered = d.vec2f((at.x + 2) * 0.5 - 0.5, (at.y + 0.4) / 0.4 - 0.5); const uv = d.vec2f(centered.x, 1 - (std.abs(centered.y - 0.5) * 2) ** 2 * 0.3); const baked = std.textureSampleLevel(field.$, sampler.$, uv, 0).x; const density = std.max(0, (baked - 0.25) * 8); const stretch = (end.$.x + 1) * 0.5; const fade = std.saturate(std.smoothstep(0, -0.2, p.x - end.$.x)) * std.saturate(1 - (std.abs(centered.y - 0.5) * 2) ** 3) * std.saturate(1 - stretch); return (density ** 3 * fade * 3 * (1 + light.z)) / 1.5; }; const floorColor = (eye: d.v3f, direction: d.v3f, distance: number, blur: number) => { 'use gpu'; const p = eye + direction * distance; const height = std.saturate(eye.y + 0.01); const shaken = p + d.vec3f(0, blur * (height / (1 + height)) * (1 + randf.sample() / 2), 0); const normal = floorNormal(shaken); // Light the jelly bounces onto the floor around its end const near = p - d.vec3f(end.$.x, 0, 0); const near2 = std.dot(near, near); const bounce = look.$.jelly.rgb * (0.4 / (near2 * 15 + 1) + (0.3 / (near2 * 40 + 1)) * std.abs(normal.z)); const shadow = shadowAt(shaken, look.$.light * -1); const open = occlusion(shaken, normal); const color = lit(shaken, normal, eye, shadow) * open + bounce; const ink = labelAt(p); // The gathered light rolls off below a bound and takes on the jelly's colour const gathered = caustic(p); const glow = (gathered / (1 + gathered)) * CAUSTIC.most; const printed = std.mix(color, std.saturate(color * 0.5), ink) * (1 + std.mix(d.vec3f(1), look.$.jelly.rgb, CAUSTIC.tint) * glow); const point = Floor({ at: p, shaken, normal, direction, color: printed, ink, shadow, open, bounce, soft: distance * FOOTPRINT.slope + FOOTPRINT.least, }); return scenery.ground(point, look.$.light); }; const floorThrough = (eye: d.v3f, direction: d.v3f) => { 'use gpu'; let travelled = d.f32(0); for (let i = 0; i < THROUGH_STEPS; i++) { const step = floor(eye + direction * travelled); travelled += step; if (travelled > MARCH.far || step < MARCH.hit * 10) break; } const critter = scenery.glimpse(eye, direction, std.min(travelled, MARCH.far), look.$.light); if (critter.w > 0.99) return critter.xyz; let behind = d.vec3f(); if (travelled >= MARCH.far) behind = sky(direction, look.$.light * -1, now.$.time); else behind = floorColor(eye, direction, travelled, d.f32(0.87) * look.$.blur); return std.mix(behind, critter.xyz, critter.w); }; const jellyColor = (p: d.v3f, direction: d.v3f, hit: d.Infer<typeof Hit>) => { 'use gpu'; const normal = normalAt(p, hit); const cosine = std.saturate(std.dot(direction * -1, normal)); const r0 = ((1 - IOR) / (1 + IOR)) ** 2; const fresnel = r0 + (1 - r0) * (1 - cosine) ** 5; const mirrored = std.reflect(direction, normal); const critter = scenery.glimpse( p + mirrored * (MARCH.hit * 4), mirrored, REFLECTED, look.$.light, ); const room = std.mix(ROOM.low, ROOM.high, std.smoothstep(-0.2, 0.8, mirrored.y)); const reflection = std.mix(d.vec3f(room), critter.xyz, critter.w); // Bounded highlights that roll off at their edges, over the colour the jelly lets through const softbox = std.smoothstep( ROOM.edge.x, ROOM.edge.y, std.dot(mirrored, std.normalize(ROOM.softbox)), ); const glint = std.saturate(std.dot(mirrored, look.$.light * -1)) ** ROOM.glint.x; // Squared, the softbox fades in slowly, so the faceted bends of the spine don't show in it const shine = (softbox * softbox * ROOM.bright + glint * ROOM.glint.y) * (1 - critter.w); const eta = 1 / IOR; const k = 1 - eta * eta * (1 - cosine * cosine); let refracted = d.vec3f(); if (k > 0) { const bent = std.normalize(direction * eta + normal * (eta * cosine - std.sqrt(k))); const behind = floorThrough(p + bent * (MARCH.hit * 4), bent); const tint = look.$.jelly.rgb; const depth = (ABSORPTION.least + hit.t ** 2) * ABSORPTION.depth * (1 + ABSORPTION.glancing * (1 - cosine)); const absorbed = std.exp((tint - 1) * ABSORPTION.density * depth); const forward = std.max(0, std.dot(look.$.light * -1, bent)); refracted = behind * absorbed + tint * (1.5 * SCATTER * forward * hit.t ** 3); } return std.mix(refracted, reflection, fresnel) + d.vec3f(shine); }; /** The jelly's colour where a ray meets it before `limit` and how far along, or -1 in w. */ const jellyAlong = (eye: d.v3f, direction: d.v3f, limit: number) => { 'use gpu'; // The jelly lives inside its baked box, so rays that miss the box skip it const inverse = 1 / direction; const low = (d.vec3f(BOX.left, BOX.bottom, -BOX.z) - eye) * inverse; const high = (d.vec3f(BOX.right, BOX.top, BOX.z) - eye) * inverse; const near = std.min(low, high); const far = std.max(low, high); const enter = std.max(near.x, near.y, near.z); const leave = std.min(far.x, far.y, far.z); // Past the box the field clamps to its edge, and past `limit` the jelly is hidden const stop = std.min(leave, limit); if (stop < enter || leave < 0) return Seen({ color: d.vec3f(), distance: -1, moved: d.vec3f() }); let travelled = std.max(0, enter); for (let i = 0; i < MARCH.steps; i++) { const p = eye + direction * travelled; const hit = scene(p); travelled += hit.distance; if (travelled > stop) break; if (hit.distance < MARCH.hit) { return Seen({ color: jellyColor(eye + direction * travelled, direction, hit), distance: travelled, moved: d.vec3f(spineMoved(hit.t), 0), }); } } return Seen({ color: d.vec3f(), distance: -1, moved: d.vec3f() }); }; /** * The colour a ray sees, how far along it the surface is, or -1 for the sky, and how far back * the surface was when the last frame was drawn. */ const march = (eye: d.v3f, direction: d.v3f, pixel: d.v2f) => { 'use gpu'; const open = direction.y > -1e-3 || (eye.y - SLAB.thickness) / -direction.y > HORIZON; let toFloor = d.f32(MARCH.far); if (!open) { // Nothing of the floor stands above its top toFloor = (eye.y - SLAB.thickness) / -direction.y; for (let i = 0; i < MARCH.steps; i++) { const step = floor(eye + direction * toFloor); toFloor += step; if (step < MARCH.hit) break; } } const found = scenery.find(eye, direction, toFloor, pixel); let limit = toFloor; if (found.index >= 0) limit = found.t; // The jelly goes first, so what it hides is never shaded const jelly = jellyAlong(eye, direction, limit); if (jelly.distance >= 0) return jelly; if (found.index >= 0) { const color = scenery.shade(eye + direction * found.t, direction, found.index, look.$.light); return Seen({ color: fog(color, eye, direction, limit), distance: limit, moved: scenery.moved(found.index), }); } if (open) { const color = sky(direction, look.$.light * -1, now.$.time); return Seen({ color, distance: -1, moved: d.vec3f() }); } const ground = floorColor(eye, direction, toFloor, d.f32(0)); return Seen({ color: fog(ground, eye, direction, limit), distance: limit, moved: d.vec3f() }); }; const fragment = tgpu.fragmentFn({ in: { position: d.builtin.position, uv: d.vec2f }, out: { color: d.vec4f, motion: d.vec4f, depth: d.builtin.fragDepth }, })(({ position, uv }) => { 'use gpu'; randf.seed2(now.$.seed * uv); const ndc = d.vec2f(uv.x, 1 - uv.y) * 2 - 1 + offset.$; const ray = cameras.rayOf(cameras.current.$, ndc); const seen = march(ray.origin, ray.direction, position.xy); // Depth and motion let TAA follow the camera, the jelly and the critters let depth = d.f32(1); let distance = d.f32(SKY_DISTANCE); if (seen.distance >= 0) { distance = seen.distance; const clip = clipOf(ray.origin + ray.direction * distance); depth = std.saturate(clip.z / clip.w); } const at = ray.origin + ray.direction * distance; const motion = uvOf(clipBefore(at + seen.moved)) - uvOf(clipOf(at)); return { color: d.vec4f(seen.color, 1), motion: d.vec4f(motion, 0, 0), depth }; }); const pipeline = root .pipe(options.camera.inject()) .pipe(noise.inject()) .createRenderPipeline({ vertex: common.fullScreenTriangle, fragment, targets: { color: { format: 'rgba16float' }, motion: { format: MOTION } }, depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'always' }, }); return { initAsync: () => pipeline.initAsync(), draw(pass: TgpuRenderPass) { pass.setPipeline(pipeline); pass.draw(3); }, destroy: own.destroy, };}label.ts
import { owner, passes, targets, text, type Font, type Frame } from '@typegpu/kit';import type { TgpuRoot } from 'typegpu';const SIZE = { width: 512, height: 256 };/** Digits and the percent sign, in pixels of the label texture. */const DIGITS = { em: 180, x: 330, y: 128 };const PERCENT = { em: 140, x: 340, y: 138 };/** The slider's value as text, drawn into a texture only when it changes. */export function createLabel(root: TgpuRoot, font: Font) { const own = owner(); const page = own(targets.create(root, SIZE, { ink: { format: 'rgba8unorm', sampled: true } })); const tags = own(text.create(root, font, { capacity: 2, length: 3, place: text.screen })); const paint = passes.create(root, page, { ink: passes.color }); const drawn = paint.add(tags.drawing); tags.write(1, '%', { em: PERCENT.em, align: 'left' }); tags.labels.patch({ 0: { position: [DIGITS.x, DIGITS.y, 0.5], color: [1, 1, 1, 1] }, 1: { position: [PERCENT.x, PERCENT.y, 0.5], color: [1, 1, 1, 1] }, }); let shown = -1; return { view: page.textures.ink.createView(), initAsync: () => paint.initAsync(), /** Draws `value` into the texture unless it already shows it. */ show(frame: Frame, value: number) { if (value === shown) return; shown = value; tags.write(0, String(value), { em: DIGITS.em, align: 'right' }); paint.record(frame, drawn); }, destroy: own.destroy, };}land.ts
import { baker, cameras, type CameraBase, type Injectable } from '@typegpu/kit';import { perlin2d, randf } from '@typegpu/noise';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot, type TgpuUniform } from 'typegpu';import { CRITTERS } from './body.ts';import { jelly, spanOf } from './candy.ts';import { dropAt, flowerAt, stoneAt } from './decals.ts';import { Critter, glimpse, marchCritter, shadeCritter, underCritters } from './figures.ts';import { Floor, Found, type Look, type Moment, type Scenery } from './jelly.ts';import { boundOf, castAt, marchProp, Prop, shadeProp, shownProp } from './props.ts';import { GROUND } from './tray.ts';/** * The lawn's colours, how far its edge reaches with noise, and farther than which from the front * the noise changes nothing, how much its ripples and swells tilt it, its gloss and glow, and how * far behind the front its fresh glow fades. */const LAWN = { color: d.vec3f(0.58, 0.88, 0.56), meadow: d.vec3f(0.86, 0.94, 0.55), slot: d.vec3f(0.98, 0.66, 0.78), ragged: 0.22, settled: 1.2, wobble: 0.035, swell: 0.6, finish: d.vec2f(0.15, 0.25), fresh: 0.15, glow: d.vec3f(0.85, 1, 0.62),};/** * The jelly lip at the front: how far in it rises, how steeply its edge falls to the paper, how * clear its thin edge is, and how far out it focuses a bright line onto the paper. */const LIP = { width: 0.08, tilt: 2.6, clear: 0.6, caustic: 0.02 };/** Bubbles in the young jelly behind the front, and sparkles on its lip. */const BUBBLE = { cell: 0.05, share: 0.3, size: 0.006, reach: 0.2 };const SPARKLE = { cell: 0.08, size: 0.022 };/** The floor that props' shadows are baked over, in world x and z, and its texels. */const SHADED = { left: -3, right: 3, back: -2.2, front: 1.6, width: 512, height: 320 };/** * Pixels on a side of the screen tiles that list the props and critters they may show before the * march, and the tiles the list holds, enough for a march 4096 by 2304 pixels. Pixels of tiles past * them try every prop and critter. */const TILE = 8;const TILES = (4096 / TILE) * (2304 / TILE);const CULL_GROUP = 8;/** Indices `find` returns for critters start here, after the props. */const FIRST_CRITTER = 32;/** * Marches the shapes whose bits are set in `passed`, nearest entry first, until the next enters * past the hit in `before`. `bound` gives shape `i`'s bounding sphere and `march` where a ray * first meets it; a hit's index is `first + i`. */const nearestOf = ( bound: (i: number) => d.v4f, march: (eye: d.v3f, direction: d.v3f, i: number, start: number, stop: number) => number, first: number,) => { const nearestHit = ( eye: d.v3f, direction: d.v3f, count: number, passed: number, before: d.Infer<typeof Found>, ) => { 'use gpu'; let mask = d.u32(passed); let found = Found({ t: before.t, index: before.index }); for (let n = 0; n < count; n++) { if (mask === 0) break; let next = d.i32(0); let span = d.vec2f(1e9, 0); for (let i = 0; i < count; i++) { if (((mask >>> d.u32(i)) & d.u32(1)) === 0) continue; const at = spanOf(eye, direction, bound(i)); if (at.x < span.x) { span = d.vec2f(at); next = i; } } if (span.x >= found.t) break; mask = mask ^ (d.u32(1) << d.u32(next)); const t = march(eye, direction, next, span.x, std.min(span.y, found.t)); if (t >= 0) found = Found({ t, index: first + next }); } return found; }; return nearestHit;};const nearestProp = nearestOf(boundOf, marchProp, 0);const nearestCritter = nearestOf(Critter.bound, marchCritter, FIRST_CRITTER);/** The march's size in pixels and in tiles. */const Grid = d.struct({ size: d.vec2f, tiles: d.vec2u });/** Whether a sphere may show in the cone round `axis` from `eye`, `spread` radians wide. */const inCone = (sphere: d.v4f, eye: d.v3f, axis: d.v3f, spread: number) => { 'use gpu'; const to = sphere.xyz - eye; const distance = std.length(to); if (distance <= sphere.w) return true; const angle = std.acos(std.clamp(std.dot(to, axis) / distance, -1, 1)); return angle <= spread + std.asin(sphere.w / distance);};/** The direction of the ray through `pixel` of a march `size` pixels wide. */const rayAt = (pixel: d.v2f, size: d.v2f) => { 'use gpu'; const uv = pixel / size; return cameras.rayOf(cameras.current.$, d.vec2f(uv.x, 1 - uv.y) * 2 - 1).direction;};/** * The nearest of the props and critters set in `masks`, x for props and y for critters, along a * ray before `limit`, or index -1. Each one the ray passes marches alone within its bounding * sphere, nearest entry first, until the next enters past a hit. */const nearestIn = (eye: d.v3f, direction: d.v3f, limit: number, masks: d.v2u) => { 'use gpu'; const count = d.i32(Prop.span()); let shown = d.u32(masks.x); let props = d.u32(0); while (shown !== 0) { const k = d.i32(std.countTrailingZeros(shown)); shown = shown & (shown - 1); if (k >= count || !shownProp(k)) continue; const span = spanOf(eye, direction, boundOf(k)); if (span.y <= 0 || span.x > limit) continue; props = props | (d.u32(1) << d.u32(k)); } let near = d.u32(masks.y); let crowd = d.u32(0); while (near !== 0) { const c = d.i32(std.countTrailingZeros(near)); near = near & (near - 1); if (c >= CRITTERS) continue; const bound = Critter.bound(c); if (bound.w < 0.001) continue; const span = spanOf(eye, direction, bound); if (span.y <= 0 || span.x > limit) continue; crowd = crowd | (d.u32(1) << d.u32(c)); } const nearest = nearestProp(eye, direction, count, props, Found({ t: limit, index: -1 })); return nearestCritter(eye, direction, d.i32(CRITTERS), crowd, nearest);};/** * Bubbles in the young jelly `depth` behind the front and stars twinkling on its `fresh` lip, as * brightness at `p`, with `soft` a pixel's width on the floor. */const glintsAt = (p: d.v3f, depth: number, soft: number, fresh: number, time: number) => { 'use gpu'; if (depth >= BUBBLE.reach) return d.f32(0); let sparkle = d.f32(0); const fade = 1 - depth / BUBBLE.reach; const cell = std.floor(p.xz / BUBBLE.cell); randf.seed2(cell + 3.3); if (randf.sample() < BUBBLE.share) { const jitter = d.vec2f(randf.sample(), randf.sample()) - 0.5; const local = p.xz - (cell + 0.5 + jitter * 0.5) * BUBBLE.cell; const size = BUBBLE.size * (0.5 + randf.sample()); const ring = std.smoothstep(soft, -soft, std.abs(std.length(local) - size) - size * 0.2); const glint = std.smoothstep(size * 0.45, 0, std.length(local + size * 0.4)); sparkle += (ring * 0.3 + glint * 0.7) * fade; } // Sparkles twinkle on the fresh jelly, each in its own time const spot = std.floor(p.xz / SPARKLE.cell); randf.seed2(spot + 5.9); const phase = randf.sample() * 6.28; const jitter = d.vec2f(randf.sample(), randf.sample()) - 0.5; const local = std.abs(p.xz - (spot + 0.5 + jitter * 0.6) * SPARKLE.cell); const twinkle = std.max(0, std.sin(time * 2.2 + phase)) ** 6 * fresh; const size = SPARKLE.size * twinkle + 1e-5; const star = std.max( 1 - local.x / size - local.y / (size * 0.25), 1 - local.y / size - local.x / (size * 0.25), ); sparkle += std.saturate(star) * 1.2 + std.smoothstep(size * 0.3, 0, std.length(local)); // Later random draws in this pixel stay different every frame randf.seed3(d.vec3f(p.xz, time)); return sparkle;};export interface LandOptions { /** The camera the march sees through, whose tiles list what they may show. */ readonly camera: CameraBase; /** The jelly world's props, from `createWorld`, whose shadows the land bakes. */ readonly world: Injectable; /** The critters, from `createCritters`, which stand on it. */ readonly critters: Injectable; readonly now: TgpuUniform<typeof Moment>; /** The light, whose direction the props' shadows are baked along. */ readonly look: TgpuUniform<typeof Look>;}/** How far back whatever `find` found was when the last frame was drawn. Props stand still. */const moved = (index: number) => { 'use gpu'; if (index >= FIRST_CRITTER) return Critter.moved(index - FIRST_CRITTER).xyz; return d.vec3f();};/** * The jelly world on the tray: finding and shading its props and critters, and the lawn they stand * on, as the jelly's scenery. Pipelines that draw it fill perlin2d's cache. */export function createLand(root: TgpuRoot, options: LandOptions) { const { camera, world, critters, now, look } = options; const shadows = root .createTexture({ size: [SHADED.width, SHADED.height], format: 'rgba8unorm' }) .$usage('storage', 'sampled'); const shadowsRead = shadows.createView(d.texture2d(d.f32)); const sampler = root.createSampler({ magFilter: 'linear', minFilter: 'linear' }); const tiles = root.createBuffer(d.arrayOf(d.vec2u, TILES)).$usage('storage'); const tilesWritten = tiles.as('mutable'); const tilesRead = tiles.as('readonly'); const grid = root.createUniform(Grid); const measured = { width: 0, height: 0 }; /** The nearest prop or critter along a ray through `pixel` before `limit`, or index -1. */ const find = (eye: d.v3f, direction: d.v3f, limit: number, pixel: d.v2f) => { 'use gpu'; const tile = d.vec2u(pixel) / TILE; const index = tile.y * grid.$.tiles.x + tile.x; let masks = d.vec2u(0xffffffff); if (tile.x < grid.$.tiles.x && index < TILES) masks = d.vec2u(tilesRead.$[index]!); return nearestIn(eye, direction, limit, masks); }; /** The colour of whatever `find` found. */ const shade = (p: d.v3f, direction: d.v3f, index: number, light: d.v3f) => { 'use gpu'; if (index >= FIRST_CRITTER) { return shadeCritter(p, direction, index - FIRST_CRITTER, light, now.$.time); } return shadeProp(p, direction, index, light); }; /** How far into the jelly world a point is: positive behind the front. */ const behind = (p: d.v3f) => { 'use gpu'; const ahead = now.$.front - p.x; if (std.abs(ahead) > LAWN.settled) return ahead; const at = p.xz * 1.8 + d.vec2f(0, now.$.time * 0.15); const ragged = perlin2d.sample(at) + perlin2d.sample(at * 2.3 + 5.1) * 0.3; return ahead + ragged * LAWN.ragged; }; /** The props' baked shadows on the floor under `p`, as `castAt` gives them. */ const shadowAt = (p: d.v3f) => { 'use gpu'; const uv = d.vec2f( (p.x - SHADED.left) / (SHADED.right - SHADED.left), (p.z - SHADED.back) / (SHADED.front - SHADED.back), ); if (std.any(std.lt(uv, d.vec2f())) || std.any(std.gt(uv, d.vec2f(1)))) return d.vec4f(1); return std.textureSampleLevel(shadowsRead.$, sampler.$, uv, 0); }; /** * The floor at a point near or behind the front: the jelly lawn with its flowers and gumdrops, * the lip where it meets the paper with bubbles and sparkles in it, and the bright line it * focuses onto `printed`. `soft` is a pixel's width on the floor, `ink` the label's cover and * `sunlit` the slider's shadow. */ const lawn = ( p: d.v3f, normal: d.v3f, direction: d.v3f, light: d.v3f, depth: number, printed: d.v3f, ink: number, soft: number, sunlit: d.v3f, ) => { 'use gpu'; const time = now.$.time; let outward = d.vec2f(-1, 0); if (std.abs(depth) < LIP.width) { const slope = d.vec2f(behind(p + d.vec3f(0.01, 0, 0)), behind(p + d.vec3f(0, 0, 0.01))) - depth; outward = std.normalize(slope * -1 + d.vec2f(-1e-6, 0)); } const lens = std.saturate(std.dot(outward, std.normalize(light.xz)) * 0.6 + 0.4); // `**` compiles to `pow`, which is NaN for a negative base on most GPUs const off = (depth + LIP.caustic) / (LIP.caustic * 0.55); const line = std.exp(-off * off); const near = depth / 0.007; const pinch = std.exp(-near * near); const paper = printed * std.mix(d.vec3f(1), LAWN.color * 0.7, pinch * 0.45) + LAWN.color * LAWN.color * (line * lens * 0.9); if (depth < -soft) return paper; const at = p.xz * 5 + d.vec2f(time * 0.4, time * 0.3); const here = perlin2d.sample(at); const wobble = d.vec2f( here - perlin2d.sample(at + d.vec2f(0.15, 0)), here - perlin2d.sample(at + d.vec2f(0, 0.15)), ); const lip = (1 - std.saturate(depth / LIP.width)) ** 2; // Light bends away inside the edge, which shows as a deeper band const inside = (depth - LIP.width * 0.3) / (LIP.width * 0.22); const band = std.exp(-inside * inside); // Broad swells that roll the light across the lawn, and lighter meadows on their tops const broad = p.xz * 1.1 + 11.7; const swell = perlin2d.sample(broad); const roll = d.vec2f( swell - perlin2d.sample(broad + d.vec2f(0.2, 0)), swell - perlin2d.sample(broad + d.vec2f(0, 0.2)), ); let base = std.mix(LAWN.color, LAWN.meadow, std.smoothstep(0.35, 0.85, swell * 0.5 + 0.5)); // Fresh jelly just behind the front is brighter, and fades to the lawn's own colour const fresh = std.exp(-depth / LAWN.fresh); base = std.mix(base, LAWN.glow, fresh * 0.5); let tilt = wobble * LAWN.wobble + roll * LAWN.swell + outward * (LIP.tilt * lip); let dimmed = d.f32(1); if (p.y < 0) { base = d.vec3f(LAWN.slot); } else if (normal.y > 0.95) { const flower = flowerAt(p.xz, soft, depth); const drop = dropAt(p.xz, soft, depth); const stone = stoneAt(p.xz, soft, depth); base = std.mix(base, flower.color, flower.cover); base = std.mix(base, drop.color, drop.cover); base = std.mix(base, stone.color, stone.cover); tilt = std.mix(tilt + flower.tilt + drop.tilt, stone.tilt, stone.cover); dimmed = flower.shade * drop.shade * stone.shade; } const sparkle = glintsAt(p, depth, soft, fresh, time); const bent = std.normalize(normal + d.vec3f(tilt.x, 0, tilt.y)); const cover = shadowAt(p); const top = p.y > 0; const thick = std.select(0.25, 0.2 + band * 0.6, top); // The slot's floor sits in the slider's shade already, so only the tray's top takes it const sun = cover.xyz * std.select(d.vec3f(1), std.mix(d.vec3f(1), sunlit, 0.5), top); // The lip is fresh and wet, glossier than the lawn behind it const finish = std.mix(LAWN.finish, d.vec2f(1, LAWN.finish.y), lip); const lit = jelly(base, bent, direction, light, thick, sun, finish) * (cover.w * dimmed); const printedOn = std.mix(lit, lit * 0.45, ink) + d.vec3f(1, 0.97, 0.9) * sparkle; // Paper shows through where the young jelly is thinnest const seen = printed * std.mix(d.vec3f(1), base * 1.1, 0.6); const thin = 1 - std.saturate(depth / (LIP.width * 0.2)); const grown = std.mix(printedOn, seen, LIP.clear * thin); return std.mix(paper, grown, std.smoothstep(-soft, soft, depth)); }; /** The lawn behind the front, and the critters' shadows on the floor. */ const ground = (point: d.Infer<typeof Floor>, light: d.v3f) => { 'use gpu'; const depth = behind(point.at); const under = underCritters(point.at); if (depth < -0.04) { // The bare tray takes the shadows of what stands on it, such as the lever const cover = shadowAt(point.at); return point.color * cover.xyz * (cover.w * under); } const covered = lawn( point.shaken, point.normal, point.direction, light, depth, point.color, point.ink, point.soft, point.shadow, ); const sheltered = std.select(1, std.mix(1, point.open, 0.6), point.shaken.y > 0); return (covered * sheltered + point.bounce * (0.5 * std.smoothstep(0, 0.02, depth))) * under; }; const scenery: Scenery = { find: tgpu.fn(find).pipe(world.inject()).pipe(critters.inject()), shade: tgpu.fn(shade).pipe(world.inject()).pipe(critters.inject()), moved: tgpu.fn(moved).pipe(critters.inject()), glimpse: tgpu.fn(glimpse).pipe(critters.inject()), ground: tgpu.fn(ground).pipe(critters.inject()), }; const cast = (uv: d.v2f) => { 'use gpu'; const p = d.vec3f( std.mix(SHADED.left, SHADED.right, uv.x), GROUND, std.mix(SHADED.back, SHADED.front, uv.y), ); return castAt(p, look.$.light); }; const baking = baker.create(root, shadows, { texel: tgpu.fn(cast).pipe(world.inject()), }); const cullTiles = tgpu.computeFn({ workgroupSize: [CULL_GROUP, CULL_GROUP], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; const across = grid.$.tiles; if (gid.x >= across.x || gid.y >= across.y) return; const index = gid.y * across.x + gid.x; if (index >= TILES) return; const size = grid.$.size; // A pixel's margin round the tile covers TAA's jitter const low = d.vec2f(gid.xy) * TILE - 1; const high = d.vec2f(gid.xy + 1) * TILE + 1; const a = rayAt(low, size); const b = rayAt(d.vec2f(high.x, low.y), size); const c = rayAt(d.vec2f(low.x, high.y), size); const e = rayAt(high, size); const axis = std.normalize(a + b + c + e); const nearest = std.min( std.min(std.dot(axis, a), std.dot(axis, b)), std.min(std.dot(axis, c), std.dot(axis, e)), ); const spread = std.acos(std.clamp(nearest, -1, 1)); const eye = cameras.rayOf(cameras.current.$, d.vec2f()).origin; let props = d.u32(0); const count = d.i32(Prop.span()); for (let k = 0; k < count; k++) { if (!shownProp(k)) continue; if (inCone(boundOf(k), eye, axis, spread)) props = props | (d.u32(1) << d.u32(k)); } let crowd = d.u32(0); for (let k = 0; k < CRITTERS; k++) { const bound = Critter.bound(k); if (bound.w < 0.001) continue; if (inCone(bound, eye, axis, spread)) crowd = crowd | (d.u32(1) << d.u32(k)); } tilesWritten.$[index] = d.vec2u(props, crowd); }); const culling = root .pipe(camera.inject()) .pipe(world.inject()) .pipe(critters.inject()) .createComputePipeline({ compute: cullTiles }); return { scenery, initAsync: () => Promise.all([baking.initAsync(), culling.initAsync()]), /** Bakes the props' shadows, after they grow or the light turns. */ bake(pass: TgpuComputePass) { baking.record(pass); }, /** * Lists what each tile of a march `width` by `height` pixels may show, after the props and * critters move. */ cull(pass: TgpuComputePass, width: number, height: number) { if (width !== measured.width || height !== measured.height) { measured.width = width; measured.height = height; grid.write({ size: d.vec2f(width, height), tiles: d.vec2u(Math.ceil(width / TILE), Math.ceil(height / TILE)), }); } pass.setPipeline(culling); pass.dispatchWorkgroups( Math.ceil(width / TILE / CULL_GROUP), Math.ceil(height / TILE / CULL_GROUP), ); }, destroy() { shadows.destroy(); tiles.destroy(); }, };}world.ts
import { clock, table } from '@typegpu/kit';import { std, type TgpuRoot } from 'typegpu';import { ARM, BUSH, GUMDROP, HOUSE, LEVER, LOLLY, MUSHROOM, PIVOT, Prop, PUFF, TREE,} from './props.ts';import { missOf } from './slider.ts';import { GROUND, type V3 } from './tray.ts';/** Pastel jelly colours in display values. */const PALETTE = [ [1, 0.5, 0.66], [0.5, 0.8, 1], [1, 0.82, 0.36], [0.72, 0.58, 1], [0.3, 0.86, 0.6], [1, 0.6, 0.42],] as const;/** Trees as x, z, size, palette index and what they grow into, set back from the slot and beside it. */const TREES = [ [-1.45, -0.6, 1.1, 4, GUMDROP], [-0.95, -0.85, 0.9, 0, LOLLY], [-0.2, -1.25, 1.3, 4, PUFF], [0.5, -0.75, 0.85, 3, MUSHROOM], [1.3, -1.0, 1.2, 4, PUFF], [1.9, -0.5, 0.9, 5, BUSH], [-2.0, -1.15, 1.25, 2, PUFF], [2.3, -1.4, 1.3, 3, GUMDROP], [-1.5, 0.55, 0.75, 4, BUSH], [1.6, 0.62, 0.8, 0, MUSHROOM],] as const;/** Over a tree's size, how far critters keep from its middle by what it grew into. */const FOOTPRINT = [0.03, 0.02, 0.05, 0.05, 0.12] as const;/** Houses as x, z, heading, size and palette index. */const HOUSES = [ [-0.5, -0.62, 0.35, 1, 0], [1.0, -0.55, -0.4, 0.95, 2], [-1.6, -1.35, 0.2, 1.1, 1], [0.65, -1.4, -0.1, 1.2, 5], [1.75, 0.05, -0.9, 0.85, 3],] as const;/** * The lever by the slot's free end: where it stands, its colour, the angles of its arm at rest, * pulled all the way and where it sends the jelly over, how far back it must fall before it sends * it again and how far past rest it swings, how near a pointer ray must pass the knob to grab it, * nearer while a critter hangs there, how fast it follows the pointer, the spring that throws it * back, and how fast a critter's weight drags it down. */const PULL = { at: [1.08, 0.5], palette: 1, rest: -0.2, full: 1.3, flips: 1.05, rearm: 0.5, overswing: 0.4, grip: [0.06, 0.035], follow: 25, stiffness: 110, damping: 5, hung: 3.4,} as const;/** The lever's block squashing as it sends the jelly over: the kick, its spring and the most. */const KICK = { push: 4, stiffness: 200, damping: 9, most: 0.3 };/** Each kind's size over its modelled size. */const SCALE = { tree: 1.7, house: 1.6 };/** * Spring stiffness and damping of growing, which overshoots into a wobble, the most the front * passes a prop by in world x before it grows, so neighbours grow apart, and how much a prop * squashes by its growth's speed, at most `most`. */const GROW = { stiffness: 140, damping: 7, stagger: 0.12, squash: 0.08, most: 0.4 };/** How far tree tops sway, and how fast in radians a second. */const SWAY = { reach: 0.06, rate: 2 };/** Trees and houses as they stand, each with the radius critters keep from its middle. */const PLANTED = [ ...TREES.map(([x, z, size, colour, variant], i) => ({ x, z, heading: i * 1.7, size: size * SCALE.tree, kind: TREE, variant, colour, radius: FOOTPRINT[variant] * size * SCALE.tree, })), ...HOUSES.map(([x, z, heading, size, colour]) => ({ x, z, heading, size: size * SCALE.house, kind: HOUSE, variant: PUFF, colour, radius: 0.12 * size * SCALE.house, })),];/** The jelly world's trees and houses, grown by a front sweeping along x. */export function createWorld(root: TgpuRoot) { const props = table.create(root, Prop, { capacity: PLANTED.length + 1 }); const fields = props.fields; /** What grows behind the front, each a little after the last, on a spring. */ const growing = PLANTED.map(({ x, z, heading, size, kind, variant, colour }, i) => ({ id: props.add({ at: [x, z], heading, size, kind, variant, color: PALETTE[colour] }), x, heading, after: ((i * 0.618) % 1) * GROW.stagger, grow: clock.spring(GROW), })); const [leverX, leverZ] = PULL.at; const lever = props.add({ at: [leverX, leverZ], size: 1, kind: LEVER, grown: 1, color: PALETTE[PULL.palette], }); const obstacles = [...PLANTED, { x: leverX, z: leverZ, radius: 0.08 }]; /** The lever's arm angle on its spring, and where the pointer pulls it. */ const arm = clock.spring({ ...PULL, value: PULL.rest }); let wanted: number = PULL.rest; let grabbed = false; let hung = false; /** Whether the lever sends the jelly over the next time it is pulled, and its block's squash. */ let armed = true; const block = clock.spring(KICK); let moving = true; let elapsed = 0; const at: V3 = [0, 0, 0]; const knob = () => { at[0] = leverX; at[1] = GROUND + PIVOT + Math.cos(arm.value) * ARM; at[2] = leverZ + Math.sin(arm.value) * ARM; return at; }; return { /** Where trees, houses and the lever stand, for critters to walk around. */ obstacles, /** Takes hold of the lever if a pointer ray passes its knob. */ grab(origin: V3, direction: V3) { grabbed = missOf(origin, direction, knob()) < PULL.grip[hung ? 1 : 0]; return grabbed; }, /** Pulls the held lever towards where a pointer ray crosses the plane it swings in. */ pull(origin: V3, direction: V3) { if (!grabbed || Math.abs(direction[0]) < 1e-4) return; const t = (leverX - origin[0]) / direction[0]; const y = origin[1] + direction[1] * t - GROUND - PIVOT; const z = origin[2] + direction[2] * t - leverZ; wanted = std.clamp(Math.atan2(z, y), PULL.rest, PULL.full); }, release() { grabbed = false; }, /** Whether the pointer holds the lever. */ get holding() { return grabbed; }, /** Where the lever's knob is, for a critter to hang on. */ get knob() { return knob(); }, /** How far the lever is pulled, from 0 at rest to 1 all the way. */ get pulled() { return (arm.value - PULL.rest) / (PULL.full - PULL.rest); }, /** Hangs a critter's weight on the knob, which drags the arm down unless the pointer holds it. */ hang(on: boolean) { hung = on; }, /** Whether a prop grew or squashed in the last `update`. */ get moving() { return moving; }, /** * Grows what the front at `front` in world x has passed and swings the lever. Returns true on * the frame the lever is pulled far enough to send the jelly over. */ update(dt: number, front: number) { const step = Math.min(dt, 1 / 30); moving = false; // The first frame's shadows are baked from the table, so it is written even without time if (step <= 0) { props.flush(); return false; } // The pointer or a hanging critter takes the arm where it goes, else the spring throws it back if (grabbed || hung) { const before = arm.value; const to = grabbed ? wanted : PULL.full; arm.value += (to - arm.value) * clock.ease(step, grabbed ? PULL.follow : PULL.hung); arm.velocity = (arm.value - before) / step; } else { arm.value = std.clamp(arm.step(PULL.rest, step), PULL.rest - PULL.overswing, PULL.full); } const flipped = armed && arm.value > PULL.flips; if (flipped) { armed = false; block.velocity -= KICK.push; } if (arm.value < PULL.flips - PULL.rearm) armed = true; const squash = block.step(0, step); moving = Math.abs(squash) + Math.abs(block.velocity) > 1e-4; fields.squash.set(lever, std.clamp(squash, -KICK.most, KICK.most)); fields.angle.set(lever, arm.value); elapsed += step; for (const prop of growing) { const target = prop.x + prop.after < front ? 1 : 0; const { grow } = prop; grow.value = Math.max(0, grow.step(target, step)); if (Math.abs(grow.velocity) + Math.abs(target - grow.value) > 1e-4) moving = true; fields.grown.set(prop.id, grow.value); fields.squash.set(prop.id, std.clamp(grow.velocity * GROW.squash, -GROW.most, GROW.most)); fields.sway.set(prop.id, Math.sin(elapsed * SWAY.rate + prop.heading) * SWAY.reach); } props.flush(); return flipped; }, /** Fills the props' table for the pipelines that draw them. */ inject: () => props.inject(), destroy() { props.destroy(); }, };}export type World = ReturnType<typeof createWorld>;critters.ts
import { table, type Inject } from '@typegpu/kit';import { d, type TgpuRoot } from 'typegpu';import { vec3n } from 'wgpu-matrix';import { CRITTERS, FALL, createBody, ease, HERALD, jump, LAND, R, random, STANDING, UP, type Body, type Crowd, type Ground,} from './body.ts';import { Critter, nearJelly } from './figures.ts';import { carry, collide, fly, hang, perch, ride } from './moves.ts';import { blinks, createFigure, figureOf, gait, moods, rollUpright, springs, type Figure, type Sights,} from './poses.ts';import { BOX, POINTS, STEP } from './slider.ts';import { spineLength } from './spine.ts';import { GROUND, type V3 } from './tray.ts';import { walk } from './walking.ts';/** How near the jelly's bounds a critter's bounding sphere comes for the jelly to show it. */const SHOWN = 0.35;/** The lowest a held critter goes over the ground, and how far picking lifts it to the camera. */const HELD = { floor: 0.1, lift: 0.06 };/** The longest a tap may last and the slowest it may move a critter, to make it flip. */const TAP = { time: 0.25, speed: 0.4 };/** Fixed steps of the jelly's motion riders move with, a sixtieth of a second. */const FELT = 2;/** Whether a bounding sphere comes near enough the jelly for it to show in or through it. */const nearJellyBox = (bound: Figure['bound']) => { const reach = bound[3] + SHOWN; return ( bound[0] > BOX.left - reach && bound[0] < BOX.right + reach && bound[1] < BOX.top + reach && Math.abs(bound[2]) < BOX.z + reach );};// Scratch a critter's step writes its vectors intoconst was: V3 = [0, 0, 0];const accel: V3 = [0, 0, 0];const ahead: V3 = [0, 0, 0];const side: V3 = [0, 0, 0];const toward: V3 = [0, 0, 0];/** * Critters walking the jelly world, sliding down the jelly and carried by the pointer. Before the * world is revealed, one of them pops in and pulls the lever that reveals it. */export function createCritters(root: TgpuRoot, world: Ground) { const rows = table.create(root, Critter, { capacity: CRITTERS }); const near = root.createUniform(d.u32); let nearMask = 0; const bodies = Array.from({ length: CRITTERS }, (_, i) => createBody(i)); const ids = bodies.map(() => rows.add()); const figures = bodies.map(createFigure); const herald = bodies[0]!; /** The point the pointer hovers, while `watching`. */ const watched: V3 = [0, 0, 0]; let watching = false; let watchedFor = 0; let pointerMoved = 0; let pointerSpeed = 0; let held: Body | undefined; const holdAt: V3 = [0, GROUND, 0]; let depth = 0; /** The spine after each of its last `FELT` fixed steps and the one before, oldest first. */ const felt: Float32Array[] = []; /** Seconds before the herald pops in, and whether it still has the lever to pull. */ let heraldIn: number = HERALD.after; let heralding = true; const crowd: Crowd = { bodies, world, herald, errand: undefined, pointer: undefined, pointerSpeed: 0, front: 0, spine: new Float32Array(), }; const sights: Sights = { camera: [0, 0, 0], pointer: undefined, knob: [0, 0, 0] }; /** One critter's step: grown in, moved as its mode has it, its body sprung and its face eased. */ const move = (body: Body, step: number, before: Float32Array) => { const { front, spine } = crowd; vec3n.copy(body.velocity, was); // Falling bends nothing: the body falls as a whole, so its bend leaves gravity out if (body.mode === 'fly') was[1] -= FALL * step; const shown = body === herald && heraldIn <= 0; const target = shown || body.at[0] < front + 0.05 || body.mode === 'held' ? 1 : 0; body.grown = Math.max(0, body.growth.step(target, step)); body.dizzy -= step; // Thrown past the lawn and the front, it shrinks away there and pops back in on the lawn const lost = body.at[0] > Math.max(front, LAND.x[1]) + 0.05; if (body.mode === 'walk' && lost && body.grown < 0.05) { body.at[0] = LAND.x[1]; jump(body, 0.7, false); } if (body.mode === 'walk') walk(body, crowd, step); else if (body.mode === 'ride') ride(body, crowd, step, spine, before, FELT * STEP); else if (body.mode === 'fly') fly(body, crowd, step, spine); else if (body.mode === 'perch') perch(body, step); else if (body.mode === 'pull') { if (hang(body, crowd, step)) heralding = false; } else carry(body, holdAt, step, spine); if (body.mode !== 'fly' && body.spin) rollUpright(body, step); vec3n.set(Math.sin(body.heading), 0, Math.cos(body.heading), ahead); vec3n.normalize(vec3n.cross(UP, ahead, side), side); if (body.mode === 'walk' && body.asleep <= 0) gait(body, step, side); vec3n.scale(vec3n.sub(body.velocity, was, accel), 1 / step, accel); springs(body, step, accel, side, ahead, watching ? watched : undefined); moods(body, step); blinks(body, step); body.phase += step; }; return { /** The critters as last posed, for tests and tools that read them on the CPU. */ rows, /** Fills the critters' table, and which are near the jelly, for pipelines that draw them. */ inject: (): Inject => (cfg) => rows.inject()(cfg).with(nearJelly, near), /** The point on the ground the pointer hovers, which critters look at and come to see. */ watch(point: V3 | undefined) { if (point && watching) { pointerMoved += Math.hypot(point[0] - watched[0], point[2] - watched[2]); } if (point) vec3n.copy(point, watched); watching = point !== undefined; watchedFor = point ? 2.5 : 0; }, /** Picks up the critter nearest a pointer ray, if any is under it. */ pick(origin: V3, direction: V3) { let best = Infinity; for (const body of bodies) { if (body.grown < 0.5) continue; const centre = body.drawn; const along = vec3n.dot(vec3n.sub(centre, origin, toward), direction); const miss = vec3n.distance(vec3n.addScaled(origin, direction, along, toward), centre); if (miss < R * 2.2 && along < best) { best = along; held = body; } } depth = best - HELD.lift; if (held) { held.mode = 'held'; held.base = undefined; // Set down elsewhere, it no longer follows a line held.leader = undefined; held.asleep = 0; held.heldFor = 0; held.spinning = 0; held.knocked = false; vec3n.addScaled(held.drawn, UP, -STANDING, holdAt); } return held !== undefined; }, /** Moves the held critter along a pointer ray, as far from the camera as it was picked. */ hold(origin: V3, direction: V3) { if (!held) return; const centre = vec3n.addScaled(origin, direction, depth, toward); if (centre[1] < GROUND + HELD.floor && direction[1] < -1e-4) { vec3n.addScaled( origin, direction, (GROUND + HELD.floor - origin[1]) / direction[1], centre, ); } vec3n.addScaled(centre, UP, -STANDING, holdAt); }, /** Lets go of the held critter with the speed it was moving at; a quick tap makes it flip. */ drop() { if (!held) return; if (held.heldFor < TAP.time && vec3n.length(held.velocity) < TAP.speed) { jump(held, 0.95, true); held.joy = 1.2; } else { held.mode = 'fly'; vec3n.scale(held.velocity, 0.8, held.velocity); } held = undefined; }, get holding() { return held !== undefined; }, /** Makes every critter on the ground jump for joy, waking any that nap. */ hop() { for (const body of bodies) { if (body.mode !== 'walk' || body.grown < 0.5) continue; if (body.asleep > 0) body.shock = 1; else body.joy = 2; jump(body, random(0.6, 0.85), Math.random() < 0.3); } }, /** * Notes the spine after one of its fixed steps. Riders move with how it moved over the last * `FELT` steps, measured on its own clock, so the frame rate doesn't change how hard they're * thrown. */ feel(spine: Float32Array) { while (felt.length <= FELT) felt.push(spine.slice()); const oldest = felt.shift()!; oldest.set(spine); felt.push(oldest); }, update(dt: number, time: number, front: number, spine: Float32Array, camera: V3) { const step = Math.min(dt, 1 / 30); if (step <= 0) return; watchedFor -= step; pointerSpeed = ease(pointerSpeed, pointerMoved / step, 12, step); pointerMoved = 0; for (const body of bodies) body.load = 0; for (const body of bodies) if (body.mode === 'perch' && body.base) body.base.load += 1; // Pops in with a hop, and is done once the world is revealed past the lever heraldIn -= step; if (heraldIn <= 0 && heraldIn + step > 0) { jump(herald, 0.7, false); herald.joy = 1.5; } if (front > world.knob[0]) heralding = false; const pointer = watching && watchedFor > 0 ? watched : undefined; crowd.errand = heralding && heraldIn <= 0 ? herald : undefined; crowd.pointer = pointer; crowd.pointerSpeed = pointerSpeed; crowd.front = front; crowd.spine = spine; const before = felt[0] ?? spine; for (const body of bodies) move(body, step, before); collide(bodies, step); // Its weight drags the lever down only while it hangs there, however it stopped world.hang(herald.mode === 'pull' && herald.pulling >= HERALD.leap); // Critters near the jelly, which it shows in and through it vec3n.copy(camera, sights.camera); vec3n.copy(world.knob, sights.knob); sights.pointer = pointer; let mask = 0; for (let i = 0; i < CRITTERS; i++) { const { body, moved } = figures[i]!; // A critter that was not drawn last frame has nowhere to move from const shown = body[3] > 0; vec3n.copy(body, moved); const figure = figureOf(bodies[i]!, i, time, step, spine, sights, figures[i]!); if (shown) vec3n.sub(moved, body, moved); else vec3n.zero(moved); rows.write(ids[i]!, figure); if (nearJellyBox(figure.bound)) mask |= 1 << i; } rows.flush(); if (mask !== nearMask) near.write(mask); nearMask = mask; }, /** How much each spine point is pressed down by riders, for the slider. */ weigh(loads: Float32Array, spine: Float32Array) { loads.fill(0); const total = spineLength(spine); for (const body of bodies) { if (body.mode !== 'ride') continue; const at = (body.s / total) * (POINTS - 1); const i = Math.floor(at); const t = at - i; if (i > 0 && i < POINTS - 1) loads[i]! += 1 - t; if (i + 1 > 0 && i + 1 < POINTS - 1) loads[i + 1]! += t; } }, destroy() { rows.destroy(); }, };}camera.ts
import { cameras, type Surface } from '@typegpu/kit';import { std, type TgpuRoot } from 'typegpu';import { MIDDLE } from './tray.ts';/** * The camera's poses over the bare tray and in the revealed world: its pitch and its target's * offset in x and z from the tray's middle, as pairs for views `aspects[1]` wide or wider and * `aspects[0]` or narrower, blended between. A pose backs off to fit `across` to the width, no * closer than `nearest`. The host's orbit turns the view within `pitches` and zooms it within * `distances`, or out to the bare tray's distance where that is farther. */const VIEW = { bare: { pitch: [0.958, 0.958], x: [0, 0.08], z: [0, 0], across: 3.2, nearest: 2.9 }, world: { pitch: [0.55, 0.9], x: [0, 0], z: [0, -0.5], across: 2.7, nearest: 1.5 }, aspects: [0.5, 1.3], fov: Math.PI / 4, pitches: [0.08, 1.45], distances: [0.8, 6],} as const;/** Seconds the view takes to lower into the revealed world. */const DESCENT = 2.4;/** A camera pose: its pitch, distance and its target's offset from the tray's middle. */interface Pose { pitch: number; distance: number; x: number; z: number;}const createPose = (): Pose => ({ pitch: 0, distance: 0, x: 0, z: 0 });const mix = (from: number, to: number, t: number) => from + (to - from) * t;/** Writes `pose` as fitted to a view `aspect` wide to 1 high, `portrait` of the way upright. */function fit(pose: (typeof VIEW)['bare' | 'world'], aspect: number, portrait: number, out: Pose) { out.pitch = mix(pose.pitch[0], pose.pitch[1], portrait); out.distance = Math.max(pose.nearest, pose.across / aspect); out.x = mix(pose.x[0], pose.x[1], portrait); out.z = mix(pose.z[0], pose.z[1], portrait);}/** Writes the poses over the bare tray and in the revealed world for a view `aspect` wide. */function framing(aspect: number, out: { bare: Pose; world: Pose }) { const portrait = 1 - std.smoothstep(VIEW.aspects[0], VIEW.aspects[1], aspect); fit(VIEW.bare, aspect, portrait, out.bare); fit(VIEW.world, aspect, portrait, out.world); return out;}/** * The orbit camera over the tray. Until the host's orbit turns or zooms it, it follows the framing, * so it refits as the surface turns or resizes, and lowers into the jelly world once that is * revealed. */export function createCamera(root: TgpuRoot, surface: Surface) { const aspect = () => surface.width / surface.height; const poses = { bare: createPose(), world: createPose() }; const opening = framing(aspect(), poses).bare; const view = cameras.orbit(root, { size: surface, target: [MIDDLE[0] + opening.x, MIDDLE[1], MIDDLE[2] + opening.z], distance: opening.distance, pitch: opening.pitch, fov: VIEW.fov, near: 0.1, far: 20, pitchRange: VIEW.pitches, distanceRange: [VIEW.distances[0], Infinity], }); /** Whether the view still follows the framing, and how far it has lowered into the world. */ let framed = true; let descent = 0; /** The goal as the framing last left it, to tell when the host's orbit moved it. */ const left = { yaw: view.goal.yaw, pitch: view.goal.pitch, distance: view.goal.distance }; return { view, /** * Follows the framing, lowering into the world while it is `revealed`, until the host's orbit * moves the goal. Then keeps its distance within `distances` or the bare framing. */ follow(dt: number, revealed: boolean) { const pose = view.goal; framed &&= pose.yaw === left.yaw && pose.pitch === left.pitch && pose.distance === left.distance; if (!framed) { const farthest = Math.max(VIEW.distances[1], framing(aspect(), poses).bare.distance); pose.distance = Math.min(pose.distance, farthest); return; } if (revealed) descent = Math.min(1, descent + dt / DESCENT); const k = std.smoothstep(0, 1, descent); const { bare, world } = framing(aspect(), poses); pose.pitch = left.pitch = mix(bare.pitch, world.pitch, k); pose.distance = left.distance = mix(bare.distance, world.distance, k); pose.target[0] = MIDDLE[0] + mix(bare.x, world.x, k); pose.target[2] = MIDDLE[2] + mix(bare.z, world.z, k); }, };}tray.ts
/** Height of the tray's top, where the jelly world stands. */export const GROUND = 0.03;/** Half the length and width of the slot the jelly lies in, and the height of its floor. */export const SLOT = { x: 1.02, z: 0.22, floor: -0.06 } as const;/** The middle of the tray, which the view turns round. */export const MIDDLE = [0, 0.05, 0] as const;/** A point or direction in world units, which the CPU's vector math writes in place. */export type V3 = [number, number, number];backdrop.ts
import { perlin2d } from '@typegpu/noise';import { d, std } from 'typegpu';import { MIDDLE } from './tray.ts';/** * The sky's colour overhead and at the horizon, past white so the tone curve keeps it bright, and * how much of it the far tray's haze and glossy surfaces take. */const SKY = { zenith: d.vec3f(0.55, 0.78, 1.4), horizon: d.vec3f(1.3, 1.16, 1.05), glow: d.vec3f(1, 0.72, 0.5), haze: 0.85, mirrored: 0.72,};/** Distant gumdrop hills: their heights over the horizon, as sines of elevation, and colours. */const HILLS = { far: d.vec2f(0.035, 0.09), near: d.vec2f(0.01, 0.07), farColor: d.vec3f(0.78, 0.7, 1.25), nearColor: d.vec3f(0.52, 0.95, 0.66),};/** * Where the fog starts, in world units along a ray past the eye's distance to the tray's middle, * and how fast it thickens. */const FOG = { start: 1.68, density: 0.32 };/** The sky's gradient by how far up a direction points. */const skyTone = (up: number) => { 'use gpu'; return std.mix(SKY.horizon, SKY.zenith, std.saturate(up) ** 0.5);};/** The sky as glossy surfaces mirror it, kept warm near the horizon so grazing gloss stays clean. */export const mirrored = (up: number) => { 'use gpu'; return std.mix(SKY.horizon, SKY.zenith, std.saturate(up)) * SKY.mirrored;};/** The pastel sky seen along `direction`: clouds, a warm glow round the sun and gumdrop hills. */export const sky = (direction: d.v3f, toLight: d.v3f, time: number) => { 'use gpu'; const up = direction.y; let color = skyTone(up); const sunward = std.saturate(std.dot(direction, toLight)); color += SKY.glow * (sunward ** 6 * 0.35 + sunward ** 64 * 0.6); const flat = direction.xz / (up + 0.15); const at = flat * 0.9 + d.vec2f(time * 0.02, 0); const puff = perlin2d.sample(at) + perlin2d.sample(at * 2.3 + 3.7) * 0.45; const cloud = std.smoothstep(0.1, 0.4, puff) * std.smoothstep(0.01, 0.12, up); const lit = std.mix(d.vec3f(1.05, 1, 1.3), d.vec3f(1.6, 1.55, 1.5), std.saturate(puff * 1.6)); color = std.mix(color, lit, cloud * 0.85); // Heights from the direction round the horizon, so no seam shows behind the view; folding the // noise at zero rounds the crests and creases the dips between them const round = std.normalize(direction.xz); const far = HILLS.far.x + HILLS.far.y * std.abs(perlin2d.sample(round * 4 + 9.1)); const near = HILLS.near.x + HILLS.near.y * std.abs(perlin2d.sample(round * 6.5 + 1.3)); // Each range is clearest at its crest and sinks into the haze at the horizon if (up < far) color = std.mix(SKY.horizon, HILLS.farColor, std.saturate(up / far) * 0.9); if (up < near) color = std.mix(SKY.horizon, HILLS.nearColor, std.saturate(up / near) * 0.95); return color;};/** Fades a colour seen `distance` away from `eye` along `direction` into the horizon's haze. */export const fog = (color: d.v3f, eye: d.v3f, direction: d.v3f, distance: number) => { 'use gpu'; const past = distance - std.length(eye - d.vec3f(MIDDLE[0], MIDDLE[1], MIDDLE[2])); const amount = 1 - std.exp(-std.max(past - FOG.start, 0) * FOG.density); return std.mix(color, skyTone(direction.y * 0.5) * SKY.haze, amount);};candy.ts
import { d, std } from 'typegpu';import { mirrored as mirror } from './backdrop.ts';/** * Ray-march steps through each prop and critter, the distance that counts as a hit, and how much * that distance grows per unit along the ray. */export const MARCH = { steps: 56, hit: 0.0003, cone: 0.0005 };/** Sunlight, the sky's cool fill and the warm bounce off the lawn, in display values. */const LIGHT = { sun: d.vec3f(1.2, 1.04, 0.86), fill: d.vec3f(0.7, 0.86, 1.05), bounce: d.vec3f(0.85, 0.9, 0.6),};/** A soft window light high behind the view, for the broad highlight on round candy and the jelly. */export const SOFTBOX = d.vec3f(-0.3, 0.85, 0.43);/** How fast jelly deepens to its own colour with thickness, and how much sun shows through. */const GUMMY = { density: 2.4, through: 1.6 };/** Candy colours for icing, flowers and gumdrops. */export const CANDY = { white: d.vec3f(1, 0.97, 0.94), pink: d.vec3f(1, 0.5, 0.68), lilac: d.vec3f(0.75, 0.6, 1), butter: d.vec3f(1, 0.86, 0.35), sky: d.vec3f(0.45, 0.78, 1), middle: d.vec3f(1, 0.75, 0.2), cherry: d.vec3f(1, 0.25, 0.38),};export const CREAM = d.vec3f(1, 0.93, 0.82);export const INK = d.vec3f(0.03, 0.02, 0.05);/** What a point of a prop or critter is made of. */export const BODY = 0;export const TRUNK = 1;export const LINE = 2;export const EYE = 3;export const ROOF = 4;/** An antenna's ball and a sprout's leaf. */export const TIP = 10;export const LEAF = 11;export const Surface = d.struct({ distance: d.f32, material: d.i32 });/** Where a ray enters and leaves a sphere in xyz with radius w, or a leave at or below 0. */export const spanOf = (eye: d.v3f, direction: d.v3f, sphere: d.v4f) => { 'use gpu'; const from = eye - sphere.xyz; const b = std.dot(from, direction); const disc = b * b - std.dot(from, from) + sphere.w * sphere.w; if (disc <= 0) return d.vec2f(0, -1); const half = std.sqrt(disc); return d.vec2f(std.max(-b - half, 0), -b + half);};/** The normal of shape `i` of an indexed distance field, from four samples `step` apart. */export const shapeNormal = (distance: (p: d.v3f, i: number) => number, step: number) => { const fourTapNormal = (p: d.v3f, i: number) => { 'use gpu'; const k = d.vec3f(1, -1, 0) * step; return std.normalize( k.xyy * distance(p + k.xyy, i) + k.yyx * distance(p + k.yyx, i) + k.yxy * distance(p + k.yxy, i) + k.xxx * distance(p + k.xxx, i), ); }; return fourTapNormal;};/** * Pastel jelly lit by `light` travelling that way and seen along `direction`. It deepens to a * saturated core where `thick` nears 1, glows where the sun shines through thin parts, and * `sunlit` tints the sun where something shades it. `finish` scales its gloss in x and the sun * shining through it in y. */export const jelly = ( base: d.v3f, normal: d.v3f, direction: d.v3f, light: d.v3f, thick: number, sunlit: d.v3f, finish: d.v2f,) => { 'use gpu'; const toLight = light * -1; const facing = std.saturate(-std.dot(normal, direction)); const deep = base * std.exp((base - 1) * (GUMMY.density * thick)); const wrap = std.saturate(std.dot(normal, toLight) * 0.55 + 0.45); const ambient = std.mix(LIGHT.bounce, LIGHT.fill, normal.y * 0.5 + 0.5); const scattered = std.saturate(std.dot(direction * -1, std.normalize(light - normal * 0.4))) ** 2; const sun = LIGHT.sun * sunlit; const through = base * base * (scattered * (1.2 - thick) * GUMMY.through * finish.y); const body = deep * (ambient * 0.62 + sun * (wrap * 0.6)) + sun * through; // Thin edges glow with the light scattered inside them const edge = base * base * ((1 - facing) ** 2 * (1 - thick) * 0.8 * finish.y); const mirrored = std.reflect(direction, normal); const fresnel = (0.04 + 0.96 * (1 - facing) ** 5) * finish.x; const sharp = std.saturate(std.dot(mirrored, toLight)) ** 80 * 2; const soft = std.smoothstep(0.8, 0.95, std.dot(mirrored, std.normalize(SOFTBOX))) * 0.45; return ( std.mix(body + edge, mirror(mirrored.y), fresnel * 0.5) + (sun * sharp + d.vec3f(soft)) * finish.x );};/** Jelly whose thickness follows how squarely it faces the view, as a round body's does. */export const jellyLit = (base: d.v3f, normal: d.v3f, direction: d.v3f, light: d.v3f) => { 'use gpu'; const facing = std.saturate(-std.dot(normal, direction)); return jelly(base, normal, direction, light, facing * 0.6, d.vec3f(1), d.vec2f(1));};body.ts
import { clock, type VectorSpring } from '@typegpu/kit';import { vec3n } from 'wgpu-matrix';import { GROUND, SLOT, type V3 } from './tray.ts';import type { World } from './world.ts';/** How many critters there are, at most 32 for one bit each in the march's masks. */export const CRITTERS = 18;/** What grows on a critter's head. */export const ANTENNA = 0;export const SPROUT = 1;export const EARS = 2;/** Critter colours in display values. */const PALETTE: readonly (readonly [number, number, number])[] = [ [1, 0.55, 0.7], [0.55, 0.85, 1], [1, 0.85, 0.4], [0.7, 0.6, 1], [0.6, 0.95, 0.6], [1, 0.65, 0.45],];/** Where critters climb onto the jelly, beside the slot's left end. */export const LADDER = -1.06;/** The lawn critters walk on, in world x and z. Wherever they head lies within it. */export const LAND = { x: [-2.1, 2.1], z: [-1.2, 0.75] } as const;/** * The springs a critter grows in and shrinks away on, its body bends and squashes on, and what * grows on its head sways on. */const SPRINGS = { grow: { stiffness: 120, damping: 9 }, bend: { stiffness: 55, damping: 4.5 }, squash: { stiffness: 160, damping: 10 }, top: { stiffness: 170, damping: 6 },} as const;/** Adds `by` times `k` to a vector spring's velocity, as a knock does. */export const kick = (spring: VectorSpring<V3>, by: V3, k: number) => { for (let i = 0; i < 3; i++) spring.velocity[i]! += by[i]! * k;};/** Hip, knee and foot of each leg, then shoulder and hand of each arm. */export const JOINTS = 10;/** Body radius, and the hips' height over the feet, in world units. */export const R = 0.04;export const HIP = 0.05;/** The body's centre over the ground it stands on. */export const STANDING = HIP + R * 0.5;/** Walking speed, step length and time, how high a foot lifts, and turns in radians a second. */export const WALK = { speed: 0.17, stride: 0.034, time: 0.14, lift: 0.022, turn: 4 };/** Gravity, in world units a second squared. */export const FALL = 3.2;/** Climbing the jelly and sliding down it. */export const CLIMB = { speed: 0.15, /** Slopes over which walking up turns into heaving. */ steep: [0.3, 0.5], /** The steepest slope critters grip. */ steepest: 0.97, /** The downhill slope past a top where they sit to slide. */ over: 0.3, /** Friction while sliding. */ friction: 0.25, /** Step length and how high a foot lifts walking up, and the share of a step a foot is planted. */ stride: 0.03, lift: 0.012, planted: 0.65, /** How much harder a grip is to shake. */ grip: 2,} as const;/** Up steep jelly: seconds per heave, how far ahead hands grab, and how far each pull lifts. */export const HEAVE = { time: 0.75, reach: R * 1.7, pull: R * 1.1 };/** The landing speed that makes a critter dizzy, and for how long. */const DIZZY = { impact: 1.5, time: 2.5 };/** * Bumps: the closing speed that knocks critters over, how much of it bounces back, how much * becomes lift, and how fast a knocked critter tumbles. */export const KNOCK = { speed: 0.45, bounce: 0.4, lift: 0.5, tumble: 11 };/** Petting: how near the pointer rests, how slowly it moves, and how long before it counts. */export const PET = { reach: R * 1.8, still: 0.3, after: 0.35 };/** A pointer swept fast past critters startles them away for `flee` seconds. */export const SHOO = { speed: 1.5, reach: R * 3.5, flee: 1.2 };/** The herald, the first critter, which pulls the lever that reveals the jelly world. */export const HERALD = { /** Seconds before it pops in, and where in x and z. */ after: 0.8, at: [0.72, 0.72], /** How much faster than a walk it trots to the lever. */ pace: 1.6, /** Where it stands from the knob to leap. */ stand: 0.17, /** Seconds its leap to the knob takes, and how high it arcs. */ leap: 0.45, arc: 0.05, /** Where it hangs below the knob. */ hang: [0, -R * 1.45, R * 0.4] as V3, /** How far the lever goes before it lets go. */ done: 0.92,} as const;export const UP: V3 = [0, 1, 0];export type Mode = 'walk' | 'ride' | 'fly' | 'held' | 'perch' | 'pull';export interface Foot { at: V3; from: V3; to: V3; /** Swing progress, 1 when planted. */ t: number;}export interface Obstacle { readonly x: number; readonly z: number; readonly radius: number;}/** What critters need of the jelly world: where not to walk, and the lever one of them pulls. */export type Ground = Pick<World, 'obstacles' | 'knob' | 'pulled' | 'holding' | 'hang'>;/** What a critter's step reads besides itself, which the critters update each step. */export interface Crowd { readonly bodies: readonly Body[]; readonly world: Ground; /** The herald, and the same while it still has the lever to pull. */ readonly herald: Body; errand: Body | undefined; /** The point the pointer hovers while it is fresh, and how fast it moves. */ pointer: V3 | undefined; pointerSpeed: number; /** World x of the jelly world's front, which critters keep behind. */ front: number; /** The jelly's spine, which critters climb onto. */ spine: Float32Array;}/** * Critter `i` standing on the tray: the herald at its spot, the others anywhere off the slot. Its * times are seconds left unless they say otherwise. */export function createBody(i: number) { const at: V3 = [random(-2, 1.6), GROUND, random(-1, 0.7)]; if (Math.abs(at[2]) < SLOT.z + 0.1) at[2] = SLOT.z + 0.15; if (i === 0) [at[0], at[2]] = HERALD.at; const foot = (side: number): Foot => { const p: V3 = [at[0] + side * 0.02, GROUND, at[2]]; return { at: p, from: [...p], to: [...p], t: 1 }; }; const heading = random(0, Math.PI * 2); const blinkIn = random(1, 4); const phase = random(0, 10); const nerve = random(0, 1); return { mode: 'walk' as Mode, at, velocity: [0, 0, 0] as V3, heading, /** How far it has grown in, from 0 to 1, and the spring it grows on. */ grown: 0, growth: clock.spring(SPRINGS.grow), /** Arc length and speed along the spine while riding, and walking strides taken up it. */ s: 0, sliding: 0, cycle: 0, /** How steep the climb is from walking to heaving, the heave's progress, and where it started. */ steep: 0, heave: 0, from: 0, /** Where the hands hold the jelly, and held before the last reach. */ grab: 0, grabFrom: 0, /** Climbing rather than sliding, how far into the sitting pose, and turned from +s towards -s. */ gripping: true, seated: 0, turn: 0, feet: [foot(1), foot(-1)] as [Foot, Foot], /** The lean of the body's top, and the squash along its up axis. */ bend: clock.spring({ ...SPRINGS.bend, value: [0, 0, 0] as V3 }), squash: clock.spring(SPRINGS.squash), /** A flip's or tumble's turn so far, how fast it turns, and the tumble's axis. */ spin: 0, spinning: 0, spinAxis: undefined as V3 | undefined, /** Where a tumble keeps its axis. */ axis: [0, 0, 0] as V3, /** What grows on its head: its tip, placed on the first pose, and which kind. */ tip: clock.spring({ ...SPRINGS.top, value: [0, 0, 0] as V3 }), tipped: false, kind: Math.floor(i / PALETTE.length) % 3, /** Where it is going in x and z, until when, and whether there to climb onto the jelly. */ goal: [at[0], at[2]] as [number, number], plan: 0, wantsRide: false, /** * How bold it is, from 0 to 1: bolder critters want to ride the jelly more often, run up to it * and hesitate less before climbing on. Seconds it has stood at the jelly's edge, and walking * before it wants to ride again. */ nerve, brink: 0, rideIn: 0, /** Whether it stopped where it was going, until it is well away from there again. */ arrived: false, /** The side it passes what blocks its way, -1 or 1, and for how long it keeps that side. */ dodge: 0, dodgeFor: 0, /** What it last went round, which it keeps going round while another is about as near. */ avoiding: undefined as Body | Obstacle | undefined, /** Seconds its way has been blocked. */ blocked: 0, /** Seconds into a shove at `victim`, 0 when not shoving, and before it may shove again. */ shove: 0, victim: undefined as Body | undefined, shoveIn: 2 + (i % 4), /** How fast a shove sends it stumbling, and who shoved it, to shove back. */ stumble: [0, 0, 0] as V3, grudge: undefined as Body | undefined, /** Waving at `friend`, and before it waves again. */ wave: 0, friend: undefined as Body | undefined, greeted: random(2, 6), /** Cheering for `fan`, a critter flying off the jelly's end. */ cheer: 0, fan: undefined as Body | undefined, /** The critter it follows in a line. */ leader: undefined as Body | undefined, train: 0, /** The critter whose head it sits on, and how many sit on its own. */ base: undefined as Body | undefined, perchFor: 0, load: 0, asleep: 0, flee: 0, fleeFrom: [0, 0, 0] as V3, /** Seconds under a resting pointer. */ petted: 0, dizzy: 0, /** Whether it flies knocked over, which leaves it dizzy. */ knocked: false, /** Seconds held by the pointer. */ heldFor: 0, /** Seconds in the leap to the lever's knob and hanging there, and where the leap started. */ pulling: 0, leapFrom: [0, 0, 0] as V3, /** Looking surprised and looking happy. */ shock: 0, joy: 0, /** How happy, surprised, sleepy and dizzy it looks, eased. */ mood: [0, 0, 0, 0] as [number, number, number, number], blink: 0, blinkIn, mouth: 0, /** Seconds since it was made, which phases its sways. */ phase, color: PALETTE[i % PALETTE.length]!, /** * The body's centre then its joints from it as last computed, once posed, offsets easing each * from where it was drawn after a jump, and the centre and up axis as drawn. */ raw: Array.from({ length: JOINTS + 1 }, (): V3 => [0, 0, 0]), posed: false, settle: Array.from({ length: JOINTS + 1 }, (): V3 => [0, 0, 0]), drawn: [at[0], GROUND + STANDING, at[2]] as V3, upright: [0, 1, 0] as V3, };}/** One critter's state on the CPU, as `createBody` sets it up. */export interface Body extends ReturnType<typeof createBody> {}/** A random number from `low` to `high`. */export const random = (low: number, high: number) => low + Math.random() * (high - low);export const ease = (from: number, to: number, rate: number, dt: number) => from + (to - from) * Math.min(1, dt * rate);/** The turn from heading `from` to `to`, the short way round. */export const turnBetween = (from: number, to: number) => { const turn = to - from; return turn - Math.round(turn / (Math.PI * 2)) * Math.PI * 2;};/** Whether a count that grew by `dt` this step reached `mark`. */export const crossed = (count: number, dt: number, mark: number) => count >= mark && count - dt < mark;/** Whether a resting pointer has petted it long enough to show. */export const petted = (body: Body) => body.petted > PET.after;/** Lands on the ground with `impact`, dizzy after a hard landing or a knock. */export function land(body: Body, impact: number) { body.squash.velocity -= impact * 2.5; kick(body.bend, body.velocity, 0.3); vec3n.zero(body.velocity); body.mode = 'walk'; body.spinning = 0; body.blocked = 0; if (body.knocked || impact > DIZZY.impact) body.dizzy = DIZZY.time; body.knocked = false; for (const foot of body.feet) { foot.t = 1; vec3n.set(body.at[0], GROUND, body.at[2], foot.at); }}/** Jumps up at `speed`, turning a front flip on the way when `flip` is set. */export function jump(body: Body, speed: number, flip: boolean) { body.mode = 'fly'; body.base = undefined; body.asleep = 0; vec3n.set(Math.sin(body.heading) * 0.05, speed, Math.cos(body.heading) * 0.05, body.velocity); body.squash.velocity -= 2; body.spin = 0; body.spinAxis = undefined; body.spinning = flip ? (Math.PI * FALL) / speed : 0; body.shove = 0;}/** Knocks a critter over by `push` and up by `lift`, so it tumbles the way it was pushed. */export function topple(body: Body, push: V3, lift = KNOCK.lift * vec3n.length(push)) { if (body.mode === 'held') return; body.mode = 'fly'; body.base = undefined; vec3n.add(body.velocity, push, body.velocity); body.velocity[1] += lift; // Tumbles about the horizontal axis across the push const axis = vec3n.set(push[2], 0, -push[0], body.axis); const flat = vec3n.length(axis); body.spinAxis = flat > 1e-4 ? vec3n.normalize(axis, axis) : undefined; body.spin = 0; body.spinning = KNOCK.tumble * Math.min(1.5, vec3n.length(push) / 0.6 + 0.3); body.knocked = true; body.shock = 1.2; body.asleep = 0; body.wave = 0; body.leader = undefined; body.train = 0; body.wantsRide = false; body.shove = 0; body.squash.velocity -= 2;}/** Jumps away from `from` and flees it for a while. */export function startle(body: Body, from: V3) { jump(body, 0.7, false); body.shock = 1; body.flee = SHOO.flee; vec3n.copy(from, body.fleeFrom);}decals.ts
import { perlin2d, randf } from '@typegpu/noise';import * as sdf from '@typegpu/sdf';import { d, std } from 'typegpu';import { CANDY, CREAM } from './candy.ts';/** Flowers in meadows and gumdrop pebbles over the lawn: cell size, share of cells and size. */const FLOWER = { cell: 0.12, share: 0.75, size: 0.02, meadow: 0.9 };const DROP = { cell: 0.11, share: 0.08, size: 0.011 };/** Tufts of grass drawn as three strokes in some of the pebbles' cells: share and size. */const TUFT = { share: 0.3, size: 0.026, color: d.vec3f(0.4, 0.76, 0.5) };/** How far behind the front marks wait to pop up, how long they take, and how far they overshoot. */const BLOOM = { start: 0.03, stagger: 0.3, span: 0.12, overshoot: 0.35 };/** * Candy stepping stones from the lever across the front of the tray: where the path starts and * ends in x, its middle in z and how it winds, and the stones' spacing, size and stagger. */const PATH = { from: -2.8, to: 0.98, line: 0.52, sway: 0.1, wave: 2.1, phase: -2.27, spacing: 0.1, size: 0.03, stagger: 0.016,};/** A mark on the lawn: its colour and coverage, how it tilts the lawn and the shade around it. */export const Decal = d.struct({ color: d.vec3f, cover: d.f32, tilt: d.vec2f, shade: d.f32 });const clear = () => { 'use gpu'; return Decal({ color: d.vec3f(), cover: 0, tilt: d.vec2f(), shade: 1 });};/** How far a mark has popped up `depth` behind the front, overshooting a little on the way. */const bloomOf = (depth: number, delay: number) => { 'use gpu'; const grown = std.saturate((depth - BLOOM.start - delay * BLOOM.stagger) / BLOOM.span); return grown + std.sin(grown * Math.PI) * BLOOM.overshoot;};/** A five-petal flower in some cells of the lawn, raised a little off it. */export const flowerAt = (at: d.v2f, soft: number, depth: number) => { 'use gpu'; const cell = std.floor(at / FLOWER.cell); // Flowers gather in meadows rather than spread evenly const meadow = perlin2d.sample(cell * (FLOWER.cell * FLOWER.meadow) + 4.2); randf.seed2(cell + 0.37); if (randf.sample() > FLOWER.share * std.smoothstep(0.05, 0.45, meadow)) return clear(); const jitter = d.vec2f(randf.sample(), randf.sample()) - 0.5; const centre = (cell + 0.5 + jitter * 0.45) * FLOWER.cell; const scale = FLOWER.size * (0.75 + randf.sample() * 0.5); const pick = randf.sample(); const spin = randf.sample() * 6.28; const bloom = bloomOf(depth, randf.sample()); if (bloom < 0.02) return clear(); const size = scale * bloom; const local = at - centre; const r = std.length(local); const angle = std.atan2(local.y, local.x) + spin + (1 - bloom) * 2; const petals = r - size * (0.55 + 0.45 * std.abs(std.cos(angle * 2.5))); const cover = std.smoothstep(soft, -soft, petals); const middle = std.smoothstep(soft, -soft, r - size * 0.3); let petal = d.vec3f(CANDY.white); if (pick < 0.3) petal = CANDY.pink; else if (pick < 0.5) petal = CANDY.lilac; else if (pick < 0.65) petal = CANDY.butter; return Decal({ color: std.mix(petal, CANDY.middle, middle), cover, tilt: (local / size) * (0.5 * cover), shade: 1 - std.smoothstep(size * 1.4, size * 0.6, r) * (1 - cover) * 0.3, });};/** A tuft of grass, three strokes fanning up the view from `local`'s origin. */const tuftAt = (local: d.v2f, size: number, soft: number) => { 'use gpu'; const q = local / size + d.vec2f(0, 0.45); const blades = std.min( std.min( sdf.sdBezierApprox(q, d.vec2f(-0.1, 0), d.vec2f(-0.2, -0.55), d.vec2f(-0.7, -0.8)), sdf.sdBezierApprox(q, d.vec2f(0, 0), d.vec2f(0, -0.6), d.vec2f(0.1, -1.15)), ), sdf.sdBezierApprox(q, d.vec2f(0.1, 0), d.vec2f(0.25, -0.5), d.vec2f(0.65, -0.7)), ); return Decal({ color: TUFT.color, cover: std.smoothstep(soft, -soft, (blades - 0.13) * size) * 0.8, tilt: d.vec2f(), shade: 1, });};/** * A gumdrop pebble, a glossy dome with a soft shadow round it, or a tuft of grass in some cells * of the lawn. */export const dropAt = (at: d.v2f, soft: number, depth: number) => { 'use gpu'; const cell = std.floor(at / DROP.cell); randf.seed2(cell + 7.1); const chance = randf.sample(); if (chance > DROP.share + TUFT.share) return clear(); const jitter = d.vec2f(randf.sample(), randf.sample()) - 0.5; const scale = 0.7 + randf.sample() * 0.6; const bloom = bloomOf(depth, randf.sample()); if (bloom < 0.02) return clear(); if (chance > DROP.share) { const size = TUFT.size * scale * bloom; return tuftAt(at - (cell + 0.5 + jitter * 0.4) * DROP.cell, size, soft); } const centre = (cell + 0.5 + jitter * 0.5) * DROP.cell; const size = DROP.size * scale * bloom; const pick = randf.sample(); const local = at - centre; const r = std.length(local); const cover = std.smoothstep(soft, -soft, r - size); let color = d.vec3f(CANDY.pink); if (pick < 0.25) color = CANDY.sky; else if (pick < 0.5) color = CANDY.butter; else if (pick < 0.7) color = CANDY.lilac; return Decal({ color, cover, tilt: (local / size) * (1.3 * cover), shade: 1 - std.smoothstep(size * 1.8, size, r) * (1 - cover) * 0.4, });};/** A stepping stone of the path where one lies, glossy and domed with a sugary pink rim. */export const stoneAt = (at: d.v2f, soft: number, depth: number) => { 'use gpu'; const i = std.floor(at.x / PATH.spacing); const x = (i + 0.5) * PATH.spacing; if (x < PATH.from || x > PATH.to) return clear(); const side = std.fract(i * 0.5) * 4 - 1; const z = PATH.line + PATH.sway * std.sin(x * PATH.wave + PATH.phase) + side * PATH.stagger; const local = (at - d.vec2f(x, z)) / d.vec2f(1.15, 1); randf.seed(i + 2.3); const bloom = bloomOf(depth, randf.sample()); if (bloom < 0.02) return clear(); const size = PATH.size * bloom * (0.85 + randf.sample() * 0.3); const r = std.length(local); const cover = std.smoothstep(soft, -soft, r - size); const rim = std.smoothstep(size * 0.62, size * 0.72, r); return Decal({ color: std.mix(CREAM, CANDY.pink, rim * 0.7), cover, tilt: (local / size) * (0.9 * cover), shade: 1 - std.smoothstep(size * 1.5, size, r) * (1 - cover) * 0.4, });};figures.ts
import { table, unfilled } from '@typegpu/kit';import * as sdf from '@typegpu/sdf';import { d, std, tgpu } from 'typegpu';import { ANTENNA, CRITTERS, SPROUT } from './body.ts';import { BODY, EYE, INK, jellyLit, LEAF, LINE, MARCH, shapeNormal, Surface, TIP } from './candy.ts';/** One critter as the march draws it, in world units, which the CPU poses each frame. */export const Critter = table.define('Critter', { host: { /** Body centre and radius. */ body: d.vec4f, /** How far back the body was when the last frame was drawn, in xyz. */ moved: d.vec4f, /** The body's up axis, and how much it stretches along it. */ up: d.vec4f, /** Which way it faces, and how far its eyelids are shut. */ ahead: d.vec4f, /** What it looks at, and how wide its mouth is open. */ gaze: d.vec4f, /** Hip, knee and foot of each leg, then shoulder and hand of each arm. */ joints: d.arrayOf(d.vec4f, 10), /** Colour, and how far it has grown in. */ color: d.vec4f, /** A sphere around all of it. */ bound: d.vec4f, /** How far the top of the body leans over its bottom, across its up axis. */ bend: d.vec4f, /** How happy, surprised, sleepy and dizzy it looks, from 0 to 1. */ face: d.vec4f, /** The tip of what grows on its head, and which: an antenna, a sprout or ears. */ top: d.vec4f, },});/** Bit `c` is set for each critter the jelly shows in or through it. */export const nearJelly = tgpu.accessor( d.u32, unfilled('the critters near the jelly', 'critters', 'the `createCritters` they belong to'),);/** Eye placement and size, and limb thickness, over a critter's body radius. */const FACE = { ahead: 0.72, up: 0.3, apart: 0.4, eye: 0.36 };const LIMB = { leg: 0.085, foot: 0.17, arm: 0.075, hand: 0.12 };/** A critter's sphere in the jelly's reflections over its body radius, and its soft edge. */const GLIMPSE = { size: 1.15, edge: 0.3 };/** A critter's side, up and ahead axes as columns. */const frameOf = (c: number) => { 'use gpu'; const up = Critter.up(c).xyz; const ahead = Critter.ahead(c).xyz; return d.mat3x3f(std.normalize(std.cross(up, ahead)), up, ahead);};/** Where a critter's eyes sit, between them. */const faceOf = (c: number, frame: d.m3x3f) => { 'use gpu'; const body = Critter.body(c); return ( body.xyz + frame.columns[2] * (body.w * FACE.ahead) + frame.columns[1] * (body.w * FACE.up) );};/** A point of the bent body moved back to where it would be upright, as poses.ts bends it. */const unbent = (p: d.v3f, c: number) => { 'use gpu'; const body = Critter.body(c); const along = std.dot(p - body.xyz, Critter.up(c).xyz); const share = std.saturate((along / body.w + 1) * 0.5); return p - Critter.bend(c).xyz * (share * share);};/** A leg from the hip through the knee to its foot, for a critter of radius `r`. */const leg = (p: d.v3f, hip: d.v3f, knee: d.v3f, foot: d.v3f, r: number) => { 'use gpu'; const bones = std.min( sdf.sdCapsule(p, hip, knee, r * LIMB.leg), sdf.sdCapsule(p, knee, foot, r * LIMB.leg), ); return std.min(bones, sdf.sdSphere(p - foot, r * LIMB.foot));};/** An arm from the shoulder to its hand, for a critter of radius `r`. */const arm = (p: d.v3f, shoulder: d.v3f, hand: d.v3f, r: number) => { 'use gpu'; return std.min( sdf.sdCapsule(p, shoulder, hand, r * LIMB.arm), sdf.sdSphere(p - hand, r * LIMB.hand), );};/** Critter `c`'s distance and material at `p`: its body, eyes, limbs and what grows on its head. */const critterOf = (p: d.v3f, c: number) => { 'use gpu'; const critter = Critter.host.$[c]!; const r = critter.body.w; const frame = frameOf(c); const q = unbent(p, c); const rel = q - critter.body.xyz; const along = std.dot(rel, frame.columns[1]); const wide = 1 - critter.up.w * 0.5; const tall = 1 + critter.up.w; const across = std.length(rel - frame.columns[1] * along); const body = (std.length(d.vec2f(across / wide, along / tall)) - r) * std.min(wide, tall) * 0.85; const face = faceOf(c, frame); const apart = frame.columns[0] * (r * FACE.apart); const eye = r * FACE.eye * (1 + critter.face.y * 0.25); const eyes = std.min(sdf.sdSphere(q - face - apart, eye), sdf.sdSphere(q - face + apart, eye)); const j = critter.joints; const legs = std.min( leg(p, j[0].xyz, j[1].xyz, j[2].xyz, r), leg(p, j[3].xyz, j[4].xyz, j[5].xyz, r), ); const arms = std.min(arm(p, j[6].xyz, j[7].xyz, r), arm(p, j[8].xyz, j[9].xyz, r)); const lines = std.min(legs, arms); let found = Surface({ distance: body, material: BODY }); if (eyes < found.distance) found = Surface({ distance: eyes, material: EYE }); if (lines < found.distance) found = Surface({ distance: lines, material: LINE }); // What grows on its head, from the top of the bent body to the sprung tip const tip = critter.top.xyz; const crown = critter.body.xyz + frame.columns[1] * (r * 0.95 * tall) + critter.bend.xyz; const kind = d.i32(critter.top.w); if (kind === ANTENNA) { const stalk = sdf.sdCapsule(p, crown, tip, r * 0.06); const ball = sdf.sdSphere(p - tip, r * 0.2); if (stalk < found.distance) found = Surface({ distance: stalk, material: LINE }); if (ball < found.distance) found = Surface({ distance: ball, material: TIP }); } else if (kind === SPROUT) { const sprout = std.min( sdf.sdCapsule(p, crown, std.mix(crown, tip, 0.5), r * 0.07), sdf.sdCapsule(p, std.mix(crown, tip, 0.4), tip, r * 0.17), ); if (sprout < found.distance) found = Surface({ distance: sprout, material: LEAF }); } else { // Ears splay a little and round off at the tips const reach = tip - crown; const base = crown - frame.columns[1] * (r * 0.25); const out = frame.columns[0] * (r * 0.45); const ears = std.min( sdf.sdCapsule(p, base + out, crown + out * 1.6 + reach, r * 0.2), sdf.sdCapsule(p, base - out, crown - out * 1.6 + reach, r * 0.2), ); if (ears < found.distance) found = Surface({ distance: ears, material: BODY }); } return found;};const critterDistance = (p: d.v3f, c: number) => { 'use gpu'; return critterOf(p, c).distance;};/** Where a ray first meets critter `c` between `start` and `stop`, or -1. */export const marchCritter = ( eye: d.v3f, direction: d.v3f, c: number, start: number, stop: number,) => { 'use gpu'; let t = start; for (let i = 0; i < MARCH.steps; i++) { const distance = critterDistance(eye + direction * t, c); if (distance < MARCH.hit + t * MARCH.cone) return t; t += distance; if (t > stop) break; } return d.f32(-1);};const critterNormal = shapeNormal(critterDistance, 0.0004);/** * A critter's colour where a ray found it: jelly body, googly eyes, face and ink limbs. Dizzy * pupils spiral round with `time`. */export const shadeCritter = (p: d.v3f, direction: d.v3f, c: number, light: d.v3f, time: number) => { 'use gpu'; const critter = Critter.host.$[c]!; const color = critter.color.xyz * 0.82; const material = critterOf(p, c).material; const normal = critterNormal(p, c); const frame = frameOf(c); const r = critter.body.w; const q = unbent(p, c); const mood = critter.face; if (material === LINE) return jellyLit(INK, normal, direction, light) * 0.6; if (material === TIP) { return jellyLit(std.mix(color, d.vec3f(1, 0.95, 0.75), 0.35), normal, direction, light); } if (material === LEAF) return jellyLit(d.vec3f(0.42, 0.85, 0.38), normal, direction, light); if (material === EYE) { const face = faceOf(c, frame); const side = std.sign(std.dot(q - face, frame.columns[0])); const eye = face + frame.columns[0] * (r * FACE.apart * side); const out = std.normalize(q - eye); const look = std.normalize(critter.gaze.xyz - eye); // Surprise shrinks the pupils; dizziness turns them into spirals const small = std.mix(0.72, 0.86, mood.y); let pupil = std.smoothstep(small, small + 0.08, std.dot(out, look)); const off = out - look * std.dot(out, look); const turn = std.atan2(std.dot(off, frame.columns[1]), std.dot(off, frame.columns[0])); const coil = std.fract(turn / (Math.PI * 2) + std.length(off) * 2.5 - time * 1.5); const spiral = std.smoothstep(0.3, 0.2, std.abs(coil - 0.5)) * std.step(0.2, std.dot(out, look)); pupil = std.mix(pupil, spiral, mood.w); const shine = std.normalize(look + frame.columns[1] * 0.6); const glint = std.smoothstep(0.93, 0.96, std.dot(out, shine)); const white = std.mix(d.vec3f(1), INK, pupil * (1 - glint * (1 - mood.w))); // Lids close from above in the body's colour, blinking or sleepy, and shut into a line const u = std.dot(out, frame.columns[0]); const v = std.dot(out, frame.columns[1]); const shut = std.max(critter.ahead.w, mood.z * 0.95); const lid = std.smoothstep(0.02, -0.02, 1 - shut * 2.2 - v); const lash = std.smoothstep(0.07, 0.04, std.abs(v + 0.1 - 0.6 * u * u)) * std.smoothstep(0.7, 0.95, shut); let seen = std.mix(std.mix(white, color, lid), INK, lash); // Happy eyes squeeze into arcs const arc = std.smoothstep(0.08, 0.05, std.abs(v - 0.12 + 1.6 * u * u)) * std.step(std.abs(u), 0.5); seen = std.mix(seen, std.mix(color, INK, arc), mood.x); return jellyLit(seen, normal, direction, light); } const v = std.normalize(q - critter.body.xyz); const across = std.dot(v, frame.columns[0]); const up = std.dot(v, frame.columns[1]); const front = std.dot(v, frame.columns[2]); let base = d.vec3f(color); const blush = std.smoothstep(0.12, 0.06, std.length(d.vec2f(std.abs(across) - 0.52, up + 0.08))); base = std.mix(base, d.vec3f(1, 0.42, 0.5), blush * std.step(0, front) * (0.7 + mood.x * 0.3)); if (front > 0.45) { // Wider when happy, a little round 'o' when surprised const open = std.max(critter.gaze.w, mood.y * 0.55); const curve = 1.8 + mood.x * 0.8; const smile = std.smoothstep(0.035, 0.018, std.abs(up + 0.2 - curve * across * across)) * std.step(std.abs(across), 0.26 + mood.x * 0.06); const wide = std.mix(0.12 + open * 0.05, 0.075, mood.y); const mouth = std.length(d.vec2f(across / wide, (up + 0.26) / (0.02 + open * 0.13))); const inside = std.smoothstep(1.05, 0.95, mouth); const tongue = std.smoothstep(0.2, 0.6, (up + 0.26) / -(0.02 + open * 0.13)); const opened = std.mix(INK, d.vec3f(1, 0.4, 0.5), tongue * 0.8); if (open > 0.15) base = std.mix(base, opened, inside); else base = std.mix(base, INK, smile); } return jellyLit(base, normal, direction, light);};/** * The critters near the jelly a ray meets before `limit`, as lit spheres in their colours with * their eyes, and how much of the ray they cover, for what the jelly reflects and shows through. */export const glimpse = (eye: d.v3f, direction: d.v3f, limit: number, light: d.v3f) => { 'use gpu'; let nearest = d.f32(limit); let found = d.i32(-1); let cover = d.f32(0); for (let c = 0; c < CRITTERS; c++) { if (((nearJelly.$ >>> d.u32(c)) & d.u32(1)) === 0) continue; const body = Critter.body(c); const from = eye - body.xyz; const b = std.dot(from, direction); const r = body.w * GLIMPSE.size; const miss = std.dot(from, from) - b * b; if (b > 0 || miss >= r * r) continue; const t = -b - std.sqrt(r * r - miss); if (t < 0 || t > nearest) continue; nearest = t; found = c; cover = std.saturate((r - std.sqrt(miss)) / (r * GLIMPSE.edge)); } if (found < 0) return d.vec4f(); const body = Critter.body(found); const r = body.w; const p = eye + direction * nearest; const normal = std.normalize(p - body.xyz); const frame = frameOf(found); const face = faceOf(found, frame); const apart = frame.columns[0] * (r * FACE.apart); const eyeAt = std.min(std.length(p - face - apart), std.length(p - face + apart)) / (r * FACE.eye); const white = std.mix(INK, d.vec3f(1), std.smoothstep(0.4, 0.55, eyeAt)); const base = std.mix(Critter.color(found).xyz * 0.82, white, std.smoothstep(1.15, 1, eyeAt)); return d.vec4f(jellyLit(base, normal, direction, light), cover);};/** Soft shadows under the critters on a floor point, by their height over it. */export const underCritters = (p: d.v3f) => { 'use gpu'; let shaded = d.f32(1); for (let c = 0; c < CRITTERS; c++) { const body = Critter.body(c); if (body.w < 0.001) continue; const height = std.max(body.y - p.y, 0); const spread = body.w * (1.1 + height * 4); const under = std.smoothstep(spread, spread * 0.3, std.length(p.xz - body.xz)); shaded *= 1 - under * 0.45 * std.exp(-height * 5); } return shaded;};props.ts
import { table } from '@typegpu/kit';import * as sdf from '@typegpu/sdf';import { d, std } from 'typegpu';import { BODY, CANDY, CREAM, jelly, MARCH, ROOF, shapeNormal, Surface, TRUNK } from './candy.ts';import { GROUND } from './tray.ts';export const TREE = 0;export const HOUSE = 1;export const LEVER = 2;/** What a tree grows into. */export const PUFF = 0;export const LOLLY = 1;export const GUMDROP = 2;export const MUSHROOM = 3;export const BUSH = 4;/** Height over the ground of the lever's pivot, and its arm's length to the knob's centre. */export const PIVOT = 0.075;export const ARM = 0.17;/** The jelly world's trees, houses and lever, which the CPU places, grows and swings. */export const Prop = table.define('Prop', { host: { /** Where it stands on the ground in x and z. */ at: d.vec2f, /** Its heading in radians, and its size over its modelled size. */ heading: d.f32, size: d.f32, kind: d.u32, /** What a tree grows into. */ variant: d.u32, /** How far it has grown from 0 to 1, and its squash. */ grown: d.f32, squash: d.f32, /** The lever's arm, in radians from upright towards +z. */ angle: d.f32, /** How far a tree's top sways along x. */ sway: d.f32, color: d.vec3f, },});/** * Each prop's bounding sphere before scaling, as its centre's height and its radius, and how wide * its shadow falls over that radius. Trees go by what they grow into. */const EXTENTS = { puff: d.vec3f(0.17, 0.2, 0.6), lolly: d.vec3f(0.19, 0.2, 0.38), gumdrop: d.vec3f(0.15, 0.19, 0.55), mushroom: d.vec3f(0.09, 0.14, 0.75), bush: d.vec3f(0.05, 0.14, 0.9), house: d.vec3f(0.12, 0.17, 0.75), lever: d.vec3f(0.12, 0.24, 0.35),};/** Props cast shadows from a light this much steeper than the scene's, so they stay short. */const SHADOW = { steep: 0.9 };/** The houses' lit windows, past white. */const WINDOW = d.vec3f(1.5, 1.05, 0.5);const ellipsoid = (q: d.v3f, radii: d.v3f) => { 'use gpu'; const k0 = std.length(q / radii); const k1 = std.length(q / (radii * radii)); return (k0 * (k0 - 1)) / k1;};const house = (q: d.v3f) => { 'use gpu'; const walls = sdf.sdRoundedBox3d(q - d.vec3f(0, 0.07, 0), d.vec3f(0.08, 0.07, 0.07), 0.018); const raised = q - d.vec3f(0, 0.14, 0); const turned = d.vec3f( (raised.x - raised.y) * Math.SQRT1_2, (raised.x + raised.y) * Math.SQRT1_2, raised.z, ); const ridge = sdf.sdRoundedBox3d(turned, d.vec3f(0.075, 0.075, 0.088), 0.014); const chimney = sdf.sdRoundedBox3d( q - d.vec3f(0.035, 0.2, -0.03), d.vec3f(0.015, 0.035, 0.015), 0.006, ); const roof = std.min(std.max(ridge, 0.13 - q.y), chimney); if (roof < walls) return Surface({ distance: roof, material: ROOF }); return Surface({ distance: walls, material: BODY });};/** A slot machine's arm on a jelly block, swung `angle` radians from upright towards +z. */const lever = (q: d.v3f, angle: number) => { 'use gpu'; const block = sdf.opSmoothUnion( sdf.sdRoundedBox3d(q - d.vec3f(0, 0.035, 0), d.vec3f(0.05, 0.035, 0.06), 0.03), sdf.sdCapsule(q, d.vec3f(-0.05, PIVOT, 0), d.vec3f(0.05, PIVOT, 0), 0.022), 0.02, ); const tip = d.vec3f(0, PIVOT + std.cos(angle) * ARM, std.sin(angle) * ARM); const arm = sdf.sdCapsule(q, d.vec3f(0, PIVOT, 0), tip, 0.011); const knob = sdf.sdSphere(q - tip, 0.032); if (knob < block && knob < arm) return Surface({ distance: knob, material: ROOF }); if (arm < block) return Surface({ distance: arm, material: TRUNK }); return Surface({ distance: block, material: BODY });};const puff = (q: d.v3f) => { 'use gpu'; const crown = sdf.opSmoothUnion( sdf.opSmoothUnion( sdf.sdSphere(q - d.vec3f(0, 0.25, 0), 0.1), sdf.sdSphere(q - d.vec3f(0.07, 0.2, 0.03), 0.07), 0.04, ), sdf.sdSphere(q - d.vec3f(-0.06, 0.21, -0.04), 0.075), 0.04, ); const trunk = sdf.sdCapsule(q, d.vec3f(), d.vec3f(0, 0.2, 0), 0.022); if (crown < trunk) return Surface({ distance: crown, material: BODY }); return Surface({ distance: trunk, material: TRUNK });};/** A round candy facing local +z on a stick. */const lolly = (q: d.v3f) => { 'use gpu'; const c = q - d.vec3f(0, 0.25, 0); const candy = sdf.opExtrudeZ(c, sdf.sdDisk(c.xy, 0.08), 0.008) - 0.02; const stick = sdf.sdCapsule(q, d.vec3f(), d.vec3f(0, 0.24, 0), 0.011); if (candy < stick) return Surface({ distance: candy, material: BODY }); return Surface({ distance: stick, material: TRUNK });};/** Three gumdrops stacked on a stub, with a bead on top. */const gumdrop = (q: d.v3f) => { 'use gpu'; const tiers = sdf.opSmoothUnion( sdf.opSmoothUnion( ellipsoid(q - d.vec3f(0, 0.1, 0), d.vec3f(0.1, 0.06, 0.1)), ellipsoid(q - d.vec3f(0, 0.175, 0), d.vec3f(0.078, 0.05, 0.078)), 0.012, ), ellipsoid(q - d.vec3f(0, 0.24, 0), d.vec3f(0.055, 0.042, 0.055)), 0.012, ); const bead = sdf.sdSphere(q - d.vec3f(0, 0.292, 0), 0.02); const trunk = sdf.sdCapsule(q, d.vec3f(), d.vec3f(0, 0.06, 0), 0.022); if (bead < tiers && bead < trunk) return Surface({ distance: bead, material: ROOF }); if (tiers < trunk) return Surface({ distance: tiers, material: BODY }); return Surface({ distance: trunk, material: TRUNK });};const mushroom = (q: d.v3f) => { 'use gpu'; const cap = std.max(sdf.sdSphere(q - d.vec3f(0, 0.06, 0), 0.088), 0.1 - q.y) - 0.012; const stem = sdf.sdCapsule(q, d.vec3f(), d.vec3f(0, 0.085, 0), 0.03); if (cap < stem) return Surface({ distance: cap, material: BODY }); return Surface({ distance: stem, material: TRUNK });};const bush = (q: d.v3f) => { 'use gpu'; const left = sdf.opSmoothUnion( sdf.sdSphere(q - d.vec3f(0, 0.06, 0), 0.075), sdf.sdSphere(q - d.vec3f(0.075, 0.04, 0.02), 0.055), 0.03, ); const right = sdf.opSmoothUnion( sdf.sdSphere(q - d.vec3f(-0.07, 0.045, -0.015), 0.06), sdf.sdSphere(q - d.vec3f(0.01, 0.04, 0.07), 0.05), 0.03, ); return Surface({ distance: sdf.opSmoothUnion(left, right, 0.03), material: BODY });};/** * A prop's shape at `q` in its own frame, by its kind and what a tree grows into, how far a tree * sways and the lever's angle. */const shapeAt = (q: d.v3f, kind: number, variant: number, sway: number, angle: number) => { 'use gpu'; if (kind === HOUSE) return house(q); if (kind === LEVER) return lever(q, angle); const swayed = q + d.vec3f(sway * q.y * q.y, 0, 0); if (variant === LOLLY) return lolly(swayed); if (variant === GUMDROP) return gumdrop(swayed); if (variant === MUSHROOM) return mushroom(q); if (variant === BUSH) return bush(q); return puff(swayed);};/** A prop's size across and up, grown and stretched by its squash. */const scaleOf = (k: number) => { 'use gpu'; const size = Prop.size(k) * Prop.grown(k); const squash = Prop.squash(k); return d.vec2f(size * (1 - squash * 0.5), size * (1 + squash));};/** Turns a world direction, or a point from the prop's foot, by the prop's heading. */const unturn = (v: d.v3f, k: number) => { 'use gpu'; const c = std.cos(Prop.heading(k)); const s = std.sin(Prop.heading(k)); return d.vec3f(c * v.x - s * v.z, v.y, s * v.x + c * v.z);};/** Into a prop's frame: heading turned away and size divided out, y stretched by its squash. */const toLocal = (p: d.v3f, k: number) => { 'use gpu'; const at = Prop.at(k); return unturn(p - d.vec3f(at.x, GROUND, at.y), k) / scaleOf(k).xyx;};const surfaceOf = (p: d.v3f, k: number) => { 'use gpu'; const scale = scaleOf(k); const local = toLocal(p, k); const found = shapeAt(local, Prop.kind(k), Prop.variant(k), Prop.sway(k), Prop.angle(k)); return Surface({ distance: found.distance * std.min(scale.x, scale.y), material: found.material, });};const distanceOf = (p: d.v3f, k: number) => { 'use gpu'; return surfaceOf(p, k).distance;};const normalOf = shapeNormal(distanceOf, 0.0008);/** Prop `k`'s entry in `EXTENTS`. */const extentOf = (k: number) => { 'use gpu'; const kind = Prop.kind(k); const variant = Prop.variant(k); if (kind === HOUSE) return EXTENTS.house; if (kind === LEVER) return EXTENTS.lever; if (variant === LOLLY) return EXTENTS.lolly; if (variant === GUMDROP) return EXTENTS.gumdrop; if (variant === MUSHROOM) return EXTENTS.mushroom; if (variant === BUSH) return EXTENTS.bush; return EXTENTS.puff;};/** Whether prop `k` has grown enough to show. */export const shownProp = (k: number) => { 'use gpu'; return Prop.grown(k) >= 0.01;};/** A sphere around prop `k`. */export const boundOf = (k: number) => { 'use gpu'; const scale = scaleOf(k); const bound = extentOf(k); const at = Prop.at(k); return d.vec4f(at.x, GROUND + bound.x * scale.y, at.y, bound.y * std.max(scale.x, scale.y));};/** Where a ray first meets prop `k` between `start` and `stop`, or -1. */export const marchProp = (eye: d.v3f, direction: d.v3f, k: number, start: number, stop: number) => { 'use gpu'; // The ray moves into the prop's frame once, rather than each point along it const scale = scaleOf(k); const at = Prop.at(k); const origin = unturn(eye - d.vec3f(at.x, GROUND, at.y), k) / scale.xyx; const along = unturn(direction, k) / scale.xyx; const unit = std.min(scale.x, scale.y); const kind = Prop.kind(k); const variant = Prop.variant(k); const sway = Prop.sway(k); const angle = Prop.angle(k); let t = start; for (let i = 0; i < MARCH.steps; i++) { const q = origin + along * t; const distance = shapeAt(q, kind, variant, sway, angle).distance * unit; if (distance < MARCH.hit + t * MARCH.cone) return t; t += distance; if (t > stop) break; } return d.f32(-1);};/** The rest of a ray's way through jelly, from 0 at its surface to 1 deep inside. */const depthOf = (inside: number, probe: number) => { 'use gpu'; return std.saturate(-inside / probe);};/** * A tree's body colour at `q` in its frame, with the swirl of a lolly, the cherries of a puff or * the spots of a mushroom. */const decorated = (base: d.v3f, q: d.v3f, variant: number) => { 'use gpu'; if (variant === LOLLY) { const c = q.xy - d.vec2f(0, 0.25); const turns = (std.atan2(c.y, c.x) / Math.PI) * 1.5 + std.length(c) * 16; const stripe = std.fract(turns); const swirl = std.smoothstep(0.25, 0.32, stripe) * std.smoothstep(0.75, 0.68, stripe); return std.mix(base, CANDY.white, swirl); } if (variant === PUFF) { const v = std.normalize(q - d.vec3f(0, 0.22, 0)); const fruit = std.max( std.max( std.max(std.dot(v, d.vec3f(0.81, 0.3, 0.51)), std.dot(v, d.vec3f(-0.7, 0.2, 0.68))), std.dot(v, d.vec3f(0.2, 0.35, 0.92)), ), std.max( std.max(std.dot(v, d.vec3f(-0.3, 0.4, -0.86)), std.dot(v, d.vec3f(0.84, 0.2, -0.5))), std.dot(v, d.vec3f(-0.91, 0.35, -0.2)), ), ); return std.mix(base, CANDY.cherry, std.smoothstep(0.984, 0.99, fruit)); } if (variant === MUSHROOM) { const v = std.normalize(q - d.vec3f(0, 0.06, 0)); const spots = std.max( std.max(std.dot(v, d.vec3f(0, 1, 0)), std.dot(v, d.vec3f(0.72, 0.6, 0.35))), std.max( std.max(std.dot(v, d.vec3f(-0.66, 0.55, 0.5)), std.dot(v, d.vec3f(0.1, 0.55, -0.83))), std.dot(v, d.vec3f(-0.6, 0.62, -0.5)), ), ); return std.mix(base, CANDY.white, std.smoothstep(0.945, 0.96, spots)); } return d.vec3f(base);};/** A prop's colour where a ray found it. */export const shadeProp = (p: d.v3f, direction: d.v3f, k: number, light: d.v3f) => { 'use gpu'; const color = Prop.color(k); const material = surfaceOf(p, k).material; const normal = normalOf(p, k); const q = toLocal(p, k); const kind = Prop.kind(k); const variant = Prop.variant(k); // How deep the jelly runs on behind this point, from two samples along the ray const thick = (depthOf(distanceOf(p + direction * 0.03, k), 0.03) + depthOf(distanceOf(p + direction * 0.09, k), 0.09)) * 0.5; let base = d.vec3f(color); let shine = d.vec3f(); if (material === TRUNK) base = std.mix(color, CREAM, 0.75); if (material === ROOF) base = std.mix(color, d.vec3f(1, 0.35, 0.45), 0.55); if (kind === TREE && material === BODY) base = decorated(base, q, variant); if (kind === HOUSE && material === BODY) { const front = q.z > 0.06; const side = std.abs(q.x) > 0.07; const pane = std.select(q.zy - d.vec2f(0, 0.08), q.xy - d.vec2f(0.035, 0.08), front); const round = std.length(pane); if ((front || side) && round < 0.028) { base = d.vec3f(CREAM); const bar = std.min(std.abs(pane.x), std.abs(pane.y)); if (round < 0.021 && bar > 0.0035) { base = WINDOW * 0.3; shine = WINDOW * (0.6 + 0.4 * std.smoothstep(0.021, 0, round)); } } const door = d.vec2f(q.x + 0.03, q.y); const arch = std.length(door - d.vec2f(0, 0.068)); if (front && ((std.abs(door.x) < 0.02 && door.y < 0.068) || arch < 0.02)) { base = color * color * 0.6; if (std.length(door - d.vec2f(0.011, 0.04)) < 0.004) base = d.vec3f(CANDY.butter); } } // Icing along the eaves, dripping a little if (kind === HOUSE && material === ROOF && q.y < 0.147 + 0.007 * std.cos((q.x + q.z) * 70)) { base = d.vec3f(CANDY.white); } // Darker where it meets the lawn, so it sits on it const grounded = std.mix(0.6, 1, std.smoothstep(0, 0.06, p.y - GROUND)); return jelly(base, normal, direction, light, thick, d.vec3f(1), d.vec2f(1)) * grounded + shine;};/** * Short coloured shadows the props cast on a floor point along the light, in rgb, and a soft * contact under each in w. */export const castAt = (p: d.v3f, sun: d.v3f) => { 'use gpu'; let tint = d.vec3f(1); let open = d.f32(1); const light = std.normalize(sun + d.vec3f(0, -SHADOW.steep, 0)); const count = d.i32(Prop.span()); for (let k = 0; k < count; k++) { if (!shownProp(k)) continue; const bound = boundOf(k); // From the prop's foot out to where its middle falls, widening along the way const base = bound.xz; const tip = bound.xz + light.xz * ((bound.y - p.y) / -light.y); const span = tip - base; const share = std.saturate(std.dot(p.xz - base, span) / std.max(std.dot(span, span), 1e-6)); const gap = std.length(p.xz - base - span * share); const spread = bound.w * extentOf(k).z; const radius = spread * std.mix(0.6, 1, share); const cast = std.smoothstep(radius, radius * 0.5, gap); const contact = std.smoothstep(spread, 0, std.length(p.xz - bound.xz)) * 0.4; // Jelly passes on some of the sun, in its own colour const color = Prop.color(k); tint *= std.mix(d.vec3f(1), color * color * 0.6 + 0.15, cast * 0.8); open *= 1 - contact; } return d.vec4f(tint, open);};moves.ts
import { clock } from '@typegpu/kit';import { std } from 'typegpu';import { vec3n } from 'wgpu-matrix';import { CLIMB, CRITTERS, ease, FALL, HEAVE, HERALD, jump, KNOCK, land, R, random, STANDING, topple, turnBetween, UP, type Body, type Crowd,} from './body.ts';import { createSeat, nearest, seat, spineLength } from './spine.ts';import { GROUND, SLOT, type V3 } from './tray.ts';import { STEER } from './steering.ts';/** The half-width critters ride the jelly within. */const RIDE_Z = 0.12;/** How far up the jelly faces where critters land on it, and below which riders fall off. */const FACING = { land: 0, leave: -0.2 };/** * The jelly throws a rider when it drops away under it faster than `away`, or pushes it up faster * than `toward` while moving faster than the square root of `fast`, each times the rider's grip. * Riders thrown or off an end hop up as they leave, at `hop.thrown` and `hop.end`. */const THROW = { away: 1.2, toward: 0.9, fast: 1.4, hop: { end: 0.6, thrown: 0.2 } };/** Seconds a critter that climbed on walks before it wants to ride again. */const RIDDEN = { shortest: 5, longest: 12 };/** How long a critter sits on another's head. */const PERCH = { shortest: 4, longest: 9 };/** Walkers this near cheer one flying off the jelly's end, this long, and again this long after. */const CHEER = { reach: 1, time: 1.6, again: 6 };// Scratch the steps below write their vectors intoconst here = createSeat();const there = createSeat();const then = createSeat();const found = { s: 0, above: 0 };const carried: V3 = [0, 0, 0];const along: V3 = [0, 0, 0];const out: V3 = [0, 0, 0];const top: V3 = [0, 0, 0];const before: V3 = [0, 0, 0];const hanging: V3 = [0, 0, 0];const centre: V3 = [0, 0, 0];const apart: V3 = [0, 0, 0];const direction: V3 = [0, 0, 0];const relative: V3 = [0, 0, 0];const push: V3 = [0, 0, 0];/** * Climbs the jelly or slides down it, carried as it moves, until it goes off an end, falls off * where it overhangs or is thrown. Critters near the end it flies off cheer. The jelly moved from * `previous` to `spine` over the last `over` seconds of its fixed steps. */export function ride( body: Body, crowd: Crowd, dt: number, spine: Float32Array, previous: Float32Array, over: number,) { const total = spineLength(spine); const { tangent, normal } = seat(spine, body.s, body.at[2], here); const slope = tangent[1]; if (body.gripping) { climb(body, slope, dt); if (slope > CLIMB.steepest || slope < -CLIMB.over) body.gripping = false; } else { body.sliding += -FALL * slope * dt - body.sliding * CLIMB.friction * dt; body.s += body.sliding * dt; if (Math.abs(body.sliding) < 0.05 && slope > -0.1 && slope < 0.5) { body.gripping = true; body.steep = 0; } } body.seated = ease(body.seated, body.gripping ? 0 : 1, 8, dt); body.turn = ease(body.turn, !body.gripping && body.sliding < 0 ? Math.PI : 0, 6, dt); const effort = body.gripping && body.steep > 0.5 && body.heave > 0.35 && body.heave < 0.8; let mouth = body.gripping ? 0.1 : Math.min(1, Math.abs(body.sliding) * 1.5); if (effort) mouth = 0.25; body.mouth = ease(body.mouth, mouth, 8, dt); // The jelly carries them, and throws them when it moves away faster than they hold on const { surface } = seat(spine, body.s, body.at[2], there); vec3n.sub(surface, seat(previous, body.s, body.at[2], then).surface, carried); vec3n.scale(carried, 1 / over, carried); const lift = vec3n.dot(carried, normal); const hold = body.gripping ? CLIMB.grip : 1; const end = body.s >= total - 0.02; const launched = end || body.s <= 0; const overhang = normal[1] < FACING.leave; vec3n.scale(tangent, body.sliding, along); const thrown = lift < -THROW.away * hold || (lift > THROW.toward * hold && vec3n.dot(carried, carried) > THROW.fast * hold); if (launched || overhang || thrown) { const hop = launched ? THROW.hop.end : thrown ? THROW.hop.thrown : 0; body.mode = 'fly'; vec3n.add(along, carried, body.velocity); body.velocity[1] += hop; body.heading = Math.atan2(body.velocity[0], body.velocity[2]); vec3n.copy(surface, body.at); if (end) cheered(body, crowd.bodies); return; } vec3n.add(along, carried, body.velocity); vec3n.copy(surface, body.at);}/** Flies off the jelly's end for joy, cheered by walkers near enough that haven't cheered lately. */function cheered(body: Body, bodies: readonly Body[]) { body.joy = 1.5; for (const fan of bodies) { // Walking counts a cheer on down past 0, to the seconds since it ended if (fan.mode !== 'walk' || fan.asleep > 0 || fan.cheer > -CHEER.again) continue; if (Math.hypot(fan.at[0] - body.at[0], fan.at[2] - body.at[2]) > CHEER.reach) continue; fan.cheer = CHEER.time; fan.fan = body; }}/** Walks up the jelly leaning into the slope, or where steep reaches, grabs and heaves itself. */function climb(body: Body, slope: number, dt: number) { body.steep = ease(body.steep, std.smoothstep(CLIMB.steep[0], CLIMB.steep[1], slope), 5, dt); let moved: number; if (body.steep < 0.5) { moved = CLIMB.speed * (1 - 0.4 * Math.max(slope, 0)) * dt; body.cycle += (moved * CLIMB.planted) / CLIMB.stride; body.heave = 0; body.from = body.s + moved; body.grabFrom = body.s + R * 0.6; body.grab = body.s + HEAVE.reach; } else { body.heave += dt / HEAVE.time; if (body.heave >= 1) { body.heave -= 1; body.from = body.s; body.grabFrom = body.grab; body.grab = body.s + HEAVE.reach; } moved = body.from + HEAVE.pull * std.smoothstep(0.35, 0.75, body.heave) - body.s; } body.sliding = moved / dt; body.s += moved;}/** * Falls, off props, onto a head, onto the jelly where it faces up, or onto the ground. A critter * still wanting to ride, hopping on, missing the jelly, tries again from the ground. */export function fly(body: Body, crowd: Crowd, dt: number, spine: Float32Array) { body.velocity[1] -= FALL * dt; vec3n.addScaled(body.at, body.velocity, dt, body.at); body.mouth = Math.min(1, body.mouth + dt * 4); if (body.spinning) { body.spin += body.spinning * dt; if (!body.spinAxis && body.spin >= Math.PI * 2) { body.spin = 0; body.spinning = 0; } } const x = body.at[0]; const y = body.at[1]; const z = body.at[2]; for (const o of crowd.world.obstacles) { const gap = Math.hypot(x - o.x, z - o.z); if (y > GROUND + 0.25 || gap > o.radius + R || gap < 1e-4) continue; vec3n.set((x - o.x) / gap, 0, (z - o.z) / gap, out); vec3n.addScaled(body.at, out, o.radius + R - gap, body.at); const into = vec3n.dot(body.velocity, out); if (into < 0) vec3n.addScaled(body.velocity, out, -into * 1.5, body.velocity); body.squash.velocity -= 1; } const falling = body.velocity[1] < 0; if (falling && (onHead(body, crowd.bodies, x, y, z) || onJelly(body, spine, x, y, z))) return; const inSlot = Math.abs(x) < SLOT.x && Math.abs(z) < SLOT.z; const floor = inSlot ? SLOT.floor : GROUND; if (body.at[1] > floor) return; body.at[1] = floor; const impact = Math.abs(body.velocity[1]); if (inSlot) { // Out of the slot with a hop towards the nearer side vec3n.set(0, 1.1, Math.sign(z || 1) * 0.6, body.velocity); body.squash.velocity -= impact * 2; return; } body.at[1] = GROUND; land(body, impact);}/** Sits on the head of a critter under `x, y, z` it falls onto. Returns whether it did. */function onHead(body: Body, bodies: readonly Body[], x: number, y: number, z: number) { for (const other of bodies) { if (other === body || !other.posed || other.grown < 0.5 || under(other, body)) continue; if (other.mode === 'fly') continue; vec3n.addScaled(other.drawn, other.upright, R, top); const gap = Math.hypot(x - top[0], z - top[2]); if (gap < R * 0.9 && y < top[1] + R * 0.5 && y > top[1] - R * 0.8) { body.mode = 'perch'; body.base = other; body.perchFor = random(PERCH.shortest, PERCH.longest); body.spinning = 0; body.knocked = false; body.squash.velocity -= 1.5; return true; } } return false;}/** * Lands at `x, y, z` on the jelly where it faces up. One that hopped on to ride grips and climbs; * any other lands sliding. Returns whether it landed. */function onJelly(body: Body, spine: Float32Array, x: number, y: number, z: number) { if (Math.abs(z) >= RIDE_Z || x <= -1.02 || x >= 1.0) return false; nearest(spine, x, y, found); const short = found.s < spineLength(spine) - 0.06; const facing = seat(spine, found.s, z, here).normal[1]; if (found.above >= 0.01 || found.above <= -0.06 || !short || facing <= FACING.land) return false; const clambering = body.wantsRide; body.mode = 'ride'; body.s = found.s; body.sliding = clambering ? 0 : std.clamp(body.velocity[0], -0.6, 0.6); body.gripping = clambering; body.steep = 0; body.turn = body.sliding < 0 ? Math.PI : 0; body.wantsRide = false; body.plan = 0; if (clambering) body.rideIn = random(RIDDEN.shortest, RIDDEN.longest); body.squash.velocity -= Math.abs(body.velocity[1]) * 2; body.spinning = 0; body.knocked = false; return true;}/** Whether `body` stands anywhere under `other` in a stack. */function under(body: Body, other: Body) { for (let b: Body | undefined = body; b; b = b.base) if (b === other) return true; return false;}/** Sits on the head of its base until it jumps off, or falls when the base does. */export function perch(body: Body, dt: number) { const base = body.base; if (!base?.posed || base.mode === 'fly') { body.mode = 'fly'; body.base = undefined; if (base) vec3n.copy(base.velocity, body.velocity); else vec3n.zero(body.velocity); body.velocity[1] += 0.3; return; } body.perchFor -= dt; if (body.perchFor <= 0) { jump(body, 0.75, Math.random() < 0.6); vec3n.add(body.velocity, base.velocity, body.velocity); return; } vec3n.copy(body.at, before); vec3n.addScaled(base.drawn, base.upright, R * 0.98, body.at); vec3n.scale(vec3n.sub(body.at, before, body.velocity), 1 / dt, body.velocity); body.heading += turnBetween(body.heading, base.heading) * Math.min(1, dt * 2); body.mouth = ease(body.mouth, 0.2, 4, dt);}/** * Leaps to the lever's knob and hangs on it until the lever is down, then drops off. Returns * whether it is done with the lever. */export function hang(body: Body, crowd: Crowd, dt: number) { const { world } = crowd; vec3n.addScaled(body.at, UP, STANDING, before); body.pulling += dt; const u = Math.min(1, body.pulling / HERALD.leap); vec3n.add(world.knob, HERALD.hang, hanging); vec3n.copy(hanging, centre); if (u < 1) { vec3n.lerp(body.leapFrom, hanging, std.smoothstep(0, 1, u), centre); vec3n.addScaled(centre, UP, Math.sin(Math.PI * u) * HERALD.arc, centre); } vec3n.addScaled(centre, UP, -STANDING, body.at); vec3n.scale(vec3n.sub(centre, before, body.velocity), 1 / dt, body.velocity); body.heading = 0; body.mouth = ease(body.mouth, u < 1 ? 0.7 : 0.35, 8, dt); if (world.holding) { // Shaken off by the pointer taking the lever body.mode = 'fly'; vec3n.set(0, 0.3, 0.4, body.velocity); body.shock = 1; } else if (crowd.errand !== body || (u >= 1 && world.pulled > HERALD.done)) { // Drops off for joy, an ordinary critter from now on body.mode = 'fly'; vec3n.scale(body.velocity, 0.5, body.velocity); body.velocity[1] += 0.5; body.velocity[2] += 0.3; body.joy = 2; body.plan = 0; return true; } return false;}/** Follows the pointer to `to`, over the jelly rather than through it. */export function carry(body: Body, to: V3, dt: number, spine: Float32Array) { vec3n.copy(body.at, before); vec3n.addScaled(body.at, vec3n.sub(to, body.at, apart), clock.ease(dt, 18), body.at); if (Math.abs(body.at[2]) < RIDE_Z + 0.05) { nearest(spine, body.at[0], body.at[1], found); if (found.above < 0.01 && found.above > -0.05) { const on = seat(spine, found.s, body.at[2], here); vec3n.addScaled(on.surface, on.normal, 0.01, body.at); } } vec3n.sub(body.at, before, body.velocity); vec3n.scale(body.velocity, 1 / Math.max(dt, 1e-4), body.velocity); body.mouth = 1; body.heldFor += dt;}/** Pushes overlapping critters apart, and knocks over those that meet fast. */export function collide(bodies: readonly Body[], dt: number) { for (let i = 0; i < CRITTERS; i++) { for (let j = i + 1; j < CRITTERS; j++) { const a = bodies[i]!; const b = bodies[j]!; if (!a.posed || !b.posed || a.grown < 0.5 || b.grown < 0.5) continue; if (a.base === b || b.base === a) continue; // Walkers meet on the ground where they are now, not where they were last drawn const walking = a.mode === 'walk' && b.mode === 'walk'; if (walking) vec3n.set(a.at[0] - b.at[0], 0, a.at[2] - b.at[2], apart); else vec3n.sub(a.drawn, b.drawn, apart); const distance = vec3n.length(apart); if (distance >= R * 2 || distance < 1e-5) continue; vec3n.scale(apart, 1 / distance, direction); const closing = vec3n.dot(vec3n.sub(a.velocity, b.velocity, relative), direction); if (closing < -KNOCK.speed) { const shareA = shareOf(a, b); const shareB = shareOf(b, a); const impulse = -(1 + KNOCK.bounce) * closing; if (shareA) topple(a, vec3n.scale(direction, impulse * shareA, push)); if (shareB) topple(b, vec3n.scale(direction, -impulse * shareB, push)); continue; } const overlap = Math.min((R * 2 - distance) * 0.5, STEER.apart * dt); nudge(a, direction, overlap); nudge(b, direction, -overlap); } }}/** The share of a knock `body` takes from `other`: none while held, all from a held one. */function shareOf(body: Body, other: Body) { if (body.mode === 'held') return 0; return other.mode === 'held' ? 1 : 0.5;}/** Moves a walker or a rider `by` along `n`. */function nudge(body: Body, n: V3, by: number) { if (body.mode === 'walk') { body.at[0] += n[0] * by; body.at[2] += n[2] * by; } else if (body.mode === 'ride') body.s += n[0] * by;}poses.ts
import { std } from 'typegpu';import { vec3n } from 'wgpu-matrix';import { CLIMB, ease, HERALD, HIP, JOINTS, petted, R, random, STANDING, UP, WALK, type Body, kick,} from './body.ts';import { createSeat, seat } from './spine.ts';import { GROUND, type V3 } from './tray.ts';import { SHOVE } from './walking.ts';/** Thigh and shin lengths. */const THIGH = 0.032;const SHIN = 0.03;/** * The body's bend: a spring that lags behind its motion by `lag`, leans at most `most` radii, * and sways by `sway` radii at rest. */const BEND = { lag: 0.55, most: 0.8, sway: 0.25 };/** The length of what grows on the head, for each kind. */const TOP = { lengths: [R * 1.05, R * 0.6, R * 0.6] };/** The fastest a joint moves from the body before its jump is eased, and how fast easing fades. */const JUMP = { speed: 1.2, fade: 12 };type V4 = [number, number, number, number];/** One critter as the march draws it, a row of the `Critter` table that `figureOf` fills. */export interface Figure { readonly body: V4; /** How far back the body was when the last frame was drawn, which the caller writes. */ readonly moved: V4; readonly up: V4; readonly ahead: V4; readonly gaze: V4; readonly joints: V4[]; readonly color: V4; readonly bound: V4; readonly bend: V4; readonly face: V4; readonly top: V4;}const v4 = (): V4 => [0, 0, 0, 0];export const createFigure = (): Figure => ({ body: v4(), moved: v4(), up: v4(), ahead: v4(), gaze: v4(), joints: Array.from({ length: JOINTS }, v4), color: v4(), bound: v4(), bend: v4(), face: v4(), top: v4(),});/** What a critter looks at and reaches for besides itself. */export interface Sights { readonly camera: V3; /** The point the pointer hovers while it is fresh. */ pointer: V3 | undefined; readonly knob: V3;}const v3 = (): V3 => [0, 0, 0];const v3s = (count: number) => Array.from({ length: count }, v3);const AHEAD: V3 = [0, 0, 1];// Scratch the steps and poses write their vectors into, one critter at a timeconst home = v3();const lead = v3();const gap = v3();const rest = v3();const toward = v3();const settled = createSeat();const contact = createSeat();/** The body's centre and up axis, and which way it faces, as `frameOf` writes them. */const pose = { ahead: v3(), up: v3(), centre: v3() };const [low, high, between] = v3s(3);const [up, side, facing, bend, shown, face, look, aim, tip] = v3s(9);const [hips, shoulders, feet, hands, knees, steps, holds] = Array.from({ length: 7 }, () => v3s(2));const [walkingFoot, walkingHand, heavingFoot, heavingHand, sittingFoot, sittingHand] = v3s(6);const [forward, climb, pole] = v3s(3);const joints = v3s(JOINTS + 1);const placed = v3s(JOINTS + 1);const turned = v3();/** A walking foot's stride, as `climbStep` writes it. */const stride = { ahead: 0, lift: 0 };/** How much of the bend a body point takes, by its height along the up axis, as in figures.ts. */const bendAt = (along: number) => std.clamp((along / R + 1) * 0.5, 0, 1) ** 2;/** Rotates `v` round unit `axis` by `angle` radians into `out`, which may be `v`. */const rotate = (v: V3, axis: V3, angle: number, out: V3) => { const c = Math.cos(angle); const along = vec3n.dot(axis, v) * (1 - c); vec3n.cross(axis, v, turned); vec3n.scale(v, c, out); vec3n.addScaled(out, turned, Math.sin(angle), out); return vec3n.addScaled(out, axis, along, out);};/** * Writes into `out` a knee between hip and foot, bent towards `bendTo`, for two bones of `a` and `b`. */const knee = (hip: V3, foot: V3, a: number, b: number, bendTo: V3, out: V3) => { vec3n.sub(foot, hip, toward); const span = Math.min(vec3n.length(toward), a + b - 1e-4); vec3n.normalize(toward, toward); const along = (span * span + a * a - b * b) / (2 * span); const reach = Math.sqrt(Math.max(a * a - along * along, 0)); vec3n.addScaled(bendTo, toward, -vec3n.dot(bendTo, toward), between); vec3n.normalize(between, between); vec3n.addScaled(hip, toward, along, out); return vec3n.addScaled(out, between, reach, out);};/** How far a walking foot sits ahead of the body along the jelly, and how high it lifts. */const climbStep = (cycle: number) => { const u = cycle - Math.floor(cycle); if (u < CLIMB.planted) { stride.ahead = CLIMB.stride * (0.5 - u / CLIMB.planted); stride.lift = 0; return stride; } const w = (u - CLIMB.planted) / (1 - CLIMB.planted); stride.ahead = CLIMB.stride * (w - 0.5); stride.lift = Math.sin(Math.PI * w) * CLIMB.lift; return stride;};/** Steps a foot towards where it belongs under the hips once the other is planted. */export function gait(body: Body, dt: number, across: V3) { vec3n.scale(body.velocity, 0.12, lead); for (let i = 0; i < 2; i++) { const foot = body.feet[i]!; vec3n.set(body.at[0], GROUND, body.at[2], home); vec3n.addScaled(home, across, (i === 0 ? 1 : -1) * R * 0.45, home); vec3n.add(home, lead, home); if (foot.t < 1) { foot.t = Math.min(1, foot.t + dt / WALK.time); vec3n.lerp(foot.from, foot.to, foot.t, foot.at); foot.at[1] += Math.sin(Math.PI * foot.t) * WALK.lift; if (foot.t >= 1) body.squash.velocity -= 0.25; continue; } const other = body.feet[1 - i]!; if (other.t >= 1 && vec3n.distance(home, foot.at) > WALK.stride) { vec3n.sub(home, foot.at, gap); vec3n.copy(foot.at, foot.from); vec3n.addScaled(home, gap, 0.3, foot.to); foot.to[1] = GROUND; foot.t = 0; } }}/** How far through its swing foot `i` is, as the height of its arc. */const swingOf = (body: Body, i: number) => body.feet[i]!.t < 1 ? Math.sin(Math.PI * body.feet[i]!.t) : 0;/** * The bend lags behind the body's `accel`, sways at rest, leans over the planted foot and into a * petting pointer at `watched`. The squash springs back to 0. */export function springs( body: Body, dt: number, accel: V3, across: V3, ahead: V3, watched: V3 | undefined,) { const shift = body.mode === 'walk' ? swingOf(body, 1) - swingOf(body, 0) : 0; const sway = Math.sin(body.phase * 2.3) * BEND.sway + shift * 0.55 + (body.dizzy > 0 ? Math.sin(body.phase * 6) * 0.5 : 0) + (petted(body) ? Math.sin(body.phase * 9) * 0.25 : 0); const forth = Math.sin(body.phase * 1.6 + 1) * BEND.sway * 0.5; vec3n.scale(across, sway * R, rest); vec3n.addScaled(rest, ahead, forth * R, rest); if (petted(body) && watched) { vec3n.set(watched[0] - body.at[0], 0, watched[2] - body.at[2], gap); vec3n.addScaled(rest, vec3n.normalize(gap, gap), R * 0.5, rest); } kick(body.bend, accel, -dt * BEND.lag); body.bend.step(rest, dt); body.squash.step(0, dt);}/** Eases how happy, surprised, sleepy and dizzy it looks towards what it is doing. */export function moods(body: Body, dt: number) { body.shock -= dt; body.joy -= dt; const climbing = body.mode === 'ride' && body.gripping; const effort = climbing && body.steep > 0.5 && body.heave > 0.35 && body.heave < 0.8; const happy = body.mode === 'perch' || body.mode === 'pull' || body.cheer > 0 || petted(body) || body.joy > 0 || body.wave > 0 || (body.mode === 'ride' && !body.gripping); let surprised = 0; if (body.shock > 0) surprised = 1; else if (body.mode === 'held') surprised = 0.5; let sleepy = 0; if (body.asleep > 0) sleepy = 1; else if (effort) sleepy = 0.45; body.mood[0] = ease(body.mood[0], happy && body.shock <= 0 ? 1 : 0, 8, dt); body.mood[1] = ease(body.mood[1], surprised, 10, dt); body.mood[2] = ease(body.mood[2], sleepy, body.asleep > 0 ? 2 : 8, dt); body.mood[3] = ease(body.mood[3], body.dizzy > 0 ? 1 : 0, 6, dt);}/** Rolls on upright out of a flip or tumble that ended early. */export function rollUpright(body: Body, dt: number) { const upright = Math.round(body.spin / (Math.PI * 2)) * Math.PI * 2; body.spin = ease(body.spin, upright, 10, dt); if (Math.abs(upright - body.spin) < 0.02) { body.spin = 0; body.spinAxis = undefined; }}/** Blinks now and then, the lids shutting at once and opening over an eighth of a second. */export function blinks(body: Body, dt: number) { body.blinkIn -= dt; if (body.blinkIn <= 0) { body.blink = 1; body.blinkIn = random(1.5, 5); } body.blink = Math.max(0, body.blink - dt * 8);}/** * Writes into `out` the point on the jelly at arc length `s`, `across` times `out` to the side and * `lift` off it. */const onJelly = ( body: Body, spine: Float32Array, s: number, out: number, across: number, lift: number, into: V3,) => { const { surface, normal } = seat(spine, s, body.at[2], contact); vec3n.addScaled(surface, side, out * across, into); return vec3n.addScaled(into, normal, lift, into);};/** * Hands and feet on the jelly, walking up it, heaving up steep jelly or sitting to slide, into * `steps` and `holds`. */function ridingPose(body: Body, spine: Float32Array, time: number) { const u = body.heave; for (let k = 0; k < 2; k++) { const out = k ? -R : R; // Walking up: stepping feet and pumping arms const step = climbStep(body.cycle + k * 0.5); const pump = Math.sin((body.cycle + k * 0.5) * Math.PI * 2); onJelly(body, spine, body.s + step.ahead, out, 0.45, R * 0.17 + step.lift, walkingFoot); vec3n.addScaled(shoulders[k]!, up, -R * 0.45, walkingHand); vec3n.addScaled(walkingHand, side, out * 0.35, walkingHand); vec3n.addScaled(walkingHand, facing, pump * R * 0.45, walkingHand); // Heaving: hands reach to a new hold, then the feet scrabble as the body pulls up const reaching = u < 0.35; const handAt = reaching ? body.grabFrom + (body.grab - body.grabFrom) * std.smoothstep(0, 0.35, u) : body.grab; const handLift = reaching ? Math.sin((Math.PI * u) / 0.35) * R * 0.5 : 0; const pulling = u >= 0.35 && u < 0.8; const scrabble = pulling ? Math.sin(time * 30 + k * Math.PI) : 0; onJelly(body, spine, handAt, out, 0.55, R * 0.12 + handLift, heavingHand); const footAt = body.s - R * 0.45 + scrabble * R * 0.2; const footLift = R * 0.17 + Math.max(0, scrabble) * R * 0.25; onJelly(body, spine, footAt, out, 0.45, footLift, heavingFoot); vec3n.addScaled(hips[k]!, facing, HIP * 0.85, sittingFoot); vec3n.addScaled(sittingFoot, up, -R * 0.2, sittingFoot); vec3n.addScaled(shoulders[k]!, side, out * 0.35, sittingHand); vec3n.addScaled(sittingHand, up, R * 0.95 + Math.sin(time * 18 + k) * 0.006, sittingHand); vec3n.lerp(walkingFoot, heavingFoot, body.steep, steps[k]!); vec3n.lerp(steps[k]!, sittingFoot, body.seated, steps[k]!); vec3n.lerp(walkingHand, heavingHand, body.steep, holds[k]!); vec3n.lerp(holds[k]!, sittingHand, body.seated, holds[k]!); }}/** * Eases any joint that jumps from where it was drawn, such as on grabbing the jelly or waving. * Takes the body's centre then its joints in `joints`, and writes them as drawn into `placed`. */function smooth(body: Body, dt: number) { const [centre] = joints as [V3]; // Joints relative to the centre for (let j = 1; j <= JOINTS; j++) vec3n.sub(joints[j]!, centre, joints[j]!); const most = JUMP.speed * dt; const fade = Math.exp(-dt * JUMP.fade); for (let j = 0; j <= JOINTS; j++) { const now = joints[j]!; const settle = body.settle[j]!; if (body.posed) { const expected = j ? body.raw[j]! : vec3n.addScaled(body.raw[0]!, body.velocity, dt, aim); if (vec3n.distance(now, expected) > most) { vec3n.add(settle, vec3n.sub(expected, now, gap), settle); } } vec3n.scale(settle, fade, settle); } for (let j = 0; j <= JOINTS; j++) vec3n.copy(joints[j]!, body.raw[j]!); body.posed = true; vec3n.add(centre, body.settle[0]!, body.drawn); vec3n.copy(body.drawn, placed[0]!); for (let j = 1; j <= JOINTS; j++) { vec3n.add(body.drawn, joints[j]!, placed[j]!); vec3n.add(placed[j]!, body.settle[j]!, placed[j]!); }}/** What grows on the head, on a spring from the top of the body at `base`, into `tip`. */function crown(body: Body, base: V3, dt: number) { const length = TOP.lengths[body.kind]!; vec3n.addScaled(base, up, length, rest); const sprung = body.tip.value; if (!body.tipped) { vec3n.copy(rest, sprung); body.tipped = true; } body.tip.step(rest, dt); vec3n.normalize(vec3n.sub(sprung, base, gap), gap); vec3n.addScaled(base, gap, length, sprung); return vec3n.copy(sprung, tip);}/** Writes the body's centre and up axis, and which way it faces, for what it is doing into `pose`. */function frameOf(body: Body, spine: Float32Array, knob: V3) { const { ahead, up: tilt, centre } = pose; vec3n.set(Math.sin(body.heading), 0, Math.cos(body.heading), ahead); if (body.mode === 'ride') { const { normal, tangent, surface } = seat(spine, body.s, body.at[2], settled); vec3n.scale(tangent, Math.cos(body.turn), ahead); vec3n.normalize(vec3n.addScaled(ahead, AHEAD, Math.sin(body.turn), ahead), ahead); const bob = Math.abs(Math.sin(body.cycle * Math.PI * 2)) * 0.004; // Walking tilt, heaving tilt steep jelly, or sitting vec3n.normalize(vec3n.addScaled(UP, tangent, 0.25, low), low); vec3n.normalize(vec3n.addScaled(normal, UP, 0.4, high), high); vec3n.lerp(low, high, body.steep, tilt); vec3n.normalize(vec3n.lerp(tilt, normal, body.seated, tilt), tilt); vec3n.addScaled(surface, UP, STANDING * 0.95 + bob, low); vec3n.addScaled(surface, normal, R * 1.1, high); vec3n.lerp(low, high, body.steep, centre); vec3n.lerp(centre, vec3n.addScaled(surface, normal, R * 0.95, low), body.seated, centre); return pose; } if (body.mode === 'perch' && body.base) { vec3n.normalize(vec3n.addScaled(body.base.upright, body.base.bend.value, 4, tilt), tilt); vec3n.addScaled(body.at, tilt, R * 0.9, centre); return pose; } if (body.mode === 'walk' && body.asleep > 0) { vec3n.copy(UP, tilt); vec3n.addScaled(body.at, UP, R * 0.92, centre); return pose; } if (body.mode === 'pull') { // Upright through the leap, then hanging from the knob, facing away from the lever vec3n.addScaled(body.at, UP, STANDING, centre); const hanging = std.smoothstep(0.5, 1, body.pulling / HERALD.leap); vec3n.copy(AHEAD, ahead); vec3n.normalize(vec3n.sub(knob, centre, high), high); vec3n.normalize(vec3n.lerp(UP, high, hanging, tilt), tilt); return pose; } vec3n.set(body.velocity[0] * 0.8, 1, body.velocity[2] * 0.8, tilt); vec3n.normalize(tilt, tilt); let bob = 0; if (body.mode === 'walk') { bob = Math.max(body.feet[0].at[1] - GROUND, body.feet[1].at[1] - GROUND); } vec3n.copy(body.at, centre); centre[1] += bob * 0.5; vec3n.addScaled(centre, tilt, STANDING, centre); return pose;}/** * Where the feet are off the jelly, into `feet`: out in front sitting, walking, tucked into a flip, * or dangling and kicking, flailing while tumbling. */function feetOf(body: Body, time: number) { for (let k = 0; k < 2; k++) { const hip = hips[k]!; const foot = feet[k]!; if ((body.mode === 'walk' && body.asleep > 0) || body.mode === 'perch') { // Swinging a little on a friend's head const swing = body.mode === 'perch' ? 1 : 0; vec3n.addScaled(hip, facing, HIP * 0.85, foot); vec3n.addScaled(foot, up, -R * 0.25 + Math.sin(time * 4 + k * 2) * swing * 0.006, foot); } else if (body.mode === 'walk') { vec3n.copy(body.feet[k]!.at, foot); } else if (body.spinning && !body.spinAxis) { vec3n.addScaled(hip, up, -HIP * 0.45, foot); vec3n.addScaled(foot, facing, HIP * 0.3, foot); } else { const pace = body.spinAxis ? 30 : 16; vec3n.addScaled(hip, up, -HIP * 0.85, foot); vec3n.addScaled(foot, facing, Math.sin(time * pace + k * 2) * 0.012, foot); } }}/** Writes into `out` shoulder `k` reaching out `reach` radii to its side. */const outward = (k: number, reach: number, out: V3) => vec3n.addScaled(shoulders[k]!, side, (k ? -1 : 1) * reach * R, out);/** Where hand `k` goes for what the critter is doing, into `hands`. */function handOf(body: Body, i: number, k: number, time: number, knob: V3) { const hand = hands[k]!; const riding = body.mode === 'ride'; const asleep = body.mode === 'walk' && body.asleep > 0; // Cheering, which pulling and flying start from outward(k, 0.35, aim); vec3n.addScaled(aim, up, R * 0.95 + Math.sin(time * 18 + k * 1.3) * 0.006, aim); if (riding) { vec3n.copy(holds[k]!, hand); } else if (asleep) { vec3n.addScaled(outward(k, -0.5, hand), facing, R * 0.8, hand); vec3n.addScaled(hand, up, -R * 0.3, hand); } else if (body.mode === 'pull') { vec3n.addScaled(knob, side, (k ? -1 : 1) * R * 0.3, hand); vec3n.lerp(aim, hand, std.smoothstep(0.3, 0.9, body.pulling / HERALD.leap), hand); } else if (body.mode === 'perch') { // Holding on to the head below, waving now and then const wave = Math.max(0, Math.sin(time * 1.3 + i)) * (k ? 0 : 1); vec3n.addScaled(outward(k, 0.2, hand), up, -R * 0.9 + wave * R * 2.1, hand); vec3n.addScaled(hand, facing, R * 0.3, hand); } else if (body.mode !== 'walk') { if (body.spinAxis) vec3n.addScaled(aim, side, Math.sin(time * 25 + k * 3) * R * 0.5, hand); else vec3n.copy(aim, hand); } else if (body.shove > 0) { vec3n.addScaled(outward(k, 0.45, hand), up, R * 0.05, hand); vec3n.addScaled(hand, facing, body.shove < SHOVE.windup ? 0 : R, hand); } else if (body.wave > 0 && k === 0) { vec3n.addScaled(outward(k, 0.3 + Math.sin(time * 16) * 0.3, hand), up, R * 1.3, hand); } else if (body.load > 0) { vec3n.addScaled(outward(k, 0.4, hand), up, R, hand); } else if (body.cheer > 0) { const lift = R * (1.1 + Math.sin(time * 14 + k * 2) * 0.25); vec3n.addScaled(outward(k, 0.4, hand), up, lift, hand); } else if (body.dizzy > 0) { vec3n.addScaled(outward(k, 0.9, hand), up, Math.sin(time * 6 + k * 2) * R * 0.4, hand); } else if (body.flee > 0) { const lift = R * 1.2 + Math.sin(time * 20 + k * 3) * R * 0.2; vec3n.addScaled(outward(k, 0.4, hand), up, lift, hand); } else { // Arms swing against the legs const swing = vec3n.dot(vec3n.sub(feet[1 - k]!, hips[1 - k]!, gap), facing); vec3n.addScaled(outward(k, 0.45, hand), up, -R * 0.55, hand); vec3n.addScaled(hand, facing, swing * 0.6 + 0.004, hand); }}/** Bends `p` in place by its height along the body from `centre`. */const bendIn = (p: V3, centre: V3) => vec3n.addScaled(p, bend, bendAt(vec3n.dot(vec3n.sub(p, centre, gap), up)), p);/** Writes `v` grown in from the ground under the body by `grown`, and `w`, into `out`. */const put = (body: Body, v: V3, w: number, out: V4) => { vec3n.sub(v, body.at, gap); vec3n.addScaled(body.at, gap, body.grown, out); out[3] = w;};/** * Poses critter `i` for drawing into its `figure`: the body bent and squashed by its springs, its * legs and arms placed for what it is doing, and its eyes on what it looks at. */export function figureOf( body: Body, i: number, time: number, dt: number, spine: Float32Array, sights: Sights, figure: Figure,) { const { knob } = sights; const g = body.grown; const riding = body.mode === 'ride'; const asleep = body.mode === 'walk' && body.asleep > 0; const { centre } = frameOf(body, spine, knob); vec3n.copy(pose.up, up); vec3n.normalize(vec3n.cross(up, pose.ahead, side), side); vec3n.normalize(vec3n.cross(side, up, facing), facing); if (body.spin) { const axis = body.spinAxis ?? side; rotate(up, axis, body.spin, up); rotate(facing, axis, body.spin, facing); vec3n.normalize(vec3n.cross(up, facing, side), side); } vec3n.copy(up, body.upright); const bent = body.bend.value; vec3n.addScaled(bent, up, -vec3n.dot(bent, up), bend); if (vec3n.length(bend) > BEND.most * R) { vec3n.scale(vec3n.normalize(bend, bend), BEND.most * R, bend); } for (let k = 0; k < 2; k++) { const out = k ? -1 : 1; vec3n.addScaled(centre, side, out * R * 0.45, hips[k]!); bendIn(vec3n.addScaled(hips[k]!, up, -R * 0.55, hips[k]!), centre); bendIn(vec3n.addScaled(centre, side, out * R * 0.85, shoulders[k]!), centre); } if (riding) { ridingPose(body, spine, time); for (let k = 0; k < 2; k++) vec3n.copy(steps[k]!, feet[k]!); } else { feetOf(body, time); } for (let k = 0; k < 2; k++) { const out = k ? -1 : 1; vec3n.addScaled(facing, side, out * 0.3, forward); vec3n.addScaled(up, side, out * 0.7, climb); vec3n.lerp(forward, climb, body.steep, climb); if (riding) vec3n.lerp(climb, forward, body.seated, pole); else vec3n.copy(forward, pole); knee(hips[k]!, feet[k]!, THIGH, SHIN, pole, knees[k]!); } for (let k = 0; k < 2; k++) handOf(body, i, k, time, knob); gazeOf(body, centre, time, sights); vec3n.copy(centre, joints[0]!); for (let k = 0; k < 2; k++) { vec3n.copy(hips[k]!, joints[1 + k * 3]!); vec3n.copy(knees[k]!, joints[2 + k * 3]!); vec3n.copy(feet[k]!, joints[3 + k * 3]!); vec3n.copy(shoulders[k]!, joints[7 + k * 2]!); vec3n.copy(hands[k]!, joints[8 + k * 2]!); } smooth(body, dt); vec3n.copy(placed[0]!, shown); vec3n.addScaled(shown, facing, R * 0.72, face); vec3n.addScaled(face, up, R * 0.3, face); // Stretching to reach and squashing at the end of each heave, and breathing in a nap let squash = body.squash.value; if (riding && body.gripping && body.steep > 0.5) { const u = body.heave; squash += u < 0.35 ? Math.sin((Math.PI * u) / 0.35) * 0.15 : -std.smoothstep(0.35, 0.8, u) * 0.12; } if (asleep) squash += Math.sin(time * 2.4 + i) * 0.06; squash = std.clamp(squash, -0.35, 0.35); vec3n.addScaled(shown, up, R * 0.95 * (1 + squash), low); crown(body, vec3n.add(low, bend, low), dt); // The bound wraps the bent, squashed body with its eyes, each joint with its foot or hand, // and the tip with its ball or splayed ears let reach = R * (1 + Math.abs(squash)) * 1.3 + vec3n.length(bend); for (let j = 1; j <= JOINTS; j++) { reach = Math.max(reach, vec3n.distance(placed[j]!, shown) + R * 0.2); } reach = Math.max(reach, vec3n.distance(tip, shown) + R); put(body, shown, R * g, figure.body); vec3n.copy(up, figure.up); figure.up[3] = squash; vec3n.copy(facing, figure.ahead); figure.ahead[3] = body.blink; put(body, vec3n.add(face, look, face), body.mouth, figure.gaze); for (let j = 0; j < JOINTS; j++) put(body, placed[j + 1]!, 0, figure.joints[j]!); vec3n.copy(body.color, figure.color); figure.color[3] = g; put(body, shown, reach * g, figure.bound); vec3n.scale(bend, g, figure.bend); for (let m = 0; m < 4; m++) figure.face[m] = body.mood[m]!; put(body, tip, body.kind, figure.top); return figure;}/** * Which way the pupils look, into `look`: along its way while riding or flying, else at what holds * its eye. */function gazeOf(body: Body, centre: V3, time: number, sights: Sights) { const eyes = vec3n.addScaled(centre, facing, R * 0.72, high); vec3n.addScaled(eyes, up, R * 0.3, eyes); const riding = body.mode === 'ride'; let target: V3 = sights.camera; if (riding && body.gripping) { vec3n.addScaled(centre, vec3n.addScaled(facing, up, 0.3, aim), 2, aim); target = aim; } else if (riding || body.mode === 'fly') { vec3n.copy(facing, aim); aim[1] -= 0.2; vec3n.addScaled(centre, aim, 2, aim); target = aim; } else if (body.wave > 0 && body.friend) { vec3n.copy(body.friend.at, aim); aim[1] += HIP + R; target = aim; } else if (body.cheer > 0 && body.fan?.posed) target = body.fan.drawn; else if (body.victim?.posed) target = body.victim.drawn; else if (body.grudge?.posed) target = body.grudge.drawn; else if (sights.pointer) target = sights.pointer; vec3n.normalize(vec3n.sub(target, eyes, look), look); if (body.dizzy > 0) { vec3n.scale(side, Math.cos(time * 9), aim); vec3n.addScaled(aim, up, Math.sin(time * 9), aim); vec3n.normalize(vec3n.addScaled(facing, aim, 0.8, look), look); } // Pupils stay on the front of the eyes, however far behind the target is const ahead = vec3n.dot(look, facing); if (ahead < 0.4) { vec3n.normalize(vec3n.addScaled(look, facing, -ahead, look), look); vec3n.scale(look, 0.92, look); vec3n.addScaled(look, facing, 0.4, look); } return look;}spine.ts
import { std } from 'typegpu';import { POINTS, SKIN } from './slider.ts';import type { V3 } from './tray.ts';/** A point along the spine and its tangent and normal, as `sample` writes them. */const along = { x: 0, y: 0, tx: 1, ty: 0, nx: 0, ny: 1 };/** The normals of a segment and of those before and after it. */const normals = { here: [0, 0], before: [0, 0], after: [0, 0] };/** Writes the unit normal of segment `i`, clamped to the spine's first and last, into `out`. */const segmentNormal = (spine: Float32Array, i: number, out: number[]) => { const k = std.clamp(i, 0, POINTS - 2); const dx = spine[k * 2 + 2]! - spine[k * 2]!; const dy = spine[k * 2 + 3]! - spine[k * 2 + 1]!; const segment = Math.hypot(dx, dy) || 1e-6; out[0] = -dy / segment; out[1] = dx / segment;};/** * Point, tangent and normal at arc length `s` along a spine of xy points, written into `along`. The * normal blends between the segments' normals at each point, so it turns smoothly along the spine. */const sample = (spine: Float32Array, s: number) => { let left = Math.max(s, 0); for (let i = 0; i < POINTS - 1; i++) { const ax = spine[i * 2]!; const ay = spine[i * 2 + 1]!; const dx = spine[i * 2 + 2]! - ax; const dy = spine[i * 2 + 3]! - ay; const segment = Math.hypot(dx, dy) || 1e-6; if (left <= segment || i === POINTS - 2) { const t = Math.min(left / segment, 1); const { here, before, after } = normals; segmentNormal(spine, i, here); segmentNormal(spine, i - 1, before); segmentNormal(spine, i + 1, after); const fromX = (here[0]! + before[0]!) * (1 - t) + (here[0]! + after[0]!) * t; const fromY = (here[1]! + before[1]!) * (1 - t) + (here[1]! + after[1]!) * t; const n = Math.hypot(fromX, fromY) || 1e-6; along.nx = fromX / n; along.ny = fromY / n; along.x = ax + dx * t; along.y = ay + dy * t; along.tx = along.ny; along.ty = -along.nx; return; } left -= segment; } along.x = along.y = along.ty = along.nx = 0; along.tx = along.ny = 1;};export const spineLength = (spine: Float32Array) => { let total = 0; for (let i = 0; i < POINTS - 1; i++) { total += Math.hypot(spine[i * 2 + 2]! - spine[i * 2]!, spine[i * 2 + 3]! - spine[i * 2 + 1]!); } return total;};/** The spine point nearest a point, as `nearest` finds it. */export interface Nearest { /** Its arc length along the spine. */ s: number; /** How far above the jelly's top the point is. */ above: number;}/** Finds the spine point nearest `x, y`, into `out`, and returns `out`. */export const nearest = (spine: Float32Array, x: number, y: number, out: Nearest) => { let best = Infinity; let at = 0; let above = 0; let travelled = 0; for (let i = 0; i < POINTS - 1; i++) { const ax = spine[i * 2]!; const ay = spine[i * 2 + 1]!; const dx = spine[i * 2 + 2]! - ax; const dy = spine[i * 2 + 3]! - ay; const segment = Math.hypot(dx, dy) || 1e-6; const t = std.clamp(((x - ax) * dx + (y - ay) * dy) / (segment * segment), 0, 1); const px = ax + dx * t; const py = ay + dy * t; const gap = Math.hypot(x - px, y - py); if (gap < best) { best = gap; at = travelled + segment * t; above = ((x - px) * -dy + (y - py) * dx) / segment; } travelled += segment; } out.s = at; out.above = above - SKIN; return out;};/** Where a critter's body sits on the jelly's top, as `seat` writes it. */export interface Seat { readonly surface: V3; readonly normal: V3; readonly tangent: V3;}export const createSeat = (): Seat => ({ surface: [0, 0, 0], normal: [0, 0, 0], tangent: [0, 0, 0],});/** * Writes where a critter's body sits on the jelly's top, which is the spine's left side, at arc * length `s` and `z` across into `out`, and returns `out`. */export const seat = (spine: Float32Array, s: number, z: number, out: Seat) => { sample(spine, s); const { surface, normal, tangent } = out; surface[0] = along.x + along.nx * SKIN; surface[1] = along.y + along.ny * SKIN; surface[2] = z; normal[0] = along.nx; normal[1] = along.ny; normal[2] = 0; tangent[0] = along.tx; tangent[1] = along.ty; tangent[2] = 0; return out;};walking.ts
import { std } from 'typegpu';import { vec3n } from 'wgpu-matrix';import { crossed, ease, FALL, HERALD, LADDER, LAND, PET, petted as isPetted, R, SHOO, STANDING, startle, topple, UP, WALK, jump, type Body, type Crowd, random, kick,} from './body.ts';import { createSeat, nearest, seat } from './spine.ts';import { asideOf, blockOf, keepOut, passing, routeOf, STEER, stride, turnTo } from './steering.ts';import { GROUND, type V3 } from './tray.ts';/** How far critters come to look at the pointer, how close they stop, and bounces per second. */const CURIOUS = { reach: 0.55, near: 0.13, bounce: 0.35 };/** How close two critters come before they wave, for how long, and the least time between. */const GREET = { reach: R * 5, time: 1.4, again: 9 };/** Naps: chance per second while idle, and how long they last. */const SLEEP = { chance: 0.04, shortest: 6, longest: 12 };/** Follow the leader: chance per plan, followers, how far they look, spacing and duration. */const TRAIN = { chance: 0.18, size: 3, reach: 1, gap: R * 3.2, time: 14 };/** Shoves at a critter in the way. */export const SHOVE = { /** Seconds blocked before one, and its chance then. */ after: 0.7, chance: 0.4, /** Seconds of its wind-up, and of all of it. */ windup: 0.35, time: 0.7, /** How fast the shover lunges and the victim stumbles. */ lunge: 0.2, push: 0.25, /** The chance it knocks the victim over, and how hard it tips and lifts it then. */ knock: 0.25, tip: 0.15, lift: 0.5, /** Seconds between shoves, and the chance of a shove back. */ again: [10, 20], back: 0.35,} as const;/** * Climbing onto the jelly, only near its fixed end, never near the free end where it rears up and * throws riders. */const BOARD = { /** The chance a plan is to ride it, from the timidest critter to the boldest. */ chance: [0.35, 0.8], /** The chance it goes up the approach at the fixed end, rather than from beside the jelly. */ approach: 0.3, /** The world x beside the jelly it climbs on from, nearest where it is, and z off its middle. */ beside: [-0.9, -0.45], edge: 0.29, /** Near it, it hesitates up to `hesitate` seconds, then dares at `dare`, up to 1 by nerve. */ near: 0.3, hesitate: 1.2, dare: 0.6, /** Seconds a hop takes, onto a top at most `reach` along, `step` up and facing up `flat`. */ hop: 0.5, reach: 0.55, step: 0.07, flat: 0.3, /** The most it lands off the jelly's middle. */ lands: 0.09, /** Bold critters run the last `runUp` metres faster. */ runUp: 0.35,} as const;/** * Where a walker heads this step, within the lawn, how near it stops and how fast it goes over a * walk, whether it comes to look at the pointer, and the critter it follows. */const intent = { goal: [0, 0] as [number, number], stop: 0, pace: 1, curious: false, leader: undefined as Body | undefined,};/** The heading a walker steers in, and whether it stops there or queues. */const steered = { want: 0, arrived: false, waiting: false };// Scratch the steps below write their vectors intoconst forward: V3 = [0, 0, 0];const push: V3 = [0, 0, 0];const found = { s: 0, above: 0 };const top = createSeat();/** The heading from `from` towards `x, z`. */const facingTo = (from: V3, x: number, z: number) => Math.atan2(x - from[0], z - from[2]);/** One step of a critter on the ground. */export function walk(body: Body, crowd: Crowd, dt: number) { countDown(body, dt); const x = body.at[0]; const z = body.at[2]; const due = crowd.errand === body; const { pointer, pointerSpeed } = crowd; const near = pointer ? Math.hypot(pointer[0] - x, pointer[2] - z) : Infinity; if (reacts(body, crowd, near, dt)) return; body.petted = pointer && near < PET.reach && pointerSpeed < PET.still ? body.petted + dt : 0; const petted = isPetted(body); if (body.plan <= 0 && !due) plan(body, crowd); intentOf(body, crowd, near, due, petted); const { curious, leader } = intent; const sociable = !body.wantsRide && !curious && !leader && !due; if (body.wave <= 0 && body.greeted <= 0 && sociable && body.grown > 0.5) greet(body, crowd); const { way, distance, toGoal } = routeOf(body, intent.goal, crowd.front, due); const { stop } = intent; if (toGoal < stop) body.arrived = true; else if (toGoal > stop + Math.max(0.04, stop * 0.5)) body.arrived = false; if (!steer(body, crowd, way, distance, toGoal, due, dt)) return; const { arrived, waiting } = steered; const aside = asideOf(body, crowd.bodies, leader); const turn = turnTo(body, headingOf(body, steered.want, arrived, due, petted, pointer), dt); let speed = arrived || waiting || body.wave > 0 || petted ? 0 : WALK.speed * body.grown * intent.pace; if (body.wantsRide && toGoal < BOARD.runUp) speed *= 1 + body.nerve * 0.6; if (body.load > 0) speed *= 0.75; if (body.dizzy > 0) { speed *= 0.4; body.heading += Math.sin(body.phase * 6) * 3 * dt; } stride(body, crowd, aside, speed * Math.max(0, Math.cos(turn)), dt); let mouth = 0; if (body.flee > 0) mouth = 0.9; else if (body.cheer > 0) mouth = 0.8; else if (body.wave > 0) mouth = 0.5; else if (curious && arrived) mouth = 0.3; body.mouth = ease(body.mouth, mouth, 6, dt); const settled = arrived && vec3n.length(aside) < 0.01 && body.wave <= 0 && body.dizzy <= 0 && !petted && !leader && !due && body.cheer <= 0 && body.load === 0 && body.grown > 0.9; // Beside its spot or the critters there, it climbs on rather than waits if (body.wantsRide && arrived && toGoal < BOARD.near) brink(body, crowd, turn, dt); else if (settled && idles(body, curious, dt)) return; if (due && arrived && Math.abs(turn) < 0.3 && !crowd.world.holding) leap(body);}function countDown(body: Body, dt: number) { body.plan -= dt; body.greeted -= dt; body.wave -= dt; body.flee -= dt; body.cheer -= dt; body.train -= dt; body.shoveIn -= dt; body.rideIn -= dt; // A line breaks up when it ends, or when its leader stops walking other than onto the jelly const mode = body.leader?.mode; if (body.train <= 0 || (mode !== 'walk' && mode !== 'ride')) body.leader = undefined;}/** Naps, startles and shoves, which take over from walking. Returns whether one did. */function reacts(body: Body, crowd: Crowd, near: number, dt: number) { const { pointer, pointerSpeed } = crowd; if (body.asleep > 0) { body.asleep -= dt; vec3n.zero(body.velocity); body.mouth = ease(body.mouth, 0, 4, dt); if (pointer && near < SHOO.reach && pointerSpeed > PET.still) startle(body, pointer); return true; } if (pointer && pointerSpeed > SHOO.speed && near < SHOO.reach && body.flee <= 0) { startle(body, pointer); return true; } if (body.shove > 0) { shoving(body, crowd, dt); return true; } // Shoves back once it has caught its balance const grudge = body.grudge; if (grudge && vec3n.length(body.stumble) < 0.03) { body.grudge = undefined; const gap = Math.hypot(grudge.at[0] - body.at[0], grudge.at[2] - body.at[2]); if (grudge.mode === 'walk' && gap < STEER.spacing + R * 2) startShove(body, grudge); } return false;}/** Starts a shove at `victim`: a lean back, then a lunge with both hands. */function startShove(body: Body, victim: Body) { body.shove = 1e-3; body.victim = victim; body.blocked = 0; body.joy = SHOVE.time + 0.6; kick(body.bend, vec3n.set(Math.sin(body.heading), 0, Math.cos(body.heading), forward), -0.3); body.squash.velocity -= 1;}/** Turns to the victim, winds up and lunges, then waits a while before it shoves again. */function shoving(body: Body, crowd: Crowd, dt: number) { const victim = body.victim; body.shove += dt; if (victim) turnTo(body, facingTo(body.at, victim.at[0], victim.at[2]), dt, WALK.turn * 2); vec3n.set(Math.sin(body.heading), 0, Math.cos(body.heading), forward); const lunge = body.shove > SHOVE.windup ? Math.max(0, 1 - (body.shove - SHOVE.windup) / 0.2) : 0; vec3n.scale(forward, lunge * SHOVE.lunge, push); vec3n.lerp(body.velocity, push, Math.min(1, dt * 20), body.velocity); vec3n.addScaled(body.at, body.velocity, dt, body.at); body.at[1] = GROUND; keepOut(body, crowd.world.obstacles, dt); body.mouth = ease(body.mouth, 0.6, 8, dt); if (crossed(body.shove, dt, SHOVE.windup)) { kick(body.bend, forward, 0.7); if (victim) shoved(victim, body); } if (body.shove >= SHOVE.time) { body.shove = 0; body.victim = undefined; body.shoveIn = random(SHOVE.again[0], SHOVE.again[1]); }}/** Stumbles away from a shove by `shover`, or now and then topples, and may come back to shove. */function shoved(victim: Body, shover: Body) { const gap = Math.hypot(victim.at[0] - shover.at[0], victim.at[2] - shover.at[2]); // A critter still surprised by the last shove or bump takes no other if (victim.mode !== 'walk' || victim.shock > 0 || gap >= STEER.spacing + R) return; vec3n.set(victim.at[0] - shover.at[0], 0, victim.at[2] - shover.at[2], push); vec3n.normalize(push, push); if (Math.random() < SHOVE.knock) { topple(victim, vec3n.scale(push, SHOVE.tip, push), SHOVE.lift); return; } vec3n.scale(push, SHOVE.push, victim.stumble); victim.shock = 1.2; victim.asleep = 0; victim.wave = 0; victim.squash.velocity -= 1.5; // Only one free to shove shoves back, so a shove back draws none const back = victim.shoveIn <= 0 && Math.random() < SHOVE.back; victim.grudge = back ? shover : undefined;}/** Picks somewhere to go: onto the jelly, more often the bolder it is, or somewhere on the lawn. */function plan(body: Body, crowd: Crowd) { if (body.leader) { body.plan = 1; return; } const { front } = crowd; const chance = BOARD.chance[0] + (BOARD.chance[1] - BOARD.chance[0]) * body.nerve; body.wantsRide = false; if (front > LADDER + 0.4 && body.rideIn <= 0 && Math.random() < chance) wantRide(body); else lawnSpot(front, body.goal); // Long enough to get there, so riders reach the ladder const distance = Math.hypot(body.goal[0] - body.at[0], body.goal[1] - body.at[2]); body.plan = (body.wantsRide ? 2 : 1) * (distance / WALK.speed) + random(1, 3); if (Math.random() < TRAIN.chance) startTrain(body, crowd);}/** Sets out to ride the jelly, up the approach or from beside it nearest where it is. */function wantRide(body: Body) { body.wantsRide = true; body.brink = 0; const along = std.clamp(body.at[0] + random(-0.15, 0.15), BOARD.beside[0], BOARD.beside[1]); if (Math.random() < BOARD.approach) approach(body.goal); else { body.goal[0] = along; body.goal[1] = Math.sign(body.at[2] || 1) * BOARD.edge; }}/** Writes where critters climb on at the jelly's fixed end into `goal`. */const approach = (goal: [number, number]) => { goal[0] = LADDER; goal[1] = random(-0.1, 0.1);};/** Writes somewhere on the lawn behind the front into `goal`. */function lawnSpot(front: number, goal: [number, number]) { const right = std.clamp(front - 0.3, LAND.x[0], LAND.x[1]); goal[0] = random(LAND.x[0], right); goal[1] = random(LAND.z[0], LAND.z[1]);}/** Whether `b` is free to follow `leader` in a line. */const followsFreely = (b: Body, leader: Body, crowd: Crowd) => b !== leader && b !== crowd.herald && b.mode === 'walk' && !b.leader && b.asleep <= 0 && !b.wantsRide && b.grown > 0.5 && Math.hypot(b.at[0] - leader.at[0], b.at[2] - leader.at[2]) < TRAIN.reach;/** Lines up to `TRAIN.size` free critters behind `leader`, nearest first. */function startTrain(leader: Body, crowd: Crowd) { let ahead = leader; for (let n = 0; n < TRAIN.size; n++) { let next: Body | undefined; let least = Infinity; for (const b of crowd.bodies) { if (!followsFreely(b, leader, crowd)) continue; const gap = Math.hypot(b.at[0] - leader.at[0], b.at[2] - leader.at[2]); if (gap < least) { least = gap; next = b; } } if (!next) break; next.leader = ahead; next.train = TRAIN.time; ahead = next; } if (ahead !== leader) leader.plan = Math.max(leader.plan, 6);}/** Works out the walker's `intent` for this step. */function intentOf(body: Body, crowd: Crowd, near: number, due: boolean, petted: boolean) { const { pointer } = crowd; const leader = body.leader; const curious = pointer !== undefined && !body.wantsRide && body.dizzy <= 0 && !leader && !due && near < CURIOUS.reach; aimOf(body, crowd, due, petted, curious ? pointer : undefined); // Whatever it heads for lies on the lawn, so it turns back short of the lawn's edge const { goal } = intent; goal[0] = std.clamp(goal[0], LAND.x[0], LAND.x[1]); goal[1] = std.clamp(goal[1], LAND.z[0], LAND.z[1]); intent.curious = curious; intent.leader = leader;}/** Sets where the walker heads in `intent`, how near it stops there and how fast it goes. */const aim = (x: number, z: number, stop: number, pace: number) => { intent.goal[0] = x; intent.goal[1] = z; intent.stop = stop; intent.pace = pace;};/** * Where it heads, first of: away from what startled it, to the lever, staying put while petted or * cheering, after its leader, to the pointer it came to see, or where it planned. */function aimOf(body: Body, crowd: Crowd, due: boolean, petted: boolean, seen: V3 | undefined) { const x = body.at[0]; const z = body.at[2]; const leader = body.leader; if (body.flee > 0) { vec3n.set(x - body.fleeFrom[0], 0, z - body.fleeFrom[2], push); vec3n.normalize(push, push); aim(x + push[0] * 0.5, z + push[2] * 0.5, 0.05, 1.8); } else if (due) { const knob = crowd.world.knob; aim(knob[0], knob[2] + HERALD.stand, R, HERALD.pace); } else if (petted || (body.cheer > 0 && body.fan)) { aim(x, z, 0.05, 1); } else if (leader?.mode === 'ride' && !body.wantsRide) { // Following the leader up the jelly, setting out once this step is done aim(body.goal[0], body.goal[1], 0.05, 1); wantRide(body); body.plan = 30; body.leader = undefined; } else if (leader) { // On the line from the leader, so the train trails it however it turns vec3n.set(x - leader.at[0], 0, z - leader.at[2], push); vec3n.normalize(push, push); aim(leader.at[0] + push[0] * TRAIN.gap, leader.at[2] + push[2] * TRAIN.gap, R * 0.6, 1.15); } else if (seen) { aim(seen[0], seen[2], CURIOUS.near, 1); } else { aim(body.goal[0], body.goal[1], 0.05, 1); }}/** Waves with the first free critter near enough, which waves back. */function greet(body: Body, crowd: Crowd) { for (const other of crowd.bodies) { if (other === body || other.mode !== 'walk' || other.grown < 0.5) continue; if (other.wave > 0 || other.greeted > 0 || other.wantsRide || other.asleep > 0) continue; if (Math.hypot(other.at[0] - body.at[0], other.at[2] - body.at[2]) > GREET.reach) continue; waveAt(body, other); waveAt(other, body); return; }}const waveAt = (body: Body, friend: Body) => { body.wave = GREET.time; body.friend = friend; body.greeted = GREET.again + random(0, 5);};/** * Writes into `steered` the heading to walk in round what blocks the way, and whether to stop * there or queue. It stops where its goal is taken or at the edge of a crowd round it, queues * behind a still critter nearer the goal, and passes on a side it keeps. Blocked for long, it tries * the other side, shoves, and at last goes elsewhere. Returns false when it starts a shove. */function steer( body: Body, crowd: Crowd, way: readonly [number, number], distance: number, toGoal: number, due: boolean, dt: number,) { const { goal, stop, curious, leader } = intent; const block = blockOf(body, crowd, way, distance, leader); steered.want = Math.atan2(way[0], way[1]); steered.arrived = body.arrived; steered.waiting = false; body.dodgeFor -= dt; if (!block) { body.blocked = Math.max(0, body.blocked - dt); return true; } const { other, room } = block; const gap = Math.hypot(block.x - body.at[0], block.z - body.at[2]); const close = gap < room + R; const taken = Math.hypot(goal[0] - block.x, goal[1] - block.z) < room; const ahead = other && Math.hypot(goal[0] - other.at[0], goal[1] - other.at[2]) < toGoal; if (close && (taken || (ahead && toGoal < stop + STEER.spacing))) steered.arrived = true; else if (close && ahead && vec3n.length(other.velocity) < 0.08 && toGoal < stop + 0.3) { steered.waiting = true; } const { arrived, waiting } = steered; // Blocked counts while it waits or barely moves const stuck = !arrived && (waiting || vec3n.length(body.velocity) < 0.05); body.blocked = stuck ? body.blocked + dt : Math.max(0, body.blocked - dt); if (!arrived && !waiting) steered.want = passing(body, block, steered.want, gap, dt); if (other && close && !arrived && crossed(body.blocked, dt, SHOVE.after) && body.shoveIn <= 0) { if (due || Math.random() < SHOVE.chance) { startShove(body, other); return false; } } if (body.blocked > STEER.give && !curious && !due && body.flee <= 0) { body.blocked = 0; body.plan = 0; body.leader = undefined; } return true;}/** * Which way it faces, first of: away from the lever once there, at the jelly once beside it to * climb on, at a friend it waves to, at a critter flying off the jelly, at the pointer petting it * or once it arrives, on as it was once it arrives, or the way it walks. */function headingOf( body: Body, way: number, arrived: boolean, due: boolean, petted: boolean, pointer: V3 | undefined,) { if (due && arrived) return 0; if (body.wantsRide && arrived) { return body.goal[0] === LADDER ? Math.PI / 2 : facingTo(body.at, body.at[0], 0); } if (body.wave > 0 && body.friend) return facingTo(body.at, body.friend.at[0], body.friend.at[2]); if (body.cheer > 0 && body.fan) return facingTo(body.at, body.fan.at[0], body.fan.at[2]); if ((petted || arrived) && pointer) return facingTo(body.at, pointer[0], pointer[2]); if (arrived) return body.heading; return way;}/** * Now and then naps, or hops or flips for nothing, and bounces at a pointer it came to see. * Returns whether it did. */function idles(body: Body, curious: boolean, dt: number) { if (!curious && Math.random() < dt * SLEEP.chance) { body.asleep = random(SLEEP.shortest, SLEEP.longest); return true; } if (Math.random() < dt * (curious ? CURIOUS.bounce : 0.05)) { jump(body, curious ? 0.5 : 0.65, !curious && Math.random() < 0.5); return true; } return false;}/** * By the jelly, facing it: hesitates as long as its nerve lets it, then beside the jelly may give * up, and otherwise hops onto the jelly's top. Where that top is too high or steep it tries the * approach, and at the approach it gives up. */function brink(body: Body, crowd: Crowd, turn: number, dt: number) { body.brink += dt; if (Math.abs(turn) > 0.3 || body.brink < BOARD.hesitate * (1 - body.nerve)) return; body.brink = 0; const atApproach = body.goal[0] === LADDER; const surface = topNear(crowd.spine, body.at[0], body.at[2]); const dares = atApproach || Math.random() < BOARD.dare + (1 - BOARD.dare) * body.nerve; if (surface && dares) hop(body, surface); else if (surface || atApproach) giveUp(body, crowd.front); else approach(body.goal);}/** * The jelly's top nearest world x, on the side of `z`, if it lies within `BOARD.reach` of the * fixed end, is low enough to hop onto and faces up. */function topNear(spine: Float32Array, x: number, z: number) { const s = Math.max(nearest(spine, x, GROUND, found).s, R); if (s > BOARD.reach) return undefined; const { surface, normal } = seat( spine, s, Math.sign(z) * Math.min(Math.abs(z), BOARD.lands), top, ); if (surface[1] > GROUND + BOARD.step || normal[1] < BOARD.flat) return undefined; return surface;}/** Hops onto `surface` in `BOARD.hop` seconds, to land there and climb on. */function hop(body: Body, surface: V3) { jump(body, 0, false); const t = BOARD.hop; const { at } = body; vec3n.set( (surface[0] - at[0]) / t, (surface[1] - at[1]) / t + (FALL * t) / 2, (surface[2] - at[2]) / t, body.velocity, ); body.joy = 1;}/** Thinks better of riding and wanders off. */function giveUp(body: Body, front: number) { body.wantsRide = false; lawnSpot(front, body.goal); body.squash.velocity -= 1;}/** Leaps up to the lever's knob. */function leap(body: Body) { body.mode = 'pull'; body.pulling = 0; vec3n.addScaled(body.at, UP, STANDING, body.leapFrom); body.squash.velocity -= 1.5;}steering.ts
import { std } from 'typegpu';import { vec3n } from 'wgpu-matrix';import { crossed, LADDER, R, turnBetween, WALK, type Body, type Crowd, type Obstacle,} from './body.ts';import { GROUND, SLOT, type V3 } from './tray.ts';/** How critters steer round props and each other. */export const STEER = { /** Metres a second at most that props, the slot and each other move them apart. */ apart: 0.5, /** How far ahead they look for what blocks their way. */ ahead: 0.35, /** The room they leave around props, and around each other. */ room: R * 1.3, spacing: R * 2.4, /** How fast they step aside from a critter too near, and how fast they reach a new speed. */ aside: 0.12, speedUp: 10, /** Seconds they keep the side they pass on. */ keep: 1, /** Going round something, they keep at it while another is under `stick` nearer, `wide` wider. */ stick: 0.03, wide: 1.2, /** Seconds blocked before they try the other side, and before they go somewhere else. */ flip: 2.5, give: 4, /** Radians at most they turn from their way passing a critter, so two never walk off together. */ swerve: 1.25,};/** What blocks a walker's way: a prop or a critter, and the room it gives it. */export interface Block { x: number; z: number; room: number; other: Body | undefined;}/** The way towards a goal in x and z, how far the walker heads along it, and how far the goal is. */export interface Route { readonly way: [number, number]; distance: number; toGoal: number;}// What the steps below write, one walker at a timeconst route: Route = { way: [0, 0], distance: 0, toGoal: 0 };const blocking: Block = { x: 0, z: 0, room: 0, other: undefined };const sidestep: V3 = [0, 0, 0];const going: V3 = [0, 0, 0];const moved: V3 = [0, 0, 0];/** * The way towards the goal: past the slot's nearer end, halfway in towards it, when the goal lies * across it; out to the goal's side first when in line with the slot past an end and the goal lies * along it; and back from the front. */export function routeOf(body: Body, goal: readonly number[], front: number, due: boolean) { const x = body.at[0]; const z = body.at[2]; const gx = goal[0]!; const gz = goal[1]!; let towardX = gx - x; let towardZ = gz - z; const steppingIn = byLadder(body); const across = Math.sign(z) !== Math.sign(gz) || Math.abs(gz) < SLOT.z; const pastEnd = Math.abs(x) > SLOT.x && Math.abs(z) < SLOT.z + 0.1; if (!steppingIn && across && Math.abs(x) < SLOT.x + 0.1) { const end = x + gx < 0 ? -1 : 1; towardX = end * (SLOT.x + 0.2) - x; towardZ = z * 0.5 - z; } else if (!steppingIn && pastEnd && Math.sign(gx - x) !== Math.sign(x)) { towardX = Math.sign(gx - x) * 0.1; towardZ = Math.sign(gz || 1) * (SLOT.z + 0.2) - z; } if (x > front - 0.15 && !due) { towardX = -1; towardZ = 0; } const distance = Math.hypot(towardX, towardZ); const { way } = route; if (distance > 1e-6) { way[0] = towardX / distance; way[1] = towardZ / distance; } else { way[0] = Math.sin(body.heading); way[1] = Math.cos(body.heading); } route.distance = distance; route.toGoal = Math.hypot(gx - x, gz - z); return route;}/** Whether it is near enough the jelly's fixed end, on its way onto it, to step into the slot. */const byLadder = (body: Body) => body.wantsRide && body.at[0] < LADDER + 0.08;/** The walker `blockOf` looks ahead for, its way, the nearest entry so far and what it avoided. */const search = { body: undefined as Body | undefined, way: [0, 0] as readonly [number, number], entry: 0, avoided: undefined as Body | Obstacle | undefined, found: false,};/** Takes what lies at `bx, bz` with `room` round it as the block, if the walker enters it first. */function inWay(bx: number, bz: number, room: number, what: Body | Obstacle) { const { body, way } = search; const x = body!.at[0]; const z = body!.at[2]; const along = (bx - x) * way[0] + (bz - z) * way[1]; const off = (bx - x) * way[1] - (bz - z) * way[0]; const kept = what === search.avoided; const wide = kept ? room * STEER.wide : room; if (along <= 0 || Math.abs(off) >= wide) return; const enters = along - Math.sqrt(wide * wide - off * off); if (enters - (kept ? STEER.stick : 0) > search.entry) return; search.entry = Math.max(enters, 0); search.found = true; blocking.x = bx; blocking.z = bz; blocking.room = room; blocking.other = 'at' in what ? what : undefined; body!.avoiding = what;}/** * The nearest prop or walker in the way, entered within `STEER.ahead`. What it went round last * step keeps a wider room and wins ties by `STEER.stick`, so it doesn't swap between two. */export function blockOf( body: Body, crowd: Crowd, way: readonly [number, number], distance: number, leader: Body | undefined,) { search.body = body; search.way = way; search.entry = Math.min(distance, STEER.ahead); search.avoided = body.avoiding; search.found = false; body.avoiding = undefined; for (const o of crowd.world.obstacles) inWay(o.x, o.z, o.radius + STEER.room, o); for (const other of crowd.bodies) { if (other === body || other.mode !== 'walk' || other === leader || other.grown < 0.5) continue; inWay(other.at[0], other.at[2], STEER.spacing, other); } return search.found ? blocking : undefined;}/** * Turns `want` to pass `block`, `gap` away, on the side it keeps, which it flips after * `STEER.flip` seconds blocked. */export function passing(body: Body, block: Block, want: number, gap: number, dt: number) { const bearing = Math.atan2(block.x - body.at[0], block.z - body.at[2]); const off = turnBetween(want, bearing); if (body.dodgeFor <= 0) { const lean = turnBetween(want, body.heading); body.dodge = Math.abs(off) > 0.05 ? -Math.sign(off) : lean >= 0 ? 1 : -1; } if (crossed(body.blocked, dt, STEER.flip)) body.dodge *= -1; body.dodgeFor = STEER.keep; const pass = bearing + body.dodge * (Math.asin(Math.min(1, block.room / Math.max(gap, 1e-6))) + 0.15); const most = block.other ? STEER.swerve : Math.PI; return want + std.clamp(turnBetween(want, pass), -most, most);}/** A step aside from walkers too near, without turning to them. */export function asideOf(body: Body, bodies: readonly Body[], leader: Body | undefined) { const x = body.at[0]; const z = body.at[2]; vec3n.zero(sidestep); for (const other of bodies) { if (other === body || other.mode !== 'walk' || other === leader || other.grown < 0.5) continue; const gap = Math.hypot(x - other.at[0], z - other.at[2]); if (gap < STEER.spacing && gap > 1e-4) { const push = 1 - gap / STEER.spacing; sidestep[0] += ((x - other.at[0]) / gap) * push; sidestep[2] += ((z - other.at[2]) / gap) * push; } } if (vec3n.length(sidestep) > 1) vec3n.normalize(sidestep, sidestep); return vec3n.scale(sidestep, STEER.aside, sidestep);}/** Turns towards heading `want` at most `WALK.turn` radians a second. Returns the turn left. */export function turnTo(body: Body, want: number, dt: number, rate: number = WALK.turn) { const turn = turnBetween(body.heading, want); body.heading += std.clamp(turn, -1, 1) * rate * dt; return turn;}/** * Eases its velocity towards walking `speed` along its heading plus `aside` and any stumble, moves, * and slides along what it walks into, never faster than it walks. */export function stride(body: Body, crowd: Crowd, aside: V3, speed: number, dt: number) { const x = body.at[0]; const z = body.at[2]; vec3n.add(aside, body.stumble, going); going[0] += Math.sin(body.heading) * speed; going[2] += Math.cos(body.heading) * speed; vec3n.lerp(body.velocity, going, Math.min(1, dt * STEER.speedUp), body.velocity); vec3n.scale(body.stumble, Math.exp(-dt * 5), body.stumble); vec3n.addScaled(body.at, body.velocity, dt, body.at); body.at[1] = GROUND; keepOut(body, crowd.world.obstacles, dt); vec3n.set((body.at[0] - x) / dt, 0, (body.at[2] - z) / dt, moved); const most = vec3n.length(body.velocity); if (vec3n.length(moved) > most) vec3n.scale(vec3n.normalize(moved, moved), most, moved); vec3n.copy(moved, body.velocity);}/** * Moves a walking critter out of props and the slot, unless it is climbing onto the jelly, at * most `STEER.apart` a second, so one landing deep inside walks out rather than jumps. */export function keepOut(body: Body, obstacles: readonly Obstacle[], dt: number) { const at = body.at; const most = STEER.apart * dt; for (const o of obstacles) { const gap = Math.hypot(at[0] - o.x, at[2] - o.z); const deep = Math.min(o.radius + R - gap, most); if (deep > 0 && gap > 1e-4) { at[0] += ((at[0] - o.x) / gap) * deep; at[2] += ((at[2] - o.z) / gap) * deep; } } if (byLadder(body)) return; const inX = SLOT.x + 0.06 - Math.abs(at[0]); const inZ = SLOT.z + 0.06 - Math.abs(at[2]); if (inX <= 0 || inZ <= 0) return; if (inZ < inX) at[2] += Math.sign(at[2] || 1) * Math.min(inZ, most); else at[0] += Math.sign(at[0] || 1) * Math.min(inX, most);}assets.ts
import { font } from '../assets.ts';/** Inter, baked and shipped with `@typegpu/kit`. */export const assets = { font: font('inter') };