zen: fluid rework — slow banked U-turns synced to the clip + calmer cruise + clip preview
Phase-B first pass was stiff/awkward — the turn whipped ~180° in ~0.7s while the turn clip ran 2.5s (fighting), and a flat fish spun that fast went edge-on. Rework: - U-turn now runs over the clip's duration (~2.4s), smootherstep ease-in-out, banks in/out (roll), glides the arc instead of stopping — heading + animation move together. - Calmer: slower cruise (0.26), fewer modes (cruise/rest, dropped jerky darts), longer timers, gentle continuous roll/pitch body sway so cruising isn't rigid. - New "preview clip" tuner control: freeze locomotion + loop any raw clip broadside — proves the animation itself is fluid (isolates engine vs asset). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,14 +26,15 @@ 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.34, // cruise speed (the tail beat follows it)
|
baseSpeed: 0.26, // cruise speed (the tail beat follows it)
|
||||||
boundsX: 1.15, // half-width UB roams before turning back
|
boundsX: 1.15, // half-width UB roams before turning back
|
||||||
boundsY: 0.5, // half-height of the vertical drift
|
boundsY: 0.45, // half-height of the vertical drift
|
||||||
liveliness: 1.0, // 0 = placid cruiser · higher = more rests + darts
|
liveliness: 1.0, // 0 = placid cruiser · higher = more rests
|
||||||
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
|
||||||
finAlphaTest: 0.5, // clip the fin-edge alpha
|
finAlphaTest: 0.5, // clip the fin-edge alpha
|
||||||
|
preview: '', // '' = live behavior; a clip name = freeze + loop it in place (diagnostic)
|
||||||
paused: false,
|
paused: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,14 +146,21 @@ export async function createAquarium(canvas, initial = {}) {
|
|||||||
const clock = new THREE.Clock();
|
const clock = new THREE.Clock();
|
||||||
renderer.setAnimationLoop(() => {
|
renderer.setAnimationLoop(() => {
|
||||||
const dt = clock.getDelta();
|
const dt = clock.getDelta();
|
||||||
if (!params.paused) {
|
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);
|
||||||
|
crossfade(params.preview);
|
||||||
|
mixer.timeScale = 1;
|
||||||
|
} else {
|
||||||
const s = swimmer.update(dt);
|
const s = swimmer.update(dt);
|
||||||
ub.position.set(s.x, s.y, s.z);
|
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
|
ub.rotation.set(s.pitch, s.yaw, s.roll); // heading + bank + nose tilt from the brain
|
||||||
crossfade(s.clip);
|
crossfade(s.clip);
|
||||||
mixer.timeScale = s.ts; // tail beats with swim speed
|
mixer.timeScale = s.ts; // tail beats with swim speed
|
||||||
mixer.update(dt);
|
|
||||||
}
|
}
|
||||||
|
mixer.update(dt);
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,30 @@
|
|||||||
// UB's swim brain. The GLB clips are all in-place, so this owns the LOCOMOTION: it moves
|
// UB's swim brain. The GLB clips are in-place, so this owns LOCOMOTION and asks the tank
|
||||||
// UB around the tank and picks which clip the tank should crossfade to. Calm by design —
|
// which clip to crossfade to. Tuned for CALM + FLUID: UB mostly cruises with a gentle
|
||||||
// UB mostly cruises, drifts up and down to new depths, occasionally rests or darts, and
|
// body sway, drifts to new depths (nose eases into it), occasionally rests, and reverses
|
||||||
// banks through smooth U-turns when it reaches an edge. Frame-rate-independent easing.
|
// 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.
|
||||||
// Heading convention: yaw is rotation about Y; worldForward.x = sin(yaw). yaw = +π/2 faces
|
|
||||||
// +X, +3π/2 faces −X, so a U-turn is just targetYaw += π (UB arcs through facing the back
|
|
||||||
// as sin(yaw) sweeps +1 → 0 → −1, naturally decelerating then reversing).
|
|
||||||
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 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));
|
||||||
function shortAngle(a, b) { let d = (b - a) % (Math.PI * 2); if (d > Math.PI) d -= Math.PI * 2; if (d < -Math.PI) d += Math.PI * 2; return d; }
|
const smoother = (t) => t * t * t * (t * (t * 6 - 15) + 10); // ease-in-out (slow start + end)
|
||||||
|
|
||||||
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.5, boundsZ: 0.28, baseSpeed: 0.34, liveliness: 1, ...opts };
|
const P = { boundsX: 1.15, boundsY: 0.45, baseSpeed: 0.26, liveliness: 1, turnDur: 2.4, ...opts };
|
||||||
const S = {
|
const S = {
|
||||||
x: 0, y: 0, z: 0, yaw: Math.PI / 2, targetYaw: Math.PI / 2, roll: 0, pitch: 0,
|
x: 0, y: 0, z: 0, yaw: Math.PI / 2, roll: 0, pitch: 0,
|
||||||
speed: P.baseSpeed, targetSpeed: P.baseSpeed, targetY: 0, targetZ: 0,
|
speed: P.baseSpeed, targetSpeed: P.baseSpeed, targetY: 0,
|
||||||
mode: 'cruise', timer: rand(4, 8), turning: false, turnSign: 1, clip: 'cruise', ts: 1,
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
function pickMode() {
|
function pickMode() {
|
||||||
const live = P.liveliness * (reduced ? 0.5 : 1);
|
const live = P.liveliness * (reduced ? 0.5 : 1);
|
||||||
const rest = 0.05 + 0.12 * live, burst = 0.11 * live, r = Math.random();
|
if (Math.random() < 0.10 + 0.12 * live) { S.mode = 'rest'; S.targetSpeed = P.baseSpeed * 0.05; S.timer = rand(3, 6); }
|
||||||
if (r < rest) { S.mode = 'rest'; S.targetSpeed = P.baseSpeed * 0.05; S.timer = rand(2.4, 5); }
|
else { S.mode = 'cruise'; S.targetSpeed = P.baseSpeed * rand(0.85, 1.2); S.timer = rand(6, 11); }
|
||||||
else if (r < rest + burst) { S.mode = 'burst'; S.targetSpeed = P.baseSpeed * rand(1.9, 2.6); S.timer = rand(0.7, 1.5); }
|
|
||||||
else { S.mode = 'cruise'; S.targetSpeed = P.baseSpeed * rand(0.8, 1.15); S.timer = rand(4, 9); }
|
|
||||||
S.targetY = rand(-P.boundsY, P.boundsY) * 0.85;
|
S.targetY = rand(-P.boundsY, P.boundsY) * 0.85;
|
||||||
S.targetZ = rand(-P.boundsZ, P.boundsZ);
|
|
||||||
}
|
}
|
||||||
pickMode();
|
pickMode();
|
||||||
|
|
||||||
@@ -36,34 +33,42 @@ export function createSwimmer(opts = {}) {
|
|||||||
params: P,
|
params: P,
|
||||||
setParams(n = {}) { Object.assign(P, n); },
|
setParams(n = {}) { Object.assign(P, n); },
|
||||||
update(dt) {
|
update(dt) {
|
||||||
dt = Math.min(dt, 0.05); // clamp long frame gaps (tab switch)
|
dt = Math.min(dt, 0.05);
|
||||||
S.timer -= dt;
|
if (!S.turning) { S.timer -= dt; if (S.timer <= 0) pickMode(); }
|
||||||
if (S.timer <= 0 && !S.turning) pickMode();
|
|
||||||
|
|
||||||
S.speed = damp(S.speed, S.turning ? P.baseSpeed * 0.5 : S.targetSpeed, 1.6, dt);
|
// vertical drift + gentle nose tilt into the climb/dive
|
||||||
|
|
||||||
// drift toward a chosen depth/height; nose tilts gently toward vertical motion
|
|
||||||
const yPrev = S.y;
|
const yPrev = S.y;
|
||||||
S.y = damp(S.y, S.targetY, 0.5, dt);
|
S.y = damp(S.y, S.targetY, 0.4, dt);
|
||||||
S.z = damp(S.z, S.targetZ, 0.4, dt);
|
const climb = clamp((S.y - yPrev) / dt * 0.5 || 0, -0.2, 0.2);
|
||||||
S.pitch = damp(S.pitch, clamp((S.y - yPrev) / dt * 0.6 || 0, -0.28, 0.28), 3, dt);
|
|
||||||
|
// 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) {
|
if (S.turning) {
|
||||||
S.yaw += shortAngle(S.yaw, S.targetYaw) * (1 - Math.exp(-3 * dt));
|
S.turnT += dt / P.turnDur;
|
||||||
S.roll = damp(S.roll, 0.42 * S.turnSign, 4, dt);
|
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';
|
S.clip = S.turnSign > 0 ? 'turnR' : 'turnL';
|
||||||
if (Math.abs(shortAngle(S.yaw, S.targetYaw)) < 0.04) S.turning = false;
|
if (t >= 1) { S.turning = false; }
|
||||||
} else {
|
} else {
|
||||||
S.roll = damp(S.roll, 0, 3, dt);
|
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);
|
const fwd = Math.sin(S.yaw);
|
||||||
if ((fwd > 0 && S.x > P.boundsX) || (fwd < 0 && S.x < -P.boundsX)) {
|
if ((fwd > 0 && S.x > P.boundsX) || (fwd < 0 && S.x < -P.boundsX)) {
|
||||||
S.turning = true; S.turnSign = fwd > 0 ? 1 : -1; S.targetYaw = S.yaw + Math.PI;
|
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' : S.mode === 'burst' ? 'burst' : 'cruise';
|
S.clip = S.mode === 'rest' ? 'idle' : 'cruise';
|
||||||
}
|
}
|
||||||
|
|
||||||
S.x += S.speed * Math.sin(S.yaw) * dt; // move along heading (arcs through the turn)
|
S.x += S.speed * Math.sin(S.yaw) * dt; // travel along heading (arcs through turn)
|
||||||
S.ts = (S.mode === 'rest' && !S.turning) ? 0.55 : clamp(S.speed / P.baseSpeed, 0.5, 2); // tail beats with speed
|
S.ts = (S.mode === 'rest' && !S.turning) ? 0.6 : clamp(S.speed / P.baseSpeed, 0.6, 1.5);
|
||||||
return S;
|
return S;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -89,6 +89,12 @@
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="prow"><strong>UB render tuner</strong><button class="copy" onclick={copyValues}>{copied ? 'copied ✓' : 'copy values'}</button></div>
|
<div class="prow"><strong>UB render tuner</strong><button class="copy" onclick={copyValues}>{copied ? 'copied ✓' : 'copy values'}</button></div>
|
||||||
|
|
||||||
|
<label>preview clip
|
||||||
|
<select bind:value={dbg.preview} onchange={apply}>
|
||||||
|
<option value="">— live behavior —</option>
|
||||||
|
<option>idle</option><option>cruise</option><option>burst</option><option>turnL</option><option>turnR</option>
|
||||||
|
</select></label>
|
||||||
|
|
||||||
<label>scale <span>{dbg.scale.toFixed(2)}</span>
|
<label>scale <span>{dbg.scale.toFixed(2)}</span>
|
||||||
<input type="range" min="0.3" max="2.5" step="0.05" bind:value={dbg.scale} oninput={apply} /></label>
|
<input type="range" min="0.3" max="2.5" step="0.05" bind:value={dbg.scale} oninput={apply} /></label>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user