From 081162b6629437ff0105ccfa534b9bba55b4c79d Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 13:55:11 +0300 Subject: [PATCH 1/6] [feat] general-purpose motion nodes: follow path, wander, orient to velocity, body wave (40 F15) - motion/motionCore.js (pure, unit-tested): centripetal Catmull-Rom paths with arc length, loop/pingpong/once + offset, signed curvature, bank roll, seeded smooth bounded noise with an analytic rate, the body-wave envelope/drive/offset - motion/motionNodes.js (three leaf): Follow Path rides a drawn Spline, a Flow path or its own waypoints in world space, faces along it and banks into turns; Wander drifts inside an area object's box (or a box around its base), seeded per object; Orient to Velocity faces the frame's motion with smoothed turning + bank and holds while still; Body Wave bends any mesh subtree with a vertex-shader travelling wave whose amplitude/frequency follow speed and whose bend follows turn rate (per-mesh material clones keeping the original uuid, put back when the wave stops) - flowRuntime applies the followers after the movers (stable sort, graphs without them unchanged) and prunes waves that stopped - catalog (Animation group), sockets (path/area take an object), cards, doc lines - tests/unit/motionCore.test.js: 23 tests incl. the bank-direction counterfactual Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/editors/Nodes.svelte | 4 + src/lib/flowRuntime.js | 43 ++- src/lib/flowSockets.js | 2 + src/lib/motion/motionCore.js | 383 +++++++++++++++++++++ src/lib/motion/motionNodes.js | 517 ++++++++++++++++++++++++++++ src/lib/nodeCatalog.js | 93 +++++ src/lib/nodeDocs.js | 4 + tests/unit/motionCore.test.js | 324 +++++++++++++++++ 8 files changed, 1369 insertions(+), 1 deletion(-) create mode 100644 src/lib/motion/motionCore.js create mode 100644 src/lib/motion/motionNodes.js create mode 100644 tests/unit/motionCore.test.js diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index ef4dd8340..dd33b741e 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -183,6 +183,10 @@ spin: AnimationNode, rotor: AnimationNode, // 36-fb F25 flowfloat: AnimationNode, // 36-fb F24 + followpath: AnimationNode, // 40 F15 + wander: AnimationNode, + orientvelocity: AnimationNode, + bodywave: AnimationNode, bounce: AnimationNode, orbit: AnimationNode, pulse: AnimationNode, diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index acf469ced..e1ed215b8 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -87,6 +87,16 @@ import { registerGameSetting, gameSettingValue } from './gameSettings'; import { setPointGrabEnabled } from './pointGrab'; import { spawnedFromOf } from './transientObjects'; // 31: a copy answers to its template (a leaf) import { applyRotor, applyFlowFloat } from './sim/motionNodes.js'; // 36-fb F25/F24 (a three leaf) +// 40 F15: general-purpose motion (a three leaf + its pure core) +import { + applyFollowPath, + applyWander, + applyOrientVelocity, + applyBodyWave, + pruneBodyWaves, + tickMotionNodes, + MOTION_PHASE +} from './motion/motionNodes.js'; // 37 (R6): variadic math/gate + the Switcher multiplexer (a leaf, no imports) import { rigGoal, rigStep, validVec } from './cameraRig.js'; // 37 (R8): a three leaf import { INPUT_LETTERS, socketCount, opFolds, foldMath, foldGate, switcherItems, switcherHandle, switcherIndexOf, switcherRadioIndex } from './variadicNodes.js'; @@ -3647,6 +3657,23 @@ function applyAnimation(object, base, anim, time, ctx) { applyFlowFloat(object, base, data, time, { root: sceneObjects, key: anim.id + '|' + object.uuid }); return; } + // 40 F15: the general-purpose motion nodes (motion/motionNodes.js, a leaf) + if (anim.type === 'followpath') { + applyFollowPath(object, base, data, time, { root: sceneObjects, key: anim.id + '|' + object.uuid }); + return; + } + if (anim.type === 'wander') { + applyWander(object, base, data, time, { root: sceneObjects }); + return; + } + if (anim.type === 'orientvelocity') { + applyOrientVelocity(object, base, data, time, { key: anim.id + '|' + object.uuid }); + return; + } + if (anim.type === 'bodywave') { + applyBodyWave(object, base, data, time, { key: anim.id + '|' + object.uuid }); + return; + } if (anim.type === 'shake') { const intensity = data.intensity ?? 0.2; const speed = data.speed ?? 10; @@ -3991,6 +4018,8 @@ function runTick(now) { } }); + /** 40 F15: the Body Wave (node, object) keys still driven this tick @type {Set} */ + const liveWaves = new Set(); active.forEach((anims, uuid) => { const object = sceneObjects.getObjectByProperty('uuid', uuid); if (!object) { @@ -4013,8 +4042,20 @@ function runTick(now) { // 21-E3: EFFECTS evaluate on the pause-folded clock, so a paused world holds its // pose and resumes where it froze instead of jumping the gap. Sounds/particles // take the same clock below; value/HUD/game nodes keep the real one. - anims.forEach((/** @type {any} */ anim) => applyAnimation(object, base, anim, effectTime(time), ctx)); + // 40 F15: the motion FOLLOWERS (Orient to Velocity, Body Wave) read what the movers + // wrote this frame, so they apply last whatever order the graph lists them in (a stable + // sort — every graph without them keeps its order byte-for-byte) + const ordered = anims.some((/** @type {any} */ a) => MOTION_PHASE[a.type]) + ? [...anims].sort((/** @type {any} */ a, /** @type {any} */ b) => (MOTION_PHASE[a.type] ?? 0) - (MOTION_PHASE[b.type] ?? 0)) + : anims; + ordered.forEach((/** @type {any} */ anim) => { + if (anim.type === 'bodywave') liveWaves.add(anim.id + '|' + object.uuid); + applyAnimation(object, base, anim, effectTime(time), ctx); + }); }); + // 40 F15: a Body Wave whose node or object went away puts its original materials back + pruneBodyWaves(liveWaves); + tickMotionNodes(); // sound nodes keep their own audio chains (97) — hand over the live pairs // 21-E4: `trigger` is the wired event's STAMP (null when nothing is wired) diff --git a/src/lib/flowSockets.js b/src/lib/flowSockets.js index 15363c4a9..1f17ae610 100644 --- a/src/lib/flowSockets.js +++ b/src/lib/flowSockets.js @@ -164,6 +164,8 @@ const INPUT = { camerarig: { target: 'object', offset: 'vector3' }, // 37 (R8) rotor: { on: 'boolean' }, // 36-fb F25 flowfloat: { path: 'object' }, // 36-fb F24 + followpath: { path: 'object' }, // 40 F15 + wander: { area: 'object' }, // 40 F15 setcolor: { color: 'color' }, visibility: { on: 'boolean' }, setuniform: { value: 'number' }, diff --git a/src/lib/motion/motionCore.js b/src/lib/motion/motionCore.js new file mode 100644 index 000000000..bfbf0960a --- /dev/null +++ b/src/lib/motion/motionCore.js @@ -0,0 +1,383 @@ +// 40 F15 — GENERAL-PURPOSE MOTION, the pure half (no THREE, no DOM, no stores — unit-tested). +// +// Four effect nodes share this file's math; motionNodes.js is the THREE half flowRuntime calls: +// Follow Path (`followpath`) — ride a smooth path (a drawn Spline, a Flow path or clicked +// waypoints) at a speed, facing along it, banking into turns. +// Orient to Velocity (`orientvelocity`) — face wherever the object is moving (after any mover: +// Wander, Orbit, Bounce, a script), turning smoothly, banking. +// Wander (`wander`) — drift around a smooth, bounded, seeded noise target inside an +// area object's box (a water volume, a room) or a box around +// where it was placed. +// Body Wave (`bodywave`) — a travelling bend along the body (a vertex shader): a fish's +// spine, a snake, a flag, a tail; its amplitude and frequency +// follow the object's speed and turn rate. +// The movers (Follow Path, Wander) are pure functions of (data, shared time) — determinism is the +// netcode, exactly like Spin and Path patrol. The followers (Orient, Body Wave) read the motion +// the movers produced, so they keep a little per-peer state; what they write is a look, not a +// pose anyone else needs, and every peer converges on the same one. + +/** @typedef {[number, number, number]} V3 */ + +export const MOTION_VERSION = 1; +/** where the forward axis of a model can point; Path patrol's convention (+Z) is the default */ +export const FORWARD_AXES = ['+z', '-z', '+x', '-x']; +/** samples per control-point span when a path is densified */ +export const SAMPLES_PER_SPAN = 16; +/** the most control points a path keeps (the spline tool's own cap) */ +export const MAX_PATH_POINTS = 200; + +/** @param {any} v @param {number} d @param {number} lo @param {number} hi */ +export function clampNum(v, d, lo, hi) { + const n = Number(v); + if (v === null || v === undefined || v === '' || !Number.isFinite(n)) return d; + return Math.min(hi, Math.max(lo, n)); +} + +/** @param {number[]} a @param {number[]} b */ +const dist = (a, b) => Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]); + +/** finite [x, y, z] points only, capped @param {any} pts @returns {V3[]} */ +export function cleanPoints(pts) { + if (!Array.isArray(pts)) return []; + /** @type {V3[]} */ + const out = []; + for (const p of pts) { + const q = Array.isArray(p) ? p : Array.isArray(p?.pos) ? p.pos : null; + if (!q || q.length < 3) continue; + const v = /** @type {V3} */ ([+q[0], +q[1], +q[2]]); + if (!v.every(Number.isFinite)) continue; + // a doubled click adds nothing but a zero-length span + if (out.length && dist(out[out.length - 1], v) < 1e-6) continue; + out.push(v); + if (out.length >= MAX_PATH_POINTS) break; + } + return out; +} + +/** + * One centripetal Catmull-Rom span (alpha 0.5 — no cusps or self-loops on uneven spacing, the + * spline tool's own curve) between p1 and p2 at u ∈ [0, 1]. + * @param {number[]} p0 @param {number[]} p1 @param {number[]} p2 @param {number[]} p3 @param {number} u + * @returns {V3} + */ +export function catmullRom(p0, p1, p2, p3, u) { + const knot = (/** @type {number[]} */ a, /** @type {number[]} */ b) => Math.max(1e-4, Math.sqrt(dist(a, b))); + const t0 = 0; + const t1 = t0 + knot(p0, p1); + const t2 = t1 + knot(p1, p2); + const t3 = t2 + knot(p2, p3); + const t = t1 + (t2 - t1) * u; + /** @type {V3} */ + const out = [0, 0, 0]; + for (let i = 0; i < 3; i++) { + const a1 = ((t1 - t) / (t1 - t0)) * p0[i] + ((t - t0) / (t1 - t0)) * p1[i]; + const a2 = ((t2 - t) / (t2 - t1)) * p1[i] + ((t - t1) / (t2 - t1)) * p2[i]; + const a3 = ((t3 - t) / (t3 - t2)) * p2[i] + ((t - t2) / (t3 - t2)) * p3[i]; + const b1 = ((t2 - t) / (t2 - t0)) * a1 + ((t - t0) / (t2 - t0)) * a2; + const b2 = ((t3 - t) / (t3 - t1)) * a2 + ((t - t1) / (t3 - t1)) * a3; + out[i] = ((t2 - t) / (t2 - t1)) * b1 + ((t - t1) / (t2 - t1)) * b2; + } + return out; +} + +/** + * A path ready to walk: the control points densified (smooth) or kept as a polyline, with the + * cumulative arc length per sample. A closed path's last sample IS its first. + * @param {any} points @param {{closed?: boolean, smooth?: boolean, samples?: number}} [opts] + * @returns {{pts: V3[], cum: number[], total: number, closed: boolean} | null} + */ +export function buildPath(points, opts = {}) { + const ctrl = cleanPoints(points); + if (ctrl.length < 2) return null; + const closed = !!opts.closed && ctrl.length >= 3; + const smooth = opts.smooth !== false && ctrl.length >= 3; + const per = Math.max(1, Math.min(64, Math.round(opts.samples ?? SAMPLES_PER_SPAN))); + const n = ctrl.length; + /** @type {V3[]} */ + let pts; + if (!smooth) pts = closed ? [...ctrl, ctrl[0]] : ctrl.slice(); + else { + const at = (/** @type {number} */ i) => { + if (closed) return ctrl[((i % n) + n) % n]; + if (i < 0) return mirror(ctrl[0], ctrl[1]); + if (i >= n) return mirror(ctrl[n - 1], ctrl[n - 2]); + return ctrl[i]; + }; + pts = []; + const spans = closed ? n : n - 1; + for (let s = 0; s < spans; s++) { + for (let k = 0; k < per; k++) pts.push(catmullRom(at(s - 1), at(s), at(s + 1), at(s + 2), k / per)); + } + pts.push(closed ? ctrl[0] : ctrl[n - 1]); + } + const cum = [0]; + for (let i = 1; i < pts.length; i++) cum.push(cum[i - 1] + dist(pts[i - 1], pts[i])); + const total = cum[cum.length - 1]; + if (!(total > 1e-6)) return null; + return { pts, cum, total, closed }; +} + +/** the phantom point beyond an open end (keeps the end tangent straight) @param {V3} end @param {V3} prev @returns {V3} */ +function mirror(end, prev) { + return [2 * end[0] - prev[0], 2 * end[1] - prev[1], 2 * end[2] - prev[2]]; +} + +/** + * Position and unit tangent at arc length s (clamped to the path). + * @param {{pts: V3[], cum: number[], total: number}} path @param {number} s + * @returns {{p: V3, t: V3}} + */ +export function samplePath(path, s) { + const { pts, cum, total } = path; + const x = Math.min(total, Math.max(0, s)); + // binary search the span + let lo = 0; + let hi = cum.length - 1; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (cum[mid] <= x) lo = mid; + else hi = mid; + } + const a = pts[lo]; + const b = pts[hi]; + const len = cum[hi] - cum[lo]; + const u = len > 0 ? (x - cum[lo]) / len : 0; + /** @type {V3} */ + const p = [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u, a[2] + (b[2] - a[2]) * u]; + const l = len || 1; + /** @type {V3} */ + const t = [(b[0] - a[0]) / l, (b[1] - a[1]) / l, (b[2] - a[2]) / l]; + return { p, t }; +} + +/** + * Where along the path at `time`: s (arc length) and dir (+1 forward / -1 on a ping-pong's way + * back). `offset` (0..1) shifts the start along the path, so several riders of one path spread + * out; a negative speed rides it backwards. + * @param {number} time @param {number} speed m/s @param {number} total @param {string} mode + * 'loop' | 'pingpong' | 'once' @param {number} [offset] + */ +export function pathDistance(time, speed, total, mode, offset = 0) { + const t = Math.max(0, time); + const start = (((offset % 1) + 1) % 1) * total; + const run = speed * t; + if (mode === 'once') { + const s = Math.min(total, Math.max(0, start + run)); + return { s, dir: speed < 0 ? -1 : 1 }; + } + if (mode === 'pingpong') { + const period = 2 * total; + const c = (((start + run) % period) + period) % period; + const back = c > total; + const s = back ? period - c : c; + const forward = speed >= 0 ? !back : back; + return { s, dir: forward ? 1 : -1 }; + } + const s = (((start + run) % total) + total) % total; + return { s, dir: speed < 0 ? -1 : 1 }; +} + +/** + * Signed horizontal curvature (1/m) at s: positive = the path turns LEFT (counter-clockwise seen + * from above, +Y up). Measured over ±h of arc so a densified polyline's corners do not alias. + * @param {{pts: V3[], cum: number[], total: number, closed: boolean}} path @param {number} s @param {number} [h] + */ +export function curvatureAt(path, s, h = 0.15) { + const span = Math.min(h, path.total / 4); + const wrap = (/** @type {number} */ x) => (path.closed ? ((x % path.total) + path.total) % path.total : x); + const a = samplePath(path, wrap(s - span)).t; + const b = samplePath(path, wrap(s + span)).t; + const ya = Math.atan2(a[0], a[2]); + const yb = Math.atan2(b[0], b[2]); + return wrapAngle(yb - ya) / (2 * span); +} + +/** an angle into (-π, π] @param {number} a */ +export function wrapAngle(a) { + let x = a % (Math.PI * 2); + if (x > Math.PI) x -= Math.PI * 2; + if (x <= -Math.PI) x += Math.PI * 2; + return x; +} + +/** + * The roll (radians) a mover leans INTO a turn: a coordinated turn's lean, shaped so the user + * sets it with two numbers — `bank` (0..1, how much) and `maxBank` (degrees, the ceiling). + * yawRate in rad/s (positive = turning left); the result is positive when the mover's top leans + * to its left. tanh keeps it smooth and bounded: 1 rad/s of turn ≈ 76 % of the ceiling. + * @param {number} yawRate @param {number} bank @param {number} maxBankDeg + */ +export function bankRoll(yawRate, bank, maxBankDeg) { + const b = Math.min(1, Math.max(0, bank)); + const max = (Math.min(85, Math.max(0, maxBankDeg)) * Math.PI) / 180; + return b * max * Math.tanh(yawRate); +} + +// ---- Wander: smooth bounded noise ------------------------------------------------------------- + +/** a 32-bit hash of a string or number → [0, 1) @param {any} seed */ +export function hash01(seed) { + const s = String(seed ?? ''); + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + h ^= h >>> 13; + h = Math.imul(h, 0x5bd1e995); + h ^= h >>> 15; + return (h >>> 0) / 4294967296; +} + +// three octaves at incommensurate rates (golden-ratio steps): smooth (C∞), never periodic in +// practice, bounded by construction — the sum of the weights is the divisor +const OCT = [ + { f: 1, w: 1 }, + { f: 1.618034, w: 0.5 }, + { f: 2.618034, w: 0.25 } +]; +const OCT_SUM = OCT.reduce((a, o) => a + o.w, 0); + +/** + * Smooth noise in [-1, 1] on one channel at time t. `seed` picks the phases (and slightly the + * rates), so two wanderers with different seeds never move in step. + * @param {number} t @param {number} seed01 a [0, 1) seed @param {number} channel 0, 1, 2 + */ +export function smoothNoise(t, seed01, channel) { + let v = 0; + for (let i = 0; i < OCT.length; i++) { + const k = hash01(seed01 * 1000 + channel * 31 + i * 7); + const rate = OCT[i].f * (0.85 + 0.3 * k); + v += OCT[i].w * Math.sin(t * rate + k * Math.PI * 2); + } + return v / OCT_SUM; +} + +/** d/dt of smoothNoise (for an analytic velocity) @param {number} t @param {number} seed01 @param {number} channel */ +export function smoothNoiseRate(t, seed01, channel) { + let v = 0; + for (let i = 0; i < OCT.length; i++) { + const k = hash01(seed01 * 1000 + channel * 31 + i * 7); + const rate = OCT[i].f * (0.85 + 0.3 * k); + v += OCT[i].w * rate * Math.cos(t * rate + k * Math.PI * 2); + } + return v / OCT_SUM; +} + +/** + * The wander target at time t inside a box (centre + half extents, any frame): each axis is its + * own noise channel, scaled to the half extent. `speed` is how fast the noise is walked (1 = the + * base octave turns about once every 6 s); `seed01` makes riders of one node differ. + * @param {number} t @param {V3} center @param {V3} half @param {number} speed @param {number} seed01 + * @returns {{p: V3, v: V3}} position and its time derivative (m/s) + */ +export function wanderAt(t, center, half, speed, seed01) { + const tt = Math.max(0, t) * speed; + /** @type {V3} */ + const p = [0, 0, 0]; + /** @type {V3} */ + const v = [0, 0, 0]; + for (let c = 0; c < 3; c++) { + p[c] = center[c] + half[c] * smoothNoise(tt, seed01, c); + v[c] = half[c] * speed * smoothNoiseRate(tt, seed01, c); + } + return { p, v }; +} + +// ---- Body Wave: the travelling bend ------------------------------------------------------------ + +export const BODY_WAVE_DEFAULTS = Object.freeze({ + forward: '+z', + side: 'x', + amplitude: 0.08, // of the body length, at the tail + wavelength: 1, // body lengths per wave + frequency: 1.5, // Hz at rest + stiffness: 0.3, // the front fraction that does not bend (a fish's head) + falloff: 2, // envelope power (2 = the tail swings most) + speedGain: 1, // extra Hz per m/s (a fast swimmer beats faster) + ampGain: 0.5, // extra amplitude (fraction) per m/s + turnBend: 0.5, // bend into a turn: fraction of body length per rad/s of turn + reverse: false // the wave travels tail → head instead (a flag rippling toward its pole) +}); + +/** + * The envelope at u (0 = the front/head, 1 = the tail): nothing ahead of `stiffness`, rising to + * 1 at the tail as a power curve. Pure, so the shader and the tests agree. + * @param {number} u @param {number} stiffness @param {number} falloff + */ +export function waveEnvelope(u, stiffness, falloff) { + const s = Math.min(0.95, Math.max(0, stiffness)); + const x = Math.min(1, Math.max(0, (u - s) / (1 - s))); + return Math.pow(x, Math.max(0.25, falloff)); +} + +/** + * The live wave numbers for a speed (m/s) and turn rate (rad/s, + = left): frequency and + * amplitude grow with speed (clamped to sane ceilings), the static bend follows the turn. + * @param {any} data node data @param {number} speed @param {number} yawRate + */ +export function waveDrive(data, speed, yawRate) { + const d = BODY_WAVE_DEFAULTS; + const v = Math.max(0, Math.min(10, speed || 0)); + const frequency = clampNum(data.frequency, d.frequency, 0, 10) + clampNum(data.speedGain, d.speedGain, 0, 10) * v; + const amplitude = clampNum(data.amplitude, d.amplitude, 0, 0.5) * (1 + clampNum(data.ampGain, d.ampGain, 0, 5) * v); + const bend = clampNum(data.turnBend, d.turnBend, 0, 2) * Math.max(-2, Math.min(2, yawRate || 0)); + return { frequency: Math.min(20, frequency), amplitude: Math.min(0.6, amplitude), bend: Math.max(-0.6, Math.min(0.6, bend)) }; +} + +/** + * The sideways offset (fraction of body length) at u for an accumulated phase (cycles). The + * shader runs this exact formula; the tests pin it. + * @param {number} u @param {number} phase cycles @param {{amplitude: number, bend: number}} drive + * @param {{wavelength: number, stiffness: number, falloff: number, reverse?: boolean}} shape + */ +export function waveOffset(u, phase, drive, shape) { + const env = waveEnvelope(u, shape.stiffness, shape.falloff); + const k = 1 / Math.max(0.05, shape.wavelength); + const dir = shape.reverse ? -1 : 1; + // head → tail: the crest moves toward larger u as the phase grows + const wave = Math.sin(Math.PI * 2 * (u * k - dir * phase)); + // a turn bends the body like a bow: no lean at the head, most at the tail (u²) + return drive.amplitude * env * wave + drive.bend * u * u; +} + +/** the unit vector of a forward-axis name @param {string} name @returns {V3} */ +export function forwardVector(name) { + switch (name) { + case '-z': + return [0, 0, -1]; + case '+x': + return [1, 0, 0]; + case '-x': + return [-1, 0, 0]; + default: + return [0, 0, 1]; + } +} + +/** + * The Euler Y (radians) that turns a model whose nose is `forward` so it faces +Z — the + * correction every mover applies after building a +Z-forward frame. + * @param {string} forward + */ +export function forwardYaw(forward) { + switch (forward) { + case '-z': + return Math.PI; + case '+x': + return -Math.PI / 2; + case '-x': + return Math.PI / 2; + default: + return 0; + } +} + +/** + * Exponential smoothing factor for a rate (1/s) over dt — frame-rate independent. + * @param {number} rate @param {number} dt + */ +export function damp(rate, dt) { + if (!(rate > 0)) return 1; + return 1 - Math.exp(-rate * Math.max(0, dt)); +} diff --git a/src/lib/motion/motionNodes.js b/src/lib/motion/motionNodes.js new file mode 100644 index 000000000..f5b4193b0 --- /dev/null +++ b/src/lib/motion/motionNodes.js @@ -0,0 +1,517 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import * as THREE from 'three'; +import { + buildPath, + samplePath, + pathDistance, + curvatureAt, + bankRoll, + wanderAt, + hash01, + waveDrive, + forwardYaw, + wrapAngle, + damp, + clampNum, + BODY_WAVE_DEFAULTS +} from './motionCore.js'; +import { normalizeSpline, isSplineObject } from '../splineTube.js'; +import { normalizeFlowPath } from '../sim/flowPathCore.js'; + +// 40 F15 — GENERAL-PURPOSE MOTION, the THREE half: what flowRuntime.applyAnimation calls for +// `followpath`, `orientvelocity`, `wander` and `bodywave`. A LEAF beside sim/motionNodes.js — it +// imports nothing of the runtime (the history cycle), only three, the pure core and two +// import-free path readers. The maths and the rules live in motionCore.js (unit-tested). +// +// The followers (Orient to Velocity, Body Wave) read the motion the movers wrote THIS frame, so +// flowRuntime applies them after every other effect on the object (MOTION_PHASE below) — the +// node order in a graph then never decides whether a fish faces where it swims. +// +// BODY WAVE draws through the vertex shader, never through the transform (the jiggle rule: the +// transform belongs to the editor, physics and the network). Each waving mesh gets its OWN +// material clone — three re-uploads material uniforms only when the material changes between +// draws, so two fish sharing one material could not wave differently — and the original goes +// back (with any edit made meanwhile) when the wave stops. Serializers need nothing: toJSON and +// GLTF never see onBeforeCompile, the clone keeps the original's uuid and parameters. + +/** the order effects apply in on one object: movers (0), then orienters (1), then deformers (2) */ +export const MOTION_PHASE = /** @type {Record} */ ({ orientvelocity: 1, bodywave: 2 }); + +const UP = new THREE.Vector3(0, 1, 0); +const vA = new THREE.Vector3(); +const vB = new THREE.Vector3(); +const vC = new THREE.Vector3(); +const qA = new THREE.Quaternion(); +const qB = new THREE.Quaternion(); +const mA = new THREE.Matrix4(); + +// ---- shared: what a frame of motion looked like --------------------------------------------- + +/** + * Per (node, object) motion tracking in WORLD space: velocity, speed and turn rate, smoothed so + * a single jittery frame does not snap a fish around. Keyed per node so two nodes reading one + * object do not share a clock. LOCAL per peer (every peer converges on the same movers). + * @type {Map} + */ +const tracks = new Map(); +let frameNo = 0; + +/** call once per runtime tick: forgets tracks nobody read for a while */ +export function tickMotionNodes() { + frameNo++; + if (frameNo % 120) return; + for (const [key, t] of tracks) if (frameNo - t.seen > 240) tracks.delete(key); + for (const [key, w] of waves) if (frameNo - w.seen > 30) detachWave(key); +} + +/** + * Advance the tracker for `key` with the object's world position at `time` and return it. + * @param {string} key @param {any} object @param {number} time + */ +function track(key, object, time) { + object.updateWorldMatrix(true, false); + const p = vA.setFromMatrixPosition(object.matrixWorld); + let t = tracks.get(key); + if (!t) { + t = { pos: p.clone(), t: time, vel: new THREE.Vector3(), speed: 0, yaw: NaN, yawRate: 0, quat: null, phase: 0, seen: frameNo }; + tracks.set(key, t); + return { t, dt: 0 }; + } + t.seen = frameNo; + const dt = time - t.t; + // a pause, a scrub or a reload: re-anchor rather than read a teleport as a speed + if (!(dt > 0) || dt > 0.5) { + t.pos.copy(p); + t.t = time; + return { t, dt: 0 }; + } + const k = damp(8, dt); + vB.copy(p).sub(t.pos).divideScalar(dt); + t.vel.lerp(vB, k); + t.speed = t.vel.length(); + const flat = Math.hypot(t.vel.x, t.vel.z); + if (flat > 0.02) { + const yaw = Math.atan2(t.vel.x, t.vel.z); + if (Number.isFinite(t.yaw)) t.yawRate += (wrapAngle(yaw - t.yaw) / dt - t.yawRate) * damp(4, dt); + t.yaw = yaw; + } else t.yawRate += (0 - t.yawRate) * damp(4, dt); + t.pos.copy(p); + t.t = time; + return { t, dt }; +} + +/** + * The world orientation that points a model's nose along `dir` with its top leaning `roll` + * radians into the turn (+ = leans left). `pitch` false keeps the nose level. + * @param {THREE.Vector3} dir world @param {number} roll @param {boolean} pitch @param {string} forward @param {THREE.Quaternion} out + */ +function headingQuat(dir, roll, pitch, forward, out) { + const z = vB.copy(dir); + if (!pitch) z.y = 0; + if (z.lengthSq() < 1e-10) return false; + z.normalize(); + // a straight-up/down heading has no horizontal: keep the basis finite + const x = vC.crossVectors(UP, z); + if (x.lengthSq() < 1e-8) x.set(1, 0, 0); + x.normalize(); + const y = vA.crossVectors(z, x); + mA.makeBasis(x, y, z); + out.setFromRotationMatrix(mA); + // lean into the turn about the nose (+roll = top to the LEFT, which is the +x side here) + if (roll) out.multiply(qB.setFromAxisAngle(vC.set(0, 0, 1), -roll)); + // the model's own nose axis + const fy = forwardYaw(forward); + if (fy) out.multiply(qB.setFromAxisAngle(UP, fy)); + return true; +} + +/** write a WORLD quaternion onto an object as its local one @param {any} object @param {THREE.Quaternion} world */ +function setWorldQuat(object, world) { + const parent = object.parent; + if (!parent) { + object.quaternion.copy(world); + return; + } + parent.updateWorldMatrix(true, false); + parent.getWorldQuaternion(qA); + object.quaternion.copy(qA.invert().multiply(world)); +} + +/** write a WORLD position onto an object as its local one @param {any} object @param {number[]} p */ +function setWorldPos(object, p) { + const parent = object.parent; + vA.set(p[0], p[1], p[2]); + if (parent) { + parent.updateWorldMatrix(true, false); + parent.worldToLocal(vA); + } + object.position.copy(vA); +} + +/** @param {any} v */ +const on = (v) => v !== false && v !== 0 && v !== 'false'; + +// ---- Follow Path --------------------------------------------------------------------------- + +/** @type {Map}>} */ +const pathCache = new Map(); + +/** + * The world-space control points (and whether they close) a Follow Path node rides: the wired + * Spline or Flow path object, else its own clicked waypoints (already world). + * @param {any} root @param {any} data + * @returns {{points: number[][], closed: boolean, smooth: boolean} | null} + */ +export function pathSourceOf(root, data) { + if (typeof data.path === 'string' && data.path && data.path !== '-None-') { + const o = root?.getObjectByProperty('uuid', data.path); + if (o) { + o.updateWorldMatrix(true, false); + if (isSplineObject(o)) { + const s = normalizeSpline(o.userData.spline); + return { + points: s.points.map((/** @type {any} */ p) => vA.fromArray(p.pos).applyMatrix4(o.matrixWorld).toArray()), + closed: !!s.closed, + smooth: true + }; + } + if (o.userData?.flowPath) { + const f = normalizeFlowPath(o.userData.flowPath); + return { + points: f.points.map((/** @type {number[]} */ p) => vA.fromArray(p).applyMatrix4(o.matrixWorld).toArray()), + closed: false, + smooth: on(data.smooth) + }; + } + } + } + const pts = Array.isArray(data.points) ? data.points : []; + if (pts.length < 2) return null; + return { points: pts, closed: on(data.closed ?? true), smooth: on(data.smooth) }; +} + +/** the built path for a source, cached by its numbers @param {string} key @param {{points: number[][], closed: boolean, smooth: boolean}} src */ +function pathFor(key, src) { + let sig = (src.closed ? 'c' : 'o') + (src.smooth ? 's' : 'p'); + for (const p of src.points) sig += '|' + (+p[0]).toFixed(4) + ',' + (+p[1]).toFixed(4) + ',' + (+p[2]).toFixed(4); + const hit = pathCache.get(key); + if (hit && hit.sig === sig) return hit.path; + const path = buildPath(src.points, { closed: src.closed, smooth: src.smooth }); + pathCache.set(key, { sig, path }); + return path; +} + +/** + * Follow Path: a pure function of (path, data, shared time) — every peer computes the same pose. + * @param {any} object @param {any} base @param {any} data @param {number} time @param {{root: any, key: string}} ctx + * @returns {{s: number, yawRate: number} | null} where it is (for tests and the readout) + */ +export function applyFollowPath(object, base, data, time, ctx) { + const src = pathSourceOf(ctx.root, data); + if (!src) return null; + const path = pathFor(ctx.key, src); + if (!path) return null; + const speed = clampNum(data.speed, 0.5, -20, 20); + const mode = data.mode === 'pingpong' || data.mode === 'once' ? data.mode : 'loop'; + const { s, dir } = pathDistance(time, speed, path.total, mode, clampNum(data.offset, 0, 0, 1)); + const here = samplePath(path, s); + setWorldPos(object, here.p); + const kappa = curvatureAt(path, s); + const moving = mode !== 'once' || (s > 0 && s < path.total); + const yawRate = moving ? kappa * Math.abs(speed) * dir : 0; + if (on(data.align ?? true)) { + const travel = vC.set(here.t[0] * dir, here.t[1] * dir, here.t[2] * dir); + const roll = bankRoll(yawRate, clampNum(data.bank, 0.5, 0, 1), clampNum(data.maxBank, 30, 0, 85)); + const q = new THREE.Quaternion(); + if (headingQuat(travel.clone(), roll, on(data.pitch ?? true), data.forward ?? '+z', q)) setWorldQuat(object, q); + } + return { s, yawRate }; +} + +// ---- Wander --------------------------------------------------------------------------------- + +const box = new THREE.Box3(); + +/** + * The box a wanderer stays in, in WORLD space: the wired area object's bounds shrunk by + * `margin`, else a box of half-extents (rx, ry, rz) around where it was placed. + * @param {any} object @param {any} base @param {any} data @param {any} root + * @returns {{center: [number, number, number], half: [number, number, number]}} + */ +export function wanderBox(object, base, data, root) { + if (typeof data.area === 'string' && data.area && data.area !== '-None-') { + const o = root?.getObjectByProperty('uuid', data.area); + if (o) { + o.updateWorldMatrix(true, true); + box.setFromObject(o); + if (!box.isEmpty()) { + const m = clampNum(data.margin, 0.2, 0, 50); + const c = box.getCenter(vA); + const sz = box.getSize(vB); + return { + center: [c.x, c.y, c.z], + half: [Math.max(0, sz.x / 2 - m), Math.max(0, sz.y / 2 - m), Math.max(0, sz.z / 2 - m)] + }; + } + } + } + const parent = object.parent; + vA.fromArray(base.pos); + if (parent) { + parent.updateWorldMatrix(true, false); + vA.applyMatrix4(parent.matrixWorld); + } + return { + center: [vA.x, vA.y, vA.z], + half: [clampNum(data.rx, 1, 0, 50), clampNum(data.ry, 0.3, 0, 50), clampNum(data.rz, 1, 0, 50)] + }; +} + +/** + * Wander: drift around a smooth, seeded noise target inside a box. Pure of (box, data, time). + * `seed` 0 = derive from the object's uuid, so one Wander node driving several objects (or a + * prefab duplicated five times) never moves them in step. + * @param {any} object @param {any} base @param {any} data @param {number} time @param {{root: any}} ctx + */ +export function applyWander(object, base, data, time, ctx) { + const { center, half } = wanderBox(object, base, data, ctx.root); + const seed = clampNum(data.seed, 0, 0, 100000); + const seed01 = seed ? hash01('seed:' + seed) : hash01(object.uuid); + const { p, v } = wanderAt(time, center, half, clampNum(data.speed, 0.3, 0, 5), seed01); + setWorldPos(object, p); + return { p, v }; +} + +// ---- Orient to Velocity --------------------------------------------------------------------- + +/** + * Orient to Velocity: face the direction the object moved this frame, turning at most + * `turnSpeed` (1/s, exponential) and leaning into turns. Holds its last heading while still. + * @param {any} object @param {any} base @param {any} data @param {number} time @param {{key: string}} ctx + */ +export function applyOrientVelocity(object, base, data, time, ctx) { + const { t, dt } = track(ctx.key, object, time); + const minSpeed = clampNum(data.minSpeed, 0.02, 0, 10); + const pitch = on(data.pitch ?? true); + if (t.speed > minSpeed) { + const roll = bankRoll(t.yawRate, clampNum(data.bank, 0.5, 0, 1), clampNum(data.maxBank, 30, 0, 85)); + const want = new THREE.Quaternion(); + if (headingQuat(t.vel.clone(), roll, pitch, data.forward ?? '+z', want)) { + if (!t.quat) t.quat = want.clone(); + else t.quat.slerp(want, dt > 0 ? damp(clampNum(data.turnSpeed, 4, 0.1, 60), dt) : 1); + } + } + // the base restore put the authored rotation back: re-assert the held heading every frame + if (t.quat) setWorldQuat(object, t.quat); + return t; +} + +// ---- Body Wave ------------------------------------------------------------------------------ + +const WAVE_DECL = /* glsl */ ` +uniform mat4 bwToRoot; +uniform mat3 bwFromRoot; +uniform vec3 bwAxis; +uniform vec3 bwSide; +uniform vec2 bwRange; +uniform vec4 bwShape; +uniform vec3 bwDrive; +`; +// u: 0 at the nose, 1 at the tail; the envelope and the travelling sine are motionCore's +// waveOffset verbatim (k = 1/wavelength in bwShape.x, dir in bwShape.w, drive in body lengths +// already multiplied by the length on the CPU) +const WAVE_VERTEX = /* glsl */ ` +#include +{ + vec3 bwR = (bwToRoot * vec4(transformed, 1.0)).xyz; + float bwU = clamp((bwRange.x - dot(bwR, bwAxis)) / max(bwRange.y, 1e-4), 0.0, 1.0); + float bwX = clamp((bwU - bwShape.y) / max(1.0 - bwShape.y, 1e-3), 0.0, 1.0); + float bwEnv = pow(bwX, bwShape.z); + float bwOff = bwDrive.x * bwEnv * sin(6.2831853 * (bwU * bwShape.x - bwShape.w * bwDrive.z)) + bwDrive.y * bwU * bwU; + transformed += bwFromRoot * (bwSide * bwOff); +} +`; + +/** @typedef {{mesh: any, original: any, clone: any, uniforms: any}} WaveMesh */ +/** @type {Map} */ +const waves = new Map(); + +/** the side (bend) axis for a nose axis: horizontal = up × nose (a fish), vertical = up (a whale) @param {THREE.Vector3} axis @param {string} side */ +function sideAxis(axis, side) { + if (side === 'vertical') { + const v = new THREE.Vector3(0, 1, 0); + return Math.abs(axis.y) > 0.9 ? v.set(1, 0, 0) : v; + } + const v = new THREE.Vector3().crossVectors(UP, axis); + return v.lengthSq() < 1e-8 ? v.set(1, 0, 0) : v.normalize(); +} + +/** meshes under an object (its own subtree), the jiggle traversal @param {any} object */ +function meshesOf(object) { + /** @type {any[]} */ + const out = []; + object.traverse((/** @type {any} */ o) => { + if (o.isMesh && o.geometry && o.material && !Array.isArray(o.material) && o.visible !== false) out.push(o); + }); + return out; +} + +/** @param {any} mesh @param {any} object */ +function attachWaveMesh(mesh, object) { + const original = mesh.material; + const clone = original.clone(); + // the SAME uuid: a save taken mid-wave serializes a material identical to the original + clone.uuid = original.uuid; + const uniforms = { + bwToRoot: { value: new THREE.Matrix4() }, + bwFromRoot: { value: new THREE.Matrix3() }, + bwAxis: { value: new THREE.Vector3(0, 0, 1) }, + bwSide: { value: new THREE.Vector3(1, 0, 0) }, + bwRange: { value: new THREE.Vector2(0, 1) }, + bwShape: { value: new THREE.Vector4(1, 0.3, 2, 1) }, + bwDrive: { value: new THREE.Vector3() } + }; + const previous = original.onBeforeCompile; + clone.onBeforeCompile = (/** @type {any} */ shader, /** @type {any} */ renderer) => { + if (typeof previous === 'function' && previous !== THREE.Material.prototype.onBeforeCompile) previous(shader, renderer); + Object.assign(shader.uniforms, uniforms); + shader.vertexShader = WAVE_DECL + shader.vertexShader.replace('#include ', WAVE_VERTEX); + }; + const baseKey = original.customProgramCacheKey?.() ?? ''; + clone.customProgramCacheKey = () => baseKey + '|bodywave1'; + mesh.material = clone; + return { mesh, original, clone, uniforms }; +} + +/** @param {WaveMesh} m */ +function detachWaveMesh(m) { + if (m.mesh.material !== m.clone) return; // something else replaced it since; leave it + const { onBeforeCompile, customProgramCacheKey, uuid } = m.original; + m.original.copy(m.clone); + m.original.uuid = uuid; + m.original.onBeforeCompile = onBeforeCompile; + m.original.customProgramCacheKey = customProgramCacheKey; + m.original.userData = { ...(m.clone.userData ?? {}) }; + m.original.needsUpdate = true; + m.mesh.material = m.original; + m.clone.dispose(); +} + +/** @param {string} key */ +function detachWave(key) { + const w = waves.get(key); + if (!w) return; + for (const m of w.meshes) detachWaveMesh(m); + waves.delete(key); +} + +/** + * (Re)build a wave's mesh list and body frame when the subtree or the axes changed — a pack + * model arriving in place of its fallback, a LOD swap, a forward-axis edit. + * @param {string} key @param {any} object @param {any} data + */ +function waveFor(key, object, data) { + const meshes = meshesOf(object); + const forward = data.forward ?? BODY_WAVE_DEFAULTS.forward; + const side = data.side === 'vertical' ? 'vertical' : 'horizontal'; + const sig = forward + side + meshes.map((m) => m.uuid + ':' + m.geometry.uuid).join(','); + let w = waves.get(key); + if (w && w.sig === sig && w.object === object) { + // a material swapped under us (an Inspector edit through applyMaterials): re-wrap + for (let i = 0; i < w.meshes.length; i++) { + const m = w.meshes[i]; + if (m.mesh.material !== m.clone && m.mesh.material && !Array.isArray(m.mesh.material)) w.meshes[i] = attachWaveMesh(m.mesh, object); + } + return w; + } + if (w) detachWave(key); + // the body frame: the nose axis in the OBJECT's local frame, the extent along it over every mesh + const axis = new THREE.Vector3(0, 0, 1).applyAxisAngle(UP, -forwardYaw(forward)).normalize(); + const sideV = sideAxis(axis, side); + object.updateWorldMatrix(true, true); + const inv = new THREE.Matrix4().copy(object.matrixWorld).invert(); + let lo = Infinity; + let hi = -Infinity; + for (const m of meshes) { + if (!m.geometry.boundingBox) m.geometry.computeBoundingBox(); + const bb = m.geometry.boundingBox; + mA.copy(inv).multiply(m.matrixWorld); + for (let i = 0; i < 8; i++) { + vA.set(i & 1 ? bb.max.x : bb.min.x, i & 2 ? bb.max.y : bb.min.y, i & 4 ? bb.max.z : bb.min.z).applyMatrix4(mA); + const d = vA.dot(axis); + lo = Math.min(lo, d); + hi = Math.max(hi, d); + } + } + if (!Number.isFinite(lo)) return null; + w = { + object, + meshes: meshes.map((m) => attachWaveMesh(m, object)), + count: meshes.length, + front: hi, + len: Math.max(1e-3, hi - lo), + axis, + side: sideV, + sig, + seen: frameNo + }; + waves.set(key, w); + return w; +} + +/** + * Body Wave: a travelling bend along the body, driven by how fast and how hard the object turns. + * @param {any} object @param {any} base @param {any} data @param {number} time @param {{key: string}} ctx + */ +export function applyBodyWave(object, base, data, time, ctx) { + const w = waveFor(ctx.key, object, data); + if (!w) return null; + w.seen = frameNo; + const { t, dt } = track(ctx.key, object, time); + const drive = waveDrive(data, t.speed, t.yawRate); + if (dt > 0) t.phase = (t.phase + drive.frequency * dt) % 1000; + const d = BODY_WAVE_DEFAULTS; + const k = 1 / Math.max(0.05, clampNum(data.wavelength, d.wavelength, 0.05, 8)); + const stiff = clampNum(data.stiffness, d.stiffness, 0, 0.95); + const fall = clampNum(data.falloff, d.falloff, 0.25, 6); + const dir = data.reverse ? -1 : 1; + object.updateWorldMatrix(true, true); + const inv = mA.copy(object.matrixWorld).invert(); + for (const m of w.meshes) { + const u = m.uniforms; + u.bwToRoot.value.copy(inv).multiply(m.mesh.matrixWorld); + u.bwFromRoot.value.setFromMatrix4(u.bwToRoot.value).invert(); + u.bwAxis.value.copy(w.axis); + u.bwSide.value.copy(w.side); + u.bwRange.value.set(w.front, w.len); + u.bwShape.value.set(k, stiff, fall, dir); + u.bwDrive.value.set(drive.amplitude * w.len, drive.bend * w.len, t.phase); + } + return { drive, phase: t.phase, len: w.len, meshes: w.meshes.length }; +} + +/** the wave nodes no longer driving an object stand down (flowRuntime's restore loop) @param {Set} liveKeys */ +export function pruneBodyWaves(liveKeys) { + for (const key of [...waves.keys()]) if (!liveKeys.has(key)) detachWave(key); +} + +/** put every original material back (a serializer's park, a scene clear) */ +export function resetMotionNodes() { + for (const key of [...waves.keys()]) detachWave(key); + tracks.clear(); + pathCache.clear(); +} + +/** debug: what the motion runtime holds */ +export function motionNodesDebug() { + return { + tracks: tracks.size, + paths: pathCache.size, + waves: [...waves.entries()].map(([key, w]) => ({ + key, + meshes: w.meshes.length, + cloned: w.meshes.every((m) => m.mesh.material === m.clone), + len: w.len + })) + }; +} diff --git a/src/lib/nodeCatalog.js b/src/lib/nodeCatalog.js index 60a4f3f22..68633acde 100644 --- a/src/lib/nodeCatalog.js +++ b/src/lib/nodeCatalog.js @@ -968,6 +968,98 @@ export const nodeCatalog = [ label: 'Path patrol', defaults: { points: [], speed: 1, mode: 'loop' } }, + // 40 F15: GENERAL-PURPOSE motion (motion/motionNodes.js). Movers first (Follow Path, + // Wander are pure functions of the shared clock), then the followers that read the + // motion they produce (Orient to Velocity turns, Body Wave bends) — flowRuntime + // applies them in that order whatever order the graph lists them in. + { + type: 'followpath', + label: 'Follow Path', + defaults: { + points: [], + speed: 0.5, + mode: 'loop', + closed: true, + smooth: true, + offset: 0, + align: true, + pitch: true, + bank: 0.5, + maxBank: 30, + forward: '+z' + }, + inputs: ['path'], + inputLabels: { path: 'path — a Spline or Flow path' }, + params: [ + { key: 'speed', kind: 'range', min: -5, max: 5, step: 0.05 }, + { key: 'offset', kind: 'range', min: 0, max: 1, step: 0.01 }, + { key: 'mode', kind: 'select', options: ['loop', 'pingpong', 'once'] }, + { key: 'align', kind: 'toggle' }, + { key: 'pitch', kind: 'toggle' }, + { key: 'bank', kind: 'range', min: 0, max: 1, step: 0.05 }, + { key: 'maxBank', kind: 'range', min: 0, max: 80, step: 1 }, + { key: 'forward', kind: 'select', options: ['+z', '-z', '+x', '-x'] } + ] + }, + { + type: 'wander', + label: 'Wander', + defaults: { speed: 0.3, seed: 0, margin: 0.2, rx: 1, ry: 0.3, rz: 1 }, + inputs: ['area'], + inputLabels: { area: 'area — stay inside this object' }, + params: [ + { key: 'speed', kind: 'range', min: 0, max: 3, step: 0.05 }, + { key: 'seed', kind: 'range', min: 0, max: 999, step: 1 }, + { key: 'margin', kind: 'range', min: 0, max: 2, step: 0.05 }, + { key: 'rx', kind: 'range', min: 0, max: 10, step: 0.1 }, + { key: 'ry', kind: 'range', min: 0, max: 10, step: 0.1 }, + { key: 'rz', kind: 'range', min: 0, max: 10, step: 0.1 } + ] + }, + { + type: 'orientvelocity', + label: 'Orient to Velocity', + defaults: { turnSpeed: 4, bank: 0.5, maxBank: 30, pitch: true, minSpeed: 0.02, forward: '+z' }, + params: [ + { key: 'turnSpeed', kind: 'range', min: 0.1, max: 30, step: 0.1 }, + { key: 'bank', kind: 'range', min: 0, max: 1, step: 0.05 }, + { key: 'maxBank', kind: 'range', min: 0, max: 80, step: 1 }, + { key: 'minSpeed', kind: 'range', min: 0, max: 2, step: 0.01 }, + { key: 'pitch', kind: 'toggle' }, + { key: 'forward', kind: 'select', options: ['+z', '-z', '+x', '-x'] } + ] + }, + { + type: 'bodywave', + label: 'Body Wave', + defaults: { + forward: '+z', + side: 'horizontal', + amplitude: 0.08, + wavelength: 1, + frequency: 1.5, + stiffness: 0.3, + falloff: 2, + speedGain: 1, + ampGain: 0.5, + turnBend: 0.5, + reverse: false + }, + params: [ + { key: 'amplitude', kind: 'range', min: 0, max: 0.5, step: 0.01 }, + { key: 'wavelength', kind: 'range', min: 0.2, max: 4, step: 0.05 }, + { key: 'frequency', kind: 'range', min: 0, max: 10, step: 0.1 }, + { key: 'stiffness', kind: 'range', min: 0, max: 0.95, step: 0.05 }, + { key: 'falloff', kind: 'range', min: 0.25, max: 6, step: 0.25 }, + { key: 'speedGain', kind: 'range', min: 0, max: 10, step: 0.1 }, + { key: 'ampGain', kind: 'range', min: 0, max: 5, step: 0.1 }, + { key: 'turnBend', kind: 'range', min: 0, max: 2, step: 0.05 }, + { key: 'forward', kind: 'select', options: ['+z', '-z', '+x', '-x'] }, + { key: 'side', kind: 'select', options: ['horizontal', 'vertical'] }, + { key: 'reverse', kind: 'toggle' } + ], + note: 'Bends the look only (a vertex shader): physics and picking see the rest pose.' + }, { // 17-E: the other half of Play Animation — pulses when a clip FINISHES, // so a movement can hand off to whatever comes next (a door that has @@ -1243,6 +1335,7 @@ export const nodeCatalog = [ export const animationTypes = [ 'shake', 'spin', 'bounce', 'orbit', 'pulse', 'blink', 'pathpatrol', 'rotor', 'flowfloat', // 36-fb F25/F24 (sim/motionNodes.js) + 'followpath', 'wander', 'orientvelocity', 'bodywave', // 40 F15 (motion/motionNodes.js) 'lookat', 'setcolor', 'visibility', 'setuniform', 'camerarig', // 37 (R8) 'deviceparam', 'notetrigger' // 23-B3 diff --git a/src/lib/nodeDocs.js b/src/lib/nodeDocs.js index a0902e1fa..684463f26 100644 --- a/src/lib/nodeDocs.js +++ b/src/lib/nodeDocs.js @@ -104,6 +104,10 @@ export const NODE_DOCS = { bounce: "Bounces the connected object up and down off its resting height, like a dribbled ball.", orbit: "Circles the connected object around its resting position on the horizontal plane.", pathpatrol: "Walks the connected object along a series of waypoints you click into the scene, facing along the path.", + followpath: "Moves the connected object along a smooth path - a drawn Spline, a Flow path or its own waypoints - at a speed, looping, ping-ponging or once, facing along it and leaning into the turns. An offset spreads several riders out along one path.", + wander: "Drifts the connected object around a smooth, never-repeating target inside an area object (a water volume, a room) or a box around where it was placed - fish, birds, fireflies, idle drones.", + orientvelocity: "Turns the connected object to face the way it is moving, smoothly, leaning into turns - put it after Wander, Orbit or any mover so it looks where it goes.", + bodywave: "Bends the connected object with a wave travelling along its body - a swimming fish, a snake, a flag, a tail - stronger and faster the faster it moves, curving into turns. A shader effect: physics and picking keep the rest pose.", animfinished: "Fires a pulse when the connected object's animation clip reaches its end.", animmarker: "Fires a pulse when the connected object's animation passes a named marker.", animstate: "Reads the connected object's animation transport as a number - playing, position or progress.", diff --git a/tests/unit/motionCore.test.js b/tests/unit/motionCore.test.js new file mode 100644 index 000000000..c4547af8e --- /dev/null +++ b/tests/unit/motionCore.test.js @@ -0,0 +1,324 @@ +// @ts-nocheck — plain fixtures; the code under test is typed +// 40 F15: the general-purpose motion nodes — the pure core (paths, distance modes, banking, the +// wander noise, the body-wave shape) and the THREE half (Follow Path, Wander, Orient to Velocity, +// Body Wave) driven on real three objects with no renderer. +import { describe, it, expect, beforeEach } from 'vitest'; +import * as THREE from 'three'; +import { + buildPath, + samplePath, + pathDistance, + curvatureAt, + bankRoll, + smoothNoise, + smoothNoiseRate, + wanderAt, + hash01, + waveEnvelope, + waveDrive, + waveOffset, + forwardYaw, + catmullRom, + cleanPoints +} from '../../src/lib/motion/motionCore.js'; +import { + applyFollowPath, + applyWander, + applyOrientVelocity, + applyBodyWave, + pruneBodyWaves, + resetMotionNodes, + motionNodesDebug, + MOTION_PHASE +} from '../../src/lib/motion/motionNodes.js'; + +const base = (o) => ({ pos: o.position.toArray(), rot: [o.rotation.x, o.rotation.y, o.rotation.z], scale: o.scale.toArray() }); +/** a square 4 × 4 loop at y = 1 */ +const SQUARE = [ + [0, 1, 0], + [4, 1, 0], + [4, 1, 4], + [0, 1, 4] +]; +/** a circle of radius r (counter-clockwise seen from above = turning LEFT for a +z nose) */ +const circle = (r, n = 16, y = 0) => + Array.from({ length: n }, (_, i) => { + const a = (i / n) * Math.PI * 2; + return [Math.sin(a) * r, y, Math.cos(a) * r]; + }); + +describe('paths', () => { + it('cleans points: drops non-finite and doubled clicks, accepts spline {pos}', () => { + expect(cleanPoints([[0, 0, 0], [0, 0, 0], [1, NaN, 0], { pos: [1, 2, 3] }, 'x'])).toEqual([ + [0, 0, 0], + [1, 2, 3] + ]); + }); + it('a Catmull-Rom span passes through its two inner points', () => { + const p = catmullRom([0, 0, 0], [1, 0, 0], [2, 1, 0], [3, 1, 0], 0); + expect(p[0]).toBeCloseTo(1, 6); + const q = catmullRom([0, 0, 0], [1, 0, 0], [2, 1, 0], [3, 1, 0], 1); + expect(q[0]).toBeCloseTo(2, 6); + expect(q[1]).toBeCloseTo(1, 6); + }); + it('a polyline loop has the perimeter as its length, and closes on its start', () => { + const path = buildPath(SQUARE, { closed: true, smooth: false }); + expect(path.total).toBeCloseTo(16, 6); + expect(samplePath(path, 16).p).toEqual([0, 1, 0]); + expect(samplePath(path, 2).p).toEqual([2, 1, 0]); + expect(samplePath(path, 2).t).toEqual([1, 0, 0]); + }); + it('a smooth loop passes through every control point and rounds the corners (between the square and its circumcircle)', () => { + // an INTERPOLATING curve through a square's corners bulges outward between them + const path = buildPath(SQUARE, { closed: true }); + expect(path.total).toBeGreaterThan(16); + expect(path.total).toBeLessThan(2 * Math.PI * 2 * Math.SQRT2); + for (const c of SQUARE) { + const hit = path.pts.some((p) => Math.hypot(p[0] - c[0], p[1] - c[1], p[2] - c[2]) < 1e-9); + expect(hit).toBe(true); + } + }); + it('refuses a path with fewer than two usable points', () => { + expect(buildPath([[0, 0, 0]])).toBeNull(); + expect(buildPath([])).toBeNull(); + }); + it('distance: loop wraps, pingpong reflects with a direction, once clamps; offset spreads riders', () => { + expect(pathDistance(3, 2, 10, 'loop').s).toBeCloseTo(6, 9); + expect(pathDistance(7, 2, 10, 'loop').s).toBeCloseTo(4, 9); + const back = pathDistance(7, 2, 10, 'pingpong'); + expect(back.s).toBeCloseTo(6, 9); + expect(back.dir).toBe(-1); + expect(pathDistance(2, 2, 10, 'pingpong').dir).toBe(1); + expect(pathDistance(100, 2, 10, 'once').s).toBe(10); + expect(pathDistance(0, 2, 10, 'loop', 0.25).s).toBeCloseTo(2.5, 9); + expect(pathDistance(1, -2, 10, 'loop').s).toBeCloseTo(8, 9); // backwards wraps the other way + }); + it('curvature: a left (counter-clockwise from above, +z nose) circle reads +1/r', () => { + // increasing angle a moves the point along (cos a, 0, -sin a): x grows, z falls — clockwise + // from above when viewed with +x right and +z down, i.e. the nose turns toward -x… measure it + const path = buildPath(circle(2, 32), { closed: true }); + const k = curvatureAt(path, path.total / 3); + expect(Math.abs(Math.abs(k) - 0.5)).toBeLessThan(0.03); + const rev = buildPath(circle(2, 32).reverse(), { closed: true }); + expect(Math.sign(curvatureAt(rev, rev.total / 3))).toBe(-Math.sign(k)); + }); + it('bank: zero on a straight, bounded by the ceiling, signed with the turn', () => { + expect(bankRoll(0, 1, 45)).toBe(0); + expect(bankRoll(100, 1, 45)).toBeCloseTo(Math.PI / 4, 6); + expect(bankRoll(-100, 1, 45)).toBeCloseTo(-Math.PI / 4, 6); + expect(bankRoll(1, 0, 45)).toBe(0); + expect(bankRoll(1, 0.5, 40)).toBeCloseTo(0.5 * ((40 * Math.PI) / 180) * Math.tanh(1), 9); + }); +}); + +describe('wander noise', () => { + it('stays inside [-1, 1] and inside its box over a long run', () => { + const seed = hash01('fish'); + let lo = Infinity; + let hi = -Infinity; + for (let t = 0; t < 600; t += 0.37) { + const v = smoothNoise(t, seed, 1); + lo = Math.min(lo, v); + hi = Math.max(hi, v); + const { p } = wanderAt(t, [1, 2, 3], [0.5, 0.25, 2], 1, seed); + expect(Math.abs(p[0] - 1)).toBeLessThanOrEqual(0.5 + 1e-9); + expect(Math.abs(p[1] - 2)).toBeLessThanOrEqual(0.25 + 1e-9); + expect(Math.abs(p[2] - 3)).toBeLessThanOrEqual(2 + 1e-9); + } + expect(lo).toBeGreaterThanOrEqual(-1); + expect(hi).toBeLessThanOrEqual(1); + // and it actually uses the box (not stuck near the centre) + expect(hi - lo).toBeGreaterThan(1); + }); + it('is deterministic per seed and differs between seeds', () => { + expect(smoothNoise(12.3, 0.4, 0)).toBe(smoothNoise(12.3, 0.4, 0)); + expect(smoothNoise(12.3, 0.4, 0)).not.toBeCloseTo(smoothNoise(12.3, 0.41, 0), 3); + expect(hash01('a')).not.toBe(hash01('b')); + }); + it('the analytic rate matches a finite difference (smooth, no jumps)', () => { + for (const t of [0.5, 7.1, 40]) { + const h = 1e-4; + const fd = (smoothNoise(t + h, 0.3, 2) - smoothNoise(t - h, 0.3, 2)) / (2 * h); + expect(smoothNoiseRate(t, 0.3, 2)).toBeCloseTo(fd, 4); + } + }); +}); + +describe('body wave shape', () => { + it('the head is still, the tail swings most', () => { + expect(waveEnvelope(0, 0.3, 2)).toBe(0); + expect(waveEnvelope(0.3, 0.3, 2)).toBe(0); + expect(waveEnvelope(1, 0.3, 2)).toBe(1); + expect(waveEnvelope(0.65, 0.3, 2)).toBeCloseTo(0.25, 9); + }); + it('faster swimming beats faster and wider; a turn bends the body', () => { + const rest = waveDrive({}, 0, 0); + const fast = waveDrive({}, 1, 0); + expect(fast.frequency).toBeGreaterThan(rest.frequency); + expect(fast.amplitude).toBeGreaterThan(rest.amplitude); + expect(rest.bend).toBe(0); + expect(waveDrive({}, 0.5, 1).bend).toBeGreaterThan(0); + expect(waveDrive({}, 0.5, -1).bend).toBeLessThan(0); + // clamped: a wild speed does not tear the mesh apart + expect(waveDrive({ amplitude: 0.5, ampGain: 5 }, 10, 0).amplitude).toBeLessThanOrEqual(0.6); + }); + it('the crest TRAVELS head → tail as the phase grows (and the other way when reversed)', () => { + // with the envelope divided out the pattern is a pure sine of (u k - dir·phase): a shift of + // the phase by Δ is the same picture shifted Δ/k toward the TAIL (larger u) + const shape = { wavelength: 1, stiffness: 0, falloff: 1 }; + const drive = { amplitude: 1, bend: 0 }; + const raw = (u, p, s) => waveOffset(u, p, drive, s) / waveEnvelope(u, s.stiffness, s.falloff); + for (const u of [0.3, 0.45, 0.6]) { + expect(raw(u + 0.1, 0.1, shape)).toBeCloseTo(raw(u, 0, shape), 9); + const rev = { ...shape, reverse: true }; + expect(raw(u - 0.1, 0.1, rev)).toBeCloseTo(raw(u, 0, rev), 9); + } + }); + it('forward axis names map to the yaw that brings that nose to +z', () => { + for (const [name, n] of [ + ['+z', [0, 0, 1]], + ['-z', [0, 0, -1]], + ['+x', [1, 0, 0]], + ['-x', [-1, 0, 0]] + ]) { + const v = new THREE.Vector3(...n).applyAxisAngle(new THREE.Vector3(0, 1, 0), forwardYaw(name)); + expect(v.z).toBeCloseTo(1, 9); + } + }); +}); + +describe('the nodes on real objects', () => { + beforeEach(() => resetMotionNodes()); + + it('Follow Path is a pure function of time: two copies agree; the nose points along the path', () => { + const root = new THREE.Group(); + const a = new THREE.Object3D(); + const b = new THREE.Object3D(); + root.add(a, b); + const data = { points: SQUARE, speed: 1, smooth: false, closed: true, bank: 0 }; + applyFollowPath(a, base(a), data, 1.5, { root, key: 'a' }); + applyFollowPath(b, base(b), data, 1.5, { root, key: 'b' }); + expect(a.position.toArray()).toEqual(b.position.toArray()); + expect(a.position.x).toBeCloseTo(1.5, 9); + const nose = new THREE.Vector3(0, 0, 1).applyQuaternion(a.quaternion); + expect(nose.x).toBeCloseTo(1, 6); + }); + it('Follow Path rides a Spline object in WORLD space (the spline moved = the path moved)', () => { + const root = new THREE.Group(); + const spline = new THREE.Mesh(new THREE.BufferGeometry()); + spline.userData.spline = { points: SQUARE.map((p) => ({ pos: p, radius: 0.1 })), closed: true }; + spline.position.set(10, 0, 0); + const fish = new THREE.Object3D(); + root.add(spline, fish); + root.updateMatrixWorld(true); + applyFollowPath(fish, base(fish), { path: spline.uuid, speed: 0 }, 0, { root, key: 'k' }); + expect(fish.position.x).toBeCloseTo(10, 6); + expect(fish.position.y).toBeCloseTo(1, 6); + }); + it('Follow Path banks INTO a left turn (top leans left) and the forward axis turns the model', () => { + const root = new THREE.Group(); + const o = new THREE.Object3D(); + root.add(o); + const pts = circle(1.5, 24); + const out = applyFollowPath(o, base(o), { points: pts, speed: 1, bank: 1, maxBank: 45 }, 2, { root, key: 'k' }); + expect(Math.abs(out.yawRate)).toBeGreaterThan(0.3); + const up = new THREE.Vector3(0, 1, 0).applyQuaternion(o.quaternion); + const left = new THREE.Vector3(1, 0, 0).applyQuaternion(o.quaternion); // +x is the model's left + // leaning into the turn: the top tips toward the side the turn goes + expect(Math.sign(up.dot(new THREE.Vector3(left.x, 0, left.z)))).toBe(Math.sign(out.yawRate)); + // a +x-nosed model: the same pose, its +x now points where +z pointed + const o2 = new THREE.Object3D(); + root.add(o2); + applyFollowPath(o2, base(o2), { points: pts, speed: 1, bank: 1, maxBank: 45, forward: '+x' }, 2, { root, key: 'k2' }); + const nose1 = new THREE.Vector3(0, 0, 1).applyQuaternion(o.quaternion); + const nose2 = new THREE.Vector3(1, 0, 0).applyQuaternion(o2.quaternion); + expect(nose1.distanceTo(nose2)).toBeLessThan(1e-6); + }); + it('Wander stays inside its area object (shrunk by the margin) and differs per object', () => { + const root = new THREE.Group(); + const tank = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 1.6)); + tank.position.set(0, 1.9, 0); + const a = new THREE.Object3D(); + const b = new THREE.Object3D(); + root.add(tank, a, b); + root.updateMatrixWorld(true); + for (let t = 0; t < 120; t += 0.5) { + applyWander(a, base(a), { area: tank.uuid, margin: 0.2, speed: 1 }, t, { root }); + expect(Math.abs(a.position.x)).toBeLessThanOrEqual(1.8 + 1e-6); + expect(Math.abs(a.position.y - 1.9)).toBeLessThanOrEqual(0.8 + 1e-6); + expect(Math.abs(a.position.z)).toBeLessThanOrEqual(0.6 + 1e-6); + } + applyWander(a, base(a), { area: tank.uuid, speed: 1 }, 7, { root }); + applyWander(b, base(b), { area: tank.uuid, speed: 1 }, 7, { root }); + expect(a.position.distanceTo(b.position)).toBeGreaterThan(1e-3); + }); + it('Orient to Velocity turns toward the motion, smoothly, and holds while still', () => { + const o = new THREE.Object3D(); + const root = new THREE.Group(); + root.add(o); + // move along +x for a second at 60 fps + let t = 0; + for (let i = 0; i < 60; i++) { + t += 1 / 60; + o.position.set(t, 0, 0); + o.rotation.set(0, 0, 0); // the base restore + applyOrientVelocity(o, base(o), { turnSpeed: 6, bank: 0 }, t, { key: 'o' }); + } + const nose = new THREE.Vector3(0, 0, 1).applyQuaternion(o.quaternion); + expect(nose.x).toBeGreaterThan(0.95); + // stop: the heading holds through the base restores + for (let i = 0; i < 10; i++) { + t += 1 / 60; + o.rotation.set(0, 0, 0); + applyOrientVelocity(o, base(o), { turnSpeed: 6 }, t, { key: 'o' }); + } + expect(new THREE.Vector3(0, 0, 1).applyQuaternion(o.quaternion).x).toBeGreaterThan(0.95); + }); + it('Body Wave clones each mesh material (same uuid, same look), drives it, and puts it back', () => { + const fish = new THREE.Group(); + const mat = new THREE.MeshStandardMaterial({ color: 0xff8800 }); + const body = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.4), mat); + const tail = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.1), mat); + tail.position.z = -0.25; + fish.add(body, tail); + const out = applyBodyWave(fish, base(fish), {}, 0, { key: 'w' }); + expect(out.meshes).toBe(2); + expect(body.material).not.toBe(mat); + expect(tail.material).not.toBe(body.material); // per-mesh uniforms need per-mesh clones + expect(body.material.uuid).toBe(mat.uuid); + expect(body.material.color.getHex()).toBe(0xff8800); + // the body length covers both meshes (0.2 nose .. -0.3 tail tip) + expect(out.len).toBeCloseTo(0.5, 6); + // the shader hook patches begin_vertex + const shader = { uniforms: {}, vertexShader: '#include \n', fragmentShader: '' }; + body.material.onBeforeCompile(shader, null); + expect(shader.vertexShader).toContain('bwDrive'); + expect(shader.uniforms.bwDrive).toBeTruthy(); + expect(body.material.customProgramCacheKey()).toContain('bodywave'); + // motion drives the phase + for (let i = 1; i <= 30; i++) { + fish.position.z = i * 0.02; + applyBodyWave(fish, base(fish), {}, i / 30, { key: 'w' }); + } + expect(shader.uniforms.bwDrive.value.z).toBeGreaterThan(0); + expect(motionNodesDebug().waves[0].cloned).toBe(true); + pruneBodyWaves(new Set()); + expect(body.material).toBe(mat); + expect(tail.material).toBe(mat); + expect(motionNodesDebug().waves.length).toBe(0); + }); + it('a counterfactual: without the per-mesh clone the two meshes would share uniforms', () => { + // the reason for cloning: three uploads a material's uniforms once per material switch + const fish = new THREE.Group(); + const mat = new THREE.MeshStandardMaterial(); + fish.add(new THREE.Mesh(new THREE.BoxGeometry(), mat), new THREE.Mesh(new THREE.BoxGeometry(), mat)); + applyBodyWave(fish, base(fish), {}, 0, { key: 'w2' }); + const [m1, m2] = fish.children.map((c) => c.material); + expect(m1).not.toBe(m2); + }); + it('followers run after movers', () => { + expect(MOTION_PHASE.orientvelocity).toBeGreaterThan(0); + expect(MOTION_PHASE.bodywave).toBeGreaterThan(MOTION_PHASE.orientvelocity); + expect(MOTION_PHASE.followpath ?? 0).toBe(0); + }); +}); From d4cfb20bc24ca9a87d554973dec4d94846b3594f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 13:59:26 +0300 Subject: [PATCH 2/6] [feat] fish-scale look: thin-film presets and a per-device look tier (40 F16) - material presets carry the thin film (iridescence, its IOR and thickness range) and the transmission volume; three's [min, max] range maps both ways - a new starter "Fish scales": an iridescent physical look over a procedural, tileable scale relief (scales hang like shingles, lower row in front), a light clear coat and a hint of transmission; the swatch shows the film as a hue sweep - materialTiersCore (pure) + materialTiers (runtime): every physical material is drawn at this device's tier, the water rule (headset or post off = low, a phone's start = mid): mid drops transmission (the extra full-scene pass), low also drops the thin film and sheen. LOCAL only; the authored numbers ride as userData.lookTierAuthored, written only when a device draws less, so a desktop save stays byte-identical and a headset's save restores the full look elsewhere; an edit made since is the new look - debug hooks: materialTiers, motionNodes - tests: materialTiers.test.js (10, incl. a save on a low device reloading at full look), the starter list grew by one Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 3 + src/lib/debugHooks.js | 4 + src/lib/materialPresetMaps.js | 48 +++++++++++- src/lib/materialPresets.js | 6 ++ src/lib/materialPresetsCore.js | 79 +++++++++++++++++-- src/lib/materialTiers.js | 116 ++++++++++++++++++++++++++++ src/lib/materialTiersCore.js | 76 ++++++++++++++++++ tests/unit/materialPresets.test.js | 2 +- tests/unit/materialTiers.test.js | 119 +++++++++++++++++++++++++++++ 9 files changed, 444 insertions(+), 9 deletions(-) create mode 100644 src/lib/materialTiers.js create mode 100644 src/lib/materialTiersCore.js create mode 100644 tests/unit/materialTiers.test.js diff --git a/src/App.svelte b/src/App.svelte index 6cd1bc9f3..3edad5aae 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -125,6 +125,7 @@ import { startMusicToolbox } from './lib/musicToolbox' import { startCloudPlugin } from '$lib/cloudPlugin' import { startShaderGraphs } from '$lib/shaderGraph' import { startMaterialSharing } from '$lib/materialSharing' + import { startMaterialTiers } from '$lib/materialTiers.js' import { startShaderSync } from '$lib/shaderSync' import { startHudSync } from '$lib/hudSync' import { startHudImages } from '$lib/hudImages' @@ -298,6 +299,8 @@ import { startMusicToolbox } from './lib/musicToolbox' // (GLTF, a peer's per-object messages, undo) rebuilds materials, and the id survives // where the instance does not startMaterialSharing() + // 40 F16: draw physical materials at this device's look tier (transmission / thin film) + startMaterialTiers() startShaderSync() startHudSync() startHudImages() diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 6160ac29e..16bd8cf0b 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -76,6 +76,10 @@ export const DEBUG_HOOKS = [ ['materialsHandler', () => import('./materialsHandler')], // 37 R5: material presets (materialPresets, peerMaterialPresets, applyMaterialPreset, materialPresetsDebug…) ['materialPresets', () => import('./materialPresets')], + // 40 F16: the look tier (materialTiersDebug, pinMaterialTier, applyMaterialTiers) + ['materialTiers', () => import('./materialTiers.js')], + // 40 F15: the motion nodes (motionNodesDebug, resetMotionNodes) + ['motionNodes', () => import('./motion/motionNodes.js')], ['objectActions', () => import('./objectActions')], ['commandsHandler', () => import('./commandsHandler.svelte')], ['moduleSDK', () => import('./moduleSDK')], diff --git a/src/lib/materialPresetMaps.js b/src/lib/materialPresetMaps.js index d938a5585..23916371d 100644 --- a/src/lib/materialPresetMaps.js +++ b/src/lib/materialPresetMaps.js @@ -61,9 +61,49 @@ function tileFbm(u, v, cu, cv, octaves, seed) { return sum / norm; } +// 40 F16: FISH SCALES — overlapping rounded scales in offset rows, each hanging DOWN from its +// centre like a shingle, the lower row in front. SCALE_ROWS rows × SCALE_COLS columns per tile +// (integers, and odd rows shift half a scale, so an even row count keeps the tile seamless). +const SCALE_ROWS = 10; +const SCALE_COLS = 8; + +/** + * The height of the scale pattern at (u, v) in [0, 1]: a domed scale (highest near its free + * edge's middle, a crisp drop at its rim) over whatever scale lies behind it. + * @param {number} u @param {number} v + */ +export function scaleHeight(u, v) { + const rowH = 1 / SCALE_ROWS; + const colW = 1 / SCALE_COLS; + const R = colW * 0.62; + const row0 = Math.floor(v * SCALE_ROWS); + // front to back: the row whose centres sit just above this pixel hangs over it first + for (let dr = 0; dr <= 2; dr++) { + const row = row0 - dr; + const cy = row * rowH; + const shift = ((row % 2) + 2) % 2 ? 0.5 : 0; + const c0 = Math.floor(u * SCALE_COLS - shift); + for (let dc = -1; dc <= 1; dc++) { + const cx = (c0 + dc + 0.5 + shift) * colW; + let dx = u - cx; + dx -= Math.round(dx); // wrap across the tile edge + let dy = v - cy; + dy -= Math.round(dy); + if (dy <= 0) continue; // a scale hangs below its centre only (strict: v = 0 and v = 1 are one row) + const d = Math.hypot(dx, dy * (colW / rowH) * 0.9) / R; + if (d >= 1) continue; + // a soft dome with a raised rim: the light catches each scale's edge + const dome = Math.sqrt(1 - d * d); + const rim = d > 0.82 ? (d - 0.82) * 2.2 : 0; + return Math.min(1, 0.35 + dome * 0.5 + rim); + } + } + return 0.25; +} + /** * The height field of a kind, size×size in [0, 1], tileable in both directions. - * @param {'wood'|'stone'} kind @param {number} [size] + * @param {'wood'|'stone'|'scales'} kind @param {number} [size] * @returns {Float32Array} */ export function heightField(kind, size = MAP_SIZE) { @@ -84,6 +124,8 @@ export function heightField(kind, size = MAP_SIZE) { // a thin dark seam between planks const seam = Math.abs(((v * 4) % 1) - 0.5) > 0.485 ? 0.35 : 0; h = Math.max(0, h - seam); + } else if (kind === 'scales') { + h = scaleHeight(u, v); } else { // stone: broad blotches, a crisp mid band and grit const broad = tileFbm(u, v, 3, 3, 4, 41); @@ -132,7 +174,7 @@ const cache = new Map(); /** * The encoded maps of a procedural kind (browser only; null elsewhere). Cached per session, * so swatches, applies and the active-swatch compare all see the same strings. - * @param {'wood'|'stone'} kind + * @param {'wood'|'stone'|'scales'} kind * @returns {{map: string, normalMap: string} | null} */ export function proceduralMaps(kind) { @@ -141,7 +183,7 @@ export function proceduralMaps(kind) { if (typeof document === 'undefined') return null; try { const size = MAP_SIZE; - const { color, normal } = mapBytes(heightField(kind, size), size, kind === 'wood' ? 2.5 : 4); + const { color, normal } = mapBytes(heightField(kind, size), size, kind === 'wood' ? 2.5 : kind === 'scales' ? 3 : 4); const encode = (/** @type {Uint8ClampedArray} */ bytes, /** @type {boolean} */ hq) => { const canvas = document.createElement('canvas'); canvas.width = canvas.height = size; diff --git a/src/lib/materialPresets.js b/src/lib/materialPresets.js index a12589bcf..87336a69a 100644 --- a/src/lib/materialPresets.js +++ b/src/lib/materialPresets.js @@ -124,6 +124,12 @@ export function presetWirePayload(preset, current) { const value = preset[key] ?? LOOK_DEFAULTS[key]; if (key in material && typeof value === 'number') material[key] = value; } + // 40 F16: the film thickness is one [min, max] pair on the material + if ('iridescenceThicknessRange' in material) + material.iridescenceThicknessRange = [ + preset.iridescenceThicknessMin ?? LOOK_DEFAULTS.iridescenceThicknessMin, + preset.iridescenceThicknessMax ?? LOOK_DEFAULTS.iridescenceThicknessMax + ]; if (material.color) material.color.set(preset.color ?? LOOK_DEFAULTS.color); if (material.emissive) material.emissive.set(preset.emissive ?? LOOK_DEFAULTS.emissive); material.transparent = !!preset.transparent; diff --git a/src/lib/materialPresetsCore.js b/src/lib/materialPresetsCore.js index dac311e85..cc6a39197 100644 --- a/src/lib/materialPresetsCore.js +++ b/src/lib/materialPresetsCore.js @@ -11,7 +11,12 @@ // roughness, metalness, clearcoat, clearcoatRoughness, transmission, ior, shininess, // emissive: '#rrggbb', emissiveIntensity, opacity, transparent, flatShading, // map: dataURL|null, normalMap: dataURL|null, normalScale, repeat: [u, v], -// procedural: 'wood'|'stone' } ← built-ins only: the maps are generated on the device +// procedural: 'wood'|'stone'|'scales' } ← built-ins only: the maps are generated on the device +// 40 F16 adds the THIN-FILM fields (MeshPhysicalMaterial): iridescence, iridescenceIOR, +// iridescenceThicknessMin/Max (nm — three's iridescenceThicknessRange), thickness (the +// transmission volume). A device may draw fewer of them than the look asks for: the look +// TIER (materialTiers.js) drops transmission on a phone and the whole thin-film set in a +// headset — the preset, the save and the wire always carry the authored numbers. // // Applying one goes through the EXISTING `objectParameters / materials` message (UV4's slot // payload), so the wire gains only the library broadcast (`matpresets`, the `envpresets` @@ -49,7 +54,12 @@ export const NUMERIC_FIELDS = [ 'ior', 'shininess', 'emissiveIntensity', - 'opacity' + 'opacity', + 'iridescence', + 'iridescenceIOR', + 'iridescenceThicknessMin', + 'iridescenceThicknessMax', + 'thickness' ]; /** @@ -126,6 +136,31 @@ export const STARTER_PRESETS = [ roughness: 0.96, metalness: 0 }, + { + // 40 F16: FISH SCALES — the look the aquarium's fish wear, for anyone: a thin-film + // iridescent sheen that shifts with the view (the silver-blue flash of a turning fish), + // a procedural scale relief, a light clear coat and, where a device can afford the pass, + // a hint of transmission so a fin reads thin. Recolour it in the Inspector for any fish, + // snake, dragon or beetle shell. + version: 1, + id: 'fishscale', + label: 'Fish scales', + type: 'MeshPhysicalMaterial', + color: '#c9d6dc', + roughness: 0.32, + metalness: 0.15, + clearcoat: 0.5, + clearcoatRoughness: 0.25, + iridescence: 0.9, + iridescenceIOR: 1.6, + iridescenceThicknessMin: 180, + iridescenceThicknessMax: 560, + transmission: 0.12, + thickness: 0.3, + procedural: 'scales', + normalScale: 0.7, + repeat: [3, 3] + }, { version: 1, id: 'neon', @@ -139,6 +174,9 @@ export const STARTER_PRESETS = [ } ]; +/** the procedural map kinds a built-in may name (materialPresetMaps generates them) */ +export const PROCEDURAL_KINDS = ['wood', 'stone', 'scales']; + /** @param {any} name */ export function cleanName(name) { return String(name ?? '') @@ -171,7 +209,12 @@ const RANGES = { ior: [1, 2.333], shininess: [0, 1000], emissiveIntensity: [0, 20], - opacity: [0, 1] + opacity: [0, 1], + iridescence: [0, 1], + iridescenceIOR: [1, 2.333], + iridescenceThicknessMin: [0, 2000], + iridescenceThicknessMax: [0, 2000], + thickness: [0, 10] }; /** a data:image URL or null — presets never carry a remote URL (a library would fetch it) @param {any} v */ @@ -215,7 +258,7 @@ export function normalizePreset(input) { const v = num(input.repeat[1], 0.01, 100); if (u !== undefined && v !== undefined && (u !== 1 || v !== 1)) out.repeat = [u, v]; } - if (input.procedural === 'wood' || input.procedural === 'stone') out.procedural = input.procedural; + if (PROCEDURAL_KINDS.includes(input.procedural)) out.procedural = input.procedural; if (Number.isFinite(input.savedAt)) out.savedAt = input.savedAt; return out; } @@ -242,6 +285,16 @@ export function snapshotLook(material, label, maps = {}) { for (const key of NUMERIC_FIELDS) { if (typeof material?.[key] === 'number') raw[key] = r4(material[key]); } + // 40 F16: three keeps the film thickness as a [min, max] pair + const range = material?.iridescenceThicknessRange; + if (Array.isArray(range) && range.length === 2) { + raw.iridescenceThicknessMin = r4(range[0]); + raw.iridescenceThicknessMax = r4(range[1]); + } + // …and a device drawing a lower look TIER holds the authored numbers beside the material + const authored = material?.userData?.lookTierAuthored; + if (authored && typeof authored === 'object') + for (const [key, value] of Object.entries(authored)) if (typeof value === 'number' && key in raw) raw[key] = r4(value); // emissiveIntensity without an emissive colour is noise; opacity 1 is the default if (!raw.emissive) delete raw.emissiveIntensity; if (raw.opacity === 1) delete raw.opacity; @@ -308,6 +361,11 @@ export const LOOK_DEFAULTS = { shininess: 30, emissiveIntensity: 1, opacity: 1, + iridescence: 0, + iridescenceIOR: 1.3, + iridescenceThicknessMin: 100, + iridescenceThicknessMax: 400, + thickness: 0, transparent: false, flatShading: false }; @@ -323,7 +381,12 @@ const TYPE_FIELDS = { 'transmission', 'ior', 'emissiveIntensity', - 'opacity' + 'opacity', + 'iridescence', + 'iridescenceIOR', + 'iridescenceThicknessMin', + 'iridescenceThicknessMax', + 'thickness' ], MeshPhongMaterial: ['shininess', 'emissiveIntensity', 'opacity'], MeshLambertMaterial: ['emissiveIntensity', 'opacity'], @@ -394,6 +457,12 @@ export function swatchLayers(p) { `radial-gradient(circle at 50% 50%, transparent 55%, color-mix(in srgb, #000 ${Math.round(25 + metal * 25)}%, transparent) 100%)` ]; if (glow) layers.push(`radial-gradient(circle at 50% 50%, ${glow} 0%, color-mix(in srgb, ${glow} 40%, transparent) 70%)`); + // 40 F16: a thin film reads as a hue sweep across the sphere + const irid = typeof p?.iridescence === 'number' ? p.iridescence : 0; + if (irid > 0) + layers.push( + `conic-gradient(from 200deg at 60% 60%, color-mix(in srgb, #ff6ad5 ${Math.round(irid * 35)}%, transparent), color-mix(in srgb, #5ad1ff ${Math.round(irid * 35)}%, transparent), color-mix(in srgb, #8cff8a ${Math.round(irid * 30)}%, transparent), color-mix(in srgb, #ff6ad5 ${Math.round(irid * 35)}%, transparent))` + ); layers.push( alpha < 1 ? `linear-gradient(color-mix(in srgb, ${base} ${Math.round(alpha * 100)}%, transparent), color-mix(in srgb, ${base} ${Math.round(alpha * 100)}%, transparent))` diff --git a/src/lib/materialTiers.js b/src/lib/materialTiers.js new file mode 100644 index 000000000..b04f79bcf --- /dev/null +++ b/src/lib/materialTiers.js @@ -0,0 +1,116 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import { get } from 'svelte/store'; +import { objectsGroup, globalRenderer } from '../stores/sceneStore'; +import { qualityOverrides } from './qualityGovernor'; +import { lookTier, planTier, TIERED_FIELDS } from './materialTiersCore.js'; + +// 40 F16 — THE LOOK TIER, the runtime: walks the scene's physical materials and draws each one at +// this device's tier (materialTiersCore.js has the rule and the why). LOCAL: it writes the live +// material only, never a message, an undo entry or a preference. +// +// The AUTHORED numbers ride beside the material as `userData.lookTierAuthored` — written only +// when this device actually draws less than the author asked for, so a desktop save of an +// untouched scene stays byte-identical (a fresh stamp on every load would mark every scene +// dirty: the signature rule). userData travels with toJSON, the GLTF extras, the wire and undo, +// so a headset's save still restores the full look on a desktop, and a preset snapshot reads the +// record (materialPresetsCore.snapshotLook). + +/** the tier pass's own writes, per material, so an edit made since is recognised @type {WeakMap>} */ +const written = new WeakMap(); +/** @type {'high'|'mid'|'low'} */ +let tier = 'high'; +/** a test/debug pin over the measured tier @type {'high'|'mid'|'low'|null} */ +let pin = null; +let overrides = /** @type {any} */ ({}); +let started = false; +/** @type {ReturnType | null} */ +let pending = null; +let passes = 0; +let lowered = 0; + +/** the tier for this device now */ +function measure() { + let xr = false; + try { + xr = !!(/** @type {any} */ (get(globalRenderer))?.xr?.isPresenting); + } catch { + /* a disposed renderer */ + } + return lookTier({ xr, overrides, pin }); +} + +/** + * Draw one material at a tier. Exported for the unit tests (a plain object with the fields works). + * @param {any} material @param {'high'|'mid'|'low'} t @returns {boolean} whether anything is drawn lower than authored + */ +export function applyTierTo(material, t) { + if (!material || !material.isMeshPhysicalMaterial) return false; + /** @type {Record} */ + const current = {}; + for (const f of TIERED_FIELDS) current[f] = typeof material[f] === 'number' ? material[f] : 0; + const record = material.userData?.lookTierAuthored ?? null; + const plan = planTier(current, record, written.get(material) ?? null, t); + // the record exists once this device has drawn the look lower (and is kept current after) + if (plan.lowers || record) { + material.userData = material.userData ?? {}; + material.userData.lookTierAuthored = plan.authored; + } + let program = false; + for (const f of TIERED_FIELDS) { + const to = plan.draw[f]; + if (material[f] === to) continue; + // three compiles transmission/sheen/iridescence in or out at zero: crossing it is a new program + if (material[f] > 0 !== to > 0) program = true; + material[f] = to; + } + if (program) material.needsUpdate = true; + written.set(material, { ...plan.draw }); + return plan.lowers; +} + +/** one pass over the scene */ +export function applyMaterialTiers() { + pending = null; + tier = measure(); + const group = /** @type {any} */ (get(objectsGroup)); + if (!group?.traverse) return; + passes++; + let n = 0; + group.traverse((/** @type {any} */ o) => { + if (!o.material) return; + const list = Array.isArray(o.material) ? o.material : [o.material]; + for (const m of list) if (applyTierTo(m, tier)) n++; + }); + lowered = n; +} + +function schedule() { + if (pending) return; + pending = setTimeout(applyMaterialTiers, 120); +} + +/** boot: follow the scene, the governor and the headset */ +export function startMaterialTiers() { + if (started) return; + started = true; + objectsGroup.subscribe(schedule); + qualityOverrides.subscribe((o) => { + overrides = o ?? {}; + schedule(); + }); + // entering or leaving a headset is a tier change no store announces: look once a second + setInterval(() => { + if (measure() !== tier) schedule(); + }, 1000); +} + +/** pin the tier (tests, a debug toggle); null follows the device again @param {'high'|'mid'|'low'|null} t */ +export function pinMaterialTier(t) { + pin = t === 'high' || t === 'mid' || t === 'low' ? t : null; + applyMaterialTiers(); +} + +/** debug: the tier and what it lowered */ +export function materialTiersDebug() { + return { tier, pin, passes, lowered }; +} diff --git a/src/lib/materialTiersCore.js b/src/lib/materialTiersCore.js new file mode 100644 index 000000000..49ea634e2 --- /dev/null +++ b/src/lib/materialTiersCore.js @@ -0,0 +1,76 @@ +// 40 F16 — THE LOOK TIER, the pure half (no THREE, no DOM, no stores — unit-tested). +// +// Some material features cost a device more than it can pay: TRANSMISSION renders the whole +// opaque scene a second time into a texture (one extra full pass per frame), and the THIN-FILM +// (iridescence) and SHEEN terms add a heavy block to every lit pixel. A desktop pays for both; a +// phone drops the pass; a headset — 2 × 1832² at 72-120 Hz — drops the per-pixel terms too. +// +// The tier is a fact about THIS device right now, decided from the same signals the water tier +// reads (WebXR presenting, the quality governor's overrides) — never a preference written into +// the scene. What a tier changes is applied LOCALLY on the live material; the authored numbers +// travel beside it (materialTiers.js), so a save, a preset snapshot or a peer always sees the +// look the author picked. + +/** the material fields a tier may lower, cheapest-to-keep last */ +export const TIERED_FIELDS = ['transmission', 'sheen', 'iridescence']; + +/** what each tier keeps: `high` everything, `mid` all but the extra pass, `low` none of them */ +const KEEP = { + high: new Set(TIERED_FIELDS), + mid: new Set(['sheen', 'iridescence']), + low: new Set() +}; + +/** + * The tier for this device now. Mirrors water's rule so the two never disagree about what a + * phone is: a headset is `low`; the governor's "post off" step is `low`; a phone's start (AO + * off, resolution under 80 %) is `mid`; otherwise `high`. + * @param {{xr?: boolean, overrides?: {postOff?: boolean, aoOff?: boolean, dprScale?: number} | null, pin?: 'high'|'mid'|'low'|null}} s + * @returns {'high'|'mid'|'low'} + */ +export function lookTier(s) { + if (s?.pin === 'high' || s?.pin === 'mid' || s?.pin === 'low') return s.pin; + if (s?.xr) return 'low'; + const o = s?.overrides ?? {}; + if (o.postOff) return 'low'; + if (o.aoOff || (typeof o.dprScale === 'number' && o.dprScale < 0.8)) return 'mid'; + return 'high'; +} + +/** + * The value a field is DRAWN with on a tier, from its authored value. + * @param {string} field @param {number} authored @param {'high'|'mid'|'low'} tier + */ +export function tieredValue(field, authored, tier) { + if (!TIERED_FIELDS.includes(field)) return authored; + const keep = KEEP[tier] ?? KEEP.high; + return keep.has(field) ? authored : 0; +} + +/** + * Plan one material's tier pass. `current` = what the live material holds, `authored` = the + * record beside it (or null), `written` = what this pass wrote last time (or null). A field + * whose live value is not what we wrote was EDITED since (an Inspector change, a peer's + * material message): that edit is the new authored value. + * @param {Record} current @param {Record | null} authored + * @param {Record | null} written @param {'high'|'mid'|'low'} tier + * @returns {{authored: Record, draw: Record, lowers: boolean}} + */ +export function planTier(current, authored, written, tier) { + /** @type {Record} */ + const next = {}; + /** @type {Record} */ + const draw = {}; + let lowers = false; + for (const f of TIERED_FIELDS) { + const cur = typeof current[f] === 'number' ? current[f] : 0; + let a; + if (written && typeof written[f] === 'number' && cur !== written[f]) a = cur; // edited since + else if (authored && typeof authored[f] === 'number') a = authored[f]; + else a = cur; + next[f] = a; + draw[f] = tieredValue(f, a, tier); + if (draw[f] !== a) lowers = true; + } + return { authored: next, draw, lowers }; +} diff --git a/tests/unit/materialPresets.test.js b/tests/unit/materialPresets.test.js index 8ba2763ca..3f0d1f28b 100644 --- a/tests/unit/materialPresets.test.js +++ b/tests/unit/materialPresets.test.js @@ -36,7 +36,7 @@ function fakeMaterial(fields) { describe('the starter set', () => { it('is the seven the brief names, in order', () => { - expect(STARTER_PRESETS.map((p) => p.id)).toEqual(['wood', 'metal', 'plastic', 'glass', 'stone', 'rubber', 'neon']); + expect(STARTER_PRESETS.map((p) => p.id)).toEqual(['wood', 'metal', 'plastic', 'glass', 'stone', 'rubber', 'fishscale', 'neon']); }); it('every starter survives its own normalisation unchanged in look', () => { diff --git a/tests/unit/materialTiers.test.js b/tests/unit/materialTiers.test.js new file mode 100644 index 000000000..f2ede14c4 --- /dev/null +++ b/tests/unit/materialTiers.test.js @@ -0,0 +1,119 @@ +// @ts-nocheck — plain fixtures; the code under test is typed +// 40 F16: the fish-scale look — the Fish scales starter, the tileable scale relief, and the +// LOOK TIER that draws transmission / thin film only where a device can pay for them while the +// authored look rides beside the material. +import { describe, it, expect, vi } from 'vitest'; +import * as THREE from 'three'; + +vi.mock('../../src/lib/qualityGovernor', async () => { + const { writable } = await import('svelte/store'); + return { qualityOverrides: writable({}) }; +}); + +import { lookTier, tieredValue, planTier, TIERED_FIELDS } from '../../src/lib/materialTiersCore.js'; +import { applyTierTo } from '../../src/lib/materialTiers.js'; +import { STARTER_PRESETS, normalizePreset, snapshotLook, swatchLayers } from '../../src/lib/materialPresetsCore.js'; +import { heightField, scaleHeight, MAP_SIZE } from '../../src/lib/materialPresetMaps.js'; + +describe('the look tier rule', () => { + it('desktop full quality = high; a phone start = mid; post off or a headset = low', () => { + expect(lookTier({ overrides: {} })).toBe('high'); + expect(lookTier({ overrides: { aoOff: true, dprScale: 0.72 } })).toBe('mid'); + expect(lookTier({ overrides: { dprScale: 0.72 } })).toBe('mid'); + expect(lookTier({ overrides: { postOff: true } })).toBe('low'); + expect(lookTier({ xr: true, overrides: {} })).toBe('low'); + expect(lookTier({ xr: true, pin: 'high' })).toBe('high'); + }); + it('mid drops only the extra pass; low drops the per-pixel terms too', () => { + expect(tieredValue('transmission', 0.3, 'high')).toBe(0.3); + expect(tieredValue('transmission', 0.3, 'mid')).toBe(0); + expect(tieredValue('iridescence', 0.9, 'mid')).toBe(0.9); + expect(tieredValue('iridescence', 0.9, 'low')).toBe(0); + expect(tieredValue('roughness', 0.4, 'low')).toBe(0.4); // not a tiered field + }); + it('an edit made since the last pass becomes the authored value', () => { + const first = planTier({ transmission: 0.3, sheen: 0, iridescence: 0.8 }, null, null, 'low'); + expect(first.authored).toEqual({ transmission: 0.3, sheen: 0, iridescence: 0.8 }); + expect(first.draw).toEqual({ transmission: 0, sheen: 0, iridescence: 0 }); + // nobody touched it: the record wins over the drawn zeros + const again = planTier(first.draw, first.authored, first.draw, 'low'); + expect(again.authored.iridescence).toBe(0.8); + // the Inspector set transmission to 0.5 while drawn at 0: that is the new look + const edited = planTier({ ...first.draw, transmission: 0.5 }, first.authored, first.draw, 'high'); + expect(edited.authored.transmission).toBe(0.5); + expect(edited.draw.transmission).toBe(0.5); + }); +}); + +describe('the tier on a real material', () => { + it('lowers, records, restores — and recompiles only when a term crosses zero', () => { + const m = new THREE.MeshPhysicalMaterial({ iridescence: 0.9, transmission: 0.2 }); + const v0 = m.version; + expect(applyTierTo(m, 'high')).toBe(false); + expect(m.userData.lookTierAuthored).toBeUndefined(); // a desktop writes nothing + expect(m.version).toBe(v0); + expect(applyTierTo(m, 'mid')).toBe(true); + expect(m.transmission).toBe(0); + expect(m.iridescence).toBe(0.9); + expect(m.version).toBeGreaterThan(v0); // needsUpdate + applyTierTo(m, 'low'); + expect(m.iridescence).toBe(0); + expect(m.userData.lookTierAuthored).toEqual({ transmission: 0.2, sheen: 0, iridescence: 0.9 }); + // a save on this device: toJSON carries the drawn zeros AND the authored record … + const json = m.toJSON(); + const loaded = new THREE.MaterialLoader().parse(json); + expect(loaded.iridescence).toBe(0); + // … so the desktop that opens it draws the full look again + applyTierTo(loaded, 'high'); + expect(loaded.iridescence).toBeCloseTo(0.9, 9); + expect(loaded.transmission).toBeCloseTo(0.2, 9); + }); + it('a preset snapshot taken on a low device reads the authored look', () => { + const m = new THREE.MeshPhysicalMaterial({ iridescence: 0.7 }); + applyTierTo(m, 'low'); + expect(m.iridescence).toBe(0); + expect(snapshotLook(m, 'Mine').iridescence).toBeCloseTo(0.7, 4); + }); + it('leaves standard materials alone', () => { + const m = new THREE.MeshStandardMaterial(); + expect(applyTierTo(m, 'low')).toBe(false); + expect(m.userData.lookTierAuthored).toBeUndefined(); + }); + it('every tiered field is a real MeshPhysicalMaterial property', () => { + const m = new THREE.MeshPhysicalMaterial(); + for (const f of TIERED_FIELDS) expect(typeof m[f]).toBe('number'); + }); +}); + +describe('the Fish scales preset', () => { + const fish = STARTER_PRESETS.find((p) => p.id === 'fishscale'); + it('is a physical, iridescent, scale-relief look that survives normalisation', () => { + expect(fish.type).toBe('MeshPhysicalMaterial'); + const n = normalizePreset(fish); + expect(n.iridescence).toBeGreaterThan(0.5); + expect(n.iridescenceThicknessMax).toBeGreaterThan(n.iridescenceThicknessMin); + expect(n.procedural).toBe('scales'); + expect(n.transmission).toBeGreaterThan(0); + }); + it('its swatch shows the film', () => { + expect(swatchLayers(fish).some((l) => l.startsWith('conic-gradient'))).toBe(true); + expect(swatchLayers(STARTER_PRESETS.find((p) => p.id === 'metal')).some((l) => l.startsWith('conic'))).toBe(false); + }); + it('the scale relief tiles seamlessly and has real relief', () => { + const n = 64; + const h = heightField('scales', n); + let lo = 1; + let hi = 0; + for (const v of h) { + lo = Math.min(lo, v); + hi = Math.max(hi, v); + } + expect(hi - lo).toBeGreaterThan(0.5); + // the left edge continues the right edge, the top the bottom (a wrap, not a seam) + for (let i = 0; i < 1; i += 0.05) { + expect(Math.abs(scaleHeight(0, i) - scaleHeight(1 - 1e-9, i))).toBeLessThan(0.05); + expect(Math.abs(scaleHeight(i, 0) - scaleHeight(i, 1 - 1e-9))).toBeLessThan(0.05); + } + expect(MAP_SIZE).toBe(256); + }); +}); From d5020d0acc5d7d8b82f85c92f0b16c847e025f5f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 14:29:55 +0300 Subject: [PATCH 3/6] [feat] the Aquarium becomes a reef: fallback LOD groups + Meshy fish swimming with the motion nodes (40 F14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lodGroupCore: an additive `fallback: true` group — LOD0 (the object itself) is a STAND-IN, drawn only while the real model's levels are loading or unreachable; the pick runs over levels 1..n (pickFallbackLevel, hysteresis kept). Unit tests incl. the counterfactual. - lodGroup: a pack level that does not pair with the object (or any level of a fallback group) is a TREE level, cloned per object, casting shadows; lodTrees.js (a leaf) lists the substitutes so Body Wave bends the real model and the look tier reaches its film - Body Wave: each mesh set (the object's own, each substitute) bends in its own frame, from the local matrix chain - author-templates: a `lod` object field; the card builds every fallback level first and draws each object's finest real model - the Aquarium: six reef fish (clownfish Wander + Orient to Velocity, tangs and angelfish Follow Path loops with banking, every fish a Body Wave), a reef rock arch (was the torus), staghorn coral, eelgrass and Amazon swords — all aquarium-kit models over the old primitives as stand-ins wearing the thin film - tests: e2e aquarium-reef (motion, fallback LOD, saves, tier, offline counterfactual); select-inside-36 / fb-water-probe find the fish the loaded Aquarium has Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/author-templates.cjs | 62 ++++++ scripts/templates/aquarium.cjs | 295 +++++++++++++--------------- src/lib/debugHooks.js | 3 + src/lib/lodGroup.js | 40 +++- src/lib/lodGroupCore.js | 21 +- src/lib/lodTrees.js | 44 +++++ src/lib/materialTiers.js | 10 +- src/lib/motion/motionNodes.js | 104 ++++++---- tests/e2e/aquarium-reef.test.cjs | 231 ++++++++++++++++++++++ tests/e2e/fb-water-probe.cjs | 2 +- tests/e2e/select-inside-36.test.cjs | 9 +- tests/unit/lodFallback.test.js | 60 ++++++ 12 files changed, 668 insertions(+), 213 deletions(-) create mode 100644 src/lib/lodTrees.js create mode 100644 tests/e2e/aquarium-reef.test.cjs create mode 100644 tests/unit/lodFallback.test.js diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index 01295c18f..ea31d0624 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -116,6 +116,10 @@ // toon (MeshToonMaterial), physical (MeshPhysicalMaterial — also implied by any of: // clearcoat, clearcoatRoughness, transmission, thickness, ior, sheen, sheenColor, // sheenRoughness, iridescence, specularIntensity) +// LOD (any object): lod {fallback?, levels: [{source: 'self'|'pack'|…, ref?, screenSize?}, …]} — the +// object's userData.lod (lodGroupCore). 40 F14: `fallback: true` = the object's own primitives are a +// STAND-IN drawn only while its real model (the pack levels) is loading or unreachable; the card +// draws the real model (every level is built before the thumbnail) // FLAGS (any object): physics {mode, mass, restitution, friction, ...} (userData.physics) · // shadow false (no cast/receive) · pick 'through' (select-through shells: walls, glass) · // origin [x, y, z] (the local pivot a Door preset swings about) · anim '' | [..] @@ -760,6 +764,13 @@ const DEFS = require('./templates/index.cjs').loadDefs(moduleDef); } else if (o.bubbles) { object.userData.bubbles = { ...s.waterPresets.BUBBLE_DEFAULTS, enabled: true, ...o.bubbles }; } + // 40 F14: a LOD group block (lodGroupCore's userData.lod) — `fallback: true` makes the + // object's own primitives the stand-in for the pack model its levels name + if (o.lod) { + const block = s.lodGroupCore?.normalizeLodGroup?.(o.lod) ?? o.lod; + if (!block?.levels) throw new Error('object "' + o.name + '": its lod block has no usable levels'); + object.userData.lod = block; + } // animation presets are applied once the object has a uuid in the scene if (o.anim) animQueue.push({ object, anim: o.anim }); } @@ -1170,6 +1181,43 @@ const DEFS = require('./templates/index.cjs').loadDefs(moduleDef); // The camera is `thumb.camera`, else `view`, else a 3/4 framing of the CONTENT: the // objects' bounds without the camera markers and without floor-like slabs (a 30x40 m // ground framed whole leaves every game piece a speck — the 1690-byte Waves card). + // 40 F14: every level of a FALLBACK LOD group, built now (levels are built when a pick + // first wants one), so the card can draw each object's finest real model + /** @type {{uuid: string, tree: any, world: any}[]} */ + const fallbackSubs = []; + { + /** @type {any[]} */ + const roots = []; + group.traverse((/** @type {any} */ n) => { + if (n.userData?.lod?.fallback) roots.push(n); + }); + for (const r of roots) s.lodGroup.buildAllLevels(r.uuid); + const until = Date.now() + 45000; + while (roots.length && Date.now() < until) { + const pending = roots.some((r) => (s.lodGroup.lodGroupInfo(r.uuid)?.levels ?? []).some((/** @type {any} */ l, /** @type {number} */ i) => i > 0 && (l.status === 'loading' || l.status === 'idle'))); + if (!pending) break; + await new Promise((ok) => setTimeout(ok, 250)); + } + for (const r of roots) { + const trees = s.lodTrees.lodTreesOf(r); + if (!trees.length) { + console.log(' WARN lod fallback: no model built for "' + r.name + '" (the card shows its stand-in)'); + continue; + } + // the finest = the most triangles + /** @param {any} t */ + const tris = (t) => { + let n = 0; + t.traverse((/** @type {any} */ o) => { + if (o.isMesh) n += (o.geometry.index ? o.geometry.index.count : o.geometry.attributes.position.count) / 3; + }); + return n; + }; + const tree = trees.reduce((a, b) => (tris(b) > tris(a) ? b : a)); + r.updateWorldMatrix(true, false); + fallbackSubs.push({ uuid: r.uuid, tree, world: r.matrixWorld.clone() }); + } + } let thumb = null; /** @type {string | null} */ let thumbError = null; try { @@ -1212,6 +1260,20 @@ const DEFS = require('./templates/index.cjs').loadDefs(moduleDef); // the kit references exist to avoid. Every other def renders exactly as before. const clone = kitPacks(d.objects, new Set()).size ? group.clone(true) : new T.ObjectLoader().parse(group.toJSON()); scene.add(clone); + // 40 F14: a FALLBACK LOD group draws its real model (a scene-root substitute the live + // renderer swaps in per frame) — the card shows that model, not the stand-in + for (const sub of fallbackSubs) { + const twin = clone.getObjectByProperty('uuid', sub.uuid); + if (!twin) continue; + twin.traverse((/** @type {any} */ n) => { + if (n.isMesh) n.visible = false; + }); + const copy = sub.tree.clone(true); + copy.matrixAutoUpdate = false; + copy.matrix.copy(sub.world); + copy.visible = true; + scene.add(copy); + } // 21-C C6-b: a module's WORLD lives at the scene root (golden rule 5), so a // card rendered from objectsGroup alone shows a dungeon template as a lone // arch. `thumb.sceneGroups` names scene-root groups to include — cloned into diff --git a/scripts/templates/aquarium.cjs b/scripts/templates/aquarium.cjs index 8dd995dc2..e79f61032 100644 --- a/scripts/templates/aquarium.cjs +++ b/scripts/templates/aquarium.cjs @@ -1,59 +1,73 @@ -// Template def `aquarium` (36-water) — one file per template (34 R4 A3). A glass tank of water on a -// stand: sand, rocks, plants, an air stone and five fish swimming loops (Path patrol in each fish's -// own graph). Authored by scripts/author-templates.cjs; the schema is the comment block there. +// Template def `aquarium` (36-water; 40 F14-F16) — one file per template (34 R4 A3). A glass tank of +// water on a stand: sand, rocks, coral, plants, an air stone and six reef fish. Authored by +// scripts/author-templates.cjs; the schema is the comment block there. +// +// 40: the fish, the coral and the plants are REAL MODELS from the aquarium-kit pack (Meshy, CC0), +// each drawn through a FALLBACK LOD group: the object itself is the simple primitive model the +// scene always had, and it stays on screen while the pack file loads (or when it cannot be +// reached); the pack model and its LOD files are levels 1..n. The fish swim with core's +// GENERAL-PURPOSE motion nodes, no fish-specific code: clownfish WANDER inside the water volume +// and ORIENT TO VELOCITY, tangs and angelfish FOLLOW PATH loops with banking, and every fish +// carries a BODY WAVE whose beat follows its speed and bends into its turns. Their stand-ins wear +// the thin-film look of the "Fish scales" preset (iridescence), as the pack models do. const { graphBuilder } = require('./_builders.cjs'); // the tank: 4 × 2 × 1.6 m, its floor on the stand top (y 0.9), water to the brim const TANK = { w: 4, h: 2, d: 1.6, y: 0.9 }; const IN = TANK.y + 0.15; // the sand top -/** a fish: a rounded body, a tail fin and two eyes, facing +Z (Path patrol faces +Z along the path) */ -function fish(name, color, size, start, next) { - const s = size; - return { - type: 'group', - name, - // start on the path, facing along it (Path patrol takes over in the app) - pos: start, - rot: [0, Math.atan2(next[0] - start[0], next[2] - start[2]), 0], - children: [ - { - type: 'box', - name: name + ' body', - color, - size: [0.12 * s, 0.16 * s, 0.34 * s], - bevel: 0.05 * s, - roughness: 0.45 - }, - { - type: 'cone', - name: name + ' tail', - color, - r: 0.08 * s, - h: 0.14 * s, - pos: [0, 0, -0.22 * s], - rot: [-Math.PI / 2, 0, 0], - roughness: 0.5 - }, - { - type: 'sphere', - name: name + ' eye L', - color: 0x101418, - r: 0.018 * s, - pos: [0.058 * s, 0.03 * s, 0.1 * s] - }, - { - type: 'sphere', - name: name + ' eye R', - color: 0x101418, - r: 0.018 * s, - pos: [-0.058 * s, 0.03 * s, 0.1 * s] - } - ] - }; +/** the pack folder of an aquarium-kit item @param {string} item @param {string} file */ +const kitFile = (item, file) => 'aquarium-kit/' + item + '/glTF-Binary/' + file; + +/** + * A FALLBACK LOD group block: LOD0 = the object's own primitives (the stand-in), then the pack + * model (closest), then its LOD files. Thresholds are screen-height shares (lodGroupCore). + * @param {string} item @param {string} base file name without .glb @param {number} lods LOD files + * @param {number[]} [sizes] the screen sizes after which each real level gives way to the next + */ +function fallbackLod(item, base, lods, sizes = [0.05, 0.02, 0.008]) { + const levels = [{ source: 'self', screenSize: 0.9 }, { source: 'pack', ref: kitFile(item, base + '.glb'), screenSize: sizes[0] }]; + for (let i = 1; i <= lods; i++) levels.push({ source: 'pack', ref: kitFile(item, base + '.lod' + i + '.glb'), screenSize: sizes[i] ?? sizes[sizes.length - 1] / (i + 1) }); + return { fallback: true, levels }; +} + +/** the thin-film look of the "Fish scales" preset, on a stand-in part @param {number|string} color */ +const scales = (color) => ({ color, roughness: 0.32, metalness: 0.15, clearcoat: 0.5, clearcoatRoughness: 0.25, iridescence: 0.9 }); + +/** + * A stand-in fish, nose +Z (what Follow Path / Orient to Velocity / Body Wave expect): a rounded + * body, a tail fin, a dorsal fin, optional stripes and two eyes — sized to the pack model's body. + * @param {string} name @param {{body: number[], color: number, fin: number, stripe?: number, stripes?: number[]}} look + */ +function fishParts(name, look) { + const [w, h, l] = look.body; + /** @type {any[]} */ + const parts = [ + { type: 'box', name: name + ' body', size: [w, h, l * 0.78], bevel: Math.min(w, h) * 0.45, ...scales(look.color) }, + { type: 'cone', name: name + ' tail', r: h * 0.42, h: l * 0.26, pos: [0, 0, -l * 0.42], rot: [-Math.PI / 2, 0, 0], ...scales(look.fin), side: 'double' }, + { type: 'cone', name: name + ' dorsal', r: w * 0.25, h: h * 0.45, pos: [0, h * 0.55, -l * 0.05], ...scales(look.fin) }, + { type: 'sphere', name: name + ' eye L', color: 0x101418, r: h * 0.09, pos: [w * 0.45, h * 0.12, l * 0.28] }, + { type: 'sphere', name: name + ' eye R', color: 0x101418, r: h * 0.09, pos: [-w * 0.45, h * 0.12, l * 0.28] } + ]; + for (const [i, z] of (look.stripes ?? []).entries()) + parts.push({ type: 'box', name: name + ' stripe ' + (i + 1), size: [w * 1.04, h * 1.02, l * 0.06], bevel: Math.min(w, h) * 0.45, pos: [0, 0, z * l], color: look.stripe ?? 0xffffff, roughness: 0.4 }); + return parts; } -/** a closed loop inside the tank at height y (world points) */ +const SPECIES = { + clown: { item: 'FishClown', file: 'fish-clown', lods: 2, look: { body: [0.13, 0.14, 0.32], color: 0xff7a1a, fin: 0xff9a3a, stripe: 0xfafafa, stripes: [0.22, 0.02, -0.2] } }, + tang: { item: 'FishTang', file: 'fish-tang', lods: 1, look: { body: [0.09, 0.2, 0.42], color: 0x1f5bff, fin: 0xffd23a } }, + angel: { item: 'FishAngel', file: 'fish-angel', lods: 1, look: { body: [0.08, 0.24, 0.42], color: 0x2a3fb8, fin: 0xffc93a, stripe: 0xffd84a, stripes: [0.12, -0.02, -0.16] } } +}; + +/** a fish object: a top-level group of stand-in parts, its fallback LOD naming the pack model + * @param {string} name @param {keyof typeof SPECIES} kind @param {number[]} pos @param {number} [yaw] */ +function fish(name, kind, pos, yaw = 0) { + const sp = SPECIES[kind]; + return { type: 'group', name, pos, rot: [0, yaw, 0], children: fishParts(name, sp.look), lod: fallbackLod(sp.item, sp.file, sp.lods) }; +} + +/** a closed loop inside the tank (world points): an oval at height y, rising and falling */ function loop(cx, cz, rx, rz, y, wobble, n = 8) { const pts = []; for (let i = 0; i < n; i++) { @@ -63,58 +77,75 @@ function loop(cx, cz, rx, rz, y, wobble, n = 8) { return pts; } -const FISH = [ - { - name: 'Fish orange', - color: 0xff8a2a, - size: 1.1, - path: loop(0, 0, 1.45, 0.5, IN + 0.75, 0.12), - speed: 0.45 - }, - { - name: 'Fish blue', - color: 0x3a8dff, - size: 0.9, - path: loop(0.4, 0.05, 1.1, 0.42, IN + 1.15, 0.1, 10).reverse(), - speed: 0.55 - }, - { - name: 'Fish yellow', - color: 0xffd23a, - size: 0.8, - path: loop(-0.6, -0.1, 0.8, 0.35, IN + 0.45, 0.08), - speed: 0.35 - }, - { - name: 'Fish red', - color: 0xe84a5f, - size: 1.0, - path: loop(0.7, 0, 0.9, 0.4, IN + 1.35, 0.06, 9), - speed: 0.4 - }, - { - name: 'Fish striped', - color: 0xf2f2f2, - size: 1.25, - path: loop(-0.2, 0.1, 1.5, 0.48, IN + 0.95, 0.15, 12).reverse(), - speed: 0.3 +/** a figure of eight across the tank, dipping at the crossing */ +function eight(cx, cz, rx, rz, y, dip, n = 12) { + const pts = []; + for (let i = 0; i < n; i++) { + const a = (i / n) * Math.PI * 2; + pts.push([cx + Math.sin(a) * rx, y - Math.abs(Math.cos(a)) * dip, cz + Math.sin(a * 2) * rz]); } + return pts; +} + +const TANG_PATH = loop(0.1, 0.05, 1.45, 0.5, IN + 1.05, 0.15, 10); +const ANGEL_PATH = eight(-0.1, 0, 1.3, 0.42, IN + 1.45, 0.45); + +const FISH = [ + { name: 'Clownfish 1', kind: 'clown', pos: [-0.9, IN + 0.55, 0.2] }, + { name: 'Clownfish 2', kind: 'clown', pos: [-0.6, IN + 0.45, -0.1] }, + { name: 'Blue tang 1', kind: 'tang', pos: TANG_PATH[0] }, + { name: 'Blue tang 2', kind: 'tang', pos: TANG_PATH[5] }, + { name: 'Angelfish 1', kind: 'angel', pos: ANGEL_PATH[0] }, + { name: 'Angelfish 2', kind: 'angel', pos: ANGEL_PATH[6] } ]; -/** every fish swims its own loop: one Path patrol node in its own graph (targets its owner) */ +// every fish's own graph (its owner is the fish): movers, then the followers that read them const graphs = {}; for (const f of FISH) { const g = graphBuilder(); - g.N('swim', 'pathpatrol', 'Swim', 0, 0, { points: f.path, speed: f.speed, mode: 'loop' }); + if (f.kind === 'clown') { + // clownfish potter about inside the water volume, turning to face where they go + g.N('wander', 'wander', 'Wander', 0, 0, { area: 'Aquarium water', margin: 0.4, speed: 0.22, seed: 0 }); + g.N('face', 'orientvelocity', 'Orient to Velocity', 0, 160, { turnSpeed: 2.5, bank: 0.6, maxBank: 30, pitch: true, minSpeed: 0.02 }); + g.N('wave', 'bodywave', 'Body Wave', 0, 320, { amplitude: 0.07, wavelength: 1, frequency: 2.2, stiffness: 0.3, falloff: 2, speedGain: 4, ampGain: 1, turnBend: 0.35 }); + } else { + const tang = f.kind === 'tang'; + g.N('swim', 'followpath', 'Follow Path', 0, 0, { + points: tang ? TANG_PATH : ANGEL_PATH, + closed: true, + smooth: true, + speed: tang ? 0.42 : 0.3, + offset: f.name.endsWith('2') ? 0.5 : 0, + mode: 'loop', + align: true, + pitch: true, + bank: tang ? 0.7 : 0.5, + maxBank: tang ? 35 : 25 + }); + g.N('wave', 'bodywave', 'Body Wave', 0, 200, { + amplitude: tang ? 0.06 : 0.05, + wavelength: tang ? 1 : 1.2, + frequency: tang ? 1.8 : 1.2, + stiffness: tang ? 0.35 : 0.45, + falloff: 2, + speedGain: 3, + ampGain: 0.6, + turnBend: 0.4 + }); + } graphs[f.name] = g.done(); } +/** a plant or coral: a top-level group on the sand, its stand-in primitives inside, the pack model + * as its real level @param {string} name @param {number[]} pos @param {any[]} parts @param {any} lod @param {number} [yaw] */ +const planted = (name, pos, parts, lod, yaw = 0) => ({ type: 'group', name, pos, rot: [0, yaw, 0], children: parts, lod }); + module.exports = { kind: 'example', slug: 'aquarium', title: 'Aquarium', description: - 'A glass tank of water with fish swimming loops, plants, rocks and bubbles rising from an air stone', + 'A reef tank: clownfish, blue tangs and angelfish swimming with the motion nodes (Follow Path, Wander, Body Wave) among coral, plants and an air stone', license: 'CC0-1.0', author: 'theprototype', tags: ['water', 'showcase'], @@ -154,79 +185,23 @@ module.exports = { size: [TANK.w - 0.04, 0.15, TANK.d - 0.04], pos: [0, TANK.y + 0.075, 0] }, - { - type: 'dodecahedron', - name: 'Rock big', - color: 0x7a7f86, - r: 0.32, - pos: [-1.3, IN + 0.18, -0.35] - }, - { - type: 'dodecahedron', - name: 'Rock small', - color: 0x8d8478, - r: 0.18, - pos: [-0.95, IN + 0.1, -0.5] - }, - { - type: 'icosahedron', - name: 'Rock flat', - color: 0x6b6f75, - r: 0.22, - pos: [1.35, IN + 0.08, 0.35] - }, - { - type: 'capsule', - name: 'Plant 1', - color: 0x2e9b4f, - r: 0.05, - h: 0.9, - pos: [-1.6, IN + 0.5, -0.55], - rot: [0.08, 0, 0.12] - }, - { - type: 'capsule', - name: 'Plant 2', - color: 0x3cb85c, - r: 0.04, - h: 1.2, - pos: [-1.45, IN + 0.65, -0.62], - rot: [-0.06, 0, -0.1] - }, - { - type: 'capsule', - name: 'Plant 3', - color: 0x27864a, - r: 0.05, - h: 0.7, - pos: [1.6, IN + 0.4, -0.55], - rot: [0.05, 0, -0.15] - }, - { - type: 'cone', - name: 'Plant 4', - color: 0x4fbf6a, - r: 0.12, - h: 0.8, - pos: [1.25, IN + 0.4, -0.6] - }, - { - type: 'cone', - name: 'Plant 5', - color: 0x2e9b4f, - r: 0.1, - h: 1.05, - pos: [0.55, IN + 0.52, -0.62] - }, - { - type: 'torus', - name: 'Coral ring', - color: 0xff6f91, - r: 0.14, - tube: 0.05, - pos: [0.2, IN + 0.12, 0.45], - rot: [-Math.PI / 2, 0, 0] - }, + { type: 'dodecahedron', name: 'Rock big', color: 0x7a7f86, r: 0.32, pos: [-1.3, IN + 0.18, -0.35] }, + { type: 'dodecahedron', name: 'Rock small', color: 0x8d8478, r: 0.18, pos: [-0.95, IN + 0.1, -0.5] }, + { type: 'icosahedron', name: 'Rock flat', color: 0x6b6f75, r: 0.22, pos: [1.35, IN + 0.08, 0.35] }, + // the eelgrass along the back glass (was Plants 1-3: capsules) + planted('Eelgrass 1', [-1.6, IN, -0.5], [{ type: 'capsule', name: 'Eelgrass 1 stand-in', color: 0x2e9b4f, r: 0.05, h: 0.9, pos: [0, 0.5, 0], rot: [0.08, 0, 0.12] }], fallbackLod('PlantEelgrass', 'plant-eelgrass', 0, [0.02])), + planted('Eelgrass 2', [-1.2, IN, -0.6], [{ type: 'capsule', name: 'Eelgrass 2 stand-in', color: 0x3cb85c, r: 0.04, h: 1.2, pos: [0, 0.65, 0], rot: [-0.06, 0, -0.1] }], fallbackLod('PlantEelgrass', 'plant-eelgrass', 0, [0.02]), 1.3), + planted('Eelgrass 3', [1.65, IN, -0.5], [{ type: 'capsule', name: 'Eelgrass 3 stand-in', color: 0x27864a, r: 0.05, h: 0.7, pos: [0, 0.4, 0], rot: [0.05, 0, -0.15] }], fallbackLod('PlantEelgrass', 'plant-eelgrass', 0, [0.02]), 2.4), + // the Amazon swords (was Plants 4-5: cones) + planted('Amazon sword 1', [1.15, IN, -0.55], [{ type: 'cone', name: 'Amazon sword 1 stand-in', color: 0x4fbf6a, r: 0.12, h: 0.8, pos: [0, 0.4, 0] }], fallbackLod('PlantSword', 'plant-sword', 0, [0.02])), + planted('Amazon sword 2', [0.45, IN, -0.6], [{ type: 'cone', name: 'Amazon sword 2 stand-in', color: 0x2e9b4f, r: 0.1, h: 1.05, pos: [0, 0.52, 0] }], fallbackLod('PlantSword', 'plant-sword', 0, [0.02]), 0.7), + // the reef rock arch a fish can swim through (was the torus "Coral ring") + planted('Coral arch', [0.2, IN, 0.3], [{ type: 'torus', name: 'Coral arch stand-in', color: 0xd9a77e, r: 0.3, tube: 0.1, pos: [0, 0.3, 0] }], fallbackLod('CoralArch', 'coral-arch', 1, [0.04, 0.015])), + planted('Staghorn coral', [1.0, IN, 0.15], [ + { type: 'cone', name: 'Staghorn coral stand-in 1', color: 0xf3c9b5, r: 0.06, h: 0.45, pos: [0, 0.22, 0], rot: [0, 0, 0.25] }, + { type: 'cone', name: 'Staghorn coral stand-in 2', color: 0xf0b3c8, r: 0.05, h: 0.38, pos: [0.08, 0.19, 0.05], rot: [0.2, 0, -0.3] }, + { type: 'cone', name: 'Staghorn coral stand-in 3', color: 0xf6d6c4, r: 0.05, h: 0.32, pos: [-0.07, 0.16, -0.04], rot: [-0.25, 0, 0.1] } + ], fallbackLod('CoralBranch', 'coral-branch', 0, [0.02])), { type: 'cylinder', name: 'Air stone', @@ -244,7 +219,7 @@ module.exports = { wobble: 0.6 } }, - ...FISH.map((f) => fish(f.name, f.color, f.size, f.path[0], f.path[1])) + ...FISH.map((f) => fish(f.name, /** @type {any} */ (f.kind), f.pos)) ], graphs, view: { pos: [3.4, 2.7, 4.6], target: [0, 1.75, 0] } diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 16bd8cf0b..685eb8f82 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -346,6 +346,9 @@ export const DEBUG_HOOKS = [ ['replaceModel', () => import('./replaceModel')], ['lod', () => import('./lod')], ['lodGroup', () => import('./lodGroup')], + ['lodGroupCore', () => import('./lodGroupCore')], + // 40 F14: the substitutes a fallback group draws + ['lodTrees', () => import('./lodTrees.js')], ['lodGroupActions', () => import('./lodGroupActions')], ['lodLevelEdit', () => import('./lodLevelEdit')], ['packBehavior', () => import('./packBehavior')], diff --git a/src/lib/lodGroup.js b/src/lib/lodGroup.js index 91da63b0d..96182c9ad 100644 --- a/src/lib/lodGroup.js +++ b/src/lib/lodGroup.js @@ -3,7 +3,8 @@ import { createGltfLoader } from './gltfLoader'; import { writable, get } from 'svelte/store'; import { globalScene, objectsGroup } from '../stores/sceneStore'; import { registerLodPass, simplifiedGeometry, swapGeometryForPass, swapMaterialForPass, overlayMaterial } from './lod'; -import { normalizeLodGroup, pickGroupLevel, thresholdsOf, groupFromPackLods } from './lodGroupCore'; +import { normalizeLodGroup, pickGroupLevel, pickFallbackLevel, thresholdsOf, groupFromPackLods } from './lodGroupCore'; +import { addLodTree, removeLodTree } from './lodTrees.js'; import { packRefOf, packRefUrl, loadPackFile } from './packRefs'; import { PACKS_BASE } from './packs'; import { fetchIndex } from './contentBase'; @@ -356,7 +357,13 @@ function asTree(object) { let tris = 0; object.traverse((/** @type {any} */ o) => { o.raycast = () => {}; - if (o.isMesh) tris += trianglesOf(o.geometry); + if (o.isMesh) { + tris += trianglesOf(o.geometry); + // it stands in for the object, so it shades like one (the holder is outside the + // objectsGroup sweep that turns shadows on) + o.castShadow = true; + o.receiveShadow = true; + } }); return { object, tris }; } @@ -428,6 +435,7 @@ function requestLevel(entry, i) { return; } entry.built.set(key, state); + if (state.status === 'ready' && state.kind === 'tree') addLodTree(entry.root, state.object); tick(); }; /** @param {any} error */ @@ -440,9 +448,13 @@ function requestLevel(entry, i) { if (!url) return fail('no pack reference to resolve "' + level.ref + '" against'); loadPackFile(url) .then((scene) => { - const paired = pairLevel(entry, scene); - if (!paired) throw new Error('none of its meshes match the object'); - land({ status: 'ready', kind: 'swap', pairs: paired.pairs, tris: paired.tris, owned: paired.owned, releases: [] }); + // a FALLBACK group's stand-in is by definition not the model (40 F14): never pair them, + // even when the mesh counts happen to agree + const paired = entry.block.fallback ? null : pairLevel(entry, scene); + if (paired) land({ status: 'ready', kind: 'swap', pairs: paired.pairs, tris: paired.tris, owned: paired.owned, releases: [] }); + // 40 F14: an UNRELATED pack model (a fallback group's real model) is a TREE level — + // a copy per object (the file's scene is shared by every object that asks) + else land({ status: 'ready', kind: 'tree', ...asTree(scene.clone(true)) }); }) .catch(fail); } else if (level.source === 'generated') { @@ -495,7 +507,10 @@ function disposeBuilt(entry) { if (state.kind === 'swap') { for (const g of state.owned) g.dispose(); for (const r of state.releases) r(); - } else if (state.kind === 'tree') state.object.removeFromParent(); + } else if (state.kind === 'tree') { + state.object.removeFromParent(); + removeLodTree(entry.root, state.object); + } } entry.built.clear(); } @@ -531,7 +546,10 @@ function ensureEntry(root, block, implicit) { if (state.status === 'ready' && state.kind === 'swap') { for (const g of state.owned) g.dispose(); for (const r of state.releases) r(); - } else if (state.status === 'ready' && state.kind === 'tree') state.object.removeFromParent(); + } else if (state.status === 'ready' && state.kind === 'tree') { + state.object.removeFromParent(); + removeLodTree(existing.root, state.object); + } existing.built.delete(key); } existing.block = block; @@ -755,8 +773,12 @@ function before(camera, qualityBias, enabled, overlay) { let auto = 0; if (enabled) { const t = thresholdsOf(block, qualityBias); - auto = pickGroupLevel(size, t.thresholds, entry.autoCurrent, { cull: !!block.cull, scale: t.scale }); - } + const opts = { cull: !!block.cull, scale: t.scale }; + // 40 F14: a fallback group picks among its real levels; LOD0 draws only while none is built + auto = block.fallback + ? pickFallbackLevel(size, t.thresholds, entry.autoCurrent, opts) + : pickGroupLevel(size, t.thresholds, entry.autoCurrent, opts); + } else if (block.fallback) auto = 1; // LOD switched off: the real model, not the stand-in entry.autoCurrent = auto; let want = auto; if (preview && preview.uuid === root.uuid && preview.level < block.levels.length) want = preview.level; diff --git a/src/lib/lodGroupCore.js b/src/lib/lodGroupCore.js index 87b63afef..fa67a2e0b 100644 --- a/src/lib/lodGroupCore.js +++ b/src/lib/lodGroupCore.js @@ -21,7 +21,7 @@ * @typedef {{color?: string, roughness?: number, metalness?: number}} LodMaterial * @typedef {{source: LodSource, ref?: string, name?: string, ratio?: number, screenSize: number, * offset?: LodOffset, material?: LodMaterial}} LodLevel - * @typedef {{mode: 'auto' | 'forced', forced?: number, bias?: number, cull?: boolean, levels: LodLevel[]}} LodGroup + * @typedef {{mode: 'auto' | 'forced', forced?: number, bias?: number, cull?: boolean, fallback?: boolean, levels: LodLevel[]}} LodGroup */ export const LOD_SOURCES = ['self', 'pack', 'generated', 'explorer', 'object']; @@ -137,9 +137,28 @@ export function normalizeLodGroup(block) { } if (finite(block.bias) && block.bias > 0 && block.bias !== 1) out.bias = clampNum(block.bias, 0.1, 10); if (block.cull === true) out.cull = true; + // 40 F14: LOD0 (the object itself) as the FALLBACK — see pickFallbackLevel + if (block.fallback === true && levels.length >= 2) out.fallback = true; return out; } +/** + * 40 F14 — A FALLBACK GROUP: the object's own meshes (LOD0) are a SIMPLIFIED stand-in, drawn + * only while no finer level is available (its model is still on its way, or the pack cannot be + * reached); the real model and its own levels are levels 1..n. So the pick runs over levels 1..n + * with level 1's threshold as the first edge, and LOD0 is never chosen by size — the runtime's + * "nearest built" walk lands on it exactly when nothing better is built. The Aquarium's fish are + * the first users: a primitive fish that becomes the Meshy fish when its pack file lands. + * @param {number} size @param {number[]} thresholds the group's (LOD0's first) @param {number} current + * the level drawn now (in the group's numbering) @param {{scale?: number, hysteresis?: number, cull?: boolean}} [opts] + * @returns {number} a level ≥ 1, or -1 for culled + */ +export function pickFallbackLevel(size, thresholds, current, opts = {}) { + if (thresholds.length < 2) return 0; + const inner = pickGroupLevel(size, thresholds.slice(1), current > 0 ? current - 1 : current === -1 ? -1 : 0, opts); + return inner < 0 ? -1 : inner + 1; +} + /** * The share of the viewport HEIGHT a bounding sphere covers. Perspective: r / (d·tan(fov/2)); * orthographic: 2r / (top - bottom) / zoom. Clamped to [0, 1+] — standing inside the sphere diff --git a/src/lib/lodTrees.js b/src/lib/lodTrees.js new file mode 100644 index 000000000..f2160f413 --- /dev/null +++ b/src/lib/lodTrees.js @@ -0,0 +1,44 @@ +// 40 F14 — WHICH SUBSTITUTE MODELS DRAW FOR AN OBJECT. A LEAF that imports nothing: lodGroup.js +// (which owns the scene-root substitute subtrees of TREE levels) writes it, and the readers that +// must reach those meshes without pulling the LOD runtime into their import graph read it — Body +// Wave bends a fish's real model, the look tier draws its film at this device's tier. + +/** object root -> the tree-level subtrees built for it @type {WeakMap>} */ +const trees = new WeakMap(); +/** every live subtree (for a whole-scene pass) @type {Set} */ +const all = new Set(); +/** @type {Set<() => void>} */ +const listeners = new Set(); + +/** @param {any} root @param {any} tree */ +export function addLodTree(root, tree) { + let set = trees.get(root); + if (!set) trees.set(root, (set = new Set())); + set.add(tree); + all.add(tree); + for (const fn of listeners) fn(); +} + +/** @param {any} root @param {any} tree */ +export function removeLodTree(root, tree) { + trees.get(root)?.delete(tree); + all.delete(tree); + for (const fn of listeners) fn(); +} + +/** the subtrees built for an object (empty when none) @param {any} root @returns {any[]} */ +export function lodTreesOf(root) { + const set = trees.get(root); + return set ? [...set] : []; +} + +/** every live subtree @returns {any[]} */ +export function allLodTrees() { + return [...all]; +} + +/** called when a subtree is built or dropped @param {() => void} fn @returns {() => void} */ +export function onLodTreesChanged(fn) { + listeners.add(fn); + return () => listeners.delete(fn); +} diff --git a/src/lib/materialTiers.js b/src/lib/materialTiers.js index b04f79bcf..bab6a0dd9 100644 --- a/src/lib/materialTiers.js +++ b/src/lib/materialTiers.js @@ -3,6 +3,7 @@ import { get } from 'svelte/store'; import { objectsGroup, globalRenderer } from '../stores/sceneStore'; import { qualityOverrides } from './qualityGovernor'; import { lookTier, planTier, TIERED_FIELDS } from './materialTiersCore.js'; +import { allLodTrees, onLodTreesChanged } from './lodTrees.js'; // 40 F16 — THE LOOK TIER, the runtime: walks the scene's physical materials and draws each one at // this device's tier (materialTiersCore.js has the rule and the why). LOCAL: it writes the live @@ -76,11 +77,15 @@ export function applyMaterialTiers() { if (!group?.traverse) return; passes++; let n = 0; - group.traverse((/** @type {any} */ o) => { + /** @param {any} o */ + const visit = (o) => { if (!o.material) return; const list = Array.isArray(o.material) ? o.material : [o.material]; for (const m of list) if (applyTierTo(m, tier)) n++; - }); + }; + group.traverse(visit); + // 40 F14: the substitute models LOD groups draw at the scene root (a fallback group's real model) + for (const tree of allLodTrees()) tree.traverse(visit); lowered = n; } @@ -94,6 +99,7 @@ export function startMaterialTiers() { if (started) return; started = true; objectsGroup.subscribe(schedule); + onLodTreesChanged(schedule); qualityOverrides.subscribe((o) => { overrides = o ?? {}; schedule(); diff --git a/src/lib/motion/motionNodes.js b/src/lib/motion/motionNodes.js index f5b4193b0..87b821762 100644 --- a/src/lib/motion/motionNodes.js +++ b/src/lib/motion/motionNodes.js @@ -17,6 +17,7 @@ import { } from './motionCore.js'; import { normalizeSpline, isSplineObject } from '../splineTube.js'; import { normalizeFlowPath } from '../sim/flowPathCore.js'; +import { lodTreesOf } from '../lodTrees.js'; // 40 F15 — GENERAL-PURPOSE MOTION, the THREE half: what flowRuntime.applyAnimation calls for // `followpath`, `orientvelocity`, `wander` and `bodywave`. A LEAF beside sim/motionNodes.js — it @@ -332,8 +333,8 @@ const WAVE_VERTEX = /* glsl */ ` } `; -/** @typedef {{mesh: any, original: any, clone: any, uniforms: any}} WaveMesh */ -/** @type {Map} */ +/** @typedef {{mesh: any, original: any, clone: any, uniforms: any, frame: any, front: number, len: number}} WaveMesh */ +/** @type {Map} */ const waves = new Map(); /** the side (bend) axis for a nose axis: horizontal = up × nose (a fish), vertical = up (a whale) @param {THREE.Vector3} axis @param {string} side */ @@ -356,6 +357,39 @@ function meshesOf(object) { return out; } +/** a node's matrix relative to an ancestor, from the LOCAL chain (independent of where the + * renderer last placed the ancestor) @param {any} node @param {any} root @param {THREE.Matrix4} out */ +function relTo(node, root, out) { + node.updateMatrix(); + out.copy(node.matrix); + for (let p = node.parent; p && p !== root; p = p.parent) { + p.updateMatrix(); + out.premultiply(p.matrix); + } + return out; +} + +/** + * The body frame of one set of meshes in its own root's frame: where the nose is along `axis` + * and how long the body is. @param {any[]} meshes @param {any} root @param {THREE.Vector3} axis + */ +function bodyRange(meshes, root, axis) { + let lo = Infinity; + let hi = -Infinity; + for (const m of meshes) { + if (!m.geometry.boundingBox) m.geometry.computeBoundingBox(); + const bb = m.geometry.boundingBox; + relTo(m, root, mA); + for (let i = 0; i < 8; i++) { + vA.set(i & 1 ? bb.max.x : bb.min.x, i & 2 ? bb.max.y : bb.min.y, i & 4 ? bb.max.z : bb.min.z).applyMatrix4(mA); + const d = vA.dot(axis); + lo = Math.min(lo, d); + hi = Math.max(hi, d); + } + } + return Number.isFinite(lo) ? { front: hi, len: Math.max(1e-3, hi - lo) } : null; +} + /** @param {any} mesh @param {any} object */ function attachWaveMesh(mesh, object) { const original = mesh.material; @@ -380,7 +414,7 @@ function attachWaveMesh(mesh, object) { const baseKey = original.customProgramCacheKey?.() ?? ''; clone.customProgramCacheKey = () => baseKey + '|bodywave1'; mesh.material = clone; - return { mesh, original, clone, uniforms }; + return { mesh, original, clone, uniforms, frame: object, front: 0, len: 1 }; } /** @param {WaveMesh} m */ @@ -411,50 +445,46 @@ function detachWave(key) { * @param {string} key @param {any} object @param {any} data */ function waveFor(key, object, data) { - const meshes = meshesOf(object); const forward = data.forward ?? BODY_WAVE_DEFAULTS.forward; const side = data.side === 'vertical' ? 'vertical' : 'horizontal'; - const sig = forward + side + meshes.map((m) => m.uuid + ':' + m.geometry.uuid).join(','); + // the object's own meshes, plus the substitute models drawn for it (a fallback LOD group's + // real model, 40 F14) — each set bends in its OWN root's frame, which is the object's frame + const sets = [{ root: object, meshes: meshesOf(object) }, ...lodTreesOf(object).map((t) => ({ root: t, meshes: meshesOf(t) }))]; + let sig = forward + side; + for (const set of sets) sig += '|' + set.root.uuid + ':' + set.meshes.map((m) => m.uuid + ':' + m.geometry.uuid).join(','); let w = waves.get(key); if (w && w.sig === sig && w.object === object) { // a material swapped under us (an Inspector edit through applyMaterials): re-wrap for (let i = 0; i < w.meshes.length; i++) { const m = w.meshes[i]; - if (m.mesh.material !== m.clone && m.mesh.material && !Array.isArray(m.mesh.material)) w.meshes[i] = attachWaveMesh(m.mesh, object); + if (m.mesh.material !== m.clone && m.mesh.material && !Array.isArray(m.mesh.material)) { + const fresh = attachWaveMesh(m.mesh, m.frame); + fresh.front = m.front; + fresh.len = m.len; + w.meshes[i] = fresh; + } } return w; } if (w) detachWave(key); - // the body frame: the nose axis in the OBJECT's local frame, the extent along it over every mesh + // the nose axis in the object's local frame const axis = new THREE.Vector3(0, 0, 1).applyAxisAngle(UP, -forwardYaw(forward)).normalize(); - const sideV = sideAxis(axis, side); - object.updateWorldMatrix(true, true); - const inv = new THREE.Matrix4().copy(object.matrixWorld).invert(); - let lo = Infinity; - let hi = -Infinity; - for (const m of meshes) { - if (!m.geometry.boundingBox) m.geometry.computeBoundingBox(); - const bb = m.geometry.boundingBox; - mA.copy(inv).multiply(m.matrixWorld); - for (let i = 0; i < 8; i++) { - vA.set(i & 1 ? bb.max.x : bb.min.x, i & 2 ? bb.max.y : bb.min.y, i & 4 ? bb.max.z : bb.min.z).applyMatrix4(mA); - const d = vA.dot(axis); - lo = Math.min(lo, d); - hi = Math.max(hi, d); + /** @type {WaveMesh[]} */ + const meshes = []; + let len = 0; + for (const set of sets) { + const range = bodyRange(set.meshes, set.root, axis); + if (!range) continue; + len = Math.max(len, range.len); + for (const m of set.meshes) { + const wm = attachWaveMesh(m, set.root); + wm.front = range.front; + wm.len = range.len; + meshes.push(wm); } } - if (!Number.isFinite(lo)) return null; - w = { - object, - meshes: meshes.map((m) => attachWaveMesh(m, object)), - count: meshes.length, - front: hi, - len: Math.max(1e-3, hi - lo), - axis, - side: sideV, - sig, - seen: frameNo - }; + if (!meshes.length) return null; + w = { object, meshes, len, axis, side: sideAxis(axis, side), sig, seen: frameNo }; waves.set(key, w); return w; } @@ -475,17 +505,15 @@ export function applyBodyWave(object, base, data, time, ctx) { const stiff = clampNum(data.stiffness, d.stiffness, 0, 0.95); const fall = clampNum(data.falloff, d.falloff, 0.25, 6); const dir = data.reverse ? -1 : 1; - object.updateWorldMatrix(true, true); - const inv = mA.copy(object.matrixWorld).invert(); for (const m of w.meshes) { const u = m.uniforms; - u.bwToRoot.value.copy(inv).multiply(m.mesh.matrixWorld); + relTo(m.mesh, m.frame, u.bwToRoot.value); u.bwFromRoot.value.setFromMatrix4(u.bwToRoot.value).invert(); u.bwAxis.value.copy(w.axis); u.bwSide.value.copy(w.side); - u.bwRange.value.set(w.front, w.len); + u.bwRange.value.set(m.front, m.len); u.bwShape.value.set(k, stiff, fall, dir); - u.bwDrive.value.set(drive.amplitude * w.len, drive.bend * w.len, t.phase); + u.bwDrive.value.set(drive.amplitude * m.len, drive.bend * m.len, t.phase); } return { drive, phase: t.phase, len: w.len, meshes: w.meshes.length }; } diff --git a/tests/e2e/aquarium-reef.test.cjs b/tests/e2e/aquarium-reef.test.cjs new file mode 100644 index 000000000..3005e990e --- /dev/null +++ b/tests/e2e/aquarium-reef.test.cjs @@ -0,0 +1,231 @@ +// 40 F14-F16 — THE AQUARIUM REEF: real Meshy fish through FALLBACK LOD groups, swimming with the +// general-purpose motion nodes, wearing the fish-scale thin film at this device's look tier. +// 1 motion: Follow Path keeps the tangs on their loop, half a lap apart, nose along the travel, +// banked in the bends; Wander keeps the clownfish inside the water volume; Orient to Velocity +// points them where they go; every fish carries a Body Wave (shader patched, phase running) +// 2 fallback LOD: with the pack reachable every fish/plant/coral draws its pack model (a tree +// substitute) and the stand-in is hidden for the render; the SAVE carries only the stand-ins; +// the Body Wave reaches the substitute's meshes too +// 3 the counterfactual: pack unreachable → the stand-ins stay on screen (LOD0), nothing breaks +// 4 the look tier: the fish's film draws on a desktop, drops at `low`, comes back at `high`, and +// the authored numbers ride beside the material; the Fish scales preset applies the full look +// and a phone tier (`mid`) drops only its transmission +// Needs the scene (SCENES_DIR, a scenes checkout with the 40 Aquarium) and the aquarium-kit pack +// reachable at the dev server's VITE_PACKS_BASE (packs worktree: tools/town-kit/serve-pack.mjs). +// Run: VITE_PACKS_BASE=https://theprototype.app:5397 e2e-slot --dev 5396 -- +// APP_URL=https://theprototype.app:5396/ SCENES_DIR=../scenes-lane-40-aquarium npm run e2e -- aquarium-reef +const fs = require('fs'); +const path = require('path'); +const h = require('./helpers.cjs'); + +const SCENES_DIR = process.env.SCENES_DIR || '/home/deck/.code/theprototype-app/scenes'; +const AQUARIUM = path.join(SCENES_DIR, 'examples/aquarium/scene.tpscene'); +const SHOTS = process.env.SHOTS_DIR || ''; + +/** @param {any} page @param {string} file */ +async function loadScene(page, file) { + const b64 = fs.readFileSync(file).toString('base64'); + await page.evaluate(async (b64) => { + const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + const s = window.__stores.sessions; + const payload = await s.readSessionZip(bytes.buffer); + await s.applySession(payload, { backup: false, quiet: true }); + }, b64); + await h.eventually( + () => page.evaluate(() => { let v; window.__stores.sceneLoader.sceneLoad.subscribe((x) => (v = x))(); return v; }), + (v) => v === null, + 'the scene finished loading', + 30000 + ); +} + +/** in-page helpers, installed once per page */ +async function install(page) { + await page.evaluate(() => { + const s = window.__stores; + const read = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const T = s.THREE; + window.__aq = { + read, + byName: (n) => read(s.objectsGroup).getObjectByName(n), + world: (n) => { const o = window.__aq.byName(n); o.updateWorldMatrix(true, false); return new T.Vector3().setFromMatrixPosition(o.matrixWorld).toArray(); }, + nose: (n, axis = [0, 0, 1]) => { const o = window.__aq.byName(n); const q = o.getWorldQuaternion(new T.Quaternion()); return new T.Vector3(...axis).applyQuaternion(q).toArray(); }, + waterBox: () => { const b = new T.Box3().setFromObject(window.__aq.byName('Aquarium water')); return [b.min.toArray(), b.max.toArray()]; }, + fallbackRoots: () => { const out = []; read(s.objectsGroup).traverse((o) => o.userData?.lod?.fallback && out.push(o)); return out; }, + // render one frame from the editor camera (the LOD pass picks levels inside renders) + render: () => { const r = read(s.globalRenderer); r.render(read(s.globalScene), read(s.globalCamera)); } + }; + }); +} + +/** sample a function of the page over time @param {any} page @param {() => any} fn @param {number} n @param {number} ms */ +async function sample(page, fn, n, ms) { + const out = []; + for (let i = 0; i < n; i++) { + out.push(await page.evaluate(fn)); + await page.waitForTimeout(ms); + } + return out; +} + +h.run(async () => { + if (!fs.existsSync(AQUARIUM)) { + console.log('SKIP: no ' + AQUARIUM); + return; + } + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1440, height: 900 } } }); + const page = A.page; + await loadScene(page, AQUARIUM); + await install(page); + + // ---- premise: the 40 scene ------------------------------------------------------------------ + const roots = await page.evaluate(() => window.__aq.fallbackRoots().map((o) => ({ name: o.name, levels: o.userData.lod.levels.length, refs: o.userData.lod.levels.slice(1).map((l) => l.ref) }))); + const fishNames = ['Clownfish 1', 'Clownfish 2', 'Blue tang 1', 'Blue tang 2', 'Angelfish 1', 'Angelfish 2']; + h.check(fishNames.every((n) => roots.some((r) => r.name === n)), '0.1 the six reef fish are FALLBACK LOD groups (' + roots.map((r) => r.name).join(', ') + ')'); + h.check(roots.length >= 12 && roots.every((r) => r.refs.every((ref) => ref.startsWith('aquarium-kit/'))), '0.2 fish, coral and plants (' + roots.length + ') name aquarium-kit levels'); + const graphs = await page.evaluate(() => { + const all = window.__stores.allNodes(); + return ['followpath', 'wander', 'orientvelocity', 'bodywave'].map((t) => all.filter((n) => n.type === t).length); + }); + h.check(graphs[0] === 4 && graphs[1] === 2 && graphs[2] === 2 && graphs[3] === 6, '0.3 the fish graphs: Follow Path ×4, Wander ×2, Orient to Velocity ×2, Body Wave ×6 (' + graphs.join('/') + ')'); + + // ---- 1 motion ------------------------------------------------------------------------------- + const tang = await sample(page, () => ({ a: window.__aq.world('Blue tang 1'), b: window.__aq.world('Blue tang 2'), nose: window.__aq.nose('Blue tang 1'), up: window.__aq.nose('Blue tang 1', [0, 1, 0]) }), 12, 250); + const moved = Math.hypot(tang[11].a[0] - tang[0].a[0], tang[11].a[1] - tang[0].a[1], tang[11].a[2] - tang[0].a[2]); + h.check(moved > 0.3, '1.1 Follow Path moves the tang (' + moved.toFixed(2) + ' m in ~3 s at 0.42 m/s)'); + const apart = tang.map((t) => Math.hypot(t.a[0] - t.b[0], t.a[2] - t.b[2])); + h.check(Math.min(...apart) > 1.2, '1.2 the two tangs ride HALF A LAP apart (offset 0.5): ' + Math.min(...apart).toFixed(2) + ' m at the closest'); + let along = 0; + for (let i = 1; i < tang.length; i++) { + const d = [tang[i].a[0] - tang[i - 1].a[0], tang[i].a[1] - tang[i - 1].a[1], tang[i].a[2] - tang[i - 1].a[2]]; + const l = Math.hypot(...d) || 1; + const n = tang[i].nose; + along += (d[0] * n[0] + d[1] * n[1] + d[2] * n[2]) / l; + } + h.check(along / (tang.length - 1) > 0.85, '1.3 its nose points along the travel (mean cos ' + (along / (tang.length - 1)).toFixed(2) + ')'); + const maxRoll = Math.max(...tang.map((t) => Math.abs(t.up[0] * t.nose[2] - t.up[2] * t.nose[0]) + Math.abs(Math.hypot(t.up[0], t.up[2])))); + h.check(maxRoll > 0.05, '1.4 it BANKS in the bends (top tilts off vertical by up to ' + maxRoll.toFixed(2) + ')'); + const box = await page.evaluate(() => window.__aq.waterBox()); + const clown = await sample(page, () => ({ p: window.__aq.world('Clownfish 1'), nose: window.__aq.nose('Clownfish 1') }), 16, 250); + const inside = clown.every((c) => c.p.every((v, i) => v >= box[0][i] - 1e-3 && v <= box[1][i] + 1e-3)); + h.check(inside, '1.5 Wander keeps the clownfish INSIDE the water volume (16 samples over 4 s)'); + const cMoved = Math.hypot(clown[15].p[0] - clown[0].p[0], clown[15].p[1] - clown[0].p[1], clown[15].p[2] - clown[0].p[2]); + h.check(cMoved > 0.05, '1.6 …and it does wander (' + cMoved.toFixed(2) + ' m)'); + let cAlong = 0; + let cn = 0; + for (let i = 2; i < clown.length; i++) { + const d = [clown[i].p[0] - clown[i - 2].p[0], clown[i].p[1] - clown[i - 2].p[1], clown[i].p[2] - clown[i - 2].p[2]]; + const l = Math.hypot(...d); + if (l < 0.03) continue; + cAlong += (d[0] * clown[i].nose[0] + d[1] * clown[i].nose[1] + d[2] * clown[i].nose[2]) / l; + cn++; + } + h.check(cn === 0 || cAlong / cn > 0.5, '1.7 Orient to Velocity points it where it goes (mean cos ' + (cn ? (cAlong / cn).toFixed(2) : 'n/a') + ' over ' + cn + ' moving samples)'); + const waves = await page.evaluate(() => window.__stores.motionNodes.motionNodesDebug().waves); + h.check(waves.length === 6 && waves.every((w) => w.cloned && w.meshes > 0), '1.8 a Body Wave runs on every fish, each mesh on its own material copy (' + waves.map((w) => w.meshes).join(',') + ' meshes)'); + const wave = await page.evaluate(() => { + const o = window.__aq.byName('Blue tang 1'); + let key = null; + o.traverse((m) => { if (m.isMesh && !key) key = m.material.customProgramCacheKey(); }); + return { key }; + }); + h.check(/bodywave/.test(wave.key ?? ''), '1.9 the stand-in\'s material is patched by the wave (' + wave.key + ')'); + + // ---- 2 fallback LOD with the pack reachable ------------------------------------------------- + await page.evaluate(() => window.__aq.render()); + await h.eventually( + () => page.evaluate(() => window.__aq.fallbackRoots().map((o) => window.__stores.lodTrees.lodTreesOf(o).length)), + (n) => n.every((c) => c >= 1), + '2.1 every fallback group built its PACK model (a tree substitute)', + 45000 + ); + const drawn = await page.evaluate(() => { + window.__aq.render(); + return window.__aq.fallbackRoots().map((o) => ({ name: o.name, current: window.__stores.lodGroup.lodGroupInfo(o.uuid)?.current })); + }); + h.check(drawn.every((d) => d.current >= 1), '2.2 every group DRAWS a pack level, not its stand-in (' + drawn.map((d) => d.name.split(' ')[0] + ':' + d.current).join(' ') + ')'); + const tri = await page.evaluate(() => { + const tree = window.__stores.lodTrees.lodTreesOf(window.__aq.byName('Clownfish 1'))[0]; + let n = 0; + tree.traverse((m) => m.isMesh && (n += (m.geometry.index ? m.geometry.index.count : m.geometry.attributes.position.count) / 3)); + let irid = 0; + tree.traverse((m) => m.isMesh && (irid = Math.max(irid, m.material.iridescence ?? 0))); + let key = ''; + tree.traverse((m) => m.isMesh && (key = m.material.customProgramCacheKey?.() ?? '')); + return { n, irid, key }; + }); + h.check(tri.n > 1000, '2.3 the clownfish substitute is the Meshy model (' + tri.n + ' triangles)'); + h.check(/bodywave/.test(tri.key), '2.4 the Body Wave bends the SUBSTITUTE too (' + tri.key + ')'); + const saved = await page.evaluate(() => { + const payload = window.__stores.sessions.buildSessionPayload('probe'); + const json = JSON.stringify(payload.objects ?? payload); + const fish = (payload.objects ?? []).find((o) => o.object?.name === 'Clownfish 1'); + return { bytes: json.length, holders: json.includes('lod-group-holders'), kids: fish?.object?.children?.length ?? -1, lod: !!fish?.object?.userData?.lod?.fallback }; + }); + h.check(!saved.holders && saved.kids >= 5 && saved.kids <= 9 && saved.lod, '2.5 a SAVE carries only the stand-in (' + saved.kids + ' parts) and its fallback block — no model in the file'); + h.check(saved.bytes < 2_000_000, '2.6 the whole scene payload stays small (' + Math.round(saved.bytes / 1024) + ' KB)'); + const film = await page.evaluate(() => window.__stores.materialTiers.materialTiersDebug()); + h.check(film.tier === 'high' && tri.irid > 0.3, '2.7 a desktop draws the fish\'s thin film (tier ' + film.tier + ', iridescence ' + tri.irid + ')'); + if (SHOTS) await page.screenshot({ path: SHOTS + '-desktop.png' }); + + // ---- 4 the look tier ------------------------------------------------------------------------ + const lowered = await page.evaluate(() => { + const s = window.__stores; + s.materialTiers.pinMaterialTier('low'); + const tree = s.lodTrees.lodTreesOf(window.__aq.byName('Clownfish 1'))[0]; + let m0 = null; + tree.traverse((m) => m.isMesh && !m0 && (m0 = m.material)); + return { irid: m0.iridescence, authored: m0.userData.lookTierAuthored ?? null }; + }); + h.check(lowered.irid === 0 && lowered.authored?.iridescence > 0.3, '4.1 at the HEADSET tier the film is not drawn, the authored value rides beside it (' + JSON.stringify(lowered.authored) + ')'); + const back = await page.evaluate(() => { + const s = window.__stores; + s.materialTiers.pinMaterialTier('high'); + const tree = s.lodTrees.lodTreesOf(window.__aq.byName('Clownfish 1'))[0]; + let m0 = null; + tree.traverse((m) => m.isMesh && !m0 && (m0 = m.material)); + return m0.iridescence; + }); + h.check(back > 0.3, '4.2 back at the desktop tier the film returns (' + back + ')'); + const preset = await page.evaluate(async () => { + const s = window.__stores; + const group = window.__aq.read(s.objectsGroup); + const box = group.getObjectByName('Rock big'); + const starter = s.materialPresets.starterPresets().find((p) => p.id === 'fishscale'); + const ok = s.materialPresets.applyMaterialPreset(box.uuid, starter); + const m = box.material; + const high = { ok, type: m.type, irid: m.iridescence, trans: m.transmission, normal: !!m.normalMap, range: m.iridescenceThicknessRange }; + s.materialTiers.pinMaterialTier('mid'); + const mid = { irid: box.material.iridescence, trans: box.material.transmission }; + s.materialTiers.pinMaterialTier(null); + return { high, mid }; + }); + h.check(preset.high.ok && preset.high.type === 'MeshPhysicalMaterial' && preset.high.irid > 0.5 && preset.high.normal && preset.high.trans > 0, '4.3 the Fish scales preset applies the full look: film, scale relief, transmission (' + JSON.stringify(preset.high) + ')'); + h.check(preset.mid.trans === 0 && preset.mid.irid > 0.5, '4.4 a PHONE tier drops only the transmission pass (' + JSON.stringify(preset.mid) + ')'); + + // ---- 3 the counterfactual: the pack cannot be reached --------------------------------------- + const B = await h.setupPage(browser, 'B', { context: { viewport: { width: 1440, height: 900 } } }); + await B.page.route(/aquarium-kit\/.*\.glb/, (route) => route.abort()); + await loadScene(B.page, AQUARIUM); + await install(B.page); + await B.page.evaluate(() => window.__aq.render()); + await B.page.waitForTimeout(3000); + const offline = await B.page.evaluate(() => { + window.__aq.render(); + return window.__aq.fallbackRoots().map((o) => ({ + name: o.name, + current: window.__stores.lodGroup.lodGroupInfo(o.uuid)?.current, + trees: window.__stores.lodTrees.lodTreesOf(o).length, + failed: (window.__stores.lodGroup.lodGroupInfo(o.uuid)?.levels ?? []).filter((l) => l.status === 'failed').length + })); + }); + h.check(offline.every((d) => d.current === 0 && d.trees === 0), '3.1 pack unreachable: every group draws its STAND-IN (LOD0) — the scene still has its fish, coral and plants'); + h.check(offline.some((d) => d.failed > 0), '3.2 …and says why on the level (failed levels reported, not thrown)'); + const offMove = await sample(B.page, () => window.__aq.world('Blue tang 1'), 4, 300); + h.check(Math.hypot(offMove[3][0] - offMove[0][0], offMove[3][2] - offMove[0][2]) > 0.1, '3.3 …and the stand-ins still swim'); + if (SHOTS) await B.page.screenshot({ path: SHOTS + '-offline.png' }); + + h.check(h.pageErrors(A).length === 0 && h.pageErrors(B).length === 0, '9.1 no page errors (' + [...h.pageErrors(A), ...h.pageErrors(B)].slice(0, 2).join(' | ') + ')'); + await h.finish(browser); +}); diff --git a/tests/e2e/fb-water-probe.cjs b/tests/e2e/fb-water-probe.cjs index 7393036d0..07c6b8e17 100644 --- a/tests/e2e/fb-water-probe.cjs +++ b/tests/e2e/fb-water-probe.cjs @@ -38,7 +38,7 @@ h.run(async () => { await load(A.page, 'aquarium'); const wb = await A.page.evaluate(() => { let g; window.__stores.objectsGroup.subscribe((v) => (g = v))(); const T = window.__stores.THREE; const b = new T.Box3().setFromObject(g.getObjectByName('Aquarium water')); return [b.min.toArray(), b.max.toArray()]; }); console.log('water box', JSON.stringify(wb)); - const f = await sel(A.page, 'Fish orange'); + const f = (await sel(A.page, 'Clownfish 1')) ?? (await sel(A.page, 'Fish orange')); // 40: the reef fish console.log('fish', f); await look(A.page, [f[0] + 0.3, f[1] + 0.2, wb[1][2] + 1.4], f); await A.page.waitForTimeout(1500); diff --git a/tests/e2e/select-inside-36.test.cjs b/tests/e2e/select-inside-36.test.cjs index 58139c347..f01c22755 100644 --- a/tests/e2e/select-inside-36.test.cjs +++ b/tests/e2e/select-inside-36.test.cjs @@ -128,7 +128,12 @@ h.run(async () => { const page = A.page; await loadScene(page, AQUARIUM); - const fishName = 'Fish orange'; + // the fish the loaded Aquarium has: 1.32 (40 F14) swapped the primitive fish for reef fish + const fishName = await page.evaluate(() => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + return ['Clownfish 1', 'Fish orange'].find((n) => g.getObjectByName(n)) ?? 'Fish orange'; + }); const aim = await aimThrough(page, fishName, 'Aquarium water'); h.check(!!aim.px, 'premise: a pixel on the orange fish whose nearest hit is the water (' + aim.names.join(' > ') + ')'); if (!aim.px) return h.finish(browser); @@ -170,7 +175,7 @@ h.run(async () => { await altClick(); h.check((await selection(page)).join() === fishName, 'Alt+click #2 takes the next one down: the fish'); const chip2 = (await page.locator('#pick-cycle-hint').innerText().catch(() => '')).replace(/\s+/g, ' '); - h.check(/^2 of \d+ Fish orange/.test(chip2), 'the chip says "2 of N" (' + chip2 + ')'); + h.check(new RegExp('^2 of \\d+ ' + fishName).test(chip2), 'the chip says "2 of N" (' + chip2 + ')'); if (SHOTS) await page.screenshot({ path: SHOTS + '-alt-cycle.png' }); const seen = new Set(['Aquarium water', ...(await selection(page))]); // N comes from the app's own chip: what the editor's pick sees under the cursor diff --git a/tests/unit/lodFallback.test.js b/tests/unit/lodFallback.test.js new file mode 100644 index 000000000..25df240f8 --- /dev/null +++ b/tests/unit/lodFallback.test.js @@ -0,0 +1,60 @@ +// @ts-nocheck — plain fixtures; the code under test is typed +// 40 F14: a FALLBACK LOD group — the object's own (simplified) meshes are LOD0 and draw only +// while the real model's levels are unavailable; the pick runs over levels 1..n. +import { describe, it, expect } from 'vitest'; +import { normalizeLodGroup, pickFallbackLevel, pickGroupLevel, thresholdsOf, withThreshold } from '../../src/lib/lodGroupCore.js'; +import { addLodTree, removeLodTree, lodTreesOf, allLodTrees, onLodTreesChanged } from '../../src/lib/lodTrees.js'; + +const BLOCK = { + fallback: true, + levels: [ + { source: 'self', screenSize: 0.9 }, + { source: 'pack', ref: 'aquarium-kit/FishClown/fish-clown.glb', screenSize: 0.06 }, + { source: 'pack', ref: 'aquarium-kit/FishClown/fish-clown.lod1.glb', screenSize: 0.025 }, + { source: 'pack', ref: 'aquarium-kit/FishClown/fish-clown.lod2.glb', screenSize: 0.01 } + ] +}; + +describe('fallback LOD groups', () => { + it('normalize keeps the flag (2+ levels only) and withThreshold keeps it too', () => { + const g = normalizeLodGroup(BLOCK); + expect(g.fallback).toBe(true); + expect(g.levels[0].source).toBe('self'); + expect(normalizeLodGroup({ fallback: true, levels: [{ source: 'self' }] }).fallback).toBeUndefined(); + expect(normalizeLodGroup({ ...BLOCK, fallback: 'yes' }).fallback).toBeUndefined(); + expect(withThreshold(g, 2, 0.03).fallback).toBe(true); + }); + it('never picks LOD0 by size: close = level 1, far = the coarse levels', () => { + const g = normalizeLodGroup(BLOCK); + const { thresholds } = thresholdsOf(g); + expect(pickFallbackLevel(2, thresholds, 1)).toBe(1); // filling the screen: still the real model + expect(pickFallbackLevel(0.3, thresholds, 1)).toBe(1); + expect(pickFallbackLevel(0.04, thresholds, 1)).toBe(2); + expect(pickFallbackLevel(0.015, thresholds, 2)).toBe(3); + expect(pickFallbackLevel(0.001, thresholds, 3)).toBe(3); // no cull: stays on the last + expect(pickFallbackLevel(0.001, thresholds, 3, { cull: true })).toBe(-1); + // the counterfactual: an ordinary group with the same thresholds DOES pick LOD0 up close + expect(pickGroupLevel(2, thresholds, 0)).toBe(0); + }); + it('keeps hysteresis in the shifted numbering (coming back finer needs the band)', () => { + const g = normalizeLodGroup(BLOCK); + const { thresholds } = thresholdsOf(g); + const edge = thresholds[1]; + expect(pickFallbackLevel(edge * 0.99, thresholds, 1)).toBe(2); + expect(pickFallbackLevel(edge * 1.05, thresholds, 2)).toBe(2); // inside the 15 % band + expect(pickFallbackLevel(edge * 1.2, thresholds, 2)).toBe(1); + }); + it('the substitute registry: per object, all, and change listeners', () => { + const root = {}; + const tree = { uuid: 't' }; + let n = 0; + const off = onLodTreesChanged(() => n++); + addLodTree(root, tree); + expect(lodTreesOf(root)).toEqual([tree]); + expect(allLodTrees()).toContain(tree); + removeLodTree(root, tree); + expect(lodTreesOf(root)).toEqual([]); + expect(n).toBe(2); + off(); + }); +}); From e9d206fd0ed6f9cdd70219ca02c6df9aa63e8b36 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 14:48:22 +0300 Subject: [PATCH 4/6] [wip] lodGroup pass counter + want in stats (debug the stalled pass); reef suite test fixes (40 F14) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/lodGroup.js | 10 ++++++++++ tests/e2e/aquarium-reef.test.cjs | 9 +++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/lib/lodGroup.js b/src/lib/lodGroup.js index 96182c9ad..02fc5a412 100644 --- a/src/lib/lodGroup.js +++ b/src/lib/lodGroup.js @@ -59,6 +59,12 @@ export const lodGroupTick = writable(0); /** @type {Map} */ const entries = new Map(); +/** render passes run (debug: lodGroupPasses) */ +let passCount = 0; +/** how many render passes the group runtime has run — a stalled count is a stalled pass */ +export function lodGroupPasses() { + return passCount; +} /** every mesh some group draws — lod.js's auto scan stands down for these */ /** @type {WeakSet} */ let owned = new WeakSet(); @@ -733,6 +739,7 @@ function nearestBuilt(entry, want) { * @param {any} camera @param {number} qualityBias @param {boolean} enabled @param {boolean} overlay */ function before(camera, qualityBias, enabled, overlay) { + passCount++; restorePass(); // a render that threw last time left a pass behind if (!entries.size) return; const P = camera.projectionMatrix?.elements; @@ -784,6 +791,7 @@ function before(camera, qualityBias, enabled, overlay) { if (preview && preview.uuid === root.uuid && preview.level < block.levels.length) want = preview.level; else if (block.mode === 'forced' && block.forced !== undefined) want = block.forced; const drawn = want < 0 ? -1 : nearestBuilt(entry, want); + entry.lastWant = want; if (drawn !== entry.current) { entry.current = drawn; tick(); @@ -916,6 +924,8 @@ export function lodGroupStats() { implicit: e.implicit, meshes: e.meshes.length, current: e.current, + want: /** @type {any} */ (e).lastWant ?? null, + fallback: !!e.block.fallback, size: Number(e.size.toFixed(4)), radius: Number(e.radius.toFixed(4)), levels: e.block.levels.map((l, i) => { diff --git a/tests/e2e/aquarium-reef.test.cjs b/tests/e2e/aquarium-reef.test.cjs index 3005e990e..b40654cf3 100644 --- a/tests/e2e/aquarium-reef.test.cjs +++ b/tests/e2e/aquarium-reef.test.cjs @@ -164,7 +164,12 @@ h.run(async () => { return { bytes: json.length, holders: json.includes('lod-group-holders'), kids: fish?.object?.children?.length ?? -1, lod: !!fish?.object?.userData?.lod?.fallback }; }); h.check(!saved.holders && saved.kids >= 5 && saved.kids <= 9 && saved.lod, '2.5 a SAVE carries only the stand-in (' + saved.kids + ' parts) and its fallback block — no model in the file'); - h.check(saved.bytes < 2_000_000, '2.6 the whole scene payload stays small (' + Math.round(saved.bytes / 1024) + ' KB)'); + const zipped = await page.evaluate(async () => { + const s = window.__stores.sessions; + const bytes = await s.exportSessionZip(s.buildSessionPayload('probe'), { assets: false, packs: false, flow: true }); + return bytes.byteLength ?? bytes.length ?? bytes.size; + }); + h.check(zipped < 600_000, '2.6 the saved .tpscene stays small with the models drawn (' + Math.round(zipped / 1024) + ' KB zipped; the models are references)'); const film = await page.evaluate(() => window.__stores.materialTiers.materialTiersDebug()); h.check(film.tier === 'high' && tri.irid > 0.3, '2.7 a desktop draws the fish\'s thin film (tier ' + film.tier + ', iridescence ' + tri.irid + ')'); if (SHOTS) await page.screenshot({ path: SHOTS + '-desktop.png' }); @@ -192,7 +197,7 @@ h.run(async () => { const s = window.__stores; const group = window.__aq.read(s.objectsGroup); const box = group.getObjectByName('Rock big'); - const starter = s.materialPresets.starterPresets().find((p) => p.id === 'fishscale'); + const starter = s.materialPresets.starterPresets().find((p) => p.id === 'fishscale').preset; const ok = s.materialPresets.applyMaterialPreset(box.uuid, starter); const m = box.material; const high = { ok, type: m.type, irid: m.iridescence, trans: m.transmission, normal: !!m.normalMap, range: m.iridescenceThicknessRange }; From ccd9b9349d8823e373bfdf802bae18b2ce314a5d Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 10 Oct 2026 17:08:27 +0300 Subject: [PATCH 5/6] [fix] a nested render no longer freezes every LOD group (40 F14) - lod.js afterRender and the water runtime's afterSceneRender returned early on a nested render BEFORE calling the hook they wrap, while their before-hooks always call theirs: every water reflection pass leaked one depth into the wrapped hook, so the LOD group pass was skipped forever after the first frames (pre-existing, seen on the 1.31 Aquarium too). Both now always hand on to prevAfter. - vitest lodHookChain (counterfactual: depth 5 / 0 passes with the old hook); aquarium-reef 2.0 asserts the pass keeps running with water in the scene. - GroupEntry typedef carries the lastWant debug field (svelte-check back to 300/41). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/lod.js | 14 ++++++---- src/lib/lodGroup.js | 2 +- src/lib/water/waterRuntime.js | 19 +++++++------ tests/e2e/aquarium-reef.test.cjs | 6 ++++ tests/unit/lodHookChain.test.js | 47 ++++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 14 deletions(-) create mode 100644 tests/unit/lodHookChain.test.js diff --git a/src/lib/lod.js b/src/lib/lod.js index 8886712d3..af7881e6f 100644 --- a/src/lib/lod.js +++ b/src/lib/lod.js @@ -593,11 +593,15 @@ function swapLevels(camera) { /** scene.onAfterRender: every mesh gets its source geometry back. @param {any[]} args */ function afterRender(...args) { - if (--renderDepth > 0) return; - renderDepth = 0; - batchPass?.after(); - groupPass?.after(); - restoreSwapped(); + // a nested render still hands on to the hook we wrap: beforeRender always calls prevBefore, + // so skipping prevAfter here leaked one depth into it per nested render (40 F14 — the water + // reflection pass froze every LOD group after its first frame) + if (--renderDepth <= 0) { + renderDepth = 0; + batchPass?.after(); + groupPass?.after(); + restoreSwapped(); + } prevAfter?.(...args); } diff --git a/src/lib/lodGroup.js b/src/lib/lodGroup.js index 02fc5a412..f10bc4722 100644 --- a/src/lib/lodGroup.js +++ b/src/lib/lodGroup.js @@ -47,7 +47,7 @@ import { explorerItems, itemByHash, itemBlob } from './explorer'; * | {status: 'ready', kind: 'tree', object: any, tris: number}} LevelState * @typedef {{root: any, block: LodGroup, sig: string, implicit: boolean, meshes: any[], meshKey: string, * center: any, radius: number, built: Map, current: number, autoCurrent: number, - * size: number, holder: any, matCache: Map, raw: any}} GroupEntry + * size: number, holder: any, matCache: Map, raw: any, lastWant?: number}} GroupEntry */ /** A LOCAL preview: the level selected in the LOD panel draws on this screen until the diff --git a/src/lib/water/waterRuntime.js b/src/lib/water/waterRuntime.js index cde6586eb..cc5993403 100644 --- a/src/lib/water/waterRuntime.js +++ b/src/lib/water/waterRuntime.js @@ -1234,14 +1234,17 @@ function beforeSceneRender(renderer, scene, camera, target) { /** @param {any[]} args */ function afterSceneRender(...args) { - if (--renderDepth > 0) return; - renderDepth = 0; - for (let i = swapped.length - 2; i >= 0; i -= 2) swapped[i].material = swapped[i + 1]; - swapped.length = 0; - if (underwater) { - const scene = args[1]; - scene.fog = underwater.savedFog; - scene.background = underwater.savedBg; + // a nested render (our own reflection pass) still hands on to the hook we wrap — before + // always calls prevBefore, so returning early here leaked a depth into it every frame + if (--renderDepth <= 0) { + renderDepth = 0; + for (let i = swapped.length - 2; i >= 0; i -= 2) swapped[i].material = swapped[i + 1]; + swapped.length = 0; + if (underwater) { + const scene = args[1]; + scene.fog = underwater.savedFog; + scene.background = underwater.savedBg; + } } prevAfter?.(...args); } diff --git a/tests/e2e/aquarium-reef.test.cjs b/tests/e2e/aquarium-reef.test.cjs index b40654cf3..8431b54ef 100644 --- a/tests/e2e/aquarium-reef.test.cjs +++ b/tests/e2e/aquarium-reef.test.cjs @@ -133,6 +133,12 @@ h.run(async () => { h.check(/bodywave/.test(wave.key ?? ''), '1.9 the stand-in\'s material is patched by the wave (' + wave.key + ')'); // ---- 2 fallback LOD with the pack reachable ------------------------------------------------- + // 2.0 the LOD pass keeps running frame after frame (the water reflection pass is a NESTED + // render; an after-hook that skipped the hook it wraps froze every LOD group after frame 1) + const p0 = await page.evaluate(() => window.__stores.lodGroup.lodGroupPasses()); + await page.waitForTimeout(1500); + const p1 = await page.evaluate(() => window.__stores.lodGroup.lodGroupPasses()); + h.check(p1 - p0 > 10, '2.0 the LOD group pass keeps running with water in the scene (' + p0 + ' -> ' + p1 + ' in 1.5 s)'); await page.evaluate(() => window.__aq.render()); await h.eventually( () => page.evaluate(() => window.__aq.fallbackRoots().map((o) => window.__stores.lodTrees.lodTreesOf(o).length)), diff --git a/tests/unit/lodHookChain.test.js b/tests/unit/lodHookChain.test.js new file mode 100644 index 000000000..01a09a72c --- /dev/null +++ b/tests/unit/lodHookChain.test.js @@ -0,0 +1,47 @@ +// @ts-nocheck — plain fixtures; the code under test is typed +// 40 F14: the scene render hooks are a CHAIN (lod.js wraps whatever hook was there before it, +// the water runtime wraps lod's or the other way round). A nested render (the water reflection +// pass renders the scene inside the outer render) must unwind EVERY link: before always calls +// the wrapped hook, so after must too, or the wrapped hook's depth leaks one per nested render +// and its pass is skipped forever (the Aquarium's LOD groups froze after their first frame). +import { describe, it, expect } from 'vitest'; +import * as THREE from 'three'; +import { globalScene } from '../../src/stores/sceneStore'; +import { registerLodPass, scanForLod } from '../../src/lib/lod.js'; + +/** A wrapped hook with the same depth rule as the water runtime's. */ +function depthHook() { + const h = { depth: 0, outer: 0 }; + h.before = () => { + if (h.depth++ === 0) h.outer++; + }; + h.after = () => { + if (--h.depth < 0) h.depth = 0; + }; + return h; +} + +describe('scene render hook chain', () => { + it('a nested render unwinds the hook lod.js wraps, and the LOD pass runs every frame', () => { + const scene = new THREE.Scene(); + const inner = depthHook(); + scene.onBeforeRender = inner.before; + scene.onAfterRender = inner.after; + globalScene.set(scene); + let passes = 0; + registerLodPass({ before: () => passes++, after: () => {}, owns: () => false }); + scanForLod(); + expect(scene.onBeforeRender).not.toBe(inner.before); // lod wraps the old hook + const cam = new THREE.PerspectiveCamera(); + cam.updateMatrixWorld(); + for (let frame = 0; frame < 5; frame++) { + scene.onBeforeRender(null, scene, cam); // the frame + scene.onBeforeRender(null, scene, cam); // a reflection pass inside it + scene.onAfterRender(null, scene, cam); + scene.onAfterRender(null, scene, cam); + } + expect(inner.depth).toBe(0); + expect(inner.outer).toBe(5); + expect(passes).toBe(5); + }); +}); From 73a63bc6fa240a374deaa862b3879bf29673f4dd Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 10 Oct 2026 17:11:07 +0300 Subject: [PATCH 6/6] [fix] aquarium-reef 1.2 measures the half-lap offset by its real property (40 F14) - the tang loop is an ellipse (1.45 x 0.5 m), so two fish half a lap apart are never closer than its minor diameter, 1.0 m: the old > 1.2 m threshold could not pass. The check now asserts their midpoint stays on the loop centre (measured 0.00 m); with a broken offset it would be 0.5-1.45 m off. - suite 32/32 green with the packs reachable. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/e2e/aquarium-reef.test.cjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/e2e/aquarium-reef.test.cjs b/tests/e2e/aquarium-reef.test.cjs index 8431b54ef..1bf95eef2 100644 --- a/tests/e2e/aquarium-reef.test.cjs +++ b/tests/e2e/aquarium-reef.test.cjs @@ -95,7 +95,11 @@ h.run(async () => { const moved = Math.hypot(tang[11].a[0] - tang[0].a[0], tang[11].a[1] - tang[0].a[1], tang[11].a[2] - tang[0].a[2]); h.check(moved > 0.3, '1.1 Follow Path moves the tang (' + moved.toFixed(2) + ' m in ~3 s at 0.42 m/s)'); const apart = tang.map((t) => Math.hypot(t.a[0] - t.b[0], t.a[2] - t.b[2])); - h.check(Math.min(...apart) > 1.2, '1.2 the two tangs ride HALF A LAP apart (offset 0.5): ' + Math.min(...apart).toFixed(2) + ' m at the closest'); + // the tang loop is an ellipse (radii 1.45 x 0.5 about (0.1, 0.05)): half a lap apart puts the two + // fish opposite each other THROUGH ITS CENTRE (a point reflection keeps arc length), so their + // midpoint sits on the centre and the closest they get is the minor diameter, 1.0 m + const mid = tang.map((t) => Math.hypot((t.a[0] + t.b[0]) / 2 - 0.1, (t.a[2] + t.b[2]) / 2 - 0.05)); + h.check(Math.max(...mid) < 0.15 && Math.min(...apart) > 0.9, '1.2 the two tangs ride HALF A LAP apart (offset 0.5): midpoint within ' + Math.max(...mid).toFixed(2) + ' m of the loop centre, ' + Math.min(...apart).toFixed(2) + ' m at the closest'); let along = 0; for (let i = 1; i < tang.length; i++) { const d = [tang[i].a[0] - tang[i - 1].a[0], tang[i].a[1] - tang[i - 1].a[1], tang[i].a[2] - tang[i - 1].a[2]];