Spider
A spider walks over hills toward the ground under the pointer. FABRIK chains solve its legs, and springs move its body, both using pmndrs/math on the CPU. The GPU generates fractal Perlin terrain with @typegpu/noise and bakes it into a mesh. A single readback lets the feet land on the drawn triangles.
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- terrain.tsGenerates terrain heights and bakes the mesh on the GPU.
- walker.tsUses pmndrs/math CPU arrays for a spring-driven body and FABRIK legs.
Kit modules bloom, cameras, finish, instances, lighting, materials, meshPool, owner, passes, pipelines, shading, targets. Also @typegpu/color, @typegpu/noise, math, math/ik, math/time, typegpu.
scene.ts
import { bloom, cameras, finish, instances, lighting, materials, meshPool, owner, passes, pipelines, targets, type Frame, type Surface,} from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { quat, vec3, type Quat, type Vec3 } from 'math';import { d, std, type TgpuRoot } from 'typegpu';import type { Pointing } from '../contract.ts';import type { Settings } from '../settings.ts';import { createTerrain } from './terrain.ts';import { createWalker, GAIT } from './walker.ts';const SAMPLES = 4;const HDR = 'rgba16float';const HAZE = [0.9, 0.74, 0.56] as const;/** Maximum target distance from the ground centre. */const ROAM = 13;/** * Automatic wandering range of `out` world units either side of the centre along x and z. * Frequency is `pace` radians per second, with z one radian ahead. */const WANDER = { out: [9, 8], pace: [0.13, 0.21], ahead: 1 } as const;/** How steeply a unit ray must fall, in its y, to target the ground, and how far it searches. */const FALLING = { grazing: 0.01, reach: 100 };/** World units the ring under the goal floats over the ground. */const RING_LIFT = 0.03;/** Tube axis in bone-local coordinates. */const ALONG: Vec3 = [0, 1, 0];/** Tube radii at the hip and foot ends of the coxa, femur, tibia and foot segments. */const RADII = [ [0.065, 0.058], [0.06, 0.046], [0.044, 0.03], [0.03, 0.004],] as const;/** The ankle lies this fraction along the solved tibia, raised `LIFT`. */const ANKLE = 0.68;const LIFT: Vec3 = [0, 0.07, 0];/** Joint knob radius over the radius of the tube it starts. */const KNOB = 1.05;const SHELL = materials.glossy(d.vec3f(0.008, 0.007, 0.007), 0.16);const LEG = materials.glossy(d.vec3f(0.012, 0.01, 0.009), 0.3);const KNEE = materials.glossy(d.vec3f(0.1, 0.045, 0.012), 0.3);const EYE = { ...materials.glossy(d.vec3f(0.004), 0.05), emission: d.vec3f(0.08, 0.015, 0.004) };const MARKER = materials.glow(d.vec3f(1, 0.55, 0.15), 3);const UPRIGHT: Quat = [0, 0, 0, 1];/** The abdomen's tilt, nose down about x. */const TILTED = quat.setAxisAngle(quat.create(), [1, 0, 0], 0.22);/** A ball of `scale` at `at` in body coordinates, turned by `turn`. */const piece = (at: Vec3, scale: number | Vec3, look = SHELL, turn = UPRIGHT) => ({ at, scale, look, turn,});/** Balls mirrored across the body's middle. */const pair = ([x, y, z]: Vec3, scale: number | Vec3, look = SHELL) => [ piece([-x, y, z], scale, look), piece([x, y, z], scale, look),];/** Body parts in body coordinates with +z forward: carapace, waist, abdomen, fangs and eyes. */const PARTS = [ piece([0, 0.05, 0.04], [0.44, 0.17, 0.55]), piece([0, 0.18, 0.34], [0.09, 0.06, 0.1]), piece([0, 0.07, -0.53], 0.09), piece([0, 0.22, -1.2], [0.5, 0.4, 0.66], SHELL, TILTED), ...pair([0.09, -0.01, 0.56], [0.06, 0.09, 0.13]), ...pair([0.035, 0.225, 0.41], 0.028, EYE), ...pair([0.075, 0.215, 0.39], 0.016, EYE), ...pair([0.05, 0.242, 0.33], 0.015, EYE),];/** An open tube of unit length along +y, narrowing from radius `from` to `to`. */const tube = (from: number, to: number) => meshes.parametric( { at: (u, v) => { 'use gpu'; const turn = u * Math.PI * 2; const radius = std.mix(from, to, v); return d.vec3f(std.cos(turn) * radius, v - 0.5, -std.sin(turn) * radius); }, }, { cols: 12, rows: 2 }, );/** * Solves spider legs and body on the CPU with pmndrs/math. Draws instances under a sun while * walking toward the pointer or wandering. */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 }, }), ); const view = cameras.orbit(root, { size: surface, target: [0, 0.8, 0], distance: 9, yaw: 0.8, pitch: 0.55, pitchRange: [0.15, 1.4], distanceRange: [4, 20], damping: 4, }); const light = own( lighting.create(root, { settings: { sunDirection: [-0.9, 0.36, 0.43], sunColor: [1, 0.72, 0.44], sunIntensity: 3.6, skyTop: [0.2, 0.34, 0.76], skyBottom: [0.14, 0.1, 0.07], skyIntensity: 0.75, fogColor: HAZE, fogDensity: 0.011, }, shadow: { extent: 14 }, }), ); const scene = passes .create( root, screen, { scene: passes.color, depth: passes.depth }, { resolve: { scene: 'hdr' }, clear: [...HAZE, 1] }, ) .pipe(view.inject()) .beneath(light.inject()); const pool = own(meshPool.create(root, { capacity: 64 })); const ground = own(await createTerrain(root, pool)); const { heightAt } = ground; const ball = pool.add(meshes.sphere({ radius: 1 })); const tubes = RADII.map(([from, to]) => pool.add(tube(from, to))); const ring = pool.add(meshes.torus({ radius: 0.3, tube: 0.03 })); const things = own(instances.create(root, pool, { capacity: 80 })); const renderer = things.renderer(); const shown = [scene.add(renderer), scene.add(ground.drawing)]; const casters = [light.shadow.add(renderer)]; const values = { ...GAIT }; const walker = createWalker(heightAt, values); const parts = PARTS.map((part) => ({ ...part, id: things.add(ball, { scale: part.scale, ...part.look }), })); const legs = walker.legs.map(({ chain }) => { const [coxa, femur, tibia] = chain.bones; const ankle = vec3.create(); return { tibia: tibia!, ankle, joints: [coxa!.start, femur!.start, tibia!.start, ankle, tibia!.end], drawn: RADII.map(([from], i) => ({ id: things.add(tubes[i]!, LEG), knob: things.add(ball, { scale: from * KNOB, ...KNEE }), })), }; }); const marker = things.add(ring, MARKER); const glow = own(bloom.create(root, { source: () => screen.textures.hdr })); const final = own( finish.create(root, { format: surface.format, source: () => screen.textures.hdr, glow, settings: { exposure: 0.8, glowIntensity: 0.05 }, }), ); await pipelines.compile([scene, light.shadow, glow, final]); const settings: Settings<typeof values> = { values, knobs: { speed: { min: 0.5, max: 3, step: 0.1 }, stride: { min: 0.4, max: 1.2, step: 0.05 }, step: { label: 'step duration (s)', min: 0.1, max: 0.5, step: 0.01 }, lift: { min: 0.1, max: 1, step: 0.05 }, }, }; const { body } = walker; const ray = { origin: vec3.create(), direction: vec3.create() }; const position = vec3.create(); const rotation: Quat = [0, 0, 0, 1]; const posed = { position, rotation }; const placed = { position }; const stretch: Vec3 = [1, 1, 1]; const stretched = { position, rotation, scale: stretch }; const axis = vec3.create(); const ringAt = vec3.create(); const marked = { position: ringAt }; let following = false; /** Walks toward ground point x, z, kept within `ROAM` of the centre. */ const setGoal = (x: number, z: number) => { const out = Math.hypot(x, z); const kept = Math.min(1, ROAM / (out || 1)); walker.goal[0] = x * kept; walker.goal[2] = z * kept; }; const pointing: Pointing = { /** Targets ground under the pointer. Ignores points above the horizon. */ move(x, y) { view.rayAt(x, y, ray); if (cameras.hitHeight(ray, heightAt, position, FALLING) === undefined) return; setGoal(position[0], position[2]); following = true; }, /** Resumes automatic wandering. */ leave() { following = false; }, }; /** Places each body part by the walker's body position and rotation. */ const placeBody = () => { for (const part of parts) { vec3.add(position, vec3.transformQuat(position, part.at, walker.rotation), body); quat.multiply(rotation, walker.rotation, part.turn); things.write(part.id, posed); } }; /** Stretches each leg segment's tube from one joint to the next, with a knob at its start. */ const placeLegs = () => { for (const { tibia, ankle, joints, drawn } of legs) { vec3.add(ankle, vec3.lerp(ankle, tibia.start, tibia.end, ANKLE), LIFT); for (let i = 0; i < drawn.length; i++) { const from = joints[i]!; const to = joints[i + 1]!; vec3.copy(position, from); things.write(drawn[i]!.knob, placed); stretch[1] = vec3.length(vec3.sub(axis, to, from)); quat.rotationTo(rotation, ALONG, vec3.normalize(axis, axis)); vec3.lerp(position, from, to, 0.5); things.write(drawn[i]!.id, stretched); } } }; return { view, settings, pointing, frame(frame: Frame) { if (!following) { const { out, pace, ahead } = WANDER; setGoal( Math.sin(frame.time * pace[0]) * out[0], Math.sin(frame.time * pace[1] + ahead) * out[1], ); } walker.step(frame.dt); vec3.copy(view.goal.target, body); view.update(frame.dt); light.shadow.follow(body); placeBody(); placeLegs(); vec3.set(ringAt, walker.goal[0], walker.goal[1] + RING_LIFT, walker.goal[2]); things.write(marker, marked); screen.fit(); things.flush(); light.shadow.record(frame, casters); scene.record(frame, shown); glow.record(frame); final.record(frame, surface.view(frame)); }, destroy: own.destroy, };}terrain.ts
import { srgbToLinear } from '@typegpu/color';import { instances, materials, shading, type meshPool } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { perlin2d } from '@typegpu/noise';import { clamp } from 'math';import { d, std, tgpu, type TgpuRoot } from 'typegpu';/** Square ground width in world units and grid cells per side. */const SIZE = 80;const CELLS = 200;/** Grid points per side, and world units across a cell. */const SIDE = CELLS + 1;const CELL = SIZE / CELLS;/** * Fractal Perlin frequency in cells per world unit and octave count. Each octave multiplies * frequency by `DETAIL` and amplitude by `GAIN`. Initial amplitude is in world units. */const FREQUENCY = 0.11;const OCTAVES = 4;const DETAIL = 2;const GAIN = 0.45;const HEIGHT = 1.8;const WORKGROUP = 8;const SAND = srgbToLinear(d.vec3f(0.92, 0.76, 0.5));const STRAW = srgbToLinear(d.vec3f(0.86, 0.62, 0.34));const KHAKI = srgbToLinear(d.vec3f(0.7, 0.6, 0.4));const EARTH = srgbToLinear(d.vec3f(0.66, 0.4, 0.24));const SCRUB = srgbToLinear(d.vec3f(0.36, 0.3, 0.18));/** Wind ripples per world unit and scrub tufts per world unit. */const RIPPLES = 2.2;const TUFTS = 5;/** Radii from the centre in world units over which the ground fades into the fog. */const FADE = [24, 38] as const;/** * Fades detail of `scale` cells per world unit out where a cell spans more than about two pixels, * so it does not shimmer at distance. */const resolved = (at: d.v2f, scale: number) => { 'use gpu'; return std.saturate(1.5 - std.length(std.fwidth(at)) * scale * 4);};/** Applies height fog, then fades the ground into the fog colour before its edge. */const fog = (color: d.v3f, point: shading.Point) => { 'use gpu'; const edge = std.smoothstep(FADE[0], FADE[1], std.length(point.position.xz)); return std.mix(shading.heightFog(color, point), shading.settings().fogColor, edge);};/** * Dry grass on the slopes, sand on the crests, khaki in the hollows and bare earth where steep, * with wind ripples and scattered scrub up close. */const Ground = instances.define('Ground', { material: (point) => { 'use gpu'; const at = point.position.xz; const patches = perlin2d.sample(at * 0.13) * 0.5 + 0.5; const scrub = perlin2d.sample(at * 1.7 + 31); const hollow = std.smoothstep(0.05, -0.7, point.position.y + scrub * 0.15); const steep = std.smoothstep(0.1, 0.32, 1 - point.normal.y); let color = std.mix(STRAW, SAND, std.saturate(patches * 1.4 - 0.2)); color = std.mix(color, KHAKI, hollow * 0.6); color = std.mix(color, EARTH, steep * 0.6); const bend = perlin2d.sample(at * 0.4 + 7) * 4; const ripple = std.sin((at.x * 0.8 + at.y * 0.6) * RIPPLES * Math.PI * 2 + bend); color *= 1 + ripple * 0.1 * resolved(at, RIPPLES) * (1 - hollow); const tuft = std.smoothstep(0.38, 0.5, perlin2d.sample(at * TUFTS + 53)); color = std.mix(color, SCRUB, tuft * 0.35 * resolved(at, d.f32(TUFTS)) * (1 - steep)); return materials.matte(color * (1 + scrub * 0.1)); },});/** * Generates terrain heights and bakes the mesh on the GPU. Reads heights back once so CPU foot * placement matches the drawn triangles. */export async function createTerrain(root: TgpuRoot, pool: meshPool.MeshPool) { const grid = root.createBuffer(d.arrayOf(d.f32, SIDE * SIDE)).$usage('storage'); const writing = grid.as('mutable'); const reading = grid.as('readonly'); /** Fills the grid with fractal Perlin heights, its columns and rows along the plane's u and v. */ const fill = tgpu.computeFn({ workgroupSize: [WORKGROUP, WORKGROUP], in: { id: d.builtin.globalInvocationId }, })(({ id }) => { 'use gpu'; if (id.x >= SIDE || id.y >= SIDE) return; const at = (d.vec2f(id.xy) / CELLS - 0.5) * d.vec2f(1, -1) * SIZE * FREQUENCY; let height = d.f32(0); let amplitude = d.f32(1); let scale = d.f32(1); for (let octave = 0; octave < OCTAVES; octave++) { height += perlin2d.sample(at * scale) * amplitude; amplitude *= GAIN; scale *= DETAIL; } writing.$[id.y * SIDE + id.x] = height * HEIGHT; }); const heightOf = (column: number, row: number) => { 'use gpu'; const kept = std.clamp(d.vec2i(column, row), d.vec2i(0), d.vec2i(CELLS)); return reading.$[kept.y * SIDE + kept.x]!; }; const shape = meshes.parametric( { at: (u, v) => { 'use gpu'; const cell = d.vec2i(std.round(d.vec2f(u, v) * CELLS)); return d.vec3f((u - 0.5) * SIZE, heightOf(cell.x, cell.y), (0.5 - v) * SIZE); }, // Rows run toward -z normalAt: (u, v) => { 'use gpu'; const cell = d.vec2i(std.round(d.vec2f(u, v) * CELLS)); const dx = heightOf(cell.x + 1, cell.y) - heightOf(cell.x - 1, cell.y); const dz = heightOf(cell.x, cell.y - 1) - heightOf(cell.x, cell.y + 1); return std.normalize(d.vec3f(-dx, 2 * CELL, -dz)); }, }, { cols: CELLS, rows: CELLS }, ); const encoder = root.createCommandEncoder(); const groups = Math.ceil(SIDE / WORKGROUP); const pass = encoder.beginComputePass(); pass.setPipeline(root.createComputePipeline({ compute: fill })); pass.dispatchWorkgroups(groups, groups); pass.end(); const mesh = pool.add(shape, encoder); encoder.submit(); const heights = Float32Array.from(await grid.read()); grid.destroy(); const field = instances.create(root, pool, { kind: Ground, capacity: 1 }); field.add(mesh); field.flush(); return { drawing: field.renderer().pipe((cfg) => cfg.with(shading.fog, fog)), destroy: () => field.destroy(), /** Ground height on the rendered triangle at a position. */ heightAt(x: number, z: number) { const u = clamp((x / SIZE + 0.5) * CELLS, 0, CELLS); const v = clamp((0.5 - z / SIZE) * CELLS, 0, CELLS); const column = Math.min(CELLS - 1, Math.floor(u)); const row = Math.min(CELLS - 1, Math.floor(v)); const across = u - column; const along = v - row; const corner = row * SIDE + column; const h00 = heights[corner]!; const h11 = heights[corner + SIDE + 1]!; // Match the mesh diagonal from (0, 0) to (1, 1) in each cell if (across >= along) { const h10 = heights[corner + 1]!; return h00 + across * (h10 - h00) + along * (h11 - h10); } const h01 = heights[corner + SIDE]!; return h00 + along * (h01 - h00) + across * (h11 - h01); }, };}walker.ts
import { clamp, deltaAngle, quat, type Quat, vec3, type Vec3 } from 'math';import { fabrik3 } from 'math/ik';import { spring, spring3 } from 'math/time';/** Leg segments ordered from the hip through coxa, femur and tibia. */const BONES = [0.5, 1, 1.35] as const;/** Initial gait settings, editable while walking. */export const GAIT = { /** World units a second the body walks at most. */ speed: 1.8, /** Maximum planted-foot displacement before stepping. */ stride: 0.75, /** Seconds a step takes. */ step: 0.2, /** Foot lift height in world units. */ lift: 0.4,};export type Gait = typeof GAIT;/** Planted-foot tolerance as a fraction of stride during its group's step. */const PLANTED = 0.15;/** Hip offsets from the body centre, laterally and longitudinally, ordered front to back. */const HIPS = { across: 0.32, along: [0.42, 0.14, -0.14, -0.42] };/** Forward or backward leg splay per unit longitudinal hip offset. */const SPLAY = 1.4;/** Horizontal foot distance from its hip. */const SPREAD = 1.65;/** Body height above feet and height-following rate per second. */const RIDE = 0.75;const RISING = 8;/** Foot spans front to back and across in world units, turning height differences into tilt. */const SPAN = { along: 1.2, across: 1.4 };/** Seconds the body takes to come to a stop. */const SETTLE = 1;/** Maximum turn rate in radians per second and turning response time in seconds. */const TURN = 1.6;const TURNING = 0.35;/** Foot-target lookahead in seconds after liftoff. */const LEAD = 0.25;/** World units a second below which the body keeps its heading instead of turning to its motion. */const STILL = 0.15;const UP: Vec3 = [0, 1, 0];/** Bone angle limits below and above horizontal, in radians. */const RISE = { coxa: [0.2, 1.05], femur: [0.5, 1.4], tibia: [2.6, 0.2],} as const satisfies Record<string, readonly [number, number]>;const BASE_HINGE = fabrik3.BaseboneConstraintType.GLOBAL_HINGE;const { GLOBAL_HINGE } = fabrik3.JointType;interface Leg { readonly chain: fabrik3.Chain3; /** Hip position in body coordinates. */ readonly hip: Vec3; /** Horizontal leg reach direction in body coordinates. */ readonly out: Vec3; /** Two stepping groups, with neighbouring legs in opposite groups. */ readonly group: number; readonly foot: Vec3; readonly from: Vec3; readonly to: Vec3; /** Step progress from 0 to 1. Negative while planted. */ step: number;}/** * Uses pmndrs/math CPU arrays for a spring-driven body and FABRIK legs. Feet stay planted until * they exceed their stride. Reads `gait` each step. */export function createWalker(heightAt: (x: number, z: number) => number, gait: Readonly<Gait>) { const ground = (point: Vec3) => (point[1] = heightAt(point[0], point[2])); const body = spring3.create([0, heightAt(0, 0) + RIDE, 0]); const heading = spring.create(0); const rotation: Quat = quat.create(); const goal: Vec3 = [0, 0, 0]; const world = (out: Vec3, local: Vec3) => vec3.add(out, vec3.transformQuat(out, local, rotation), body.value); const legs: Leg[] = [-1, 1].flatMap((side) => HIPS.along.map((along, k): Leg => { const hip: Vec3 = [side * HIPS.across, 0, along]; const out = vec3.normalize(vec3.create(), [side, 0, along * SPLAY]); const chain = fabrik3.createChain3(); chain.maxIterations = 8; fabrik3.addBone(chain, [0, 0, 0], [0, BONES[0], 0]); fabrik3.addConsecutiveBone(chain, UP, BONES[1]); fabrik3.addConsecutiveBone(chain, UP, BONES[2]); return { chain, hip, out, group: (k + (side > 0 ? 1 : 0)) % 2, foot: vec3.create(), from: vec3.create(), to: vec3.create(), step: -1, }; }), ); const at = vec3.create(); const across = vec3.create(); const home = vec3.create(); const lead = vec3.create(); const ahead = vec3.create(); /** Predicts the resting foot position after `seconds` of body movement and rotation. */ const homeOf = (leg: Leg, out: Vec3, seconds = LEAD) => { vec3.scaleAndAdd(at, leg.hip, leg.out, SPREAD); vec3.transformQuat(at, at, rotation); const turn = heading.velocity * seconds; const [x, , z] = at; out[0] = x * Math.cos(turn) + z * Math.sin(turn) + body.value[0] + body.velocity[0] * seconds; out[2] = z * Math.cos(turn) - x * Math.sin(turn) + body.value[2] + body.velocity[2] * seconds; ground(out); return out; }; /** Raises the knees into arches so the first FABRIK solve bends the right way. */ const arch = (leg: Leg) => { const [coxa, femur, tibia] = leg.chain.bones; world(coxa!.start, leg.hip); vec3.transformQuat(across, leg.out, rotation); vec3.scaleAndAdd(coxa!.end, coxa!.start, across, BONES[0] * 0.7); coxa!.end[1] += BONES[0] * 0.7; vec3.copy(femur!.start, coxa!.end); vec3.scaleAndAdd(femur!.end, femur!.start, across, BONES[1] * 0.8); femur!.end[1] += BONES[1] * 0.6; vec3.copy(tibia!.start, femur!.end); vec3.copy(tibia!.end, leg.foot); }; for (const leg of legs) { homeOf(leg, leg.foot); arch(leg); } /** Starts a step for each displaced foot in `group` and returns how many lifted. */ const lift = (group: number) => { let lifted = 0; for (const leg of legs) { if (leg.group !== group) continue; if (vec3.distance(leg.foot, homeOf(leg, home)) < gait.stride * PLANTED) continue; vec3.copy(leg.from, leg.foot); vec3.copy(leg.to, home); leg.step = 0; lifted += 1; } return lifted; }; /** First overextended planted leg, or none while any leg is stepping. */ const strayedLeg = () => { for (const leg of legs) if (leg.step >= 0) return undefined; for (const leg of legs) { if (vec3.distance(leg.foot, homeOf(leg, home)) > gait.stride) return leg; } return undefined; }; /** * Constrains the leg to the vertical plane through hip and foot. Positive angles rise above the * floor: coxa and femur rise while the tibia falls, keeping the knee up. */ const hinge = (leg: Leg) => { vec3.subtract(across, leg.foot, leg.chain.base); across[1] = 0; vec3.normalize(across, across); const axis = vec3.normalize(at, vec3.cross(at, across, UP)); const [, femur, tibia] = leg.chain.bones; fabrik3.setBaseboneHingeConstraint(leg.chain, BASE_HINGE, axis, ...RISE.coxa, across); fabrik3.setHingeJoint(femur!.joint, GLOBAL_HINGE, axis, ...RISE.femur, across); fabrik3.setHingeJoint(tibia!.joint, GLOBAL_HINGE, axis, ...RISE.tibia, across); }; /** Springs the body toward the goal and turns it to its motion, capping speed and turn rate. */ const steer = (dt: number) => { ground(goal); vec3.subtract(ahead, goal, body.value); ahead[1] = 0; const far = vec3.length(ahead); vec3.scaleAndAdd(ahead, body.value, ahead, Math.min(1, (gait.speed * SETTLE) / (far || 1))); spring3.damp(body, ahead, SETTLE, dt); const speed = Math.hypot(body.velocity[0], body.velocity[2]); const facing = speed > STILL ? Math.atan2(body.velocity[0], body.velocity[2]) : heading.value; const turn = clamp(deltaAngle(heading.value, facing), -TURN * TURNING, TURN * TURNING); spring.dampAngle(heading, heading.value + turn, TURNING, dt); }; /** Rides at the feet's mean height, tilted by their height differences along and across. */ const ride = (dt: number) => { const half = legs.length / 2; let height = 0; let pitch = 0; let roll = 0; for (let i = 0; i < legs.length; i++) { const leg = legs[i]!; height += leg.foot[1] / legs.length; pitch += ((leg.hip[2] > 0 ? 1 : -1) * leg.foot[1]) / half; roll += ((i < half ? -1 : 1) * leg.foot[1]) / half; } body.value[1] += (height + RIDE - body.value[1]) * clamp(dt * RISING, 0, 1); quat.setAxisAngle(rotation, UP, heading.value); quat.rotateX(rotation, rotation, -Math.atan2(pitch, SPAN.along)); quat.rotateZ(rotation, rotation, Math.atan2(roll, SPAN.across)); }; /** The group that stepped last. */ let stepped = 1; /** * Lifts the other group once a planted foot strays, or the stray's own group if the other has * nothing to step, so one group can't take every step in fast turns. */ const pace = () => { const strayed = strayedLeg(); if (!strayed) return; if (lift(1 - stepped) > 0) stepped = 1 - stepped; else if (lift(strayed.group) > 0) stepped = strayed.group; }; return { legs: legs as readonly Leg[], /** Body position and rotation. Read-only to callers. */ body: body.value, rotation, /** Ground-plane walking target. */ goal, step(dt: number) { steer(dt); ride(dt); pace(); for (const leg of legs) { if (leg.step >= 0) { // Update foot targets during steps as the body moves and turns homeOf(leg, leg.to, LEAD - leg.step * gait.step); leg.step = Math.min(1, leg.step + dt / gait.step); const t = leg.step * leg.step * (3 - 2 * leg.step); vec3.lerp(leg.foot, leg.from, leg.to, t); leg.foot[1] += Math.sin(leg.step * Math.PI) * gait.lift; if (leg.step === 1) leg.step = -1; } world(lead, leg.hip); fabrik3.setBaseLocation(leg.chain, lead); hinge(leg); fabrik3.solve(leg.chain, leg.foot); } }, };}