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.
- package/README.md +63 -2
- package/docs/api/README.md +28 -0
- package/docs/api/anim.md +125 -0
- package/docs/api/assets.md +208 -0
- package/docs/api/collision.md +101 -0
- package/docs/api/fbxloader.md +50 -0
- package/docs/api/grass.md +103 -0
- package/docs/api/input.md +164 -0
- package/docs/api/quality.md +159 -0
- package/docs/api/renderer.md +115 -0
- package/docs/api/retarget.md +149 -0
- package/docs/api/shatter.md +121 -0
- package/docs/api/utils-decals.md +188 -0
- package/docs/api/water.md +143 -0
- package/docs/api/wind-weather-uniforms.md +105 -0
- package/docs/contracts/render.md +32 -0
- package/docs/contracts/time-feel.md +52 -0
- package/package.json +1 -1
- package/src/core/anim.js +184 -0
- package/src/core/assets.js +596 -0
- package/src/core/engine.js +284 -0
- package/src/core/input/bindings.js +30 -0
- package/src/core/input/gamepad.js +92 -0
- package/src/core/input/index.js +62 -0
- package/src/core/input/keyboard.js +16 -0
- package/src/core/input/mouse.js +48 -0
- package/src/core/quality.js +5 -1
- package/src/core/renderer.js +104 -0
- package/src/core/retarget.js +282 -0
- package/src/index.js +19 -2
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/entity.js +524 -0
- package/src/systems/fistCurl.js +38 -0
- package/src/systems/footsteps.js +161 -0
- package/src/systems/glass.js +67 -0
- package/src/systems/herd.js +277 -0
- package/src/systems/horse.js +361 -0
- package/src/systems/mountedTraveller.js +518 -0
- package/src/systems/nav.js +343 -0
- package/src/systems/npc.js +880 -0
- package/src/systems/pathFollow.js +107 -0
- package/src/systems/platform.js +484 -0
- package/src/systems/riding.js +580 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/trainCamera.js +414 -0
- package/src/systems/traveller.js +254 -0
- package/src/systems/tumbleweeds.js +92 -0
- package/src/systems/vat.js +327 -0
- package/src/systems/vfx.js +472 -0
- package/src/world/blobShadows.js +109 -0
- package/src/world/clouds.js +61 -0
- package/src/world/flora.js +87 -0
- package/src/world/footprints.js +117 -0
- package/src/world/lampField.js +58 -0
- package/src/world/lampGlow.js +92 -0
- package/src/world/scatter.js +1077 -0
- package/src/world/sky.js +363 -0
- package/src/world/tileManager.js +197 -0
- package/src/world/walkHumps.js +74 -0
- 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
|
+
}
|