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,596 @@
1
+ // Asset system: loads Synty FBX models + animation clips, binds atlas
2
+ // textures (FBXLoader doesn't auto-link Synty's atlases), caches everything.
3
+ import * as THREE from 'three/webgpu';
4
+ import { texture, uv } from 'three/tsl';
5
+
6
+ // Optional TSL term a game registers to be added into every atlas material's
7
+ // emissiveNode (albedoNode) => node — e.g. western's nearest-N lamp field.
8
+ // Register BEFORE the first applyAtlas/instantiate call (materials are cached).
9
+ let atlasEmissiveHook = null;
10
+ export function setAtlasEmissiveHook(fn) { atlasEmissiveHook = fn; }
11
+ // Patched loader from vendor/ — merges ALL FBX animation layers per stack. The
12
+ // engine does NOT depend on a node_modules postinstall patch.
13
+ import { FBXLoader } from '../vendor/FBXLoader.js';
14
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
15
+ import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
16
+ import { clone as skeletonClone } from 'three/addons/utils/SkeletonUtils.js';
17
+
18
+ const fbxLoader = new FBXLoader();
19
+ const gltfLoader = new GLTFLoader();
20
+ // the LOD .glb files are EXT_meshopt-compressed — without this decoder every
21
+ // *_LOD*.glb fails to load, so trees (scatterLOD) and building LODs silently
22
+ // never appear, leaving only invisible colliders behind.
23
+ gltfLoader.setMeshoptDecoder(MeshoptDecoder);
24
+ const texLoader = new THREE.TextureLoader();
25
+ const gltfCache = new Map();
26
+
27
+ const modelCache = new Map(); // url -> THREE.Group (master copy)
28
+ const clipCache = new Map(); // url -> THREE.AnimationClip
29
+ const texCache = new Map(); // url -> THREE.Texture
30
+
31
+ let progressCb = null;
32
+ let loadedCount = 0;
33
+ let totalCount = 0;
34
+
35
+ export function onProgress(cb) { progressCb = cb; }
36
+
37
+ function tick(msg) {
38
+ loadedCount++;
39
+ progressCb?.(loadedCount, totalCount, msg);
40
+ }
41
+
42
+ export function expectLoads(n) { totalCount += n; }
43
+
44
+ // GPU MEMORY DIET (re-landed solo): ~8GB of textures were resident — Synty atlases are
45
+ // flat palettes, 4096² carries nothing 2048² doesn't, and the pressure causes recurring
46
+ // spin-jolts (renderer's weak object cache + GC evictions under memory strain) plus the
47
+ // old poisoned-bind-group black screens. ?fulltex disables for an A/B eyeball.
48
+
49
+ export async function loadTexture(url, { flipY } = {}) {
50
+ // flipY-specific cache key: a packed GLB dwarf atlas needs flipY=false (glTF UV convention),
51
+ // while the same-named atlas on an FBX model wants the TextureLoader default (true). Keying
52
+ // by url alone would let one flipY setting win for all consumers.
53
+ const key = flipY === undefined ? url : `${url}|flip${flipY ? 1 : 0}`;
54
+ if (texCache.has(key)) return texCache.get(key);
55
+ const p = texLoader.loadAsync(url).then((t) => {
56
+ // NOTE: the 2048 downscale cap is RETIRED — even nearest-sampled, capped atlases
57
+ // came out wrong-coloured on small parts (mip-chain sampling of the resized canvas).
58
+ // The embedded-texture dedup below carries the memory diet instead. ?fulltex is dead.
59
+ t.colorSpace = THREE.SRGBColorSpace;
60
+ // Synty atlases are flat color blocks — nearest avoids edge bleeding
61
+ // between atlas cells when mipmapped at glancing angles.
62
+ t.minFilter = THREE.LinearMipmapLinearFilter;
63
+ t.magFilter = THREE.LinearFilter;
64
+ if (flipY !== undefined) t.flipY = flipY;
65
+ t.needsUpdate = true;
66
+ return t;
67
+ });
68
+ texCache.set(key, p);
69
+ return p;
70
+ }
71
+
72
+ // Force-upload every cached texture to the GPU. The boot warm's compileAsync compiles PIPELINES
73
+ // but does NOT upload textures — each then uploaded on FIRST SIGHT mid-play instead (~75ms hitch
74
+ // apiece, measured by the freeze profiler as tex:+1 records). Run under the loading screen.
75
+ // Safe to call again later (phase-2): initTexture on an already-resident texture is a no-op.
76
+ export async function warmTextures(renderer) {
77
+ let n = 0;
78
+ for (const p of texCache.values()) {
79
+ try { renderer.initTexture(await p); n++; } catch (e) { /* failed load — nothing to warm */ }
80
+ }
81
+ return n;
82
+ }
83
+
84
+ // The 'Head' bone of a skinned model (for seating hair/beard attachments), tolerant of
85
+ // naming variants. Returns null for static models.
86
+ function headBoneOf(model) {
87
+ let sm = null; model.traverse((o) => { if (o.isSkinnedMesh && !sm) sm = o; });
88
+ if (!sm) return null;
89
+ const skel = sm.skeleton;
90
+ let idx = skel.bones.findIndex((b) => b.name === 'Head');
91
+ if (idx < 0) idx = skel.bones.findIndex((b) => /head/i.test(b.name));
92
+ return idx < 0 ? null : skel.bones[idx];
93
+ }
94
+
95
+ // Synty source FBX are authored in centimetres; scale to metres.
96
+ const SYNTY_SCALE = 0.01;
97
+
98
+ // FBX→GLB redirect (re-land, recipe verified in /glbtest.html this time): scripts/
99
+ // convert_runtime_statics.mjs converts every runtime static FBX to a same-dir .glb twin
100
+ // (fbx2gltf --binary, DEFAULT V-flip — the reverted batch's --no-flip-v mirrored every
101
+ // atlas cell) and lists them in glb-twins.json. Redirect ONLY when (a) an atlas texture
102
+ // is supplied (applyAtlas replaces the GLB's materials, so conversion material fidelity
103
+ // is irrelevant) and (b) no explicit scale (fbx callers mean cm→m 0.01; twins are already
104
+ // metres). Missing manifest degrades to plain FBX loading.
105
+ let glbTwins = null;
106
+ const glbTwinsReady = fetch('/assets/glb-twins.json')
107
+ .then((r) => (r.ok ? r.json() : []))
108
+ .catch(() => [])
109
+ .then((a) => { glbTwins = new Set(a); });
110
+
111
+ // EMBEDDED-TEXTURE DEDUP (memory diet, part 2): character/castle GLBs each embed their own
112
+ // copy of shared atlases (atlas_fantasykingdom measured resident 3× ≈ 270MB alone). Key each
113
+ // embedded image by dims + a 16×16 full-byte downsample hash and share ONE THREE.Texture per
114
+ // unique image; duplicates are disposed before they ever reach the GPU. 64×64 full-byte
115
+ // hash (16k+ samples) — distinct atlases cannot realistically collide.
116
+ const _embedCache = new Map();
117
+ const _hashCnv = typeof document !== 'undefined' ? document.createElement('canvas') : null;
118
+ function dedupEmbeddedTextures(root) {
119
+ if (!_hashCnv) return;
120
+ root.traverse((o) => {
121
+ const mats = o.material ? (Array.isArray(o.material) ? o.material : [o.material]) : [];
122
+ for (const m of mats) {
123
+ for (const k of ['map', 'emissiveMap', 'normalMap', 'roughnessMap', 'metalnessMap', 'aoMap', 'alphaMap']) {
124
+ const t = m[k];
125
+ if (!t || !t.isTexture || !t.image || t.userData.__dedup) continue;
126
+ try {
127
+ const im = t.image, w = im.width || 0, h = im.height || 0;
128
+ if (!w) continue;
129
+ _hashCnv.width = 64; _hashCnv.height = 64;
130
+ const cx = _hashCnv.getContext('2d', { willReadFrequently: true });
131
+ cx.clearRect(0, 0, 64, 64);
132
+ cx.drawImage(im, 0, 0, 64, 64);
133
+ const px = cx.getImageData(0, 0, 64, 64).data;
134
+ let hsh = (w * 31 + h) | 0;
135
+ for (let i = 0; i < px.length; i++) hsh = ((hsh << 5) - hsh + px[i]) | 0;
136
+ const key = `${w}x${h}:${hsh}:${t.flipY ? 1 : 0}:${t.colorSpace}`;
137
+ const kept = _embedCache.get(key);
138
+ if (kept && kept !== t) { m[k] = kept; t.dispose(); }
139
+ else { t.userData.__dedup = true; _embedCache.set(key, t); }
140
+ } catch (e) { /* unreadable image type — keep as-is */ }
141
+ }
142
+ }
143
+ });
144
+ }
145
+
146
+ // The redirect decision, exported: callers that build their OWN materials from a model's
147
+ // flattened geometry (scatter/scatterResident) must know which format actually loaded —
148
+ // glTF-convention UVs need the atlas at flipY:false, FBX-convention at the default true.
149
+ // scatter() not knowing this was the mirrored-cell bug on every scatter-placed prop (the
150
+ // red wells / washed stall tents) after the twins landed.
151
+ export async function resolveModelUrl(url, { texture = null, scale, twin = false } = {}) {
152
+ // scale===undefined → cm-authored pack, twin is metres, loader default 1 fits.
153
+ // DUNGEON kit is metre-authored with cm FBX headers: callers pass scale:1 and the raw
154
+ // twins came out 100× small — those twins carry a baked ×100 root rescale
155
+ // (scripts note in convert_runtime_statics.mjs), so scale:1 loads them correctly.
156
+ // `twin: true` opts in texture-less callers whose downstream DISCARDS the file's own
157
+ // materials anyway (scatterTree UV-splits geometry and builds its own mats).
158
+ const redirectable = scale === undefined || (scale === 1 && /\/assets\/dungeon\//.test(url));
159
+ if (/\.fbx$/i.test(url) && (texture || twin) && redirectable) {
160
+ if (!glbTwins) await glbTwinsReady;
161
+ if (glbTwins.has(url)) return url.replace(/\.fbx$/i, '.glb');
162
+ }
163
+ return url;
164
+ }
165
+
166
+ export async function loadModel(url, { texture = null, scale, twin = false } = {}) {
167
+ url = await resolveModelUrl(url, { texture, scale, twin });
168
+ // CACHE BY WHAT ACTUALLY VARIES: url + atlas + scale. Keying by url ALONE meant the first
169
+ // caller to load a file decided its skin for the whole run — every later caller asking for the
170
+ // same body in a different atlas silently got the first one's master back. Worse, a caller that
171
+ // wanted no texture at all cached a master whose materials had never been through applyAtlas, so
172
+ // everyone downstream inherited the raw FBX's MeshLambertMaterial pointing at a texture that
173
+ // does not resolve. That is not a subtle wrong tint: it renders BLACK. It blacked out all 29
174
+ // bodies in the casting bench, then the coach's driver, mate and every randomly-skinned fare.
175
+ // One body in two atlases is two masters. That is the correct amount of masters.
176
+ const key = `${url}|${texture ?? ''}|${scale ?? 'auto'}`;
177
+ if (!modelCache.has(key)) {
178
+ // Dispatch by extension: FBXLoader parses on the main thread and is far too slow
179
+ // for batch scatter (the tropical-beach load hang) — a .glb of the same model loads
180
+ // through GLTFLoader an order of magnitude faster. UNITS DIFFER by source, so the
181
+ // default scale does too: Synty FBX is authored in centimetres (× SYNTY_SCALE 0.01
182
+ // → metres), whereas the fbx2gltf-converted biome .glb is ALREADY in metres (the
183
+ // converter baked the cm→m factor) → loads at 1:1. Applying 0.01 to those metre-scale
184
+ // GLBs shrank every tropical prop 100× → microscopic, invisible (the palms/rocks).
185
+ const isGLB = /\.glb$/i.test(url);
186
+ if (scale === undefined) scale = isGLB ? 1 : SYNTY_SCALE;
187
+ const load = isGLB
188
+ ? gltfLoader.loadAsync(url).then((g) => { g.scene.animations = g.animations ?? []; dedupEmbeddedTextures(g.scene); return g.scene; }) // FBX carries clips ON the model; glTF returns them separately — the VAT bakers read .animations
189
+ : ((window.__fbxLoads ??= []).push(url), fbxLoader.loadAsync(url)); // dev census: what still parses FBX (window.__fbxLoads)
190
+ const p = load.then(async (group) => {
191
+ group.scale.setScalar(scale);
192
+ // Strip any lights the source file carries — Synty FBX embed a grey AmbientLight in their
193
+ // global settings, so every loaded model stacked one (7+ at intensity 1) and washed out the
194
+ // whole scene, including the dungeon's dark mood. We light the world explicitly; FBX never should.
195
+ const stray = []; group.traverse((o) => { if (o.isLight) stray.push(o); }); stray.forEach((l) => l.removeFromParent());
196
+ if (texture) {
197
+ // GLB models carry glTF-convention UVs (top-left origin), so the atlas must NOT be flipped
198
+ // vertically or every cell samples its vertical mirror (skin→blue, clothing swapped). FBX
199
+ // models keep the TextureLoader default (flipY true). Same fix as the packed dwarf GLB atlas.
200
+ const tex = await loadTexture(texture, isGLB ? { flipY: false } : {});
201
+ applyAtlas(group, tex);
202
+ }
203
+ tick(url.split('/').pop());
204
+ return group;
205
+ });
206
+ p.catch(() => modelCache.delete(key)); // a transient 404 must not cache the rejection forever — retries re-fetch
207
+ modelCache.set(key, p);
208
+ }
209
+ return modelCache.get(key);
210
+ }
211
+
212
+ // A bone contributing nothing: the identity pass-throughs the multi-character FBX packs stack up
213
+ // (see instantiate's keep path). Tolerances are float-noise wide, not "close enough" wide — a bone
214
+ // with a REAL offset must never be collapsed away.
215
+ function isIdentity(o) {
216
+ return o.position.lengthSq() < 1e-8
217
+ && Math.abs(Math.abs(o.quaternion.w) - 1) < 1e-6
218
+ && Math.abs(o.scale.x - 1) < 1e-5 && Math.abs(o.scale.y - 1) < 1e-5 && Math.abs(o.scale.z - 1) < 1e-5;
219
+ }
220
+
221
+ // ════════════════════════════════════════════════════════════════════════════════════════════════
222
+ // COLLAPSE A PACK ONTO ONE SKELETON — the load-bearing step for POLYGON Kids, and the ONLY way a
223
+ // child can be made of more than one mesh.
224
+ //
225
+ // The keep-path prune below (instantiate) cuts a pack's rival skeletons down to the body's own,
226
+ // and that is enough when a character IS one mesh, which every adult here is: Synty bakes an
227
+ // adult's hair and face into his body. A CHILD IS FIVE MESHES — outfit + mouth decal + freckle
228
+ // decal + pupils + eyebrows — and the parts DO NOT BELONG TO HIM:
229
+ //
230
+ // · SM_Chr_Kid_Face_01 / _Face_Freckles_01 are unskinned, and hang off the Head bone of
231
+ // SM_Chr_Kid_EASTERN_01 — whichever child you asked for.
232
+ // · SM_Chr_Eyes_*_01 / SM_Chr_Eyebrows_01 are skinned to Eyes_/Eyebrow_ bones belonging to
233
+ // ANOTHER outfit's copy of the skeleton.
234
+ //
235
+ // Characters_Kids.fbx carries 5,082 bones and ONE HUNDRED bones called 'Head'. In the bind pose
236
+ // every one of those skeletons coincides, so a child standing still looks perfect — and the moment
237
+ // he walks, his body strides off and his face, eyes and eyebrows stay behind in the T-pose. The
238
+ // prune cannot fix that: it would CUT the bones the face is hanging from, which is worse.
239
+ //
240
+ // So rebind every mesh onto ONE reference skeleton — the SHALLOWEST, because every rival is nested
241
+ // INSIDE it and so its own bones cannot move when the rivals are cut — re-parent the face decals to
242
+ // THAT skeleton's Head, and snip the 5,031 rivals. Measured on all 106 meshes: 0.000 mm of drift,
243
+ // 5,082 bones → 51. Run ONCE on the shared MASTER (memoised), so every child cloned from it is born
244
+ // with 51 bones and the clone is cheap.
245
+ export function collapseSkeletons(model) {
246
+ if (model.userData.__collapsed) return model.userData.__collapsed;
247
+ // the reference: the skeleton whose Hips sit nearest the root. `>= 51` skips the 2-bone
248
+ // eye/eyebrow skeletons, which are not candidates for anything.
249
+ let ref = null, best = Infinity;
250
+ model.traverse((o) => {
251
+ if (!o.isSkinnedMesh || o.skeleton.bones.length < 51) return;
252
+ const hips = o.skeleton.bones.find((x) => x.name === 'Hips');
253
+ let d = 0; for (let p = hips; p; p = p.parent) d++;
254
+ if (d < best) { best = d; ref = o.skeleton; }
255
+ });
256
+ if (!ref) return null;
257
+ const bone = (n) => ref.bones.find((x) => x.name === n);
258
+ model.traverse((o) => {
259
+ if (!o.isSkinnedMesh) return;
260
+ // an OUTFIT: 51 bones in the same order as the reference → just point it at the reference
261
+ if (o.skeleton.bones.length === ref.bones.length) { o.bind(ref, o.bindMatrix); return; }
262
+ // EYES / EYEBROWS: 2 bones, matched by name. Eyes_Left, Eyebrow_Right… are unique within the
263
+ // 51, so a name lookup INSIDE the reference is exact — this is not the by-name lookup across
264
+ // the whole pack that the duplicate-bone trap punishes.
265
+ const bones = o.skeleton.bones.map((x) => bone(x.name));
266
+ if (bones.every(Boolean)) o.bind(new THREE.Skeleton(bones, o.skeleton.boneInverses), o.bindMatrix);
267
+ else console.warn('[collapse] cannot rebind', o.name);
268
+ });
269
+ // the face decals: unskinned meshes parented to SOME bone. They sit at local identity on a Head,
270
+ // so moving them to the reference's Head is transform-exact.
271
+ const head = bone('Head');
272
+ const decals = [];
273
+ model.traverse((o) => { if (o.isMesh && !o.isSkinnedMesh && o.parent?.isBone) decals.push(o); });
274
+ if (head) decals.forEach((d) => { d.removeFromParent(); head.add(d); });
275
+ // and now the rivals. Cutting at the boundary (a non-kept bone whose PARENT is kept) takes each
276
+ // whole nested subtree in one snip and can never orphan a bone we still use.
277
+ const keep = new Set(ref.bones), cut = [];
278
+ model.traverse((o) => { if (o.isBone && !keep.has(o) && o.parent && keep.has(o.parent)) cut.push(o); });
279
+ cut.forEach((o) => o.removeFromParent());
280
+ model.updateMatrixWorld(true);
281
+ model.userData.__collapsed = ref;
282
+ console.log(`[collapse] onto one skeleton: ${ref.bones.length} bones, ${decals.length} face decals re-parented, ${cut.length} rival chains cut`);
283
+ return ref;
284
+ }
285
+
286
+ // The Head bone reached THROUGH A NAMED MESH'S OWN SKELETON — never by a name search of the graph.
287
+ // That is the whole lesson of the duplicate-bone trap: a hundred bones answer to 'Head' in the kid
288
+ // pack, and the one a name search finds is not the one this child's body is skinned to.
289
+ function headBoneOfMesh(model, meshName) {
290
+ let sm = null;
291
+ model.traverse((o) => { if (o.isSkinnedMesh && o.name === meshName) sm = o; });
292
+ return sm?.skeleton.bones.find((b) => b.name === 'Head') ?? null;
293
+ }
294
+
295
+ // HAIR AND HATS ARE AUTHORED IN DIFFERENT SPACES — in the same pack, from the same folder, and
296
+ // nothing in the file says which. MEASURED: SM_Chr_Hair_Messy_01 spans Y 92.9..121.3 cm — absolute,
297
+ // i.e. drawn already sitting on a head that is standing on the floor (RIG SPACE) — while
298
+ // SM_Chr_Attach_Hat_Cowboy_01 spans Y -3.8..24.4 cm, i.e. relative to the Head bone, which binds at
299
+ // Y 100.1 (HEAD-LOCAL). So classify by the mesh's own height and apply the transform that space
300
+ // needs: rig-space geometry has the head's bind pose CANCELLED out of it (inverse(head.world) ·
301
+ // root.world puts it exactly where a direct child of the root would sit, and the bone then carries
302
+ // it); head-local geometry is simply parented. The kid Head binds with an identity rotation, so
303
+ // there is no twist to undo either way.
304
+ const RIG_SPACE_CM = 60; // a hat's centroid is ~10 cm, a rig-space hairstyle's ~107 — nothing is near 60
305
+ async function seatHeadProp(inst, bodyMesh, name) {
306
+ const src = await loadModel(`/assets/characters/kids/${name}.fbx`, { scale: 1 }); // scale 1 = keep centimetres
307
+ const att = src.clone(true);
308
+ const head = headBoneOfMesh(inst, bodyMesh);
309
+ if (!head) { console.warn('[hair] no head bone on', bodyMesh); return null; }
310
+ inst.updateMatrixWorld(true);
311
+ const box = new THREE.Box3().setFromObject(att);
312
+ if ((box.min.y + box.max.y) / 2 > RIG_SPACE_CM) {
313
+ att.applyMatrix4(new THREE.Matrix4().copy(head.matrixWorld).invert().multiply(inst.matrixWorld));
314
+ }
315
+ att.name = name;
316
+ head.add(att);
317
+ return att;
318
+ }
319
+
320
+ // THE SECOND TEXTURE. applyAtlas puts ONE map on a whole model, which is all an adult ever needs.
321
+ // A kid needs two: his clothes, pupils, eyebrows and hair ride the BODY atlas, and his FACE is two
322
+ // decals on the FACIAL atlases — a mouth, and a freckle spray, each drawn on transparent black. So
323
+ // this runs AFTER applyAtlas and re-materials just the decals.
324
+ //
325
+ // THEY ARE DIFFERENT SHEETS AND THEY ARE NOT INTERCHANGEABLE. The freckle mesh's UVs address the
326
+ // FRECKLE sheet; hand it the mouth sheet and it samples a mouth at freckle UVs — a second mouth
327
+ // across the cheeks. So a child with no freckle texture has the decal HIDDEN, not mis-textured.
328
+ // (visible=false is free: the renderer skips it, and it is set once on the loading screen.)
329
+ //
330
+ // A decal sits a hair's breadth off the face, so it must not sort and must not z-fight: alphaTest
331
+ // (a hard cutout — no blending, no draw order) plus a polygon offset that pulls it forward. It does
332
+ // NOT cast a shadow: it is a flat plane inside the head's own silhouette, so its shadow can only
333
+ // ever be wrong, and skipping it saves two shadow draws per child.
334
+ const faceMatCache = new Map();
335
+ function faceMaterial(tex) {
336
+ let mat = faceMatCache.get(tex.uuid);
337
+ if (!mat) {
338
+ mat = new THREE.MeshStandardMaterial({
339
+ map: tex, transparent: true, alphaTest: 0.5, roughness: 0.9, metalness: 0,
340
+ side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2,
341
+ });
342
+ faceMatCache.set(tex.uuid, mat);
343
+ }
344
+ return mat;
345
+ }
346
+ export function applyFaceAtlas(root, mouthTex, freckleTex = null) {
347
+ const mouth = faceMaterial(mouthTex);
348
+ const freckles = freckleTex ? faceMaterial(freckleTex) : null;
349
+ root.traverse((o) => {
350
+ if (!o.isMesh || !FACE_DECALS.has(o.name)) return;
351
+ if (o.name === FRECKLE_DECAL) {
352
+ if (freckles) o.material = freckles; else o.visible = false;
353
+ } else o.material = mouth;
354
+ o.castShadow = false;
355
+ o.receiveShadow = false;
356
+ });
357
+ }
358
+ // The two unskinned decals of the kid pack. (SM_Chr_Kid_Robot_Face_01 is the third — costume box,
359
+ // not cast, and it is dropped by the keep list.)
360
+ const FRECKLE_DECAL = 'SM_Chr_Kid_Face_Freckles_01';
361
+ export const FACE_DECALS = new Set(['SM_Chr_Kid_Face_01', FRECKLE_DECAL]);
362
+
363
+ // Returns an independent copy safe to add to the scene.
364
+ // SkinnedMesh needs SkeletonUtils.clone; static meshes can share geometry.
365
+ export async function instantiate(url, opts) {
366
+ const master = await loadModel(url, opts);
367
+ // COLLAPSE FIRST, ON THE MASTER — before the clone, so a child is cloned with 51 bones and not
368
+ // with 5,082 of them to be pruned back down one at a time. Memoised inside; the second kid is
369
+ // free. Nothing else in the pipeline can do this job: see collapseSkeletons.
370
+ if (opts && opts.collapse) collapseSkeletons(master);
371
+ let hasSkin = false;
372
+ master.traverse((o) => { if (o.isSkinnedMesh) hasSkin = true; });
373
+ const inst = hasSkin ? skeletonClone(master) : master.clone();
374
+ // `keep`: for multi-character source files, drop every mesh except the one whose name matches.
375
+ // A LIST is allowed, and a child needs one: he is five meshes (body, mouth, freckles, pupils,
376
+ // eyebrows), not one. A lone string keeps the old single-substring behaviour every adult uses.
377
+ //
378
+ // AND THEN DROP THE OTHER CHARACTERS' BONES. The old code kept "the full skeleton" — which is
379
+ // right for a pack that shares ONE skeleton across its bodies (Dungeon Realms' 8 dwarves), and
380
+ // WRONG for the Synty western packs, where EVERY character carries its OWN skeleton. Keeping
381
+ // them left the extracted body dragging 8–27 rival skeletons around, ALL USING THE SAME BONE
382
+ // NAMES (Hips, Spine_01, Shoulder_R…). Everything downstream resolves bones BY NAME — three's
383
+ // PropertyBinding, and our captureBind — so they silently bound to another character's bones:
384
+ // measured on Character_GoldMiner_Male_01, 21 bones answered to "Hips" and the one the name
385
+ // lookup found was NOT the one its mesh is skinned to. That is the contorted-limbs bug (arms
386
+ // flung overhead, hands inside out), and it is the same duplicate-name hazard player.js and
387
+ // footsteps.js already work around by resolving bones from the mixer's own bindings.
388
+ if (opts && opts.keep) {
389
+ const keeps = (Array.isArray(opts.keep) ? opts.keep : [opts.keep]).map((s) => s.toLowerCase());
390
+ const drop = [];
391
+ inst.traverse((o) => {
392
+ if (o.isMesh && !keeps.some((k) => (o.name || '').toLowerCase().includes(k))) drop.push(o);
393
+ });
394
+ drop.forEach((o) => o.removeFromParent());
395
+
396
+ // ── AND THEN THE OTHER CHARACTERS' BONES, WHICH COST MORE THAN ANYTHING ELSE IN THIS FILE ──
397
+ //
398
+ // FBXLoader hands these multi-character packs back with the bodies' skeletons NESTED INSIDE
399
+ // ONE ANOTHER, one duplicate per bone NAME, per body:
400
+ // Root > Root > Root > … (8 deep in WesternTownChars, 100 in Characters_Kids)
401
+ // Hips > Hips > Hips > …
402
+ // Spine_01 > Spine_01 > …
403
+ // MEASURED (the running game, /assets/characters): the OUTERMOST copy of each name carries the
404
+ // real bind offset AND is the one three's PropertyBinding finds by name, so it is the one the
405
+ // mixer actually drives; every copy below it is a pure IDENTITY pass-through (position 0,
406
+ // quaternion 1, scale 1), and a body's own skeleton simply picks the copy at its own index.
407
+ // That is why this works at all — the extracted body inherits the animation through a stack of
408
+ // identity transforms.
409
+ //
410
+ // It is also why a keep-extracted body dragged the WHOLE PACK's bones around: 392 of them for
411
+ // one western townsman, and 5,082 for one child (the Kids pack has ~100 bodies). three walks
412
+ // every Object3D in the scene graph every frame in updateMatrixWorld, visible or not — so
413
+ // twenty-three children put 117,000 dead bones in the graph and cost 32 ms A FRAME, in the
414
+ // RENDER phase, for nothing. Measured: scene nodes 276k, render 42 ms; after this, 25k and
415
+ // 6.6 ms, which is FASTER than the fourteen-NPC town was before any of them existed.
416
+ //
417
+ // Two removals, both transform-EXACT, and the distinction is the whole safety argument:
418
+ // 1. PRUNE any bone that is neither ours nor an ANCESTOR of one of ours. Those are leafward
419
+ // subtrees belonging to bodies we dropped; nothing references them and no kept bone's
420
+ // parent chain runs through them.
421
+ // 2. COLLAPSE the identity pass-throughs BETWEEN the outermost copy and ours — a bone whose
422
+ // own parent carries the same name is by construction a middle of such a chain. Its
423
+ // children are re-parented to its parent, which changes no world transform because its
424
+ // local transform is the identity (asserted, not assumed — a non-identity middle is left
425
+ // alone). The OUTERMOST copy is never touched, so it is still the first hit of a
426
+ // depth-first name search and the mixer binds exactly the node it bound before.
427
+ const own = new Set();
428
+ inst.traverse((o) => { if (o.isSkinnedMesh && o.skeleton) for (const b of o.skeleton.bones) if (b) own.add(b); });
429
+ if (own.size) {
430
+ const anc = new Set();
431
+ for (const b of own) { let p = b.parent; while (p && p.isBone) { anc.add(p); p = p.parent; } }
432
+ const cut = [], mid = [];
433
+ inst.traverse((o) => {
434
+ if (!o.isBone || own.has(o)) return;
435
+ if (!anc.has(o)) { cut.push(o); return; } // foreign subtree
436
+ if (o.parent?.isBone && o.parent.name === o.name && isIdentity(o)) mid.push(o); // identity pass-through
437
+ });
438
+ for (const b of cut) b.removeFromParent();
439
+ for (const b of mid) { // top-down: a middle's parent is final by the time we reach it
440
+ const p = b.parent;
441
+ if (!p) continue;
442
+ for (const c of [...b.children]) p.add(c); // add() re-parents and keeps the local transform
443
+ b.removeFromParent();
444
+ }
445
+ }
446
+ }
447
+ // `attach`: seat static head props (dwarf beards/hair) on the Head bone. The meshes are
448
+ // authored head-local and the pack's reorienting Head_Attachments node is pruned by
449
+ // fbx2gltf, so cancel the bone's bind-pose world rotation (att.local = inverse(bone world
450
+ // quat) after updateMatrixWorld) → the attachment renders upright and rides the head.
451
+ if (opts && opts.attach && opts.attach.length) {
452
+ inst.updateMatrixWorld(true);
453
+ const bone = headBoneOf(inst);
454
+ if (bone) {
455
+ const q = new THREE.Quaternion(); bone.getWorldQuaternion(q); q.invert();
456
+ for (const name of opts.attach) {
457
+ try {
458
+ const src = await loadGLB(`/assets/characters/attach/${name}.glb`);
459
+ const att = src.clone(true);
460
+ att.quaternion.copy(q);
461
+ bone.add(att);
462
+ } catch (e) { console.warn('[attach] failed', name, e); }
463
+ }
464
+ } else console.warn('[attach] no head bone on', url);
465
+ }
466
+ // `hair`: the kid pack's head props — one FBX per style in /assets/characters/kids (97 of them).
467
+ // A child's hair is NOT in his body mesh (an adult's is), so a kid with no entry here is BALD.
468
+ // They are hung off the Head bone resolved through keep[0] — the BODY mesh — and carry NO
469
+ // materials of their own: the applyAtlas the caller runs next walks the whole model, bones and
470
+ // their children included, so the hair picks up the body atlas and therefore the atlas's hair
471
+ // COLOUR. (Which is the only colour it can have: every hair vertex sits on one texel of it.)
472
+ if (opts && opts.hair && opts.hair.length) {
473
+ const body = Array.isArray(opts.keep) ? opts.keep[0] : opts.keep;
474
+ for (const name of opts.hair) {
475
+ try { await seatHeadProp(inst, body, name); }
476
+ catch (e) { console.warn('[hair] failed', name, e); }
477
+ }
478
+ }
479
+ return inst;
480
+ }
481
+
482
+ // ONE material per (atlas, emissive, transparency) shared by EVERY model — not a memory
483
+ // nicety, a pipeline-compile fix: per-model fresh materials meant each first-seen model
484
+ // compiled its own WebGPU pipeline family (the shop's 2.7s per-item hover stalls,
485
+ // pipe:+10 in the freeze telemetry). With a shared instance + the uniform Synty vertex
486
+ // layout, a new model reuses a pipeline the world already compiled at boot → pipe:0.
487
+ // CONTRACT: these materials are shared game-wide — never mutate one on an instance;
488
+ // clone first (see player.applyMoralityLook).
489
+ const atlasMatCache = new Map();
490
+ export function applyAtlas(root, tex, { emissive = null } = {}) {
491
+ root.traverse((o) => {
492
+ if (!o.isMesh) return;
493
+ const mats = Array.isArray(o.material) ? o.material : [o.material];
494
+ o.material = mats.map((m) => {
495
+ const key = `${tex.uuid}|${emissive?.uuid ?? ''}|${m.transparent ? `t${m.opacity}` : 'o'}|${o.isSkinnedMesh ? 's' : 'f'}`;
496
+ let mat = atlasMatCache.get(key);
497
+ if (!mat) {
498
+ mat = new THREE.MeshStandardNodeMaterial({
499
+ map: tex,
500
+ roughness: 0.85,
501
+ metalness: 0.0,
502
+ // STATICS are flat-shaded: Synty meshes are authored flat, and merged vertex
503
+ // normals come out inverted on some env pieces — faces render pure BLACK when
504
+ // the sun is on their front side (the villages sun-angle black-render; same
505
+ // fix that cured Bloodfang's black stairs, see bloodfang.js). Skinned meshes
506
+ // keep smooth normals (deformation needs them; theirs aren't corrupted).
507
+ flatShading: !o.isSkinnedMesh,
508
+ });
509
+ if (emissive) { mat.emissiveMap = emissive; mat.emissive = new THREE.Color(0xffffff); }
510
+ if (m.transparent) { mat.transparent = true; mat.opacity = m.opacity; }
511
+ // EMISSIVE HOOK: a game may register a world-lighting term (e.g. a nearest-N
512
+ // lamp field) that is ADDED into every atlas material's emissive so props,
513
+ // faces and walls light for real after dark. Preserve any emissiveMap by
514
+ // ADDING to it, never replacing it (a few atlas mats glow).
515
+ const albN = texture(tex, uv()).rgb;
516
+ const emisTex = emissive ? texture(emissive, uv()).rgb : null;
517
+ const hooked = atlasEmissiveHook ? atlasEmissiveHook(albN) : null;
518
+ mat.emissiveNode = hooked ? (emisTex ? emisTex.add(hooked) : hooked) : emisTex;
519
+ atlasMatCache.set(key, mat);
520
+ }
521
+ return mat;
522
+ });
523
+ if (o.material.length === 1) o.material = o.material[0];
524
+ o.castShadow = true;
525
+ o.receiveShadow = true;
526
+ });
527
+ }
528
+
529
+ // PARSED-CLIP FILES (same cook-to-file scheme as the VAT/nav bins): FBXLoader parses on
530
+ // the main thread and the ~100 clip files dominate the loading bar's climb. A parsed
531
+ // AnimationClip round-trips losslessly through toJSON/parse, so cook each to
532
+ // public/assets/clips/<name>.json on first parse (self-written via /__save-clipjson)
533
+ // and read the JSON on every later boot. Bump CLIP_JSON_VER after changing any source
534
+ // animation (the name alone can't see content changes), or delete the folder.
535
+ const CLIP_JSON_VER = 2; // v2: full-pack gsbake rebake (97 clips, 2026-07-12)
536
+ function clipJsonName(url) {
537
+ let h = 5381;
538
+ for (let i = 0; i < url.length; i++) h = ((h << 5) + h + url.charCodeAt(i)) >>> 0;
539
+ return `${url.split('/').pop().replace(/\.fbx$/i, '').replace(/[^\w-]/g, '_')}-v${CLIP_JSON_VER}-${h.toString(16)}.json`;
540
+ }
541
+ export async function loadClip(url, name) {
542
+ if (!clipCache.has(url)) {
543
+ const jsonName = clipJsonName(url);
544
+ const p = (async () => {
545
+ try {
546
+ const r = await fetch(`/assets/clips/${jsonName}`);
547
+ if (r.ok) {
548
+ const clip = THREE.AnimationClip.parse(await r.json());
549
+ clip.name = name ?? url.split('/').pop().replace('.fbx', '');
550
+ tick(clip.name);
551
+ return clip;
552
+ }
553
+ } catch (e) { /* no json — parse the FBX below */ }
554
+ const g = await fbxLoader.loadAsync(url);
555
+ // MultiTake FBX (e.g. the gunslinger pack) lead with a 1-frame stub stack —
556
+ // take the LONGEST animation, which for single-take files is animations[0] anyway.
557
+ const clip = (g.animations ?? []).reduce((best, c) => (c.duration > (best?.duration ?? -1) ? c : best), null);
558
+ if (!clip) throw new Error(`No animation in ${url}`);
559
+ clip.name = name ?? url.split('/').pop().replace('.fbx', '');
560
+ if (import.meta.env?.DEV) {
561
+ try {
562
+ fetch(`/__save-clipjson?name=${jsonName}`, { method: 'POST', body: JSON.stringify(clip.toJSON()) }).catch(() => {});
563
+ } catch (e) { /* live clip still returned */ }
564
+ }
565
+ tick(clip.name);
566
+ return clip;
567
+ })();
568
+ clipCache.set(url, p);
569
+ }
570
+ return clipCache.get(url);
571
+ }
572
+
573
+ // Load a glodify LOD .glb (units preserved from the FBX source — cm).
574
+ export async function loadGLB(url) {
575
+ if (!gltfCache.has(url)) {
576
+ const p = gltfLoader.loadAsync(url).then((g) => {
577
+ dedupEmbeddedTextures(g.scene);
578
+ tick(url.split('/').pop());
579
+ return g.scene;
580
+ });
581
+ gltfCache.set(url, p);
582
+ }
583
+ return gltfCache.get(url);
584
+ }
585
+
586
+ export async function loadClips(entries) {
587
+ const out = {};
588
+ await Promise.all(
589
+ Object.entries(entries).map(async ([name, url]) => {
590
+ // one bad/missing clip must not break the whole boot — the animator falls back to 'idle'
591
+ try { out[name] = await loadClip(url, name); }
592
+ catch (e) { console.warn(`clip "${name}" failed to load (${url})`, e); }
593
+ })
594
+ );
595
+ return out;
596
+ }