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:
@@ -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 }; },
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 & 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.
@@ -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(', '));
|
||||
Reference in New Issue
Block a user