Robot arm
Tap a block to have a toy robot arm pick it up, then tap the bench or another block to set it down or stack it. Left alone, it sorts the painted blocks into a tower of each colour, then scatters a few to sort again. The CPU solves its joints with cyclic coordinate descent using wgpu-matrix, runs each task as a machine from machines.run and pushes blocks out of the gripper's way. Instances draw @typegpu/kit/geometry shapes, their oak planks, pegboard and painted grain from material hooks, in a dim workshop under a desk lamp. The lamp is a spot light whose shadow an attenuation hook samples across the bulb so its edges soften, ambient occlusion from a depth prepass darkens the seams between blocks, and FXAA smooths the finished frame.
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- arm.tsSolves the hinge chain from
STARTon the CPU. - blocks.tsPicks up and sets down
blockswithgripper. - bench.tsA workbench of oak planks with a pegboard of tools and a shelf of tins behind it, and a desk lamp whose shade points at the arm.
Kit modules ao, bindings, bloom, cameras, clock, finish, fxaa, instances, lighting, machines, materials, meshPool, owner, passes, pipelines, shading, shadows, table, targets. Also @typegpu/color, @typegpu/noise, math, typegpu, wgpu-matrix.
scene.ts
import { ao, bindings, bloom, cameras, clock, finish, fxaa, instances, lighting, materials, meshPool, owner, passes, pipelines, shading, shadows, table, targets, type Frame, type Surface,} from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { srgbToLinear } from '@typegpu/color';import { clamp } from 'math';import { quat, vec3, type Mat4, type Quat, type Vec3Arg } from 'wgpu-matrix';import { d, std, tgpu, type TgpuRoot } from 'typegpu';import type { Pointing } from '../contract.ts';import type { Settings } from '../settings.ts';import { createArm, HAND, LINKS, TIP, WRIST } from './arm.ts';import { createBlocks, HOVER, type Block } from './blocks.ts';import { createBench, Painted } from './bench.ts';const SAMPLES = 4;const HDR = 'rgba16float';const TAU = Math.PI * 2;const UP = [0, 1, 0] as const;/** Minimum and maximum horizontal reach from the turntable axis. */const REACH = [0.8, 2.7] as const;/** The way the fingers point while reaching. */const DOWN = [0, -1, 0] as const;/** Automatic target orbit: `turn` rad/s at radius `out`, swinging by `swing` at `pace` rad/s. */const WANDER = { turn: 0.45, out: 1.9, swing: 0.8, pace: 0.7 };/** Target smoothing rate. After one second, `exp(-EASE)` of the gap remains. */const EASE = 5;/** Minimum downward y component of a unit ray that can target the bench. */const FALLING = { grazing: 0.01 };/** World units the ring under the goal floats over the bench. */const RING_LIFT = 0.02;const paint = (r: number, g: number, b: number) => srgbToLinear(d.vec3f(r, g, b));const PAINT = materials.glossy(paint(0.95, 0.42, 0.08), 0.35);const STEEL = materials.glossy(paint(0.6, 0.61, 0.64), 0.3);const DARK = materials.glossy(paint(0.36, 0.37, 0.4), 0.38);const MARKER = materials.glow(d.vec3f(0.25, 0.75, 1), 2.5);const LED = materials.glow(d.vec3f(1, 0.55, 0.12), 8);/** The dim workshop beyond the lamp's pool, which the fog matches. */const ROOM = [0.012, 0.009, 0.007] as const;/** How far occluders darken sky light: about the gap under a block's bevel. */const CREASE = 0.25;/** Darkens sky light in creases, such as between stacked blocks and round the arm's foot. */const occludedSky = (material: shading.Material, point: shading.Point) => { 'use gpu'; return shading.hemisphere(material, point) * ao.at(point.position);};/** World radius of the bulb's disc, which sets how wide the lamp's shadows soften. */const BULB_RADIUS = 0.04;/** Shadow samples across the bulb's disc, on a sunflower spiral, as offsets along its two axes. */const BULB_TAPS = Array.from({ length: 12 }, (_, i) => { const angle = i * 2.39996; const out = Math.sqrt((i + 0.5) / 12) * BULB_RADIUS; return [Math.cos(angle) * out, Math.sin(angle) * out] as const;});/** The lamp's shadow at a sample of the spiral, `offset` from `point` over its surface. */const bulbTap = ( point: shading.Point, offset: d.v3f, toLight: d.v3f, distance: number, slot: number,) => { 'use gpu'; const moved = shading.Point({ position: point.position + offset, normal: point.normal, view: point.view, uv: point.uv, uv1: point.uv1, }); return shadows.shadowed(moved, toLight, distance, slot);};/** * Point light through its cone, and the lamp's shadow averaged from several spots across its bulb, * so shadow edges soften into a penumbra. */const softShadow = ( point: shading.Point, toLight: d.v3f, distance: number, range: number, slot: number,) => { 'use gpu'; const share = shading.falloff(distance, range) * shading.beamOf(toLight, slot); if (share <= 0) return d.f32(0); // Spread the samples over the surface, so none sinks below it into its own shadow const facing = std.cross(point.normal, toLight); const spare = std.cross(point.normal, d.vec3f(0.6, 0, 0.8)); const side = std.normalize(std.select(facing, spare, std.length(facing) < 0.05)); const across = std.cross(point.normal, side); let lit = d.f32(0); for (const tap of tgpu.unroll(BULB_TAPS)) { lit += bulbTap(point, side * tap[0] + across * tap[1], toLight, distance, slot); } return (share * lit) / BULB_TAPS.length;};/** Each block's paint brightness, so blocks of a colour stay apart. */const TINTS = [1, 0.86, 1.1, 0.94, 1.04, 0.9];/** The blocks' paints, and where each colour's tower stands. */const COLOURS = [ paint(0.85, 0.16, 0.1), paint(0.95, 0.72, 0.12), paint(0.12, 0.32, 0.7), paint(0.2, 0.52, 0.26),];const HOME = 1.6;const HOMES = COLOURS.map((_, k) => { const angle = Math.PI / 4 + (k * Math.PI) / 2; return [Math.cos(angle) * HOME, Math.sin(angle) * HOME] as const;});/** A block's width, depth and height. */const BLOCK = 0.42;/** * Blocks by colour, bench angle and distance from the axis, the height they fall from to settle * onto any block below, and the radians they turn past their bench angle. Some start in their * towers, the rest loose between them. */const BLOCKS: [colour: number, angle: number, out: number, lift: number, turn: number][] = [ [0, Math.PI / 4, HOME, 0, 0], [0, Math.PI / 4, HOME, 1, 0.1], [0, 3.3, 1.45, 0, 0.5], [1, (3 * Math.PI) / 4, HOME, 0, 0], [1, 0.1, 1.85, 0, 0.3], [1, 4.6, 1.4, 0, 0.7], [2, (5 * Math.PI) / 4, HOME, 0, 0], [2, (5 * Math.PI) / 4, HOME, 1, 0.15], [2, 1.7, 1.9, 0, 0.6], [3, 0, 1.35, 0, 0.2], [3, 2.95, 1.9, 0, 0.4], [3, 6.1, 1.95, 1, 0.4],];type Triple = [number, number, number];/** `angle` in radians wrapped to -π through π. */const wrapped = (angle: number) => angle - Math.round(angle / TAU) * TAU;/** * A toy robot arm on a workbench under a desk lamp, sorting painted blocks into a tower of each * colour. Its joints are solved on the CPU. */export async function createScene(root: TgpuRoot, surface: Surface) { const own = owner(); const screen = own( targets.create(root, surface, { scene: { format: HDR, samples: SAMPLES }, depth: { format: 'depth24plus', samples: SAMPLES }, hdr: { format: HDR, sampled: true }, ldr: { format: 'rgba8unorm', sampled: true }, }), ); const view = cameras.orbit(root, { size: surface, target: [0, 1.3, 0.1], distance: 5.8, yaw: 0.5, pitch: 0.46, pitchRange: [0.1, 1.4], distanceRange: [3, 18], }); /** Half-resolution depth the solid drawings draw first, for the occlusion to read. */ const early = own( targets.create(root, surface, { depth: { format: 'depth32float', scale: 0.5, sampled: true }, }), ); const prepass = passes .create(root, early, { depth: passes.depth }, { label: 'prepass' }) .pipe(view.inject()); const occlusion = own( ao.create(root, view, { depth: () => early.textures.depth, settings: { radius: CREASE }, }), ); const lamps = own( table.create(root, lighting.SpotLight, { capacity: 2, defaults: { ...lighting.WHITE_SPOT }, }), ); const light = own( lighting.create(root, { settings: { sunDirection: [0.6, 0.5, 0.6], sunColor: [0.55, 0.65, 0.9], sunIntensity: 0.25, skyTop: [0.07, 0.075, 0.09], skyBottom: [0.16, 0.09, 0.045], skyIntensity: 1, fogColor: ROOM, fogDensity: 0.02, }, shading: { ambient: tgpu.fn(occludedSky).pipe(occlusion.inject()), attenuation: softShadow, }, points: { table: lamps }, pointShadow: { count: 1, filter: 4, size: 1024 }, }), ); const shared = bindings.share(root, { ao: occlusion.part }); const scene = passes .create( root, screen, { scene: passes.color, depth: passes.depth }, { resolve: { scene: 'hdr' }, clear: [...ROOM, 1], shared }, ) .pipe(view.inject()) .beneath(light.inject()); const pool = own(meshPool.create(root, { capacity: 64 })); const shape = { pedestal: pool.add(meshes.cylinder({ radius: 0.55, height: LINKS.pedestal })), housing: pool.add( meshes.roundedBox({ width: 0.62, height: LINKS.housing, depth: 0.7, radius: 0.08 }), ), hinge: pool.add(meshes.cylinder({ radius: 0.2, height: 0.44 })), upper: pool.add(meshes.capsule({ radius: 0.13, height: LINKS.upper })), fore: pool.add(meshes.capsule({ radius: 0.11, height: LINKS.fore })), palm: pool.add(meshes.box(HAND.palm)), finger: pool.add(meshes.box(HAND.finger)), ring: pool.add(meshes.torus({ radius: 0.24, tube: 0.025 })), block: pool.add(meshes.box({ width: 1, height: 1, depth: 1 })), toy: pool.add(meshes.roundedBox({ width: 1, height: 1, depth: 1, radius: 0.07 })), }; const things = own(instances.create(root, pool, { capacity: 96 })); const renderer = things.renderer(); const bench = own(createBench(root, pool)); const painted = own(instances.create(root, pool, { kind: Painted, capacity: BLOCKS.length })); const drawings = [renderer, painted.renderer(), ...bench.drawings.map((set) => set.renderer())]; const shown = drawings.map((drawing) => scene.add(drawing)); const casters = drawings.map((drawing) => light.pointShadow.add(drawing)); const shapes = drawings.map((drawing) => prepass.add(drawing)); const lamp = lamps.add({ ...bench.lamp, cone: lighting.cone(0.35, 0.85), color: [1, 0.78, 0.5], intensity: 55, range: 24, }); /** A cool fill from over the viewer's shoulder, which keeps the arm's dark side in form. */ lamps.add({ position: [5, 6, 7], color: [0.6, 0.72, 1], intensity: 14, range: 30, }); light.pointShadow.cast([lamp]); lamps.flush(); const boxes = BLOCKS.map(([colour], i) => painted.add(shape.toy, { scale: BLOCK, color: COLOURS[colour]!.mul(TINTS[i % TINTS.length]!), }), ); things.add(shape.pedestal, { position: [0, LINKS.pedestal / 2, 0], ...DARK }); /** A mesh carried by `joint`, at `at` in its frame and turned by `turn`. */ const attach = ( joint: number, mesh: typeof shape.hinge, at: Triple, fields: Parameters<typeof things.add>[1], turn?: Quat, ) => ({ joint, at, turn, id: things.add(mesh, fields) }); /** Lays a hinge's cylinder along its joint's axis. */ const across = quat.fromAxisAngle([0, 0, 1], Math.PI / 2); const parts = [ attach(0, shape.housing, [0, LINKS.housing / 2, 0], DARK), attach(0, shape.block, [0, 0.28, -0.355], { ...LED, scale: [0.2, 0.05, 0.02] }), attach(1, shape.hinge, [0, 0, 0], STEEL, across), attach(1, shape.upper, [0, LINKS.upper / 2, 0], PAINT), attach(2, shape.hinge, [0, 0, 0], { ...STEEL, scale: 0.8 }, across), attach(2, shape.fore, [0, LINKS.fore / 2, 0], PAINT), attach(WRIST, shape.hinge, [0, 0, 0], { ...STEEL, scale: 0.6 }, across), attach(WRIST, shape.palm, [0, HAND.palm.at, 0], DARK), attach(WRIST, shape.finger, [-HAND.open, HAND.finger.at, 0], STEEL), attach(WRIST, shape.finger, [HAND.open, HAND.finger.at, 0], STEEL), ]; const fingers = parts.slice(-2); const ring = things.add(shape.ring, MARKER); const arm = createArm(); const stack: Block[] = BLOCKS.map(([colour, angle, out, lift, turn]) => ({ colour, size: BLOCK, height: BLOCK, x: Math.cos(angle) * out, y: lift, z: Math.sin(angle) * out, yaw: angle + turn, })); const blocks = createBlocks(stack, { tip: arm.tip, wrist: arm.frames[WRIST]! }, HOMES); const glow = own( bloom.create(root, { source: () => screen.textures.hdr, settings: { threshold: 1.5 } }), ); const final = own( finish.create(root, { format: 'rgba8unorm', source: () => screen.textures.hdr, glow, settings: { exposure: 1.1, glowIntensity: 0.1, vignette: 0.35 }, }), ); /** Smooths what MSAA leaves stepped: edges of the glowing ring, the bulb and bright highlights. */ const smooth = own( fxaa.create(root, { format: surface.format, source: () => screen.textures.ldr }), ); await pipelines.compile([prepass, occlusion, scene, light.pointShadow, glow, final, smooth]); const values = { /** Maximum joint speed in radians per second. */ speed: 3, /** Cyclic-coordinate-descent iterations per frame. */ sweeps: 4, }; const settings: Settings<typeof values> = { values, knobs: { speed: { label: 'joint speed (rad/s)', min: 0, max: 10, step: 0.1 }, sweeps: { label: 'IK sweeps per frame', min: 1, max: 12, step: 1 }, }, }; /** The bench point x, z the fingers head for: the pointer's, or one wandering round. */ const aim: [x: number, z: number] = [arm.tip[0]!, arm.tip[2]!]; /** The smoothed fingertip goal: a turn about the axis, a distance from it and a height. */ const goal = { turn: Math.atan2(aim[1], aim[0]), out: Math.hypot(...aim), y: HOVER }; const wrist: Triple = [0, 0, 0]; const previous = new Float64Array(arm.angles.length); let following = false; const ray = { origin: vec3.create(), direction: vec3.create() }; /** Where `ray` meets the bench, written by `benchHit`. */ const spot: Triple = [0, 0, 0]; /** Casts `ray` through surface point x, y and returns whether it meets the bench at `spot`. */ const benchHit = (x: number, y: number) => { view.rayAt(x, y, ray); return cameras.hitHeight(ray, 0, spot, FALLING) !== undefined; }; const pointing: Pointing = { /** Targets the bench under the pointer. Ignores points above the horizon. */ move(x, y) { if (!benchHit(x, y)) return; aim[0] = spot[0]; aim[1] = spot[2]; following = true; }, /** Picks up the block under the pointer, or sets the held one down there. */ press(x, y) { const onBench = benchHit(x, y); const hit = blocks.hit(ray.origin, ray.direction); const block = stack[hit]; if (block && !blocks.holding) blocks.grab(hit); else if (block) blocks.drop(block.x, block.z); else if (onBench) blocks.drop(spot[0], spot[2]); }, /** Resumes automatic target movement. */ leave() { following = false; }, }; /** Moves the aim round the turntable on its own. */ const wander = (time: number) => { const angle = time * WANDER.turn; const out = WANDER.out + Math.sin(time * WANDER.pace) * WANDER.swing; aim[0] = Math.cos(angle) * out; aim[1] = Math.sin(angle) * out; }; /** Eases the goal toward `target`, swinging round the axis by the shorter way. */ const follow = (target: Vec3Arg, dt: number) => { const ease = clock.ease(dt, EASE); goal.turn += wrapped(Math.atan2(target[2]!, target[0]!) - goal.turn) * ease; const out = Math.hypot(target[0]!, target[2]!); goal.out += (clamp(out, REACH[0], REACH[1]) - goal.out) * ease; goal.y += (target[1]! - goal.y) * ease; }; /** Places the wrist above the goal, fingers down, within each joint's speed limit. */ const reach = (dt: number) => { const { angles } = arm; previous.set(angles); wrist[0] = Math.cos(goal.turn) * goal.out; wrist[1] = goal.y + TIP[1]; wrist[2] = Math.sin(goal.turn) * goal.out; arm.solve(wrist, WRIST, values.sweeps); arm.aim(WRIST, DOWN); const most = values.speed * dt; for (let i = 0; i < angles.length; i++) { angles[i] = previous[i]! + clamp(angles[i]! - previous[i]!, -most, most); } arm.pose(); }; const position: Triple = [0, 0, 0]; const rotation: [number, number, number, number] = [0, 0, 0, 1]; const placed = { position, rotation }; const ringAt: Triple = [0, RING_LIFT, 0]; const marked = { position: ringAt }; const place = (frame: Mat4, part: (typeof parts)[number]) => { vec3.transformMat4(part.at, frame, position); quat.fromMat(frame, rotation); if (part.turn) quat.multiply(rotation, part.turn, rotation); things.write(part.id, placed); }; /** Writes the arm's parts, fingers `open` from the palm centre, and the ring under the wrist. */ const placeArm = (open: number) => { fingers[0]!.at[0] = -open; fingers[1]!.at[0] = open; for (const part of parts) place(arm.frames[part.joint]!, part); ringAt[0] = wrist[0]; ringAt[2] = wrist[2]; things.write(ring, marked); }; const placeBlocks = () => { for (let i = 0; i < stack.length; i++) { const block = stack[i]!; position[0] = block.x; position[1] = block.y + block.height / 2; position[2] = block.z; quat.fromAxisAngle(UP, block.yaw, rotation); painted.write(boxes[i]!, placed); } }; return { view, settings, pointing, frame(frame: Frame) { view.update(frame.dt); early.fit(); screen.fit(); if (!following) { wander(frame.time); blocks.auto(); } blocks.step(frame.dt, aim); follow(blocks.target, frame.dt); reach(frame.dt); placeArm(blocks.fingers); placeBlocks(); things.flush(); painted.flush(); light.pointShadow.record(frame, casters); prepass.record(frame, shapes); occlusion.record(frame); scene.record(frame, shown); glow.record(frame); final.record(frame, screen.views.ldr); smooth.record(frame, surface.view(frame)); }, destroy: own.destroy, };}arm.ts
import { clamp } from 'math';import { mat4, vec3, type Mat4, type Vec3, type Vec3Arg } from 'wgpu-matrix';type Triple = readonly [number, number, number];/** Hinge at the end of the preceding link. */export interface Joint { /** * Position in the preceding joint's rotated frame. The first joint uses world coordinates on * the bench. */ readonly offset: Triple; /** Rotation axis in joint-local coordinates. */ readonly axis: Triple; /** Radians it may turn either way from straight. */ readonly range: readonly [number, number];}const UP: Triple = [0, 1, 0];const ACROSS: Triple = [1, 0, 0];/** Link lengths in world units for the pedestal, shoulder housing, upper arm and forearm. */export const LINKS = { pedestal: 0.3, housing: 0.4, upper: 1.5, fore: 1.25 };/** Turntable, shoulder, elbow and wrist. Shoulder and elbow bend mainly forward. */export const JOINTS: readonly Joint[] = [ { offset: [0, LINKS.pedestal, 0], axis: UP, range: [-Infinity, Infinity] }, { offset: [0, LINKS.housing, 0], axis: ACROSS, range: [-0.5, 1.7] }, { offset: [0, LINKS.upper, 0], axis: ACROSS, range: [0, 2.6] }, { offset: [0, LINKS.fore, 0], axis: ACROSS, range: [-2.2, 2.2] },];/** Index of the wrist, the last joint. */export const WRIST = JOINTS.length - 1;/** Initial joint angles in radians. */const START: readonly number[] = [0.6, 0.5, 1.2, 0.8];/** Offset from wrist to fingertips. */export const TIP: Triple = [0, 0.84, 0];/** The gripper's boxes, each centred `at` along the wrist's +y, which points to the tip. */export const HAND = { palm: { at: 0.22, width: 0.9, height: 0.14, depth: 0.26 }, finger: { at: 0.565, width: 0.07, height: 0.55, depth: 0.2 }, /** Distance of each finger from the palm centre while open. */ open: 0.41,};/** Removes the part of `v` along unit `axis`, in place. */const flatten = (v: Vec3, axis: Vec3) => vec3.addScaled(v, axis, -vec3.dot(v, axis), v);/** * Solves the hinge chain from `START` on the CPU. `frames` contains rotated world transforms, * and `tip` is the chain endpoint. */export function createArm() { const angles = [...START]; const frames: Mat4[] = JOINTS.map(() => mat4.identity()); const tip = vec3.create(); const at = vec3.create(); const axis = vec3.create(); const end = vec3.create(); const from = vec3.create(); const to = vec3.create(); const across = vec3.create(); const pose = () => { for (let i = 0; i < JOINTS.length; i++) { const joint = JOINTS[i]!; const frame = frames[i]!; if (i === 0) mat4.translation(joint.offset, frame); else mat4.translate(frames[i - 1]!, joint.offset, frame); mat4.axisRotate(frame, joint.axis, angles[i]!, frame); } vec3.transformMat4(TIP, frames[WRIST]!, tip); }; pose(); /** Rotates joint `i` to bring `from` as close to `to` as its hinge axis and range allow. */ const turn = (i: number) => { const joint = JOINTS[i]!; vec3.normalize(vec3.transformMat4Upper3x3(joint.axis, frames[i]!, axis), axis); flatten(from, axis); flatten(to, axis); const sine = vec3.dot(axis, vec3.cross(from, to, across)); const turned = angles[i]! + Math.atan2(sine, vec3.dot(from, to)); angles[i] = clamp(turned, joint.range[0], joint.range[1]); pose(); }; return { angles, frames: frames as readonly Mat4[], tip: tip as Readonly<Vec3>, /** Recomputes frames and tip after direct changes to `angles`. */ pose, /** * Runs `sweeps` cyclic-coordinate-descent iterations to bring joint `last` toward `goal`. * Adjusts preceding joints from nearest to farthest. */ solve(goal: Vec3Arg, last: number, sweeps: number) { for (let sweep = 0; sweep < sweeps; sweep++) { for (let i = last - 1; i >= 0; i--) { mat4.getTranslation(frames[last]!, end); mat4.getTranslation(frames[i]!, at); vec3.sub(end, at, from); vec3.sub(goal, at, to); turn(i); } } }, /** Turns joint `i` to bring the tip as close to `direction` as possible. */ aim(i: number, direction: Vec3Arg) { mat4.getTranslation(frames[i]!, at); vec3.sub(tip, at, from); vec3.copy(direction, to); turn(i); }, };}blocks.ts
import { machines } from '@typegpu/kit';import { clamp } from 'math';import { vec3, type Mat4, type Vec3Arg } from 'wgpu-matrix';import { HAND, TIP } from './arm.ts';/** * Fingertip height while free, reaching and carrying, which clears a tower of three blocks. The * hand leaves it only to dip onto a block it grabs or a spot it sets one down on. */export const HOVER = 1.5;/** Fingertip height above a block's bottom while gripping. */const GRIP = 0.04;/** Distance from the target at which the tip has arrived. */const NEAR = 0.05;/** Seconds before a move stops waiting for the tip. */const PATIENCE = 3;/** Seconds the fingers take to close or open. */const SQUEEZE = 0.3;/** Horizontal distances from the turntable axis that block centres stay within. */const RING = [1.2, 2.0] as const;/** Height overlap below which blocks pass over each other. */const REST = 0.06;/** Height a block's top may stand above a point and still hold what is set there. */const STANDING = 0.01;/** Fall speed in world units per second. */const FALL = 3;/** Centre distance resting blocks keep apart, over the sum of their sizes. */const APART = 0.6;/** Seconds between tasks in attract mode. */const PAUSE = 0.6;/** * Once sorted, attract mode scatters this share of the blocks onto the bench between the towers, * the `n`th landing `out` to `out + width` from the axis, stepping by `step` a task. */const SCATTER = { share: 0.5, out: 1.3, width: 0.6, step: 0.37 };/** A box with a square footprint and a colour, resting on the bench or another box, or held. */export interface Block { /** Index of its colour, whose tower stands at the home of that index. */ readonly colour: number; /** Width and depth in world units. */ readonly size: number; readonly height: number; /** Footprint centre and bottom height. */ x: number; y: number; z: number; /** Radians turned about +y. */ yaw: number;}/** The arm's fingertips and wrist frame, which `step` reads as they move. */export interface Gripper { readonly tip: Vec3Arg; /** The wrist's world transform; its +y points from the wrist to the tip. */ readonly wrist: Mat4;}const topOf = (block: Block) => block.y + block.height;/** How far `angle` is turned from the nearest quarter turn. */const offSquare = (angle: number) => angle - Math.round(angle / (Math.PI / 2)) * (Math.PI / 2);/** Moves `block` along its radius into `RING`. */const ring = (block: { x: number; z: number }) => { const out = Math.hypot(block.x, block.z) || 1e-6; const kept = clamp(out, RING[0], RING[1]) / out; block.x *= kept; block.z *= kept;};/** Moves `block` along `dx`, `dz` until it is `reach` from where that offset starts. */const shove = (block: Block, dx: number, dz: number, reach: number) => { const gap = Math.hypot(dx, dz) || 1e-6; const overlap = reach - gap; if (overlap <= 0) return; block.x += (dx / gap) * overlap; block.z += (dz / gap) * overlap;};/** Distance along the ray to where it enters `block`, or Infinity: a slab test in its frame. */const entryOf = (block: Block, origin: Vec3Arg, direction: Vec3Arg) => { const [c, s] = [Math.cos(block.yaw), Math.sin(block.yaw)]; const half = [block.size / 2, block.height / 2, block.size / 2]; const [px, py, pz] = [origin[0]! - block.x, origin[1]! - block.y, origin[2]! - block.z]; const o = [px * c - pz * s, py - half[1]!, px * s + pz * c]; const [dx, dy, dz] = [direction[0]!, direction[1]!, direction[2]!]; const d = [dx * c - dz * s, dy, dx * s + dz * c]; let [near, far] = [-Infinity, Infinity]; for (let k = 0; k < 3; k++) { const a = (-half[k]! - o[k]!) / d[k]!; const b = (half[k]! - o[k]!) / d[k]!; near = Math.max(near, Math.min(a, b)); far = Math.min(far, Math.max(a, b)); } return near <= far && far > 0 ? near : Infinity;};/** * Picks up and sets down `blocks` with `gripper`. A task is a machine that reaches over a block, * lowers onto it, closes the fingers, lifts, carries, places, opens and rises. `step` moves the * fingertip `target` and the `fingers`, and pushes resting blocks away from the gripper and each * other. */export function createBlocks( blocks: Block[], gripper: Gripper, homes: readonly (readonly [x: number, z: number])[],) { let tasks = 0; /** Blocks left to scatter before sorting again; 0 while sorting. */ let scattering = 0; /** Block indices of the task, the one held, the one last grabbed and the one tapped; -1 for none. */ let chosen = -1; let held = -1; let last = -1; let tapped = -1; const drop = { x: 0, z: 0, set: false }; const target = vec3.create(); const at = vec3.create(); const local = vec3.create(); /** The highest block under bench point x, z whose top is no higher than `below`, if any. */ const surfaceAt = (x: number, z: number, below: number, except: number) => { let found: Block | undefined; for (let i = 0; i < blocks.length; i++) { const block = blocks[i]!; const inside = Math.hypot(block.x - x, block.z - z) < block.size / 2; if (i === except || i === held || !inside || topOf(block) > below + STANDING) continue; if (!found || topOf(block) > topOf(found)) found = block; } return found; }; /** Whether nothing rests on block `i`. */ const isBare = (i: number) => { const block = blocks[i]!; for (const other of blocks) { const touching = Math.abs(other.y - topOf(block)) < REST; if (touching && Math.hypot(other.x - block.x, other.z - block.z) < block.size / 2) { return false; } } return true; }; /** The block that block `i` rests on, or -1 for the bench. */ const beneath = (i: number) => { const block = blocks[i]!; for (let j = 0; j < blocks.length; j++) { const other = blocks[j]!; if (j === i || j === held || Math.abs(topOf(other) - block.y) > REST) continue; if (Math.hypot(other.x - block.x, other.z - block.z) < other.size / 2) return j; } return -1; }; /** Whether block `i` stands in its colour's tower: at its home, on blocks of its colour only. */ const isSorted = (i: number) => { const { colour } = blocks[i]!; const home = homes[colour]; if (!home) return false; for (let k = i; k >= 0; k = beneath(k)) { const block = blocks[k]!; if (block.colour !== colour) return false; if (block.y < REST) return Math.hypot(block.x - home[0], block.z - home[1]) < block.size / 2; } return false; }; /** Whether block `i` is on top and out of its tower, so sorting moves it. */ const isLoose = (i: number) => i !== held && isBare(i) && !isSorted(i); /** Whether block `i` tops its colour's tower, so scattering takes it. */ const isTop = (i: number) => i !== held && isBare(i) && isSorted(i); /** The first block from index `from` onwards, wrapping round, that `accepts` takes, or -1. */ const nextFrom = (from: number, accepts: (i: number) => boolean) => { for (let k = 0; k < blocks.length; k++) { const i = (from + k) % blocks.length; if (accepts(i)) return i; } return -1; }; /** Sets the held block down at bench point x, z, or on top of the blocks there. */ const dropAt = (x: number, z: number) => { drop.x = x; drop.z = z; ring(drop); const under = surfaceAt(drop.x, drop.z, Infinity, -1); if (under) { drop.x = under.x; drop.z = under.z; } drop.set = true; }; /** * Sets the held block on its colour's tower, or while scattering on the bench halfway between * two homes. */ const placeHeld = () => { const home = homes[blocks[held]!.colour]; if (scattering > 0 || !home) { scattering = Math.max(0, scattering - 1); const [x, z] = homes[tasks % homes.length]!; const angle = Math.atan2(z, x) + Math.PI / homes.length; const out = SCATTER.out + ((tasks * SCATTER.step) % 1) * SCATTER.width; dropAt(Math.cos(angle) * out, Math.sin(angle) * out); } else dropAt(home[0], home[1]); tasks++; }; const arrived = () => vec3.distance(target, gripper.tip) < NEAR; /** The next block attract mode moves: one out of its tower, or a tower's top while scattering. */ const nextTask = () => nextFrom(last + 1, scattering > 0 ? isTop : isLoose); const hasTask = () => nextTask() >= 0; const placing = () => drop.set && arrived(); /** Moves on to `to` once the tip arrives, or after `PATIENCE` seconds. */ const onceThere = (to: 'lower' | 'close' | 'carry' | 'open' | 'free') => [ { to, when: arrived }, { to, after: PATIENCE }, ] as const; const Task = machines.define('Task', { events: ['grab', 'sort'], states: { free: { next: [ { to: 'reach', on: 'grab' }, { to: 'reach', on: 'sort', after: PAUSE, when: hasTask }, ], }, reach: { enter: () => { chosen = tapped >= 0 ? tapped : nextTask(); last = chosen; tapped = -1; }, next: onceThere('lower'), }, lower: { next: onceThere('close') }, close: { exit: () => { held = chosen; }, next: [{ to: 'lift', after: SQUEEZE }], }, lift: { next: onceThere('carry') }, carry: { next: [{ to: 'place', when: placing }] }, place: { next: onceThere('open') }, open: { exit: () => { held = -1; chosen = -1; drop.set = false; }, next: [{ to: 'rise', after: SQUEEZE }], }, rise: { next: onceThere('free') }, }, }); const task = machines.run(Task); /** Moves resting blocks out of an upright cylinder around `centre`. */ const push = (centre: Vec3Arg, radius: number, half: number) => { const y = centre[1]!; for (let i = 0; i < blocks.length; i++) { const block = blocks[i]!; const above = y - half > topOf(block) - REST; const below = y + half < block.y; if (i === held || i === chosen || above || below) continue; shove(block, block.x - centre[0]!, block.z - centre[2]!, radius + block.size / 2); } }; /** Pushes a gripper part centred `x`, `y` in the wrist's frame. */ const pushPart = (x: number, y: number, radius: number, half: number) => { vec3.transformMat4(vec3.set(x, y, 0, local), gripper.wrist, at); push(at, radius, half); }; /** Pushes overlapping blocks apart and into the ring, then drops each onto what is under it. */ const settle = (dt: number) => { for (let i = 0; i < blocks.length; i++) { for (let j = i + 1; j < blocks.length; j++) { if (i === held || j === held) continue; const a = blocks[i]!; const b = blocks[j]!; if (topOf(a) - REST < b.y || topOf(b) - REST < a.y) continue; const x = (a.x + b.x) / 2; const z = (a.z + b.z) / 2; const reach = (a.size + b.size) * (APART / 2); shove(a, a.x - x, a.z - z, reach); shove(b, b.x - x, b.z - z, reach); } } for (let i = 0; i < blocks.length; i++) { if (i === held) continue; const block = blocks[i]!; ring(block); const under = surfaceAt(block.x, block.z, block.y, i); block.y = Math.max(under ? topOf(under) : 0, block.y - FALL * dt); } }; /** Points the target where the task's state heads, at bench point `aim` while free. */ const aimFor = (aim: readonly [x: number, z: number]) => { const block = blocks[chosen]; switch (task.state()) { case Task.free: vec3.set(aim[0], HOVER, aim[1], target); break; case Task.reach: if (block) vec3.set(block.x, HOVER, block.z, target); break; case Task.lower: if (block) vec3.set(block.x, block.y + GRIP, block.z, target); break; case Task.lift: case Task.rise: target[1] = HOVER; break; case Task.carry: if (drop.set) vec3.set(drop.x, HOVER, drop.z, target); else vec3.set(aim[0], HOVER, aim[1], target); break; case Task.place: { const under = surfaceAt(drop.x, drop.z, Infinity, -1); target[1] = (under ? topOf(under) : 0) + GRIP; break; } } }; settle(Infinity); let fingers = HAND.open; return { /** Where the fingertips head, written by `step`. */ target: target as Readonly<typeof target>, /** How far each finger stands from the palm centre, written by `step`. */ get fingers() { return fingers; }, get holding() { return held >= 0; }, /** Returns the nearest block the ray hits, or -1. */ hit(origin: Vec3Arg, direction: Vec3Arg) { let nearest = Infinity; let found = -1; for (let i = 0; i < blocks.length; i++) { const distance = entryOf(blocks[i]!, origin, direction); if (distance >= nearest) continue; nearest = distance; found = i; } return found; }, /** Sends the gripper to pick up block `i` unless it is busy. */ grab(i: number) { if (task.state() !== Task.free || !blocks[i]) return; tapped = i; task.send.grab(); }, /** Sets the held block down at bench point x, z, or on the blocks there, once it is lifted. */ drop(x: number, z: number) { const state = task.state(); if (state === Task.lift || state === Task.carry) dropAt(x, z); }, /** * Sorts on its own for the next step: after a pause, moves a block out of its tower onto its * colour's, and once all are sorted scatters some across the bench to sort again. */ auto() { if (task.state() === Task.free) { if (scattering === 0 && nextFrom(0, isLoose) < 0) { scattering = Math.ceil(blocks.length * SCATTER.share); } else if (scattering > 0 && nextFrom(0, isTop) < 0) scattering = 0; } task.send.sort(); if (task.state() === Task.carry && !drop.set) placeHeld(); }, /** * Advances by `dt` seconds. While free, the fingertips head for bench point `aim` at `HOVER` * height. */ step(dt: number, aim: readonly [x: number, z: number]) { // The guards measure the tip against the target of the state it is in aimFor(aim); task.step(dt); aimFor(aim); const block = blocks[chosen]; const squeeze = Math.min(1, task.since() / SQUEEZE); const shut = block ? block.size / 2 + HAND.finger.width / 2 : HAND.open; const { wrist } = gripper; const yaw = Math.atan2(-wrist[2]!, wrist[0]!); const state = task.state(); if (state === Task.close) { fingers = HAND.open + (shut - HAND.open) * squeeze; if (block) block.yaw -= offSquare(block.yaw - yaw) * squeeze; } else if (state === Task.open) { fingers = shut + (HAND.open - shut) * squeeze; } else { fingers = held >= 0 ? shut : HAND.open; } const holding = blocks[held]; if (holding) { vec3.set(0, TIP[1] + GRIP - holding.height / 2, 0, local); vec3.transformMat4(local, wrist, at); holding.x = at[0]!; holding.y = at[1]! - holding.height / 2; holding.z = at[2]!; holding.yaw = yaw; push(at, holding.size / 2, holding.height / 2); } const { palm, finger } = HAND; pushPart(0, palm.at, palm.width / 3, palm.height / 2); pushPart(-fingers, finger.at, finger.width / 2, finger.height / 2); pushPart(fingers, finger.at, finger.width / 2, finger.height / 2); settle(dt); }, };}bench.ts
import { srgbToLinear } from '@typegpu/color';import { instances, materials, type MeshPool } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { perlin2d } from '@typegpu/noise';import { quat, vec3, type Vec3Arg } from 'wgpu-matrix';import { d, std, type TgpuRoot } from 'typegpu';type Triple = [number, number, number];type Look = ReturnType<typeof materials.matte>;const paint = (r: number, g: number, b: number) => srgbToLinear(d.vec3f(r, g, b));const OAK = paint(0.62, 0.43, 0.26);const WALNUT = paint(0.42, 0.27, 0.15);const SEAM = paint(0.2, 0.12, 0.07);const HARDBOARD = paint(0.36, 0.28, 0.2);const HOLE = paint(0.12, 0.09, 0.06);const IRON = materials.metal(paint(0.55, 0.56, 0.58), 0.35);const RED_HANDLE = materials.glossy(paint(0.7, 0.12, 0.08), 0.4);const YELLOW_HANDLE = materials.glossy(paint(0.92, 0.7, 0.12), 0.4);const ASH = materials.matte(paint(0.72, 0.58, 0.4));const ENAMEL = materials.glossy(paint(0.2, 0.46, 0.38), 0.28);const BRASS = materials.metal(paint(0.85, 0.66, 0.36), 0.3);const TIN = materials.metal(paint(0.6, 0.6, 0.62), 0.5);const LABEL = materials.matte(paint(0.75, 0.2, 0.12));const SHELF = materials.matte(paint(0.38, 0.26, 0.16));const BULB = materials.glow(d.vec3f(1, 0.82, 0.55), 6);/** * The bench top's half width along x, half depth along z and thickness. A unit is a toy's * decimetre: the arm stands about 35 cm. */const BENCH = { x: 7, z: 4.6, thick: 0.6 };/** Plank width along z, and growth rings across a plank. */const PLANK = 1.15;const RINGS = 22;/** The pegboard's hole spacing. */const PEGS = 0.3;/** Where the desk lamp's base, elbow and shade sit, and the bench point its shade aims at. */const LAMP = { base: [-3.6, 0, -3.2] as Triple, elbow: [-4.0, 2.7, -3.0] as Triple, head: [-2.7, 3.0, -1.8] as Triple, aim: [-0.6, 0, -0.1] as Triple,};/** How far the bulb sits inside the shade, from its top towards its mouth. */const BULB_DEPTH = 0.3;/** The shade's scale. */const SHADE = 0.75;/** Tins on the shelf: across the bench and height. */const TINS: [x: number, height: number, look: Look][] = [ [-5.7, 1.2, TIN], [-4.6, 1.6, LABEL], [-3.4, 1.1, TIN], [-2.3, 1.4, TIN], [-1.2, 0.9, LABEL],];/** Fades detail of `scale` cells per unit where a cell spans less than about two pixels. */const resolved = (at: d.v2f, scale: number) => { 'use gpu'; return std.saturate(1.5 - std.length(std.fwidth(at)) * scale * 4);};/** Planks of oak along x, with wavy grain of uneven weight and thin seams. */const Bench = instances.define('Bench', { material: (point) => { 'use gpu'; const p = point.position; const plank = std.floor(p.z / PLANK); const across = p.z / PLANK - plank; // Growth rings run along the plank and wander across it const wander = perlin2d.sample(d.vec2f(p.x * 0.35, across * 2.5 + plank * 7.3)) * 2.5 + perlin2d.sample(d.vec2f(p.x * 1.3, across * 6 + plank * 3.1)) * 0.5; // Rings crowd and spread across the plank as the tree grew faster or slower const grown = across + perlin2d.sample(d.vec2f(across * 3, plank * 5.1)) * 0.06; const rings = grown * RINGS + wander; const weight = 0.4 + 0.6 * std.saturate(perlin2d.sample(d.vec2f(std.floor(rings) * 0.9, plank)) + 0.5); const late = std.pow(0.5 + 0.5 * std.cos(rings * Math.PI * 2), 4) * weight; const fine = std.saturate(1.2 - std.fwidth(rings) * 2.5); const tone = perlin2d.sample(d.vec2f(plank * 3.3, 0.5)) * 0.18 + perlin2d.sample(p.xz * 0.35) * 0.05; let color = std.mix(OAK, WALNUT, std.saturate(0.35 + tone)); color *= 1 - late * 0.12 * fine; const seam = std.smoothstep(0.012, 0.004, std.min(across, 1 - across) * PLANK); color = std.mix(color, SEAM, seam * 0.7 * resolved(p.xz, 1 / PLANK)); return materials.glossy(color, std.mix(0.68 + late * 0.06, 1, seam)); },});/** Painted hardboard pierced in a square grid, facing +z. */const Pegboard = instances.define('Pegboard', { material: (point) => { 'use gpu'; const at = point.position.xy / PEGS; const off = (std.fract(at) - 0.5) * PEGS; const hole = std.smoothstep(0.035, 0.022, std.length(off)) * resolved(at, d.f32(1)); const tone = 1 + perlin2d.sample(point.position.xy * 0.3) * 0.08; return materials.matte(std.mix(HARDBOARD * tone, HOLE, hole * 0.8)); },});/** Painted beech, a faint grain showing under the paint. */export const Painted = instances.define('Painted', { material: (point, _instance, base) => { 'use gpu'; const p = point.position; const grain = perlin2d.sample(d.vec2f(p.x * 2 + p.z * 2, p.y * 40)) * 0.06; return materials.glossy(base.color.xyz * (1 + grain), 0.42); },});/** The desk lamp's shade: down the outside to the rim, then back up the inside. */const shade = meshes.parametric( { at: (u, v) => { 'use gpu'; const angle = u * Math.PI * 2; const inside = v > 0.5; const t = std.select(v * 2, 2 - v * 2, inside); const radius = std.mix(0.35, 1.25, t * t) * std.select(d.f32(1), 0.95, inside); const y = -t * 1.3 + std.select(d.f32(0), 0.03, inside); return d.vec3f(std.cos(angle) * radius, y, std.sin(angle) * radius); }, }, { cols: 32, rows: 16 },);const UP: Triple = [0, 1, 0];/** * A workbench of oak planks with a pegboard of tools and a shelf of tins behind it, and a desk * lamp whose shade points at the arm. Its `lamp` gives the spot light the bulb casts. */export function createBench(root: TgpuRoot, pool: MeshPool) { const block = pool.add(meshes.box({ width: 1, height: 1, depth: 1 })); const rod = pool.add(meshes.cylinder({ radius: 1, height: 1, radialSegments: 20 })); const ball = pool.add(meshes.sphere({ radius: 1 })); const loop = pool.add(meshes.torus({ radius: 0.32, tube: 0.09 })); const hood = pool.add(shade); const top = instances.create(root, pool, { kind: Bench, capacity: 1 }); top.add(block, { position: [0, -BENCH.thick / 2, 0], scale: [BENCH.x * 2, BENCH.thick, BENCH.z * 2], }); const board = instances.create(root, pool, { kind: Pegboard, capacity: 1 }); board.add(block, { position: [0, 6, -BENCH.z - 0.1], scale: [BENCH.x * 2 + 12, 16, 0.2] }); const props = instances.create(root, pool, { capacity: 32 }); const along = vec3.create(); const turn = quat.create(); /** Adds a rod of `radius` from `from` to `to`. */ const between = (from: Vec3Arg, to: Vec3Arg, radius: number, look: Look) => { vec3.sub(to, from, along); const length = vec3.length(along); quat.rotationTo(UP, vec3.normalize(along, along), turn); props.add(rod, { position: vec3.lerp(from, to, 0.5), rotation: turn, scale: [radius, length, radius], ...look, }); }; // The desk lamp, its shade's mouth towards the aim and the bulb inside it const { base, elbow, head, aim } = LAMP; props.add(rod, { position: [base[0], 0.12, base[2]], scale: [0.85, 0.24, 0.85], ...ENAMEL }); between([base[0], 0.24, base[2]], elbow, 0.07, BRASS); between(elbow, head, 0.07, BRASS); props.add(ball, { position: elbow, scale: 0.15, ...BRASS }); const facing = vec3.normalize(vec3.sub(aim, head)); quat.rotationTo(UP, vec3.negate(facing), turn); props.add(hood, { position: head, rotation: turn, scale: SHADE, ...ENAMEL }); props.add(ball, { position: head, scale: 0.3, ...ENAMEL }); const bulb = vec3.addScaled(head, facing, BULB_DEPTH); props.add(ball, { position: bulb, scale: 0.2, ...BULB }); // Tools on the pegboard: two screwdrivers, a hammer and a wrench const wall = -BENCH.z + 0.15; between([1.6, 0.7, wall], [1.6, 1.9, wall], 0.035, IRON); between([1.6, 1.9, wall], [1.6, 2.7, wall], 0.12, RED_HANDLE); between([2.2, 0.9, wall], [2.2, 1.9, wall], 0.03, IRON); between([2.2, 1.9, wall], [2.2, 2.6, wall], 0.11, YELLOW_HANDLE); between([3.2, 0.7, wall], [3.2, 2.5, wall], 0.08, ASH); props.add(block, { position: [3.2, 2.65, wall + 0.08], scale: [0.8, 0.26, 0.26], ...IRON }); props.add(block, { position: [4.2, 1.6, wall], scale: [0.16, 1.5, 0.06], ...IRON }); props.add(loop, { position: [4.2, 2.5, wall], rotation: [Math.SQRT1_2, 0, 0, Math.SQRT1_2], scale: 0.65, ...IRON, }); // A shelf of tins over the bench props.add(block, { position: [-3.5, 6.8, wall + 0.6], scale: [6, 0.2, 1.4], ...SHELF }); for (const [x, height, look] of TINS) { props.add(rod, { position: [x, 6.9 + height / 2, wall + 0.6], scale: [0.42, height, 0.42], ...look, }); } top.flush(); board.flush(); props.flush(); return { drawings: [top, board, props], /** Where the lamp's light starts and the way it shines, for a spot light. */ lamp: { position: [bulb[0]!, bulb[1]!, bulb[2]!] as Triple, aim: [facing[0]!, facing[1]!, facing[2]!] as Triple, }, destroy() { top.destroy(); board.destroy(); props.destroy(); }, };}