diff --git a/frontend/src/lib/zen/aquarium.js b/frontend/src/lib/zen/aquarium.js index 628374c..a00dc24 100644 --- a/frontend/src/lib/zen/aquarium.js +++ b/frontend/src/lib/zen/aquarium.js @@ -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. export const DEFAULTS = { scale: 1.0, // multiplier on the auto-fit size - baseSpeed: 0.26, // cruise speed (the tail beat follows it) - boundsX: 1.15, // half-width UB roams before turning back - boundsY: 0.45, // half-height of the vertical drift - liveliness: 1.0, // 0 = placid cruiser · higher = more rests + cruiseSpeed: 0.5, // roaming speed (tail cadence follows it) + boundsX: 1.5, // tank half-extents UB roams within (soft-avoided) + boundsY: 0.7, + 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 finSide: 'double', // front | back | double (fins are thin → double reads fuller) finOpacity: 0.9, // only when translucent @@ -112,10 +113,17 @@ export async function createAquarium(canvas, initial = {}) { applyMaterials(); ub.scale.setScalar(baseScale * (params.scale ?? 1)); - scene.add(ub); - // Clips by name (idle / cruise / burst / turnL / turnR). The behavior engine asks for - // one each frame; crossfade() blends to it over ~0.35s. + // motionRoot flies UB around the tank (position + heading + bank); the visual rig (the + // 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 actions = {}; for (const clip of gltf.animations) actions[clip.name] = mixer.clipAction(clip); @@ -128,11 +136,12 @@ export async function createAquarium(canvas, initial = {}) { 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({ reduced, - baseSpeed: params.baseSpeed * (reduced ? 0.7 : 1), - boundsX: params.boundsX, boundsY: params.boundsY, liveliness: params.liveliness, + cruiseSpeed: params.cruiseSpeed * (reduced ? 0.7 : 1), + boundsX: params.boundsX, boundsY: params.boundsY, boundsZ: params.boundsZ, + liveliness: params.liveliness, }); resize(); @@ -149,16 +158,16 @@ export async function createAquarium(canvas, initial = {}) { if (params.paused) { renderer.render(scene, camera); return; } if (params.preview && actions[params.preview]) { // diagnostic: freeze locomotion, loop one raw clip broadside at centre - ub.position.set(0, 0, 0); - ub.rotation.set(0, Math.PI / 2, 0); + motionRoot.position.set(0, 0, 0); + motionRoot.rotation.set(0, Math.PI / 2, 0); crossfade(params.preview); - mixer.timeScale = 1; + if (actions[params.preview]) actions[params.preview].timeScale = 1; } else { const s = swimmer.update(dt); - ub.position.set(s.x, s.y, s.z); - ub.rotation.set(s.pitch, s.yaw, s.roll); // heading + bank + nose tilt from the brain + motionRoot.position.copy(s.pos); + motionRoot.quaternion.copy(s.quat); // heading + bank, sole authority 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); renderer.render(scene, camera); @@ -166,15 +175,16 @@ export async function createAquarium(canvas, initial = {}) { return { // exposed for tuning + the /zen?debug=1 panel - ub, actions, mixer, swimmer, scene, camera, params, - // live setter: merge new values, re-apply materials + scale + behavior params + ub, motionRoot, actions, mixer, swimmer, scene, camera, params, + // live setter: merge new values, re-apply materials + scale + controller params setParams(next = {}) { Object.assign(params, next); applyMaterials(); ub.scale.setScalar(baseScale * (params.scale ?? 1)); swimmer.setParams({ - baseSpeed: params.baseSpeed * (reduced ? 0.7 : 1), - boundsX: params.boundsX, boundsY: params.boundsY, liveliness: params.liveliness, + cruiseSpeed: params.cruiseSpeed * (reduced ? 0.7 : 1), + boundsX: params.boundsX, boundsY: params.boundsY, boundsZ: params.boundsZ, + liveliness: params.liveliness, }); return { ...params }; }, diff --git a/frontend/src/lib/zen/behavior.js b/frontend/src/lib/zen/behavior.js index 0569c91..0f3413d 100644 --- a/frontend/src/lib/zen/behavior.js +++ b/frontend/src/lib/zen/behavior.js @@ -1,32 +1,49 @@ -// UB's swim brain. The GLB clips are in-place, so this owns LOCOMOTION and asks the tank -// which clip to crossfade to. Tuned for CALM + FLUID: UB mostly cruises with a gentle -// body sway, drifts to new depths (nose eases into it), occasionally rests, and reverses -// with a SLOW, BANKED U-turn whose duration matches the turn clip (so the animation and -// the heading change move together instead of fighting). Frame-rate-independent easing. +// UB's swim controller — real 3D steering (Codex rebuild). Owns world position, velocity +// and heading; the visual rig just plays in-place body clips. UB seeks wandering waypoints +// through the whole tank (XYZ, incl. near/far depth passes), eases speed up and down, banks +// into curves, and softly veers away from walls BEFORE reaching them — no scripted U-turns. +// 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 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 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 = {}) { const reduced = opts.reduced ?? false; - const P = { boundsX: 1.15, boundsY: 0.45, baseSpeed: 0.26, liveliness: 1, turnDur: 2.4, ...opts }; - const S = { - x: 0, y: 0, z: 0, yaw: Math.PI / 2, roll: 0, pitch: 0, - speed: P.baseSpeed, targetSpeed: P.baseSpeed, targetY: 0, - 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 P = { + boundsX: 1.5, boundsY: 0.7, boundsZ: 0.6, + cruiseSpeed: 0.5, maxSpeed: 1.15, accel: 1.1, maxTurn: 1.7, // maxTurn rad/s + liveliness: 1, ...opts, }; + 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() { 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); } - else { S.mode = 'cruise'; S.targetSpeed = P.baseSpeed * rand(0.85, 1.2); S.timer = rand(6, 11); } - S.targetY = rand(-P.boundsY, P.boundsY) * 0.85; + const r = Math.random(); + if (r < 0.12 * live) { mode = 'rest'; targetSpeed = P.cruiseSpeed * 0.06; timer = rand(3, 6); } + 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 { state: S, @@ -34,41 +51,55 @@ export function createSwimmer(opts = {}) { setParams(n = {}) { Object.assign(P, n); }, update(dt) { 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 - const yPrev = S.y; - S.y = damp(S.y, S.targetY, 0.4, dt); - const climb = clamp((S.y - yPrev) / dt * 0.5 || 0, -0.2, 0.2); + _to.copy(wp).sub(pos); + if (_to.lengthSq() < 0.12) newWaypoint(); + _des.copy(_to).normalize().multiplyScalar(targetSpeed); - // subtle continuous body sway (a real fish is never perfectly rigid) - S.swayA += dt * 1.1; S.swayB += dt * 0.8; - const swayRoll = Math.sin(S.swayA) * 0.05; - const swayPitch = Math.sin(S.swayB) * 0.03; - - if (S.turning) { - 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'; + // soft wall/surface/floor avoidance — veer inward as a boundary nears + const b = bnd(), margin = 0.55; + for (const ax of ['x', 'y', 'z']) { + 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 (pos[ax] < -lim + margin) _des[ax] += (((-lim + margin) - pos[ax]) / margin) * targetSpeed * 1.6; } - S.x += S.speed * Math.sin(S.yaw) * dt; // travel along heading (arcs through turn) - S.ts = (S.mode === 'rest' && !S.turning) ? 0.6 : clamp(S.speed / P.baseSpeed, 0.6, 1.5); + // limited-accel steering, capped speed + _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; }, }; diff --git a/frontend/src/routes/zen/+page.svelte b/frontend/src/routes/zen/+page.svelte index 0cdb662..d0cddb1 100644 --- a/frontend/src/routes/zen/+page.svelte +++ b/frontend/src/routes/zen/+page.svelte @@ -15,6 +15,8 @@ let dbg = $state(null); let debug = $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)); } async function copyValues() { @@ -44,14 +46,20 @@ h = await createAquarium(canvas); if (cancelled) { h.dispose(); return; } // left /zen mid-load — don't start a loop 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; } catch (e) { console.warn('Zen Den could not start:', e); if (!cancelled) { failed = true; loading = false; } } })(); - return () => { cancelled = true; h?.dispose(); }; + return () => { cancelled = true; clearInterval(statTimer); h?.dispose(); }; }); @@ -100,12 +108,17 @@
Behavior
- + {#if stat} +
{stat.mode} · {stat.clip} · spd {stat.speed.toFixed(2)} · turn {stat.turn.toFixed(2)}
pos {stat.x.toFixed(2)}, {stat.y.toFixed(2)}, {stat.z.toFixed(2)}
+ {/if} + + + + @@ -162,6 +175,9 @@ .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; 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 span { float: right; color: var(--accent-deep); font-variant-numeric: tabular-nums; } .panel label.chk { display: flex; align-items: center; gap: 7px; } diff --git a/frontend/static/models/ub-angelfish.glb b/frontend/static/models/ub-angelfish.glb index 7c0d8b9..72419de 100644 Binary files a/frontend/static/models/ub-angelfish.glb and b/frontend/static/models/ub-angelfish.glb differ diff --git a/tools/glb-split/build-clips.mjs b/tools/glb-split/build-clips.mjs index 8a820ca..c93ca36 100644 --- a/tools/glb-split/build-clips.mjs +++ b/tools/glb-split/build-clips.mjs @@ -14,13 +14,15 @@ const root = doc.getRoot(); const buffer = root.listBuffers()[0]; 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 = { - idle: 'Idle', - cruise: 'Swim1_norm', - burst: 'Swim2_Fast', - turnL: 'Turn_L_loop', - turnR: 'Turn_R_loop', + idle: 'Idle', + cruise: 'Swim1_norm', + fast: 'Swim2_Fast', + turnLin: 'Turn_L_in', turnLloop: 'Turn_L_loop', turnLout: 'Turn_L_out', + turnRin: 'Turn_R_in', turnRloop: 'Turn_R_loop', turnRout: 'Turn_R_out', + eatswim: 'Eat_Swim', eatground: 'Eat_Ground', }; const src = root.listAnimations()[0];