sindicate 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +63 -2
  2. package/docs/api/README.md +28 -0
  3. package/docs/api/anim.md +125 -0
  4. package/docs/api/assets.md +208 -0
  5. package/docs/api/collision.md +101 -0
  6. package/docs/api/fbxloader.md +50 -0
  7. package/docs/api/grass.md +103 -0
  8. package/docs/api/input.md +164 -0
  9. package/docs/api/quality.md +159 -0
  10. package/docs/api/renderer.md +115 -0
  11. package/docs/api/retarget.md +149 -0
  12. package/docs/api/shatter.md +121 -0
  13. package/docs/api/utils-decals.md +188 -0
  14. package/docs/api/water.md +143 -0
  15. package/docs/api/wind-weather-uniforms.md +105 -0
  16. package/docs/contracts/render.md +32 -0
  17. package/docs/contracts/time-feel.md +52 -0
  18. package/package.json +1 -1
  19. package/src/core/anim.js +184 -0
  20. package/src/core/assets.js +596 -0
  21. package/src/core/engine.js +284 -0
  22. package/src/core/input/bindings.js +30 -0
  23. package/src/core/input/gamepad.js +92 -0
  24. package/src/core/input/index.js +62 -0
  25. package/src/core/input/keyboard.js +16 -0
  26. package/src/core/input/mouse.js +48 -0
  27. package/src/core/quality.js +5 -1
  28. package/src/core/renderer.js +104 -0
  29. package/src/core/retarget.js +282 -0
  30. package/src/index.js +19 -2
  31. package/src/systems/aim.js +114 -0
  32. package/src/systems/campfire.js +150 -0
  33. package/src/systems/climbing.js +136 -0
  34. package/src/systems/deadeye.js +332 -0
  35. package/src/systems/encounters.js +123 -0
  36. package/src/systems/entity.js +524 -0
  37. package/src/systems/fistCurl.js +38 -0
  38. package/src/systems/footsteps.js +161 -0
  39. package/src/systems/glass.js +67 -0
  40. package/src/systems/herd.js +277 -0
  41. package/src/systems/horse.js +361 -0
  42. package/src/systems/mountedTraveller.js +518 -0
  43. package/src/systems/nav.js +343 -0
  44. package/src/systems/npc.js +880 -0
  45. package/src/systems/pathFollow.js +107 -0
  46. package/src/systems/platform.js +484 -0
  47. package/src/systems/riding.js +580 -0
  48. package/src/systems/seatedRider.js +396 -0
  49. package/src/systems/skybirds.js +129 -0
  50. package/src/systems/trainCamera.js +414 -0
  51. package/src/systems/traveller.js +254 -0
  52. package/src/systems/tumbleweeds.js +92 -0
  53. package/src/systems/vat.js +327 -0
  54. package/src/systems/vfx.js +472 -0
  55. package/src/world/blobShadows.js +109 -0
  56. package/src/world/clouds.js +61 -0
  57. package/src/world/flora.js +87 -0
  58. package/src/world/footprints.js +117 -0
  59. package/src/world/lampField.js +58 -0
  60. package/src/world/lampGlow.js +92 -0
  61. package/src/world/scatter.js +1077 -0
  62. package/src/world/sky.js +363 -0
  63. package/src/world/tileManager.js +197 -0
  64. package/src/world/walkHumps.js +74 -0
  65. package/src/world/weather.js +312 -0
@@ -0,0 +1,327 @@
1
+ // Vertex-Animation-Texture (VAT) instancing for the county's livestock and wildlife: bake a skinned
2
+ // creature's clips into a texture ONCE, then render any number of individuals as ONE InstancedMesh,
3
+ // each at its own clip + loop phase + coat tint. One draw call per species, and no animator on the
4
+ // CPU at all. Individual skinned meshes per animal would tank the frame rate outright (r184 WebGPU
5
+ // pays pipeline setup per skinned mesh object).
6
+ //
7
+ // PORTED FROM /Users/nick/Sites/fable/src/game/vat.js, which has been shipping it for months. Two
8
+ // things were deliberately LEFT BEHIND:
9
+ //
10
+ // 1. loadVillagerVAT — fable's humanoid-crowd baker. It calls buildRetargetContext/retargetClip and
11
+ // mergeCharacterBody, and pulling it across would have dragged core/retarget.js and game/entity.js
12
+ // into this module for no reason. THIS IS ALSO WHY THE PORT IS SAFE IN A GAME WITH TWO SOURCE
13
+ // RIGS: an animal brings its own skeleton and its own clips in its own FBX, loadSpeciesVAT never
14
+ // calls the retargeter, and so western's two-source-rig problem (core/retarget.js, game/clips.js)
15
+ // cannot touch it. Nothing here knows the anim rig exists.
16
+ // 2. The seagull's flySegment scanner. This county is eight hundred miles from the sea.
17
+ //
18
+ // THE BINS WRITE THEMSELVES. A bake is a pure function of (rig geometry, clip set) — byte-identical
19
+ // every boot — so it lives as a .bin under public/assets/vat/ and boot is a FILE READ, not a
20
+ // mixer-sampling cook. On a miss we bake live and POST the bin to the dev server (vite
21
+ // /__save-vatbin, already registered in vite.config.js). Playing once cooks everything.
22
+ import * as THREE from 'three/webgpu';
23
+ import { Fn, texture, attribute, uniform, uniformArray, vec2, int, float, floor, mod, uv } from 'three/tsl';
24
+ import { loadModel, instantiate, loadTexture } from '../core/assets.js';
25
+
26
+ // Stack N coat textures side-by-side into one atlas, so a herd can show a different colour per
27
+ // individual (a per-instance aLayer offsets U). Horizontal keeps V/flipY identical to the original.
28
+ async function stackTextures(urls) {
29
+ const imgs = (await Promise.all(urls.map(loadTexture))).map((t) => t.image);
30
+ const w = imgs[0].width, h = imgs[0].height, n = imgs.length;
31
+ const canvas = document.createElement('canvas'); canvas.width = w * n; canvas.height = h;
32
+ const ctx = canvas.getContext('2d');
33
+ imgs.forEach((img, i) => ctx.drawImage(img, i * w, 0, w, h));
34
+ const tex = new THREE.CanvasTexture(canvas);
35
+ tex.colorSpace = THREE.SRGBColorSpace;
36
+ tex.minFilter = THREE.LinearMipmapLinearFilter; tex.magFilter = THREE.LinearFilter;
37
+ return { tex, layers: n };
38
+ }
39
+
40
+ // Derive ground speed from a clip's ROOT MOTION: how far the animation carries the creature (the
41
+ // largest-travelling position track's net XZ displacement) over one cycle ÷ the cycle time, scaled to
42
+ // metres. legs-move == space-moved → no foot-sliding. 0 if in-place (caller falls back).
43
+ function rootMotionSpeed(clip, scale) {
44
+ if (!clip || clip.duration <= 0) return 0;
45
+ let best = 0;
46
+ for (const tr of clip.tracks) {
47
+ if (!tr.name.endsWith('.position') || tr.times.length < 2) continue;
48
+ const v = tr.values, n = tr.times.length;
49
+ best = Math.max(best, Math.hypot(v[(n - 1) * 3] - v[0], v[(n - 1) * 3 + 2] - v[2]));
50
+ }
51
+ const s = (best * scale) / clip.duration;
52
+ return s > 0.05 && s < 12 ? s : 0;
53
+ }
54
+
55
+ // Implied ground speed of an IN-PLACE clip (no root motion): the planted foot slides backward at the
56
+ // ground speed. Track the centroid of the bottom-of-body contact verts in the baked VAT frame to
57
+ // frame; the median horizontal speed ignores the foot-swap spikes ≈ the true ground speed.
58
+ function footSlideSpeed(vat, clipRow, scale) {
59
+ const e = vat.table[clipRow]; if (!e || e.frames < 3) return 0;
60
+ const d = vat.data ?? vat.tex.image.data, V = vat.vCount, W = vat.W, RPF = vat.rowsPerFrame, cen = [];
61
+ const tIdx = (i, gf) => (gf * RPF + ((i / W) | 0)) * W + (i % W); // tiled VAT texel index (matches bake)
62
+ for (let f = 0; f < e.frames; f++) {
63
+ const gf = e.start + f;
64
+ let minY = 1e9, maxY = -1e9;
65
+ for (let i = 0; i < V; i++) { const y = d[tIdx(i, gf) * 4 + 1]; if (y < minY) minY = y; if (y > maxY) maxY = y; }
66
+ const thr = minY + (maxY - minY) * 0.08; // contact patch = lowest 8% of the body height
67
+ let cx = 0, cz = 0, c = 0;
68
+ for (let i = 0; i < V; i++) { const b = tIdx(i, gf) * 4; if (d[b + 1] <= thr) { cx += d[b]; cz += d[b + 2]; c++; } }
69
+ cen.push(c ? [cx / c, cz / c] : [0, 0]);
70
+ }
71
+ const sp = [];
72
+ for (let f = 0; f < e.frames; f++) { const a = cen[f], b = cen[(f + 1) % e.frames]; sp.push(Math.hypot(b[0] - a[0], b[1] - a[1])); }
73
+ sp.sort((a, b) => a - b);
74
+ const s = sp[sp.length >> 1] * vat.fps * scale; // median per-frame slide × fps × display scale
75
+ return s > 0.15 ? s : 0; // below this the contact-tracking didn't lock on → caller falls back
76
+ }
77
+
78
+ const FPS = 30;
79
+
80
+ // A STANDING POSE, from a clip that has no standing in it. Take every track's value at time t and
81
+ // hold it — 2 frames of a motionless animal. The deer's pack ships walk/run/attack/death and no idle
82
+ // whatsoever, and a deer marching on the spot is a worse animal than a deer standing still.
83
+ // Duration 0.14 s × the 15 fps idle bake = exactly 2 frames, so it costs 2 rows of the VAT texture.
84
+ function freezeClip(clip, name, t = 0) {
85
+ const tracks = clip.tracks.map((tr) => {
86
+ const stride = tr.getValueSize();
87
+ let k = 0;
88
+ for (let i = 0; i < tr.times.length; i++) if (tr.times[i] <= t) k = i;
89
+ const v = Array.from(tr.values.slice(k * stride, (k + 1) * stride));
90
+ return new tr.constructor(tr.name, [0, 0.14], [...v, ...v]);
91
+ });
92
+ return new THREE.AnimationClip(name, 0.14, tracks);
93
+ }
94
+
95
+ // PolyPerfect anim FBX pack every clip onto ONE shared timeline, so each clip's `duration` is its END
96
+ // time while its keys live in a sub-range — trim to the real keyframe range and shift to 0.
97
+ export function trimClip(clip, name) {
98
+ let t0 = Infinity, t1 = -Infinity;
99
+ for (const tr of clip.tracks) if (tr.times.length) { t0 = Math.min(t0, tr.times[0]); t1 = Math.max(t1, tr.times[tr.times.length - 1]); }
100
+ if (!isFinite(t0)) return clip;
101
+ const tracks = clip.tracks.map((tr) => {
102
+ const stride = tr.getValueSize(), times = [], values = [];
103
+ for (let i = 0; i < tr.times.length; i++) {
104
+ const t = tr.times[i];
105
+ if (t >= t0 - 1e-4 && t <= t1 + 1e-4) { times.push(t - t0); for (let j = 0; j < stride; j++) values.push(tr.values[i * stride + j]); }
106
+ }
107
+ return new tr.constructor(tr.name, times, values);
108
+ });
109
+ return new THREE.AnimationClip(name || clip.name, t1 - t0, tracks);
110
+ }
111
+
112
+ // The bin name hashes model + vertex count + every clip's name/fps/duration — any content change
113
+ // misses cleanly and re-cooks. Format: [u32 header-len LE][JSON header][u16 half-float texels].
114
+ const VAT_BIN_VER = 1;
115
+ function vatBinName(key, clips, fps, vCount) {
116
+ const id = `${VAT_BIN_VER}|${key}|${vCount}|` + clips.map((c) => `${c.name}@${c.userData?.bakeFps ?? fps}x${+c.duration.toFixed(3)}`).join(',');
117
+ let h = 5381;
118
+ for (let i = 0; i < id.length; i++) h = ((h << 5) + h + id.charCodeAt(i)) >>> 0;
119
+ // fable named these off the LAST path segment, which is "anims.fbx" for every species that ships a
120
+ // separate anim file — so its vat/ directory is a wall of anims-<hash>.bin you cannot read. Name
121
+ // them for the ANIMAL.
122
+ const parts = key.split('|')[0].split('/');
123
+ const base = (parts[parts.length - 2] || 'vat').replace(/[^\w-]/g, '_');
124
+ return `${base}-${h.toString(16)}.bin`;
125
+ }
126
+
127
+ export async function bakeVATCached(key, root, skinned, clips, fps = FPS) {
128
+ const name = vatBinName(key, clips, fps, skinned.geometry.attributes.position.count);
129
+ try {
130
+ const r = await fetch(`/assets/vat/${name}`);
131
+ if (r.ok) {
132
+ const buf = await r.arrayBuffer();
133
+ const hlen = new DataView(buf).getUint32(0, true);
134
+ const meta = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 4, hlen)));
135
+ const half = new Uint16Array(buf.slice(4 + hlen));
136
+ // float32 CPU copy (footSlideSpeed reads positions back) reconstructed from the halves —
137
+ // they're exactly what the GPU renders anyway
138
+ const data = new Float32Array(half.length);
139
+ for (let i = 0; i < half.length; i++) data[i] = THREE.DataUtils.fromHalfFloat(half[i]);
140
+ const tex = new THREE.DataTexture(half, meta.W, meta.texH, THREE.RGBAFormat, THREE.HalfFloatType);
141
+ tex.minFilter = tex.magFilter = THREE.NearestFilter;
142
+ tex.needsUpdate = true;
143
+ return { tex, data, ...meta };
144
+ }
145
+ console.warn(`[vat] MISS ${name} (${key}) — cooking`);
146
+ } catch (e) { console.warn(`[vat] bin unreadable ${name} — cooking`, e); }
147
+ const vat = bakeVAT(root, skinned, clips, fps);
148
+ if (import.meta.env?.DEV) {
149
+ try {
150
+ const meta = { vCount: vat.vCount, total: vat.total, table: vat.table, fps: vat.fps, W: vat.W, rowsPerFrame: vat.rowsPerFrame, texH: vat.texH };
151
+ const hjson = new TextEncoder().encode(JSON.stringify(meta));
152
+ const out = new Uint8Array(4 + hjson.length + vat.tex.image.data.byteLength);
153
+ new DataView(out.buffer).setUint32(0, hjson.length, true);
154
+ out.set(hjson, 4);
155
+ out.set(new Uint8Array(vat.tex.image.data.buffer), 4 + hjson.length);
156
+ fetch(`/__save-vatbin?name=${name}`, { method: 'POST', body: out }).catch(() => { /* sink offline */ });
157
+ console.log(`[vat] cooked → ${name} (${(out.length / 1048576).toFixed(1)} MB)`);
158
+ } catch (e) { /* live bake still returned */ }
159
+ }
160
+ return vat;
161
+ }
162
+
163
+ // Bake clips → DataTexture: each texel = that vertex's posed LOCAL position at that frame. `root` is
164
+ // the rig group (the mixer binds on it).
165
+ export function bakeVAT(root, skinned, clips, fps = FPS) {
166
+ const savedS = root.scale.x; root.scale.set(1, 1, 1); // bake in native units, not display scale
167
+ const vCount = skinned.geometry.attributes.position.count;
168
+ const table = []; let total = 0;
169
+ for (const c of clips) { const cfps = c.userData?.bakeFps ?? fps; const frames = Math.max(2, Math.round(c.duration * cfps)); table.push({ name: c.name, start: total, frames, fps: cfps }); total += frames; }
170
+ // Tile the texture so WIDTH stays within the GPU limit: width = vertexCount would blow past the
171
+ // common maxTextureDimension2D = 8192 on a high-poly rig, and an over-wide DataTexture corrupts the
172
+ // whole frame. Pack each frame across `rowsPerFrame` rows of width W. For vCount <= 8192 this is
173
+ // W = vCount, rowsPerFrame = 1 — byte-identical to a plain single-row layout.
174
+ const MAXW = 8192;
175
+ const W = Math.min(vCount, MAXW), rowsPerFrame = Math.ceil(vCount / W), texH = total * rowsPerFrame;
176
+ const data = new Float32Array(W * texH * 4);
177
+ const mixer = new THREE.AnimationMixer(root);
178
+ const _v = new THREE.Vector3();
179
+ const posAttr = skinned.geometry.attributes.position; // bind positions
180
+ for (let ci = 0; ci < clips.length; ci++) {
181
+ const e = table[ci];
182
+ // strip position tracks → bake IN PLACE (locomotion is code-driven in herd.js, not baked)
183
+ const c = new THREE.AnimationClip(clips[ci].name, clips[ci].duration, clips[ci].tracks.filter((t) => !t.name.endsWith('.position')));
184
+ const action = mixer.clipAction(c); action.reset().play();
185
+ for (let f = 0; f < e.frames; f++) {
186
+ mixer.setTime((f / e.frames) * c.duration); // [0,duration) so the last frame wraps to the first
187
+ root.updateMatrixWorld(true);
188
+ const frameBaseRow = (e.start + f) * rowsPerFrame;
189
+ for (let i = 0; i < vCount; i++) {
190
+ _v.fromBufferAttribute(posAttr, i); // r184: applyBoneTransform reads the bind pos FROM target
191
+ skinned.applyBoneTransform(i, _v);
192
+ const idx = ((frameBaseRow + ((i / W) | 0)) * W + (i % W)) * 4; // tiled: col = i%W, row-in-frame = i/W
193
+ data[idx] = _v.x; data[idx + 1] = _v.y; data[idx + 2] = _v.z; data[idx + 3] = 1;
194
+ }
195
+ }
196
+ action.stop();
197
+ }
198
+ root.scale.setScalar(savedS); root.updateMatrixWorld(true);
199
+ // HALF-float GPU texture — poses do not need float32, and this halves the VAT's VRAM. The float32
200
+ // bake stays on vat.data for footSlideSpeed, which reads positions back on the CPU.
201
+ const half = new Uint16Array(data.length);
202
+ for (let i = 0; i < data.length; i++) half[i] = THREE.DataUtils.toHalfFloat(data[i]);
203
+ const tex = new THREE.DataTexture(half, W, texH, THREE.RGBAFormat, THREE.HalfFloatType);
204
+ tex.minFilter = tex.magFilter = THREE.NearestFilter;
205
+ tex.needsUpdate = true;
206
+ return { tex, data, vCount, total, table, fps, W, rowsPerFrame, texH };
207
+ }
208
+
209
+ // MeshStandardNodeMaterial whose vertex position is read from the VAT for THIS instance's clip and
210
+ // phase (aClip, aPhase) and whose albedo is tinted per-instance (aTint). uTime drives playback.
211
+ export function vatMaterial(vat, albedo, layers = 1) {
212
+ const mat = new THREE.MeshStandardNodeMaterial({ roughness: 0.92, metalness: 0 });
213
+ const uTime = uniform(0); mat.userData.uTime = uTime;
214
+ const aClip = attribute('aClip', 'float'), aPhase = attribute('aPhase', 'float'), aTint = attribute('aTint', 'vec3');
215
+ const vId = attribute('vertexId', 'float');
216
+ const aLayer = layers > 1 ? attribute('aLayer', 'float') : null; // coat-variant column in the atlas
217
+ const uTable = uniformArray(vat.table.map((e) => new THREE.Vector3(e.start, e.frames, (e.fps ?? vat.fps) * ((vat.playFps ?? vat.fps) / vat.fps))));
218
+ const W = float(vat.W), RPF = float(vat.rowsPerFrame), texH = float(vat.texH);
219
+ mat.positionNode = Fn(() => {
220
+ const e = uTable.element(int(aClip)); // vec3(startFrame, frameCount, fps)
221
+ const localFrame = floor(mod(uTime.add(aPhase).mul(e.z), e.y));
222
+ const globalFrame = e.x.add(localFrame); // frame index along the timeline
223
+ const col = mod(vId, W);
224
+ const texRow = globalFrame.mul(RPF).add(floor(vId.div(W)));
225
+ const u = col.add(0.5).div(W);
226
+ const v = texRow.add(0.5).div(texH);
227
+ return texture(vat.tex, vec2(u, v)).xyz;
228
+ })();
229
+ if (!albedo) mat.colorNode = aTint;
230
+ else if (layers > 1) mat.colorNode = texture(albedo, vec2(aLayer.add(uv().x).div(layers), uv().y)).mul(aTint);
231
+ else mat.colorNode = texture(albedo).mul(aTint);
232
+ return mat;
233
+ }
234
+
235
+ // Load a species' rig + animations, bake the requested clips, and return everything a Herd needs.
236
+ // `def`: { rig, anims, texture, clips: { name: /regex/ }, height }. `clips` maps a logical name
237
+ // (idle/walk/run/graze/…) to a regex picking the source clip; the KEY ORDER defines the aClip indices.
238
+ export async function loadSpeciesVAT(def) {
239
+ const rig = await instantiate(def.rig, { texture: def.texture ?? def.textures?.[0], scale: 1 });
240
+ let skinned = null; rig.traverse((o) => { if (o.isSkinnedMesh && !skinned) skinned = o; });
241
+ if (!skinned) throw new Error(`no SkinnedMesh in ${def.rig}`);
242
+ skinned.normalizeSkinWeights?.();
243
+ const srcClips = ((await loadModel(def.anims ?? def.rig, { scale: 1 })).animations || []).map((c) => trimClip(c));
244
+
245
+ // ── PICK + BAKE EACH REQUESTED CLIP. THE FALLBACK IS NOT fable's, AND IT MATTERS. ──────────────
246
+ // fable falls back to srcClips[0] when a regex matches nothing. That is a disaster here, because
247
+ // these PolyPerfect packs are NOT uniform, and I read every one of their animation stacks out of
248
+ // the FBX binaries before writing a line of this:
249
+ // cow Walk, Idle1, Idle2, Attack, Death, Sleep — NO run, NO graze
250
+ // deer walk, run, attack, death — NO idle at all
251
+ // wolf Idle, Walk, Run, Attack, Death — no graze (fine: it is a predator)
252
+ // goat Idle, Walk, Run, Attack, Death, Eating
253
+ // pig idle2, walk, run, Eat, Sleep, attack, death
254
+ // hen Idle_2/3/Roost, Walk, Run, Eat, Fly, Sleep, Attack
255
+ // eagle Idle, Walk, Run, Flying, Death
256
+ // Under fable's rule a spooked cow would flee playing whichever stack came first in its file. So:
257
+ // `alias` — a missing clip REUSES another logical clip's bake. Costs nothing: same row block.
258
+ // `still` — a missing clip is a FROZEN POSE lifted from another clip. This is what the deer's
259
+ // idle is: with no idle in the pack, aliasing idle→walk gives you a deer marching on
260
+ // the spot, which is worse than a deer standing still.
261
+ const baked = [], clipIndex = {}, missing = [];
262
+ const pick = (re) => srcClips.find((x) => re.test(x.name) && !/ to | to$|_to_/i.test(x.name)) || srcClips.find((x) => re.test(x.name));
263
+ // TWO SLOTS THAT RESOLVE TO THE SAME SOURCE CLIP GET ONE BAKE, NOT TWO. The eagle's idle, walk and
264
+ // run all pick Eagle_Flying (it has no glide reel and a soaring bird has no business walking), and
265
+ // baking that three times would put three identical copies of the same 24 MB rig's frames into the
266
+ // VAT texture. Row blocks are shared by index; nothing downstream can tell.
267
+ const seen = new Map();
268
+ for (const n of Object.keys(def.clips)) {
269
+ const c = pick(def.clips[n]);
270
+ if (!c) { missing.push(n); continue; }
271
+ if (seen.has(c)) { clipIndex[n] = seen.get(c); continue; }
272
+ seen.set(c, baked.length);
273
+ clipIndex[n] = baked.length;
274
+ baked.push(trimClip(c, n));
275
+ }
276
+ for (const n of missing) { // second pass: every real clip is now indexed
277
+ // `still: { idle: 'walk' }` freezes the source's FIRST pose; `still: { dead: ['death', 1] }`
278
+ // freezes the pose at that FRACTION of the source — how a carcass holds a death take's last frame.
279
+ const stRaw = def.still?.[n];
280
+ const [st, stFrac] = Array.isArray(stRaw) ? stRaw : [stRaw, 0];
281
+ if (st && clipIndex[st] != null) {
282
+ const src = baked[clipIndex[st]];
283
+ clipIndex[n] = baked.length; baked.push(freezeClip(src, n, src.duration * stFrac)); continue;
284
+ }
285
+ const al = def.alias?.[n];
286
+ if (al && clipIndex[al] != null) { clipIndex[n] = clipIndex[al]; continue; }
287
+ console.warn(`[vat] ${def.rig.split('/').slice(-2)[0]}: no clip and no alias for "${n}" — it will play clip 0`);
288
+ clipIndex[n] = 0;
289
+ }
290
+ if (!baked.length) throw new Error(`no clips to bake for ${def.rig}`);
291
+ // idles at 15 fps — halves the rows they cost in the VAT texture, and a sway does not need 30
292
+ for (const c of baked) if (!/^(walk|run)$/.test(c.name)) (c.userData ??= {}).bakeFps = 15;
293
+
294
+ const vat = await bakeVATCached(`${def.rig}|${def.anims ?? ''}`, rig, skinned, baked);
295
+ vat.playFps = vat.fps * (def.playRate ?? 1);
296
+
297
+ // Size and ground the species from THE BAKE ITSELF (frame-0 posed positions) — the exact space
298
+ // vatMaterial renders from. Bind-space geometry.boundingBox is a trap on GLB rigs: the bind
299
+ // geometry sits in a different space than the posed skin, which pins def.height against the wrong
300
+ // denominator (fable's half-size sheep).
301
+ const box = new THREE.Box3();
302
+ {
303
+ const Wt = vat.W, v = new THREE.Vector3();
304
+ for (let i = 0; i < vat.vCount; i++) {
305
+ const idx = ((((i / Wt) | 0)) * Wt + (i % Wt)) * 4; // frame 0 occupies rows 0..rowsPerFrame-1
306
+ v.set(vat.data[idx], vat.data[idx + 1], vat.data[idx + 2]);
307
+ box.expandByPoint(v);
308
+ }
309
+ }
310
+ const size = box.getSize(new THREE.Vector3());
311
+ // sizeBy 'max': a bird binds wings-SPREAD, so Y is its THINNEST axis and height/size.y would make a
312
+ // pterodactyl of it. Its def.height targets the WINGSPAN instead. Land animals keep Y.
313
+ const dim = def.sizeBy === 'max' ? Math.max(size.x, size.y, size.z) : size.y;
314
+ const scale = (def.height ?? 1.4) / (dim || 1);
315
+ // ground speeds derived from the walk/run animations themselves — no foot-sliding
316
+ const walkSpeed = rootMotionSpeed(baked[clipIndex.walk], scale) || footSlideSpeed(vat, clipIndex.walk, scale);
317
+ const runSpeed = rootMotionSpeed(baked[clipIndex.run], scale) || footSlideSpeed(vat, clipIndex.run, scale);
318
+ // coat: a single map, or N side-by-side variants the herd picks from per-instance
319
+ let albedo, layers = 1;
320
+ if (def.textures) { const a = await stackTextures(def.textures); albedo = a.tex; layers = a.layers; }
321
+ else albedo = (Array.isArray(skinned.material) ? skinned.material[0] : skinned.material).map;
322
+ // static geometry: positions come from the VAT; carry a vertexId column index
323
+ const geometry = skinned.geometry.clone();
324
+ const vid = new Float32Array(vat.vCount); for (let i = 0; i < vat.vCount; i++) vid[i] = i;
325
+ geometry.setAttribute('vertexId', new THREE.BufferAttribute(vid, 1));
326
+ return { vat, geometry, albedo, layers, scale, clipIndex, walkSpeed, runSpeed, footY: box.min.y * scale, size };
327
+ }