zen: Phase B — UB swims with a behavior engine (wander, cruise/rest/burst, U-turns)

UB is no longer a static in-place loop. New behavior.js owns locomotion: UB wanders a
bounded tank, cruises at a chosen speed, drifts to new depths (nose tilts into it),
occasionally rests or darts, and banks through smooth U-turns at the edges — the tail
beats faster/slower with speed. All clips are in-place, so the engine drives world
position + heading and crossfades between the named clips (idle/cruise/burst/turnL/turnR).

Multi-clip GLB built via tools/glb-split/build-clips.mjs (5 clips, 8.7MB — orphaned Take
accessors explicitly disposed). aquarium.js reworked: clip crossfade + per-frame behavior
apply. Tuner (/zen?debug=1) now exposes scale + Behavior (cruise speed / roam width /
roam height / liveliness) + the fins section. Reduced-motion calms speed + liveliness.
Still admin-gated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-07-01 20:38:43 -04:00
parent 9365f69b4a
commit d13811319d
5 changed files with 183 additions and 42 deletions
+43 -31
View File
@@ -15,25 +15,26 @@
// /zen?debug=1 — tuning a blind WebGL fish through redeploys was the slow path.
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { createSwimmer } from './behavior.js';
const MODEL_URL = '/models/ub-angelfish.glb';
// UB is now a marine angelfish (Queen): ONE mesh, TWO materials (…_body, …_fins — fins
// covers the caudal tail too), and a single trimmed loop (Swim1_norm — a ~2.5s in-place
// swim cycle, zero root drift, so it undulates without traveling). Body renders OPAQUE +
// single-sided; fins default to OPAQUE alpha-tested (clean silhouette, no blend bleed —
// the koi's lesson) but can go translucent. All live-tunable at /zen?debug=1.
// UB is the Queen angelfish: ONE mesh, TWO materials (…_body opaque single-sided; …_fins
// opaque alpha-tested), and a multi-clip GLB (idle / cruise / burst / turnL / turnR). A
// behavior engine (behavior.js) owns locomotion — UB wanders, cruises, drifts, rests,
// darts, and banks through U-turns; the loop applies its position/heading and crossfades
// to the clip it asks for. Render + behavior are live-tunable at /zen?debug=1.
export const DEFAULTS = {
yaw: Math.PI / 2, // ub.rotation.y — side-on-ish (tune live; head faces ±Z)
pitch: 0, // ub.rotation.x — nose up/down
scale: 1.2, // multiplier on the auto-fit size (a touch bigger = more presence)
speed: 0.7, // swim playback rate — <1 = calmer glide
scale: 1.0, // multiplier on the auto-fit size
baseSpeed: 0.34, // cruise speed (the tail beat follows it)
boundsX: 1.15, // half-width UB roams before turning back
boundsY: 0.5, // half-height of the vertical drift
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
finAlphaTest: 0.5, // clip the fin-edge alpha
paused: false,
frame: 0, // 0..1 scrub position when paused
};
const SIDE = { front: THREE.FrontSide, back: THREE.BackSide, double: THREE.DoubleSide };
@@ -108,25 +109,30 @@ export async function createAquarium(canvas, initial = {}) {
}
}
function applyTransform() {
ub.rotation.set(params.pitch, params.yaw, 0);
ub.scale.setScalar(baseScale * (params.scale ?? 1));
}
applyMaterials();
applyTransform();
ub.scale.setScalar(baseScale * (params.scale ?? 1));
scene.add(ub);
// Animation — keep every action by name so Phase B can crossfade (Idle_swim →
// Eat_Up / Roll). Idle_swim is the base loop.
// Clips by name (idle / cruise / burst / turnL / turnR). The behavior engine asks for
// one each frame; crossfade() blends to it over ~0.35s.
const mixer = new THREE.AnimationMixer(ub);
const actions = {};
for (const clip of gltf.animations) actions[clip.name] = mixer.clipAction(clip);
const baseClip = mixer.clipAction(gltf.animations[0]); // the trimmed swim loop
baseClip.play();
const baseDuration = baseClip.getClip().duration || 1;
const applySpeed = () => { mixer.timeScale = (params.speed ?? 0.7) * (reduced ? 0.7 : 1); };
applySpeed(); // calm glide by default; extra-calm under reduced-motion
let curClip = actions.cruise ? 'cruise' : Object.keys(actions)[0];
actions[curClip]?.play();
function crossfade(name) {
if (name === curClip || !actions[name]) return;
actions[name].reset().play();
actions[curClip]?.crossFadeTo(actions[name], 0.35, false);
curClip = name;
}
// The swim brain — 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,
});
resize();
// The canvas lives in a responsive container; a ResizeObserver catches layout
@@ -139,23 +145,29 @@ export async function createAquarium(canvas, initial = {}) {
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
const dt = clock.getDelta();
if (params.paused) {
mixer.setTime(params.frame * baseDuration); // scrub to a frozen frame
} else {
if (!params.paused) {
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
crossfade(s.clip);
mixer.timeScale = s.ts; // tail beats with swim speed
mixer.update(dt);
}
renderer.render(scene, camera);
});
return {
// exposed for Phase B tuning + the /zen?debug=1 panel
ub, actions, mixer, scene, camera, params, baseDuration,
// live setter: merge new values, re-apply materials + transform
// 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
setParams(next = {}) {
Object.assign(params, next);
applyMaterials();
applyTransform();
applySpeed();
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,
});
return { ...params };
},
getParams() { return { ...params }; },
+70
View File
@@ -0,0 +1,70 @@
// UB's swim brain. The GLB clips are all in-place, so this owns the LOCOMOTION: it moves
// UB around the tank and picks which clip the tank should crossfade to. Calm by design —
// UB mostly cruises, drifts up and down to new depths, occasionally rests or darts, and
// banks through smooth U-turns when it reaches an edge. 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 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));
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; }
export function createSwimmer(opts = {}) {
const reduced = opts.reduced ?? false;
const P = { boundsX: 1.15, boundsY: 0.5, boundsZ: 0.28, baseSpeed: 0.34, liveliness: 1, ...opts };
const S = {
x: 0, y: 0, z: 0, yaw: Math.PI / 2, targetYaw: Math.PI / 2, roll: 0, pitch: 0,
speed: P.baseSpeed, targetSpeed: P.baseSpeed, targetY: 0, targetZ: 0,
mode: 'cruise', timer: rand(4, 8), turning: false, turnSign: 1, clip: 'cruise', ts: 1,
};
function pickMode() {
const live = P.liveliness * (reduced ? 0.5 : 1);
const rest = 0.05 + 0.12 * live, burst = 0.11 * live, r = Math.random();
if (r < rest) { S.mode = 'rest'; S.targetSpeed = P.baseSpeed * 0.05; S.timer = rand(2.4, 5); }
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.targetZ = rand(-P.boundsZ, P.boundsZ);
}
pickMode();
return {
state: S,
params: P,
setParams(n = {}) { Object.assign(P, n); },
update(dt) {
dt = Math.min(dt, 0.05); // clamp long frame gaps (tab switch)
S.timer -= dt;
if (S.timer <= 0 && !S.turning) pickMode();
S.speed = damp(S.speed, S.turning ? P.baseSpeed * 0.5 : S.targetSpeed, 1.6, dt);
// drift toward a chosen depth/height; nose tilts gently toward vertical motion
const yPrev = S.y;
S.y = damp(S.y, S.targetY, 0.5, dt);
S.z = damp(S.z, S.targetZ, 0.4, dt);
S.pitch = damp(S.pitch, clamp((S.y - yPrev) / dt * 0.6 || 0, -0.28, 0.28), 3, dt);
if (S.turning) {
S.yaw += shortAngle(S.yaw, S.targetYaw) * (1 - Math.exp(-3 * dt));
S.roll = damp(S.roll, 0.42 * S.turnSign, 4, dt);
S.clip = S.turnSign > 0 ? 'turnR' : 'turnL';
if (Math.abs(shortAngle(S.yaw, S.targetYaw)) < 0.04) S.turning = false;
} else {
S.roll = damp(S.roll, 0, 3, dt);
const fwd = Math.sin(S.yaw);
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.clip = S.mode === 'rest' ? 'idle' : S.mode === 'burst' ? 'burst' : 'cruise';
}
S.x += S.speed * Math.sin(S.yaw) * dt; // move along heading (arcs through the turn)
S.ts = (S.mode === 'rest' && !S.turning) ? 0.55 : clamp(S.speed / P.baseSpeed, 0.5, 2); // tail beats with speed
return S;
},
};
}
+12 -11
View File
@@ -89,14 +89,19 @@
<div class="panel">
<div class="prow"><strong>UB render tuner</strong><button class="copy" onclick={copyValues}>{copied ? 'copied ✓' : 'copy values'}</button></div>
<label>yaw <span>{dbg.yaw.toFixed(2)}</span>
<input type="range" min="-3.15" max="3.15" step="0.01" bind:value={dbg.yaw} oninput={apply} /></label>
<label>pitch <span>{dbg.pitch.toFixed(2)}</span>
<input type="range" min="-0.6" max="0.6" step="0.01" bind:value={dbg.pitch} oninput={apply} /></label>
<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>
<label>speed <span>{dbg.speed.toFixed(2)}</span>
<input type="range" min="0.2" max="1.5" step="0.05" bind:value={dbg.speed} oninput={apply} /></label>
<hr />
<div class="ph">Behavior</div>
<label>cruise speed <span>{dbg.baseSpeed.toFixed(2)}</span>
<input type="range" min="0.1" max="0.8" step="0.02" bind:value={dbg.baseSpeed} oninput={apply} /></label>
<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>
<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>
<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>
<hr />
<div class="ph">Fins &amp; tail</div>
@@ -111,11 +116,7 @@
{/if}
<hr />
<label class="chk"><input type="checkbox" bind:checked={dbg.paused} onchange={apply} /> freeze frame</label>
{#if dbg.paused}
<label>frame <span>{dbg.frame.toFixed(2)}</span>
<input type="range" min="0" max="1" step="0.01" bind:value={dbg.frame} oninput={apply} /></label>
{/if}
<label class="chk"><input type="checkbox" bind:checked={dbg.paused} onchange={apply} /> freeze</label>
</div>
{/if}
</main>
Binary file not shown.
+58
View File
@@ -0,0 +1,58 @@
// Build a multi-clip GLB from the pack's single baked Take: extract a chosen set of
// clips (by frame range from angelfish-clips.json) into SEPARATE named animations, each
// rebased to start at 0, then drop the original 75s Take. This is what the Zen Den's
// behavior engine crossfades between. Usage: node build-clips.mjs in.glb out.glb
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
import { prune } from '@gltf-transform/functions';
import fs from 'node:fs';
const [, , inPath, outPath] = process.argv;
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const doc = await io.read(inPath);
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
const MAP = {
idle: 'Idle',
cruise: 'Swim1_norm',
burst: 'Swim2_Fast',
turnL: 'Turn_L_loop',
turnR: 'Turn_R_loop',
};
const src = root.listAnimations()[0];
const srcChannels = src.listChannels();
const EPS = 1e-4;
// The original Take's time/value accessors — captured now, disposed after we've copied
// the windows we need (new clips create their own fresh accessors, so these become dead).
const oldAccessors = new Set();
for (const sm of src.listSamplers()) { oldAccessors.add(sm.getInput()); oldAccessors.add(sm.getOutput()); }
for (const [outName, srcName] of Object.entries(MAP)) {
const [s0, s1] = meta[srcName].seconds;
const anim = doc.createAnimation(outName);
for (const ch of srcChannels) {
const sm = ch.getSampler();
const times = sm.getInput().getArray();
let a = 0; while (a < times.length - 1 && times[a] < s0 - EPS) a++;
let b = times.length - 1; while (b > a && times[b] > s1 + EPS) b--;
const base = times[a];
const nTimes = Float32Array.from(times.slice(a, b + 1), (x) => x - base);
const out = sm.getOutput();
const c = out.getElementSize();
const nOut = out.getArray().slice(a * c, (b + 1) * c);
const inAcc = doc.createAccessor().setType('SCALAR').setArray(nTimes).setBuffer(buffer);
const outAcc = doc.createAccessor().setType(out.getType()).setArray(nOut).setBuffer(buffer);
const ns = doc.createAnimationSampler().setInput(inAcc).setOutput(outAcc).setInterpolation(sm.getInterpolation());
anim.addSampler(ns);
anim.addChannel(doc.createAnimationChannel().setTargetNode(ch.getTargetNode()).setTargetPath(ch.getTargetPath()).setSampler(ns));
}
}
src.dispose(); // remove the original 75s Take
for (const acc of oldAccessors) acc.dispose(); // free its ~10MB of keyframe data
await doc.transform(prune());
await io.write(outPath, doc);
console.log('clips:', root.listAnimations().map((a) => `${a.getName()}(${a.listChannels().length}ch)`).join(', '));