zen: rebuild UB locomotion — real 3D steering + motionRoot/visualRig split (Codex)

Not a tuning pass — a locomotion rebuild per Codex's review. Replaces the X-axis shuttle +
scripted 180° U-turn with a proper 3D steering controller (behavior.js): UB seeks wandering
waypoints through the whole tank (XYZ incl. near/far depth passes), eases speed via limited
accel, steers with a rate-limited quaternion toward its velocity, banks into curves, and
softly veers away from walls BEFORE reaching them — no scripted turns.

Architecture: motionRoot (Group) owns world position + heading + bank; the visual rig plays
in-place body clips inside it, so navigation never fights the skeleton and the controller is
the sole heading authority (clips carry no root motion — verified). Fuller clip set exported
(idle/cruise/fast/turn{L,R}{in,loop,out}/eat*, build-clips.mjs). Authored clips play at their
own timing; only cruise cadence scales with speed. Debug panel: cruise speed + roam W/H/DEPTH
+ liveliness, a live readout (mode/clip/speed/turn/pos), and the raw-clip preview.

Next (Codex step 3): needs-driven personality — forage/zoomies/surface/inspect — on this base.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-07-01 21:53:35 -04:00
parent 45bd44834e
commit cf65243e07
5 changed files with 141 additions and 82 deletions
+30 -20
View File
@@ -26,10 +26,11 @@ const MODEL_URL = '/models/ub-angelfish.glb';
// to the clip it asks for. Render + behavior are live-tunable at /zen?debug=1. // to the clip it asks for. Render + behavior are live-tunable at /zen?debug=1.
export const DEFAULTS = { export const DEFAULTS = {
scale: 1.0, // multiplier on the auto-fit size scale: 1.0, // multiplier on the auto-fit size
baseSpeed: 0.26, // cruise speed (the tail beat follows it) cruiseSpeed: 0.5, // roaming speed (tail cadence follows it)
boundsX: 1.15, // half-width UB roams before turning back boundsX: 1.5, // tank half-extents UB roams within (soft-avoided)
boundsY: 0.45, // half-height of the vertical drift boundsY: 0.7,
liveliness: 1.0, // 0 = placid cruiser · higher = more rests boundsZ: 0.6, // depth → near/far passes
liveliness: 1.0, // 0 = placid cruiser · higher = more rests + darts
finTranslucent: false, // false = opaque alpha-tested (coherent); true = blended finTranslucent: false, // false = opaque alpha-tested (coherent); true = blended
finSide: 'double', // front | back | double (fins are thin → double reads fuller) finSide: 'double', // front | back | double (fins are thin → double reads fuller)
finOpacity: 0.9, // only when translucent finOpacity: 0.9, // only when translucent
@@ -112,10 +113,17 @@ export async function createAquarium(canvas, initial = {}) {
applyMaterials(); applyMaterials();
ub.scale.setScalar(baseScale * (params.scale ?? 1)); ub.scale.setScalar(baseScale * (params.scale ?? 1));
scene.add(ub);
// Clips by name (idle / cruise / burst / turnL / turnR). The behavior engine asks for // motionRoot flies UB around the tank (position + heading + bank); the visual rig (the
// one each frame; crossfade() blends to it over ~0.35s. // model) just plays in-place body clips inside it — so navigation never fights the
// skeleton (Codex). The controller is the sole authority on world movement + heading.
const motionRoot = new THREE.Group();
motionRoot.add(ub);
scene.add(motionRoot);
// Clips by name (idle / cruise / fast / turn{L,R}{in,loop,out} / eat*). The controller
// asks for one each frame; crossfade() blends to it. Authored clips play at their own
// timing; only cruise's cadence scales with swim speed.
const mixer = new THREE.AnimationMixer(ub); const mixer = new THREE.AnimationMixer(ub);
const actions = {}; const actions = {};
for (const clip of gltf.animations) actions[clip.name] = mixer.clipAction(clip); for (const clip of gltf.animations) actions[clip.name] = mixer.clipAction(clip);
@@ -128,11 +136,12 @@ export async function createAquarium(canvas, initial = {}) {
curClip = name; curClip = name;
} }
// The swim brain — owns UB's position + heading + which clip to show. // The swim controller — owns UB's position + heading + which clip to show.
const swimmer = createSwimmer({ const swimmer = createSwimmer({
reduced, reduced,
baseSpeed: params.baseSpeed * (reduced ? 0.7 : 1), cruiseSpeed: params.cruiseSpeed * (reduced ? 0.7 : 1),
boundsX: params.boundsX, boundsY: params.boundsY, liveliness: params.liveliness, boundsX: params.boundsX, boundsY: params.boundsY, boundsZ: params.boundsZ,
liveliness: params.liveliness,
}); });
resize(); resize();
@@ -149,16 +158,16 @@ export async function createAquarium(canvas, initial = {}) {
if (params.paused) { renderer.render(scene, camera); return; } if (params.paused) { renderer.render(scene, camera); return; }
if (params.preview && actions[params.preview]) { if (params.preview && actions[params.preview]) {
// diagnostic: freeze locomotion, loop one raw clip broadside at centre // diagnostic: freeze locomotion, loop one raw clip broadside at centre
ub.position.set(0, 0, 0); motionRoot.position.set(0, 0, 0);
ub.rotation.set(0, Math.PI / 2, 0); motionRoot.rotation.set(0, Math.PI / 2, 0);
crossfade(params.preview); crossfade(params.preview);
mixer.timeScale = 1; if (actions[params.preview]) actions[params.preview].timeScale = 1;
} else { } else {
const s = swimmer.update(dt); const s = swimmer.update(dt);
ub.position.set(s.x, s.y, s.z); motionRoot.position.copy(s.pos);
ub.rotation.set(s.pitch, s.yaw, s.roll); // heading + bank + nose tilt from the brain motionRoot.quaternion.copy(s.quat); // heading + bank, sole authority
crossfade(s.clip); crossfade(s.clip);
mixer.timeScale = s.ts; // tail beats with swim speed if (actions.cruise) actions.cruise.timeScale = THREE.MathUtils.clamp(s.speed / (params.cruiseSpeed || 0.5), 0.6, 1.5);
} }
mixer.update(dt); mixer.update(dt);
renderer.render(scene, camera); renderer.render(scene, camera);
@@ -166,15 +175,16 @@ export async function createAquarium(canvas, initial = {}) {
return { return {
// exposed for tuning + the /zen?debug=1 panel // exposed for tuning + the /zen?debug=1 panel
ub, actions, mixer, swimmer, scene, camera, params, ub, motionRoot, actions, mixer, swimmer, scene, camera, params,
// live setter: merge new values, re-apply materials + scale + behavior params // live setter: merge new values, re-apply materials + scale + controller params
setParams(next = {}) { setParams(next = {}) {
Object.assign(params, next); Object.assign(params, next);
applyMaterials(); applyMaterials();
ub.scale.setScalar(baseScale * (params.scale ?? 1)); ub.scale.setScalar(baseScale * (params.scale ?? 1));
swimmer.setParams({ swimmer.setParams({
baseSpeed: params.baseSpeed * (reduced ? 0.7 : 1), cruiseSpeed: params.cruiseSpeed * (reduced ? 0.7 : 1),
boundsX: params.boundsX, boundsY: params.boundsY, liveliness: params.liveliness, boundsX: params.boundsX, boundsY: params.boundsY, boundsZ: params.boundsZ,
liveliness: params.liveliness,
}); });
return { ...params }; return { ...params };
}, },
+81 -50
View File
@@ -1,32 +1,49 @@
// UB's swim brain. The GLB clips are in-place, so this owns LOCOMOTION and asks the tank // UB's swim controller — real 3D steering (Codex rebuild). Owns world position, velocity
// which clip to crossfade to. Tuned for CALM + FLUID: UB mostly cruises with a gentle // and heading; the visual rig just plays in-place body clips. UB seeks wandering waypoints
// body sway, drifts to new depths (nose eases into it), occasionally rests, and reverses // through the whole tank (XYZ, incl. near/far depth passes), eases speed up and down, banks
// with a SLOW, BANKED U-turn whose duration matches the turn clip (so the animation and // into curves, and softly veers away from walls BEFORE reaching them — no scripted U-turns.
// the heading change move together instead of fighting). Frame-rate-independent easing. // The controller is the SOLE authority on heading (clips carry no root motion), so the
// animation never fights the steering. Frame-rate-independent.
import * as THREE from 'three';
const rand = (a, b) => a + Math.random() * (b - a); const rand = (a, b) => a + Math.random() * (b - a);
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const damp = (cur, tgt, rate, dt) => cur + (tgt - cur) * (1 - Math.exp(-rate * dt)); const damp = (cur, tgt, rate, dt) => cur + (tgt - cur) * (1 - Math.exp(-rate * dt));
const smoother = (t) => t * t * t * (t * (t * 6 - 15) + 10); // ease-in-out (slow start + end) const FORWARD = new THREE.Vector3(0, 0, 1); // model nose = local +Z
const UP = new THREE.Vector3(0, 1, 0);
export function createSwimmer(opts = {}) { export function createSwimmer(opts = {}) {
const reduced = opts.reduced ?? false; const reduced = opts.reduced ?? false;
const P = { boundsX: 1.15, boundsY: 0.45, baseSpeed: 0.26, liveliness: 1, turnDur: 2.4, ...opts }; const P = {
const S = { boundsX: 1.5, boundsY: 0.7, boundsZ: 0.6,
x: 0, y: 0, z: 0, yaw: Math.PI / 2, roll: 0, pitch: 0, cruiseSpeed: 0.5, maxSpeed: 1.15, accel: 1.1, maxTurn: 1.7, // maxTurn rad/s
speed: P.baseSpeed, targetSpeed: P.baseSpeed, targetY: 0, liveliness: 1, ...opts,
mode: 'cruise', timer: rand(5, 9),
turning: false, turnFrom: 0, turnTo: 0, turnT: 0, turnSign: 1,
swayA: Math.random() * 6.28, swayB: Math.random() * 6.28,
clip: 'cruise', ts: 1,
}; };
const pos = new THREE.Vector3(0, 0, 0);
const vel = new THREE.Vector3(P.cruiseSpeed, 0, 0);
const heading = new THREE.Quaternion();
const wp = new THREE.Vector3();
let targetSpeed = P.cruiseSpeed, mode = 'cruise', timer = 3, roll = 0, yawRate = 0;
const prevF = new THREE.Vector3(0, 0, 1);
const S = { pos, quat: new THREE.Quaternion(), speed: P.cruiseSpeed, mode: 'cruise', clip: 'cruise', turnRate: 0 };
// scratch
const _des = new THREE.Vector3(), _steer = new THREE.Vector3(), _to = new THREE.Vector3();
const _f = new THREE.Vector3(), _r = new THREE.Vector3(), _u = new THREE.Vector3();
const _m = new THREE.Matrix4(), _tq = new THREE.Quaternion(), _curF = new THREE.Vector3(), _roll = new THREE.Quaternion();
const bnd = () => new THREE.Vector3(P.boundsX, P.boundsY, P.boundsZ);
function newWaypoint() {
const b = bnd();
wp.set(rand(-b.x, b.x) * 0.9, rand(-b.y, b.y) * 0.85, rand(-b.z, b.z) * 0.9);
}
function pickMode() { function pickMode() {
const live = P.liveliness * (reduced ? 0.5 : 1); const live = P.liveliness * (reduced ? 0.5 : 1);
if (Math.random() < 0.10 + 0.12 * live) { S.mode = 'rest'; S.targetSpeed = P.baseSpeed * 0.05; S.timer = rand(3, 6); } const r = Math.random();
else { S.mode = 'cruise'; S.targetSpeed = P.baseSpeed * rand(0.85, 1.2); S.timer = rand(6, 11); } if (r < 0.12 * live) { mode = 'rest'; targetSpeed = P.cruiseSpeed * 0.06; timer = rand(3, 6); }
S.targetY = rand(-P.boundsY, P.boundsY) * 0.85; else if (r < 0.12 * live + 0.09 * live) { mode = 'dart'; targetSpeed = P.maxSpeed * rand(0.8, 1); timer = rand(1.2, 2.4); newWaypoint(); }
else { mode = 'cruise'; targetSpeed = P.cruiseSpeed * rand(0.85, 1.15); timer = rand(4, 8); }
} }
pickMode(); newWaypoint(); pickMode();
return { return {
state: S, state: S,
@@ -34,41 +51,55 @@ export function createSwimmer(opts = {}) {
setParams(n = {}) { Object.assign(P, n); }, setParams(n = {}) { Object.assign(P, n); },
update(dt) { update(dt) {
dt = Math.min(dt, 0.05); dt = Math.min(dt, 0.05);
if (!S.turning) { S.timer -= dt; if (S.timer <= 0) pickMode(); } timer -= dt; if (timer <= 0) pickMode();
// vertical drift + gentle nose tilt into the climb/dive _to.copy(wp).sub(pos);
const yPrev = S.y; if (_to.lengthSq() < 0.12) newWaypoint();
S.y = damp(S.y, S.targetY, 0.4, dt); _des.copy(_to).normalize().multiplyScalar(targetSpeed);
const climb = clamp((S.y - yPrev) / dt * 0.5 || 0, -0.2, 0.2);
// subtle continuous body sway (a real fish is never perfectly rigid) // soft wall/surface/floor avoidance — veer inward as a boundary nears
S.swayA += dt * 1.1; S.swayB += dt * 0.8; const b = bnd(), margin = 0.55;
const swayRoll = Math.sin(S.swayA) * 0.05; for (const ax of ['x', 'y', 'z']) {
const swayPitch = Math.sin(S.swayB) * 0.03; const lim = ax === 'x' ? b.x : ax === 'y' ? b.y : b.z;
if (pos[ax] > lim - margin) _des[ax] -= ((pos[ax] - (lim - margin)) / margin) * targetSpeed * 1.6;
if (S.turning) { if (pos[ax] < -lim + margin) _des[ax] += (((-lim + margin) - pos[ax]) / margin) * targetSpeed * 1.6;
S.turnT += dt / P.turnDur;
const t = clamp(S.turnT, 0, 1), e = smoother(t);
S.yaw = S.turnFrom + (S.turnTo - S.turnFrom) * e;
S.roll = Math.sin(t * Math.PI) * 0.5 * S.turnSign; // bank in, level out
S.speed = P.baseSpeed * (0.4 + 0.35 * Math.sin(t * Math.PI)); // glide the arc, not a dead stop
S.pitch = damp(S.pitch, climb, 2.5, dt);
S.clip = S.turnSign > 0 ? 'turnR' : 'turnL';
if (t >= 1) { S.turning = false; }
} else {
S.speed = damp(S.speed, S.targetSpeed, 1.1, dt);
S.roll = damp(S.roll, swayRoll, 2, dt);
S.pitch = damp(S.pitch, climb + swayPitch, 2.5, dt);
const fwd = Math.sin(S.yaw);
if ((fwd > 0 && S.x > P.boundsX) || (fwd < 0 && S.x < -P.boundsX)) {
S.turning = true; S.turnT = 0; S.turnSign = fwd > 0 ? 1 : -1;
S.turnFrom = S.yaw; S.turnTo = S.yaw + Math.PI; // slow banked U-turn over turnDur
}
S.clip = S.mode === 'rest' ? 'idle' : 'cruise';
} }
S.x += S.speed * Math.sin(S.yaw) * dt; // travel along heading (arcs through turn) // limited-accel steering, capped speed
S.ts = (S.mode === 'rest' && !S.turning) ? 0.6 : clamp(S.speed / P.baseSpeed, 0.6, 1.5); _steer.copy(_des).sub(vel).clampLength(0, P.accel);
vel.addScaledVector(_steer, dt).clampLength(0, P.maxSpeed);
pos.addScaledVector(vel, dt);
pos.set(THREE.MathUtils.clamp(pos.x, -b.x, b.x), THREE.MathUtils.clamp(pos.y, -b.y, b.y), THREE.MathUtils.clamp(pos.z, -b.z, b.z));
const speed = vel.length();
S.speed = speed;
// heading toward velocity (belly-down basis), rate-limited so it can't snap
if (speed > 1e-4) {
_f.copy(vel).normalize();
_u.copy(UP).addScaledVector(_f, -UP.dot(_f)).normalize();
_r.crossVectors(_u, _f).normalize();
_m.makeBasis(_r, _u, _f);
_tq.setFromRotationMatrix(_m);
heading.rotateTowards(_tq, P.maxTurn * dt);
}
// banking: signed yaw rate → lean into the curve
_curF.copy(FORWARD).applyQuaternion(heading);
const cross = prevF.x * _curF.z - prevF.z * _curF.x; // horizontal component of prevF × curF
yawRate = damp(yawRate, (Math.asin(THREE.MathUtils.clamp(cross, -1, 1)) / dt) || 0, 6, dt);
prevF.copy(_curF);
roll = damp(roll, THREE.MathUtils.clamp(-yawRate * 0.22, -0.5, 0.5), 3, dt);
_roll.setFromAxisAngle(FORWARD, roll);
S.quat.copy(heading).multiply(_roll);
S.turnRate = yawRate;
// clip: rest→idle, dart→fast, hard turn→turn-loop (by sign), else cruise
S.mode = mode;
if (mode === 'rest') S.clip = 'idle';
else if (mode === 'dart') S.clip = 'fast';
else if (Math.abs(yawRate) > 0.6) S.clip = yawRate > 0 ? 'turnLloop' : 'turnRloop';
else S.clip = 'cruise';
return S; return S;
}, },
}; };
+22 -6
View File
@@ -15,6 +15,8 @@
let dbg = $state(null); let dbg = $state(null);
let debug = $state(false); let debug = $state(false);
let copied = $state(false); let copied = $state(false);
let stat = $state(null); // live controller readout (mode / speed / pos / turn)
let statTimer;
function apply() { handle?.setParams($state.snapshot(dbg)); } function apply() { handle?.setParams($state.snapshot(dbg)); }
async function copyValues() { async function copyValues() {
@@ -44,14 +46,20 @@
h = await createAquarium(canvas); h = await createAquarium(canvas);
if (cancelled) { h.dispose(); return; } // left /zen mid-load — don't start a loop if (cancelled) { h.dispose(); return; } // left /zen mid-load — don't start a loop
handle = h; handle = h;
if (debug) dbg = h.getParams(); if (debug) {
dbg = h.getParams();
statTimer = setInterval(() => {
const s = h.swimmer?.state;
if (s) stat = { mode: s.mode, clip: s.clip, speed: s.speed, turn: s.turnRate, x: s.pos.x, y: s.pos.y, z: s.pos.z };
}, 150);
}
loading = false; loading = false;
} catch (e) { } catch (e) {
console.warn('Zen Den could not start:', e); console.warn('Zen Den could not start:', e);
if (!cancelled) { failed = true; loading = false; } if (!cancelled) { failed = true; loading = false; }
} }
})(); })();
return () => { cancelled = true; h?.dispose(); }; return () => { cancelled = true; clearInterval(statTimer); h?.dispose(); };
}); });
</script> </script>
@@ -100,12 +108,17 @@
<hr /> <hr />
<div class="ph">Behavior</div> <div class="ph">Behavior</div>
<label>cruise speed <span>{dbg.baseSpeed.toFixed(2)}</span> {#if stat}
<input type="range" min="0.1" max="0.8" step="0.02" bind:value={dbg.baseSpeed} oninput={apply} /></label> <div class="readout">{stat.mode} · {stat.clip} · spd {stat.speed.toFixed(2)} · turn {stat.turn.toFixed(2)}<br />pos {stat.x.toFixed(2)}, {stat.y.toFixed(2)}, {stat.z.toFixed(2)}</div>
{/if}
<label>cruise speed <span>{dbg.cruiseSpeed.toFixed(2)}</span>
<input type="range" min="0.15" max="1.2" step="0.02" bind:value={dbg.cruiseSpeed} oninput={apply} /></label>
<label>roam width <span>{dbg.boundsX.toFixed(2)}</span> <label>roam width <span>{dbg.boundsX.toFixed(2)}</span>
<input type="range" min="0.3" max="2" step="0.05" bind:value={dbg.boundsX} oninput={apply} /></label> <input type="range" min="0.4" max="2.4" step="0.05" bind:value={dbg.boundsX} oninput={apply} /></label>
<label>roam height <span>{dbg.boundsY.toFixed(2)}</span> <label>roam height <span>{dbg.boundsY.toFixed(2)}</span>
<input type="range" min="0" max="1.2" step="0.05" bind:value={dbg.boundsY} oninput={apply} /></label> <input type="range" min="0.1" max="1.4" step="0.05" bind:value={dbg.boundsY} oninput={apply} /></label>
<label>roam depth <span>{dbg.boundsZ.toFixed(2)}</span>
<input type="range" min="0" max="1.4" step="0.05" bind:value={dbg.boundsZ} oninput={apply} /></label>
<label>liveliness <span>{dbg.liveliness.toFixed(2)}</span> <label>liveliness <span>{dbg.liveliness.toFixed(2)}</span>
<input type="range" min="0" max="2" step="0.1" bind:value={dbg.liveliness} oninput={apply} /></label> <input type="range" min="0" max="2" step="0.1" bind:value={dbg.liveliness} oninput={apply} /></label>
@@ -162,6 +175,9 @@
.panel hr { border: none; border-top: 1px solid var(--line); margin: 10px 0 6px; } .panel hr { border: none; border-top: 1px solid var(--line); margin: 10px 0 6px; }
.ph { font-family: var(--label); text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.72rem; .ph { font-family: var(--label); text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.72rem;
color: var(--muted); margin-bottom: 4px; } color: var(--muted); margin-bottom: 4px; }
.readout { font-family: var(--label); font-size: 0.72rem; line-height: 1.5; color: var(--accent-deep);
background: rgba(136, 87, 194, 0.08); border-radius: 6px; padding: 5px 8px; margin: 4px 0 8px;
font-variant-numeric: tabular-nums; }
.panel label { display: block; margin: 6px 0; color: var(--ink); } .panel label { display: block; margin: 6px 0; color: var(--ink); }
.panel label span { float: right; color: var(--accent-deep); font-variant-numeric: tabular-nums; } .panel label span { float: right; color: var(--accent-deep); font-variant-numeric: tabular-nums; }
.panel label.chk { display: flex; align-items: center; gap: 7px; } .panel label.chk { display: flex; align-items: center; gap: 7px; }
Binary file not shown.
+8 -6
View File
@@ -14,13 +14,15 @@ const root = doc.getRoot();
const buffer = root.listBuffers()[0]; const buffer = root.listBuffers()[0];
const meta = JSON.parse(fs.readFileSync(new URL('./angelfish-clips.json', import.meta.url))).clips; const meta = JSON.parse(fs.readFileSync(new URL('./angelfish-clips.json', import.meta.url))).clips;
// output-clip-name -> source clip in the pack // output-clip-name -> source clip in the pack. Full turn SEQUENCES (in→loop→out) so the
// controller can play a real turn; swim intensities for cadence/zoomies; eats for forage.
const MAP = { const MAP = {
idle: 'Idle', idle: 'Idle',
cruise: 'Swim1_norm', cruise: 'Swim1_norm',
burst: 'Swim2_Fast', fast: 'Swim2_Fast',
turnL: 'Turn_L_loop', turnLin: 'Turn_L_in', turnLloop: 'Turn_L_loop', turnLout: 'Turn_L_out',
turnR: 'Turn_R_loop', turnRin: 'Turn_R_in', turnRloop: 'Turn_R_loop', turnRout: 'Turn_R_out',
eatswim: 'Eat_Swim', eatground: 'Eat_Ground',
}; };
const src = root.listAnimations()[0]; const src = root.listAnimations()[0];