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
package/README.md
CHANGED
|
@@ -7,9 +7,70 @@
|
|
|
7
7
|
- **Consumption:** games depend on `"sindicate": "github:<owner>/sindicate#vX.Y.Z"` (stable, lockfile-pinned) or `"file:../Sindicate"` (live engine hacking). Source ESM, no build step; `three` and `three-mesh-bvh` are peer dependencies.
|
|
8
8
|
- **The rule:** engine code changes **only in this repo** — never copy engine files into a game to hack them.
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## What's in the box
|
|
11
11
|
|
|
12
|
-
Migration
|
|
12
|
+
Migration steps 1–2 (of the plan in ENGINE_DESIGN.md §9): the verified-clean world/systems tier plus the complete core pipeline — rendering, assets, animation, retargeting, input — extracted from western, with game data arriving through registration calls instead of imports. Full reference: [`docs/api/`](./docs/api/).
|
|
13
|
+
|
|
14
|
+
**Core**
|
|
15
|
+
|
|
16
|
+
- [assets](./docs/api/assets.md) — Synty FBX/GLB loading, atlas binding with shared cached materials, multi-skeleton pack collapse, clip cooking to JSON, texture warm-up.
|
|
17
|
+
- [anim](./docs/api/anim.md) — `Animator`/`RawAnimator` state machine: auto retargeting, bone-masked overlay layer, per-clip timing trims, safe one-shot callbacks.
|
|
18
|
+
- [retarget](./docs/api/retarget.md) — bind-pose-delta retargeting so clips authored on one rig play on differently-named rigs.
|
|
19
|
+
- [input](./docs/api/input.md) — keyboard + mouse + gamepad behind one API with edge detection, data-driven pad bindings, analog sticks.
|
|
20
|
+
- [renderer](./docs/api/renderer.md) — WebGPU with WebGL2 fallback, opt-in TRAA, post-processing with a single game-supplied TSL screen-grade slot.
|
|
21
|
+
- [quality](./docs/api/quality.md) — low/medium/high presets, per-game persistence, live apply, one-way FPS `AutoTuner`.
|
|
22
|
+
- [utils & decals](./docs/api/utils-decals.md) — shared math/noise helpers plus `DecalField`, a ring-buffer-budgeted surface decal system.
|
|
23
|
+
|
|
24
|
+
**World**
|
|
25
|
+
|
|
26
|
+
- [collision](./docs/api/collision.md) — merged-static `MeshBVH` capsule collision, built sync or in a Web Worker for streamed tiles.
|
|
27
|
+
- [grass](./docs/api/grass.md) — streaming instanced grass clumps: GPU wind, trampling with spring-back, dryness/wetness/snow.
|
|
28
|
+
- [water](./docs/api/water.md) — see-through low-poly water: scene refraction with Beer–Lambert absorption, fresnel sky reflection.
|
|
29
|
+
- [wind & weather uniforms](./docs/api/wind-weather-uniforms.md) — the single source of weather truth: CPU `Wind` sim + shared TSL uniform singletons.
|
|
30
|
+
|
|
31
|
+
**Systems**
|
|
32
|
+
|
|
33
|
+
- [shatter](./docs/api/shatter.md) — pooled prop debris: real shard meshes flung, bounced, settled and faded, pure CPU, nothing resident.
|
|
34
|
+
|
|
35
|
+
**Vendor**
|
|
36
|
+
|
|
37
|
+
- [FBXLoader](./docs/api/fbxloader.md) — vendored three.js FBXLoader patched to merge all FBX animation layers; pinned against three ^0.184.
|
|
38
|
+
|
|
39
|
+
## Callable API
|
|
40
|
+
|
|
41
|
+
Games plug in at explicit registration points — all boot-time, before first use (see each doc's gotchas for ordering):
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
import {
|
|
45
|
+
setClipDefaults, setAtlasEmissiveHook, setQualityStorageKey,
|
|
46
|
+
Input, DEFAULT_PAD_BINDINGS, createRenderer, createPostProcessing, wantsTRAA,
|
|
47
|
+
} from 'sindicate';
|
|
48
|
+
|
|
49
|
+
// Per-game quality storage key — BEFORE the first getQuality read
|
|
50
|
+
// (games sharing an origin in dev otherwise overwrite each other).
|
|
51
|
+
setQualityStorageKey('mygame_quality');
|
|
52
|
+
|
|
53
|
+
// Game animation data: per-clip timing trims + which source rig authored each clip.
|
|
54
|
+
setClipDefaults({
|
|
55
|
+
timing: { swing: { start: 0.12, speed: 1.4, end: 0.85 } },
|
|
56
|
+
rigs: { swing: 'syntyKnight' },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// TSL emissive term added into every atlas material — BEFORE the first
|
|
60
|
+
// applyAtlas/instantiate (materials are cached).
|
|
61
|
+
setAtlasEmissiveHook((albedo) => lampFieldTerm(albedo));
|
|
62
|
+
|
|
63
|
+
// Unified input with the game's own pad layout (data, not code).
|
|
64
|
+
const input = new Input(canvas, { padBindings: DEFAULT_PAD_BINDINGS });
|
|
65
|
+
|
|
66
|
+
// Renderer + post pipeline; the grade is the game's one screen-look slot,
|
|
67
|
+
// driven live via post.gradeAmount.value — never rebuilt at runtime.
|
|
68
|
+
const { renderer } = await createRenderer(container);
|
|
69
|
+
const post = createPostProcessing(renderer, scene, camera, {
|
|
70
|
+
traa: wantsTRAA(),
|
|
71
|
+
grade: Fn(([color, amount]) => /* identity at amount 0 */ color),
|
|
72
|
+
});
|
|
73
|
+
```
|
|
13
74
|
|
|
14
75
|
## Games on Sindicate
|
|
15
76
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Sindicate API reference
|
|
2
|
+
|
|
3
|
+
One page per module, written from the actual source. Each page covers every export with real signatures, one realistic usage example, and a **gotchas** section preserving the hard-won knowledge from the source comments (perf rules, ordering constraints, bug history). Contracts — who owns what state, what a game must register — live in [`../contracts/`](../contracts/); these pages are the per-module mechanics.
|
|
4
|
+
|
|
5
|
+
## Core (`src/core/`)
|
|
6
|
+
|
|
7
|
+
- [`assets.md`](./assets.md) — model/texture/clip loading: Synty FBX + GLB-twin redirect, atlas binding with shared cached materials, multi-skeleton pack collapse, clip cooking to JSON, `warmTextures`, loading-progress hooks.
|
|
8
|
+
- [`anim.md`](./anim.md) — `Animator`/`RawAnimator` state machine over `THREE.AnimationMixer`: auto retargeting per source rig, one bone-masked overlay layer, per-clip timing trims, safe one-shot callbacks; `setClipDefaults` registers game timing/rig tables.
|
|
9
|
+
- [`retarget.md`](./retarget.md) — bind-pose-delta animation retargeting: play clips authored on one skeleton (Synty ANIMATION rig) on differently-named/oriented rigs (POLYGON packs), with a named-rig registry.
|
|
10
|
+
- [`input.md`](./input.md) — keyboard + mouse + gamepad behind one API (`down`/`hit`/`held`/`consume`), data-driven pad bindings, analog `moveStick`/`stickLook`, UI-modal suppression and edge priming.
|
|
11
|
+
- [`renderer.md`](./renderer.md) — WebGPU renderer with WebGL2 fallback (`?webgl`), opt-in TRAA (`?traa`), and `createPostProcessing` with the single game-supplied TSL "screen grade" slot driven by one uniform.
|
|
12
|
+
- [`quality.md`](./quality.md) — `low`/`medium`/`high` presets, per-game `localStorage` persistence (`setQualityStorageKey`), live `applyQuality`, and the one-way `AutoTuner` FPS downgrader.
|
|
13
|
+
- [`utils-decals.md`](./utils-decals.md) — shared math/noise helpers and scratch objects (`utils.js`), plus `DecalField`, a ring-buffer-budgeted ground/surface decal system (blood, scorch, footprints).
|
|
14
|
+
|
|
15
|
+
## World (`src/world/`)
|
|
16
|
+
|
|
17
|
+
- [`collision.md`](./collision.md) — merged-static-geometry `MeshBVH` collision (sync `buildBVH` and worker-based `buildBVHAsync`) that capsule-sweeps the player against real triangles; stairs and slopes just work.
|
|
18
|
+
- [`grass.md`](./grass.md) — `GrassField`: instanced 4-blade clumps streamed in a ring around the player; wind, trampling with spring-back, dryness, wetness and snow all evaluated on the GPU in TSL.
|
|
19
|
+
- [`water.md`](./water.md) — low-poly see-through water: refracts the rendered scene with per-channel Beer–Lambert absorption, fresnel sky reflection wired to the weather uniforms, faceted swell and shoreline foam.
|
|
20
|
+
- [`wind-weather-uniforms.md`](./wind-weather-uniforms.md) — the single source of weather truth: `Wind` CPU simulation ticked once per frame, plus module-level TSL uniform singletons every weather-aware shader reads.
|
|
21
|
+
|
|
22
|
+
## Systems (`src/systems/`)
|
|
23
|
+
|
|
24
|
+
- [`shatter.md`](./shatter.md) — pooled debris for smashed props: clones the pack's real shard meshes, flings/bounces/settles/fades them with pure CPU integration — no physics engine, nothing resident.
|
|
25
|
+
|
|
26
|
+
## Vendor (`src/vendor/`)
|
|
27
|
+
|
|
28
|
+
- [`fbxloader.md`](./fbxloader.md) — vendored three.js `FBXLoader` with one local patch: merges **all** FBX animation layers per stack (stock three keeps only the first); pinned against three ^0.184.
|
package/docs/api/anim.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# anim — character animation state machine
|
|
2
|
+
|
|
3
|
+
`src/core/anim.js`
|
|
4
|
+
|
|
5
|
+
A thin state machine over `THREE.AnimationMixer` with automatic per-rig clip retargeting (via `./retarget.js`), a single persistent overlay layer for bone-masked composition, per-clip timing trims, and safe one-shot completion callbacks.
|
|
6
|
+
|
|
7
|
+
## Exports
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { setClipDefaults, Animator, RawAnimator } from './core/anim.js';
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### `setClipDefaults({ timing = null, rigs = null } = {})`
|
|
14
|
+
|
|
15
|
+
Registers game data with the engine, once at boot:
|
|
16
|
+
|
|
17
|
+
- `timing` — `{ clipName -> { start?, speed?, end? } }`. Per-clip trim/speed defaults (fractions 0..1 for `start`/`end`; `speed` is a `timeScale`). Tuned via `/anim.html`.
|
|
18
|
+
- `rigs` — `{ clipName -> sourceRigKey }`. Which source rig each clip was authored against; becomes the default `clipRigs` for every `Animator` constructed without an explicit one.
|
|
19
|
+
|
|
20
|
+
Both are module-level tables, deliberately *game data, not engine code*. Unregistered, clips play untrimmed and every `Animator` needs explicit `clipRigs`. Passing `null`/omitting a field leaves the existing table untouched.
|
|
21
|
+
|
|
22
|
+
### `class Animator`
|
|
23
|
+
|
|
24
|
+
#### `new Animator(root, clips, clipRigs = null)`
|
|
25
|
+
|
|
26
|
+
- `root` — the object the `AnimationMixer` binds to (the character's skinned model root).
|
|
27
|
+
- `clips` — `{ name -> THREE.AnimationClip }`; each is registered (retargeted) at construction.
|
|
28
|
+
- `clipRigs` — optional `{ clipName -> sourceRigKey }`; defaults to the table given to `setClipDefaults`. A clip is authored against ONE skeleton's bind pose; clips from a foreign pack (different bind, same bone names) must retarget from THAT bind or they play folded in half. One retarget context is built and cached per source rig (`_ctxByRig`).
|
|
29
|
+
|
|
30
|
+
Public fields other code relies on: `mixer`, `root`, `actions` (`name -> AnimationAction`), `current` / `currentName` (base FSM action), `ctx` (the default retarget context — `null` on `RawAnimator`).
|
|
31
|
+
|
|
32
|
+
#### `get needsRetarget` → `true`
|
|
33
|
+
|
|
34
|
+
Overridden to `false` by `RawAnimator`. The constructor *asks* this before building a default retarget context, because `buildRetargetContext` throws if no source rig has been registered yet — see gotchas.
|
|
35
|
+
|
|
36
|
+
#### `register(name, clip)`
|
|
37
|
+
|
|
38
|
+
Retargets `clip` from its source rig (looked up in `clipRigs`, else the default rig) onto `root`'s skeleton and stores the resulting `mixer.clipAction`.
|
|
39
|
+
|
|
40
|
+
#### `registerMasked(name, srcName, keepFn)`
|
|
41
|
+
|
|
42
|
+
Builds a bone-masked copy of an already-registered clip: clones `srcName`'s clip and keeps only tracks whose bone name passes `keepFn(boneName)` (the `.position`/`.quaternion`/`.scale` suffix is stripped before the callback sees it). No-op if `srcName` isn't registered. Used to split locomotion across body halves — e.g. legs from `walk` + torso/arms from `blockLoop` so you can walk with the guard up.
|
|
43
|
+
|
|
44
|
+
#### `play(name, opts = {})` → `AnimationAction | null`
|
|
45
|
+
|
|
46
|
+
Fades the base FSM action to `name`. Returns the action, or `null` if unregistered.
|
|
47
|
+
|
|
48
|
+
- `once` (default `false`) — `LoopOnce` + `clampWhenFinished`; otherwise `LoopRepeat`.
|
|
49
|
+
- `fade` (default `0.18`) — crossfade seconds (`fadeIn` on the new action, `fadeOut` on the previous base).
|
|
50
|
+
- `restart` (default `false`) — without it, re-`play`ing the current looping clip is a no-op (one-shots always restart).
|
|
51
|
+
- `onDone` — one-shot completion callback (see gotchas for the per-action storage).
|
|
52
|
+
- `startFrac` (default `CLIP_TIMING[name].start ?? 0`) — 0..1, skips into the clip past wind-up.
|
|
53
|
+
- `timeScale` (default `CLIP_TIMING[name].speed ?? 1`).
|
|
54
|
+
|
|
55
|
+
Explicit opts always override the `CLIP_TIMING` defaults.
|
|
56
|
+
|
|
57
|
+
#### `setOverlay(name, opts = {})` → `AnimationAction | null`
|
|
58
|
+
|
|
59
|
+
One persistent overlay action that plays *alongside* the base action — `play()` only fades `this.current`, never the overlay. Non-overlapping tracks compose. Pass `null`/falsy `name` to drop the overlay.
|
|
60
|
+
|
|
61
|
+
- `restart` — force a reset even if `name` is already the overlay (otherwise same-overlay calls return early).
|
|
62
|
+
- `once` — `LoopOnce` + `clampWhenFinished` on the overlay.
|
|
63
|
+
- `fade` — when *swapping* overlays of the same mask: `crossFadeFrom` duration. When *dropping* the overlay: `fadeOut` duration, which MUST equal the fade the caller uses on its full-body base `play()` (see gotchas).
|
|
64
|
+
|
|
65
|
+
Without `fade`, swaps and drops are instant (`stop()` + weight 0).
|
|
66
|
+
|
|
67
|
+
#### `update(dt)`
|
|
68
|
+
|
|
69
|
+
Advance the mixer, then enforce the current clip's `CLIP_TIMING.end` trim: when `action.time` passes `end * duration`, a one-shot clamps there, pauses, and fires its `onDone`; a loop cycles back to `start * duration`. Call every frame.
|
|
70
|
+
|
|
71
|
+
#### `stopAll()`
|
|
72
|
+
|
|
73
|
+
`mixer.stopAllAction()` and clears `current`, `currentName`, and the overlay reference.
|
|
74
|
+
|
|
75
|
+
### `class RawAnimator extends Animator`
|
|
76
|
+
|
|
77
|
+
For self-animated rigs (PolyPerfect creatures, like the horse) whose clips target the model's OWN skeleton. `needsRetarget` returns `false` and `register(name, clip)` is just `mixer.clipAction(clip)` — no retarget, no retarget context. Everything else (`play`, `setOverlay`, once-callbacks, end-trim) behaves identically.
|
|
78
|
+
|
|
79
|
+
## Usage example
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
import { setClipDefaults, Animator } from './core/anim.js';
|
|
83
|
+
|
|
84
|
+
// Once at boot — game data, tuned in /anim.html:
|
|
85
|
+
setClipDefaults({
|
|
86
|
+
timing: { swing: { start: 0.12, speed: 1.4, end: 0.85 } },
|
|
87
|
+
rigs: { swing: 'syntyKnight' }, // authored on a foreign pack's bind pose
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const anim = new Animator(model, { idle: idleClip, walk: walkClip, swing: swingClip, aim: aimClip });
|
|
91
|
+
|
|
92
|
+
// Split the body: legs keep walking, upper body holds the bow.
|
|
93
|
+
const isUpper = (b) => /Spine|Chest|Neck|Head|Shoulder|Arm|Hand/.test(b);
|
|
94
|
+
anim.registerMasked('walkLower', 'walk', (b) => !isUpper(b));
|
|
95
|
+
anim.registerMasked('aimUpper', 'aim', isUpper);
|
|
96
|
+
|
|
97
|
+
// Enter aim: swap the base to the lower-only mask and start the overlay in the SAME frame.
|
|
98
|
+
anim.play('walkLower', { fade: 0.15 });
|
|
99
|
+
anim.setOverlay('aimUpper'); // instant start — weights stay summed to 1
|
|
100
|
+
|
|
101
|
+
// Leave aim: fade the overlay out over EXACTLY the base's fade-in.
|
|
102
|
+
anim.setOverlay(null, { fade: 0.15 });
|
|
103
|
+
anim.play('walk', { fade: 0.15 });
|
|
104
|
+
|
|
105
|
+
// One-shot with completion:
|
|
106
|
+
busy = true;
|
|
107
|
+
anim.play('swing', { once: true, onDone: () => { busy = false; } });
|
|
108
|
+
|
|
109
|
+
// Every frame:
|
|
110
|
+
anim.update(dt);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Gotchas
|
|
114
|
+
|
|
115
|
+
- **Weights must sum to 1 on every bone, always.** three's `PropertyMixer` fills any cumulative-weight deficit from the binding's ORIGINAL saved value — captured at the mixer's first activation, i.e. the model's BIND POSE. A weight gap does not blend between two poses: it blends toward a T-pose. History: a 0.12s overlay `fadeOut` racing a 0.18s base `fadeIn` dipped the sum to ~0.58 and flashed the arms 42% toward T-pose on every aim release. Discipline:
|
|
116
|
+
- Overlay swaps of the *same mask* crossfade (`opts.fade`) — `crossFadeFrom` keeps the pair summing to 1.
|
|
117
|
+
- *Starting* an overlay is instant, and the caller switches the base to the complementary mask in the same frame.
|
|
118
|
+
- *Dropping* an overlay fades it out over exactly the duration the caller fades its full-body base in (pass the same `fade` to both calls): upper body gets `overlay(1-t) + base(t) = 1`, lower gets `lowerBase(1-t) + base(t) = 1`. A plain `stop()` while the base is still lower-only leaves the upper body at zero weight — a ~0.2s T-pose flash at the end of every one-shot. A hard pose cut is survivable; a T-pose is not.
|
|
119
|
+
- **One-shot callbacks are per-action (`action._onceDone`), not a shared slot.** With a single slot, any `play({once})` stomped the *previous* one-shot's pending callback — e.g. a bow release during a dodge wiped the dodge's `busy = false` and soft-locked the player. Per-action, each one-shot can only ever replace its own callback. Re-playing a clip as a loop clears any stale `_onceDone`. The end-trim path in `update()` fires and clears the same slot, so `onDone` fires exactly once whether the clip finishes naturally or hits its `end` trim.
|
|
120
|
+
- **Retargeting is per source rig.** Clips authored against a different bind pose (same bone names) MUST retarget from *that* bind or they play folded in half — that's what `clipRigs` / `setClipDefaults({rigs})` maps. One context is built and cached per rig key.
|
|
121
|
+
- **`RawAnimator` exists because building a retarget context can crash.** The base constructor used to always build a default context, and `buildRetargetContext` threw if no source rig had been registered yet. In the game that was invisible (the player registers the Synty rig long before a horse loads); anywhere else — a bench that only wants a horse — it was a hard crash on construction. Hence the `needsRetarget` getter: ask, don't assume. Other code reads `.ctx`, which is `null` on a `RawAnimator`.
|
|
122
|
+
- **`reset()` must come first in `setOverlay`** — `AnimationAction.reset()` calls `stopFading()`, so any fade set up before it would be cancelled.
|
|
123
|
+
- **`update(dt)` is where end-trims live.** `CLIP_TIMING.end < 1` only takes effect if you call `update` every frame; the mixer's own `finished` event never fires for a trimmed one-shot (it clamps and pauses at `end` instead).
|
|
124
|
+
- **`play()` on the currently looping clip is a no-op** unless `restart: true` or `once: true` — safe to call from a per-frame locomotion FSM.
|
|
125
|
+
- **Timing tables are defaults, not law.** Explicit `startFrac` / `timeScale` opts override `CLIP_TIMING`; the tables live in game data and are registered once via `setClipDefaults`.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# assets — model, texture and clip loading
|
|
2
|
+
|
|
3
|
+
`src/core/assets.js`
|
|
4
|
+
|
|
5
|
+
The asset system loads Synty FBX models and animation clips, binds atlas textures (FBXLoader does not auto-link Synty's atlases), and caches everything. It also transparently redirects static FBX loads to faster `.glb` twins, dedups embedded GLB textures, collapses the pathological multi-skeleton character packs onto one skeleton, and cooks parsed animation clips to JSON so later boots skip main-thread FBX parsing.
|
|
6
|
+
|
|
7
|
+
The module uses a patched `FBXLoader` from `src/vendor/FBXLoader.js` that merges **all** FBX animation layers per stack — the engine does not depend on a `node_modules` postinstall patch. The internal `GLTFLoader` has `MeshoptDecoder` installed; without it every `*_LOD*.glb` (EXT_meshopt-compressed) fails to load and trees/building LODs silently never appear, leaving only invisible colliders.
|
|
8
|
+
|
|
9
|
+
All loaders cache **promises**, not resolved values — concurrent callers for the same key share one in-flight load.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Loading models
|
|
14
|
+
|
|
15
|
+
### `loadModel(url, { texture = null, scale, twin = false }) → Promise<THREE.Group>`
|
|
16
|
+
|
|
17
|
+
Loads a model and returns the shared **master** copy (do not add it to the scene directly — use `instantiate`). Dispatches by extension:
|
|
18
|
+
|
|
19
|
+
- `.fbx` → patched FBXLoader (main-thread parse; slow — see gotchas). Default scale `SYNTY_SCALE = 0.01` (Synty FBX are authored in centimetres).
|
|
20
|
+
- `.glb` → GLTFLoader. Default scale `1` — fbx2gltf-converted GLBs already baked cm→m. The GLB's separate `g.animations` are copied onto `g.scene.animations` so the VAT bakers can read clips off the model just like FBX. Embedded textures are deduped (see below).
|
|
21
|
+
|
|
22
|
+
After load: the group is scaled, any stray lights the file carries are stripped (Synty FBX embed a grey AmbientLight in their global settings — 7+ of them at intensity 1 washed out the whole scene, including the dungeon's dark mood; the world is lit explicitly, never by FBX), and if `texture` is given the atlas is loaded and `applyAtlas` is run on the whole group. GLB models get the atlas at `flipY: false` (glTF top-left-origin UV convention); FBX keeps the TextureLoader default `true`.
|
|
23
|
+
|
|
24
|
+
**Cache key is `url|texture|scale` — by what actually varies, not url alone.** Keying by url alone meant the first caller to load a file decided its skin for the whole run; worse, a texture-less first caller cached a master whose materials never went through `applyAtlas`, leaving raw FBX `MeshLambertMaterial`s pointing at an unresolvable texture — which renders **BLACK** (it blacked out all 29 bodies in the casting bench, the coach's driver, mate, and every randomly-skinned fare). One body in two atlases is two masters; that is the correct amount of masters.
|
|
25
|
+
|
|
26
|
+
A rejected load deletes its cache entry, so a transient 404 does not cache the rejection forever — retries re-fetch.
|
|
27
|
+
|
|
28
|
+
Dev census: every URL that still parses through FBXLoader is pushed to `window.__fbxLoads`.
|
|
29
|
+
|
|
30
|
+
### `resolveModelUrl(url, { texture = null, scale, twin = false }) → Promise<string>`
|
|
31
|
+
|
|
32
|
+
The FBX→GLB redirect decision, exported on its own. `scripts/convert_runtime_statics.mjs` converts every runtime static FBX to a same-dir `.glb` twin (fbx2gltf `--binary`, **default V-flip** — a reverted batch used `--no-flip-v` and mirrored every atlas cell) and lists them in `/assets/glb-twins.json`. The redirect fires only when:
|
|
33
|
+
|
|
34
|
+
- the url ends `.fbx`, **and**
|
|
35
|
+
- an atlas `texture` is supplied (applyAtlas replaces the GLB's materials, so conversion material fidelity is irrelevant) **or** `twin: true` (an explicit opt-in for texture-less callers whose downstream discards the file's materials anyway, e.g. scatterTree UV-splits geometry and builds its own), **and**
|
|
36
|
+
- the scale is "redirectable": `scale === undefined` (cm-authored pack; the twin is metres and loader default 1 fits), or `scale === 1` on a `/assets/dungeon/` url — the DUNGEON kit is metre-authored with cm FBX headers, its raw twins came out 100× small, so those twins carry a baked ×100 root rescale and `scale:1` loads them correctly.
|
|
37
|
+
|
|
38
|
+
A missing manifest degrades to plain FBX loading.
|
|
39
|
+
|
|
40
|
+
**Why it's exported:** callers that build their own materials from a model's flattened geometry (scatter / scatterResident) must know which format actually loaded — glTF-convention UVs need the atlas at `flipY:false`, FBX-convention at the default `true`. scatter() not knowing this was the mirrored-cell bug on every scatter-placed prop (the red wells, the washed stall tents) after the twins landed.
|
|
41
|
+
|
|
42
|
+
### `loadGLB(url) → Promise<THREE.Group>`
|
|
43
|
+
|
|
44
|
+
Loads a glodify LOD `.glb` through a separate cache (`gltfCache`, keyed by url). Units are preserved from the FBX source — **centimetres**, no scaling applied. Embedded textures are deduped. Used by `instantiate`'s `attach` path and by LOD systems.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Instantiating
|
|
49
|
+
|
|
50
|
+
### `instantiate(url, opts) → Promise<THREE.Object3D>`
|
|
51
|
+
|
|
52
|
+
Returns an independent copy safe to add to the scene. Skinned models are cloned via `SkeletonUtils.clone`; static models via plain `.clone()` (shared geometry). `opts` is passed through to `loadModel` (`texture`, `scale`, `twin`) plus:
|
|
53
|
+
|
|
54
|
+
- **`collapse: true`** — run `collapseSkeletons` on the **master, before the clone**, so a child is cloned with 51 bones and not with 5,082 to be pruned one at a time. Memoised inside; the second kid is free.
|
|
55
|
+
- **`keep: string | string[]`** — for multi-character source files, drop every mesh whose (lowercased) name doesn't contain a keep substring. A lone string keeps the single-substring behaviour every adult uses; a child needs a list — he is five meshes (body, mouth, freckles, pupils, eyebrows). After the mesh drop, the **rival-skeleton prune** runs (see gotchas — this is the most expensive lesson in the file).
|
|
56
|
+
- **`attach: string[]`** — seat static head props (dwarf beards/hair) on the Head bone, loaded from `/assets/characters/attach/<name>.glb`. The meshes are authored head-local and the pack's reorienting `Head_Attachments` node is pruned by fbx2gltf, so the bone's bind-pose world rotation is cancelled (`att.quaternion = inverse(bone world quat)` after `updateMatrixWorld`) — the attachment renders upright and rides the head.
|
|
57
|
+
- **`hair: string[]`** — the kid pack's head props, one FBX per style in `/assets/characters/kids` (97 of them). A child's hair is **not** in his body mesh (an adult's is), so a kid with no entry here is bald. Hung off the Head bone resolved through `keep[0]` — the body mesh — and carry no materials of their own: the `applyAtlas` the caller runs next walks bones and their children too, so the hair picks up the body atlas and therefore the atlas's hair colour (the only colour it can have — every hair vertex sits on one texel of it).
|
|
58
|
+
|
|
59
|
+
Hair/hat seating (internal `seatHeadProp`) has to classify each prop's authoring space, because hair and hats are authored in **different spaces** in the same pack and nothing in the file says which. Measured: `SM_Chr_Hair_Messy_01` spans Y 92.9–121.3 cm — absolute, drawn already sitting on a head standing on the floor (**rig space**) — while `SM_Chr_Attach_Hat_Cowboy_01` spans Y −3.8–24.4 cm, relative to the Head bone which binds at Y 100.1 (**head-local**). Classified by the mesh's own bounding-box centre against `RIG_SPACE_CM = 60` (a hat's centroid is ~10 cm, a rig-space hairstyle's ~107 — nothing is near 60): rig-space geometry gets the head's bind pose cancelled out (`inverse(head.world) · root.world`), head-local geometry is simply parented. The kid Head binds with an identity rotation, so there is no twist to undo either way.
|
|
60
|
+
|
|
61
|
+
### `collapseSkeletons(model) → THREE.Skeleton | null`
|
|
62
|
+
|
|
63
|
+
Collapse a multi-character pack onto **one** skeleton — the load-bearing step for POLYGON Kids, and the only way a child can be made of more than one mesh. Memoised on `model.userData.__collapsed`; returns the reference skeleton, or `null` if no ≥51-bone skinned mesh exists.
|
|
64
|
+
|
|
65
|
+
Why it exists: the keep-path prune in `instantiate` is enough when a character **is** one mesh (every adult — Synty bakes hair and face into the body). A child is five meshes and the parts don't belong to him: the face/freckle decals are unskinned and hang off the Head bone of `SM_Chr_Kid_EASTERN_01` (whichever child you asked for), and the eyes/eyebrows are skinned to `Eyes_`/`Eyebrow_` bones of **another outfit's** copy of the skeleton. `Characters_Kids.fbx` carries 5,082 bones and **one hundred bones called 'Head'**. In the bind pose every skeleton coincides, so a standing child looks perfect — and the moment he walks, his body strides off and his face, eyes and eyebrows stay behind in the T-pose. The prune cannot fix that: it would cut the bones the face is hanging from, which is worse.
|
|
66
|
+
|
|
67
|
+
What it does:
|
|
68
|
+
|
|
69
|
+
1. Picks the reference skeleton: the ≥51-bone skeleton whose `Hips` sits nearest the root — the **shallowest**, because every rival is nested inside it, so its own bones cannot move when the rivals are cut. (`>= 51` skips the 2-bone eye/eyebrow skeletons.)
|
|
70
|
+
2. Rebinds every skinned mesh: same-length outfits are pointed at the reference via `o.bind(ref, o.bindMatrix)`; 2-bone eyes/eyebrows are matched **by name inside the reference only** (`Eyes_Left`, `Eyebrow_Right`… are unique within the 51 — this is *not* the by-name lookup across the whole pack that the duplicate-bone trap punishes).
|
|
71
|
+
3. Re-parents unskinned face decals (meshes parented to some bone) onto the reference's `Head` — transform-exact, they sit at local identity on a Head.
|
|
72
|
+
4. Cuts the rivals at the boundary (a non-kept bone whose parent is kept) — each snip takes a whole nested subtree and can never orphan a bone still in use.
|
|
73
|
+
|
|
74
|
+
Measured on all 106 meshes: 0.000 mm of drift, 5,082 bones → 51. Run once on the shared master, so every clone is born with 51 bones and cloning is cheap.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Textures
|
|
79
|
+
|
|
80
|
+
### `loadTexture(url, { flipY }) → Promise<THREE.Texture>`
|
|
81
|
+
|
|
82
|
+
Loads and caches a texture. Sets `SRGBColorSpace`, `LinearMipmapLinearFilter` min / `LinearFilter` mag, and applies `flipY` only when explicitly passed.
|
|
83
|
+
|
|
84
|
+
**The cache key is flipY-specific** (`url` alone when `flipY` is undefined, else `url|flip0/1`): a packed GLB dwarf atlas needs `flipY:false` (glTF UV convention) while the same-named atlas on an FBX model wants the TextureLoader default (`true`). Keying by url alone would let one flipY setting win for all consumers.
|
|
85
|
+
|
|
86
|
+
Note the 2048 downscale cap is **retired** — even nearest-sampled, capped atlases came out wrong-coloured on small parts (mip-chain sampling of the resized canvas). The embedded-texture dedup carries the memory diet instead; `?fulltex` is dead.
|
|
87
|
+
|
|
88
|
+
### `warmTextures(renderer) → Promise<number>`
|
|
89
|
+
|
|
90
|
+
Force-uploads every cached texture to the GPU via `renderer.initTexture`, returning the count warmed. The boot warm's `compileAsync` compiles **pipelines** but does not upload textures — each was otherwise uploaded on first sight mid-play (~75 ms hitch apiece, measured by the freeze profiler as `tex:+1` records). Run it under the loading screen. Safe to call again later (phase-2): `initTexture` on an already-resident texture is a no-op. Failed loads are skipped silently.
|
|
91
|
+
|
|
92
|
+
### Embedded-texture dedup (internal, automatic)
|
|
93
|
+
|
|
94
|
+
Memory diet, part 2: character/castle GLBs each embed their **own copy** of shared atlases (`atlas_fantasykingdom` measured resident 3× ≈ 270 MB alone). Every GLB load (`loadModel` and `loadGLB`) runs `dedupEmbeddedTextures` on the scene: each embedded image on `map`/`emissiveMap`/`normalMap`/`roughnessMap`/`metalnessMap`/`aoMap`/`alphaMap` is keyed by `dims : 64×64 full-byte downsample hash : flipY : colorSpace`, and one `THREE.Texture` per unique image is shared; duplicates are **disposed before they ever reach the GPU**. The 64×64 full-byte hash (16k+ samples) means distinct atlases cannot realistically collide. Unreadable image types are kept as-is; the pass is a no-op outside a DOM environment.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Atlas materials
|
|
99
|
+
|
|
100
|
+
### `applyAtlas(root, tex, { emissive = null })`
|
|
101
|
+
|
|
102
|
+
Replaces every mesh material under `root` with a shared `MeshStandardNodeMaterial` bound to the atlas `tex` (roughness 0.85, metalness 0), and turns on `castShadow`/`receiveShadow`.
|
|
103
|
+
|
|
104
|
+
**One material per (atlas, emissive, transparency, skinned-flag), shared by every model.** This is not a memory nicety, it is a pipeline-compile fix: per-model fresh materials meant each first-seen model compiled its own WebGPU pipeline family (the shop's 2.7 s per-item hover stalls, `pipe:+10` in the freeze telemetry). With a shared instance plus the uniform Synty vertex layout, a new model reuses a pipeline the world already compiled at boot → `pipe:0`.
|
|
105
|
+
|
|
106
|
+
**CONTRACT: these materials are shared game-wide — never mutate one on an instance; clone first** (see `player.applyMoralityLook`).
|
|
107
|
+
|
|
108
|
+
Details:
|
|
109
|
+
|
|
110
|
+
- **Statics are flat-shaded** (`flatShading: !o.isSkinnedMesh`): Synty meshes are authored flat, and merged vertex normals come out inverted on some env pieces — faces render pure black when the sun is on their front side (the villages sun-angle black-render; same fix that cured Bloodfang's black stairs, see `bloodfang.js`). Skinned meshes keep smooth normals — deformation needs them, and theirs aren't corrupted.
|
|
111
|
+
- Source-material `transparent`/`opacity` are carried over into the cache key and the new material.
|
|
112
|
+
- `emissive`, if given, becomes `emissiveMap` with white emissive colour.
|
|
113
|
+
- Multi-material meshes map per-slot; a single-element array collapses back to a lone material.
|
|
114
|
+
|
|
115
|
+
### `setAtlasEmissiveHook(fn)`
|
|
116
|
+
|
|
117
|
+
Registers an optional TSL term added into every atlas material's `emissiveNode`. Signature: `(albedoNode) => node` — e.g. western's nearest-N lamp field, so props, faces and walls light for real after dark. **Register BEFORE the first `applyAtlas`/`instantiate` call — materials are cached**, and a material built before the hook exists never gets it. Any `emissive` map is preserved by **adding** the hook's term to it, never replacing it (a few atlas mats glow).
|
|
118
|
+
|
|
119
|
+
### `applyFaceAtlas(root, mouthTex, freckleTex = null)` and `FACE_DECALS`
|
|
120
|
+
|
|
121
|
+
The second texture. `applyAtlas` puts one map on a whole model, which is all an adult ever needs. A kid needs two: clothes/pupils/eyebrows/hair ride the body atlas, and his face is two decals on the **facial** atlases — a mouth, and a freckle spray, each drawn on transparent black. Run this **after** `applyAtlas`; it re-materials just the decals.
|
|
122
|
+
|
|
123
|
+
The sheets are not interchangeable: the freckle mesh's UVs address the freckle sheet — hand it the mouth sheet and it samples a mouth at freckle UVs, a second mouth across the cheeks. So a child with no freckle texture has the decal **hidden** (`visible = false` — free, the renderer skips it), not mis-textured.
|
|
124
|
+
|
|
125
|
+
Decal materials (cached per texture uuid) sit a hair's breadth off the face, so they must not sort and must not z-fight: `alphaTest: 0.5` (a hard cutout — no blending, no draw order) plus polygon offset (−2/−2) pulling them forward, `DoubleSide`. Decals do **not** cast shadows: a flat plane inside the head's own silhouette can only ever cast a wrong shadow, and skipping it saves two shadow draws per child.
|
|
126
|
+
|
|
127
|
+
`FACE_DECALS` is the exported `Set` of the kid pack's two unskinned decal mesh names: `SM_Chr_Kid_Face_01` and `SM_Chr_Kid_Face_Freckles_01`. (`SM_Chr_Kid_Robot_Face_01` is the third — costume box, not cast, dropped by the keep list.)
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Animation clips
|
|
132
|
+
|
|
133
|
+
### `loadClip(url, name) → Promise<THREE.AnimationClip>`
|
|
134
|
+
|
|
135
|
+
Loads one clip, cached by url. Two paths:
|
|
136
|
+
|
|
137
|
+
1. **Cooked JSON** (fast path): fetch `/assets/clips/<cooked-name>.json` and `THREE.AnimationClip.parse` it. Same cook-to-file scheme as the VAT/nav bins — FBXLoader parses on the main thread and the ~100 clip files dominate the loading bar's climb; a parsed `AnimationClip` round-trips losslessly through `toJSON`/`parse`.
|
|
138
|
+
2. **FBX parse** (fallback): load the FBX and take the **longest** animation — MultiTake FBX (e.g. the gunslinger pack) lead with a 1-frame stub stack, and for single-take files the longest is `animations[0]` anyway. Throws if the file has no animation. In dev (`import.meta.env.DEV`), the parsed clip is cooked back to disk via `POST /__save-clipjson?name=…` (fire-and-forget; the live clip is returned regardless).
|
|
139
|
+
|
|
140
|
+
The cooked filename is `<basename>-v<CLIP_JSON_VER>-<djb2(url) hex>.json`. **`CLIP_JSON_VER` (currently 2 — full-pack gsbake rebake, 97 clips, 2026-07-12) must be bumped after changing any source animation** — the name alone can't see content changes — or delete the `public/assets/clips` folder.
|
|
141
|
+
|
|
142
|
+
The clip's `name` is set to the given `name`, falling back to the url basename without `.fbx`.
|
|
143
|
+
|
|
144
|
+
### `loadClips(entries) → Promise<Record<string, THREE.AnimationClip>>`
|
|
145
|
+
|
|
146
|
+
Loads a map of `{ name: url }` in parallel. **One bad/missing clip must not break the whole boot** — failures are warned and omitted from the result; the animator falls back to `'idle'`.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Loading progress
|
|
151
|
+
|
|
152
|
+
### `onProgress(cb)` / `expectLoads(n)`
|
|
153
|
+
|
|
154
|
+
`expectLoads(n)` adds `n` to the expected total; every completed model/clip/GLB load ticks the counter and calls `cb(loadedCount, totalCount, msg)` where `msg` is the file basename or clip name. `loadTexture` does **not** tick.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Example
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
import {
|
|
162
|
+
expectLoads, onProgress, setAtlasEmissiveHook,
|
|
163
|
+
instantiate, applyAtlas, applyFaceAtlas, loadTexture, loadClips, warmTextures,
|
|
164
|
+
} from './core/assets.js';
|
|
165
|
+
|
|
166
|
+
// Order matters: hook before any material is built (materials are cached).
|
|
167
|
+
setAtlasEmissiveHook((albedo) => lampFieldTerm(albedo));
|
|
168
|
+
|
|
169
|
+
onProgress((done, total, msg) => loadingBar.set(done / total, msg));
|
|
170
|
+
expectLoads(3);
|
|
171
|
+
|
|
172
|
+
// A kid: five meshes out of a 100-character pack, collapsed to one skeleton.
|
|
173
|
+
const kid = await instantiate('/assets/characters/kids/Characters_Kids.fbx', {
|
|
174
|
+
collapse: true,
|
|
175
|
+
keep: ['SM_Chr_Kid_Western_02', 'SM_Chr_Kid_Face_01', 'SM_Chr_Kid_Face_Freckles_01',
|
|
176
|
+
'SM_Chr_Eyes_Brown_01', 'SM_Chr_Eyebrows_01'],
|
|
177
|
+
hair: ['SM_Chr_Hair_Messy_01'],
|
|
178
|
+
texture: '/assets/characters/kids/PolygonKids_Texture_01_A.png',
|
|
179
|
+
});
|
|
180
|
+
// Face decals ride their own sheets, after applyAtlas has run (loadModel ran it).
|
|
181
|
+
applyFaceAtlas(kid, await loadTexture('/assets/characters/kids/Face_Mouth_01.png'));
|
|
182
|
+
scene.add(kid);
|
|
183
|
+
|
|
184
|
+
const clips = await loadClips({ idle: '/assets/anims/Idle.fbx', walk: '/assets/anims/Walk.fbx' });
|
|
185
|
+
|
|
186
|
+
// Under the loading screen — compileAsync does NOT upload textures.
|
|
187
|
+
await warmTextures(renderer);
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Gotchas
|
|
193
|
+
|
|
194
|
+
- **Register `setAtlasEmissiveHook` before the first `applyAtlas`/`instantiate`.** Atlas materials are cached; ones built before the hook never receive the emissive term.
|
|
195
|
+
- **Never mutate a shared atlas material on an instance — clone first.** One material per (atlas, emissive, transparency, skinned) is a WebGPU pipeline-compile fix, not just a memory saving; mutating it changes every model in the game.
|
|
196
|
+
- **The model cache key is `url|texture|scale`.** Do not assume one url = one master. The url-only key was the all-bodies-render-black bug (raw `MeshLambertMaterial` with an unresolvable texture renders black, not tinted).
|
|
197
|
+
- **flipY is part of the texture cache key, and it depends on what loaded.** GLB (glTF UVs) wants the atlas at `flipY:false`; FBX wants the default `true`. Callers that build their own materials from flattened geometry must call `resolveModelUrl` to learn which format actually loaded — skipping that was the mirrored-atlas-cell bug on every scatter-placed prop.
|
|
198
|
+
- **Scale defaults differ by format and must.** FBX is cm-authored (`0.01`); converted GLBs are already metres (`1`). Applying `0.01` to a metre GLB shrank every tropical prop 100× — microscopic, invisible. Exception: the metre-authored dungeon kit's twins carry a baked ×100 rescale and load at `scale:1`.
|
|
199
|
+
- **Never look bones up by name across a multi-character pack.** 21 bones answered to `Hips` in one western pack, 100 to `Head` in the kids pack, and the depth-first hit is not the one the mesh is skinned to — that was the contorted-limbs bug (arms flung overhead, hands inside out). Resolve bones through a named mesh's own skeleton (as `headBoneOfMesh` does), or from the mixer's own bindings (as `player.js`/`footsteps.js` do). Name lookup is only safe *inside one skeleton* where names are unique.
|
|
200
|
+
- **`keep` must prune rival skeletons, and the prune must be transform-exact.** FBXLoader nests the pack's skeletons inside one another, one duplicate per bone name per body; the outermost copy carries the real bind offset and is the one `PropertyBinding` finds, every copy below is an identity pass-through. Three walks every Object3D every frame in `updateMatrixWorld`, visible or not — 23 children once put 117,000 dead bones in the graph at 32 ms/frame of render cost. After the prune+collapse: 276k scene nodes → 25k, render 42 ms → 6.6 ms. The identity tolerances in `isIdentity` are float-noise wide on purpose — a bone with a real offset must never be collapsed away.
|
|
201
|
+
- **Multi-mesh characters need `collapse`, and it must run on the master, before cloning.** The keep-prune alone would cut the bones the face decals hang from; `collapseSkeletons` rebinds instead (0.000 mm drift, 5,082 → 51 bones) and is memoised so the second clone is free.
|
|
202
|
+
- **`compileAsync` does not upload textures — call `warmTextures` under the loading screen**, or pay ~75 ms per texture on first sight mid-play.
|
|
203
|
+
- **The 2048 atlas downscale is retired; the embedded-texture dedup is the memory diet now.** Downscaled atlases came out wrong-coloured on small parts (mip-chain sampling of the resized canvas). The original problem was real — ~8 GB of resident textures caused recurring spin-jolts (weak renderer object cache + GC evictions) and poisoned-bind-group black screens — but the fix moved to sharing one texture per unique embedded image.
|
|
204
|
+
- **Bump `CLIP_JSON_VER` after changing any source animation** (or delete `public/assets/clips`) — the cooked-JSON filename hashes the url, not the content, so stale cooks are served forever otherwise.
|
|
205
|
+
- **MultiTake FBX clip files lead with a 1-frame stub** — `loadClip` takes the longest take, not `animations[0]`.
|
|
206
|
+
- **One bad clip must not kill a boot** — `loadClips` warns and omits; keep an `'idle'` fallback in the animator.
|
|
207
|
+
- **FBX loads stack stray lights and parse on the main thread.** Embedded AmbientLights are stripped on load (they washed out the whole scene). Prefer GLB twins for anything batch-loaded — FBX parsing was the tropical-beach load hang. `window.__fbxLoads` lists what still parses FBX in dev.
|
|
208
|
+
- **GLB twins were converted with fbx2gltf's default V-flip** — a batch converted with `--no-flip-v` mirrored every atlas cell and had to be reverted.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Collision (`src/world/collision.js`, `src/world/bvhWorker.js`)
|
|
2
|
+
|
|
3
|
+
Unified mesh collision built on [`three-mesh-bvh`](https://github.com/gkjohnson/three-mesh-bvh). Static collider geometry is baked into **one merged, position-only, non-indexed `BufferGeometry`** (world transforms applied) and a `MeshBVH` is built over it. The player capsule is then swept against that BVH each frame (see `world.moveCapsule`), so stairs, walls and slopes "just work" as real triangles — no per-object 2D colliders or heightfield hacks. The merged mesh is never rendered.
|
|
4
|
+
|
|
5
|
+
There are two builders with identical results:
|
|
6
|
+
|
|
7
|
+
- `buildBVH()` — synchronous, on the main thread.
|
|
8
|
+
- `buildBVHAsync()` — same bake+merge+BVH (the ~30–80 ms cost) in a Web Worker (`bvhWorker.js`), used for streamed tiles so collision never blocks the frame. Falls back to the sync path if the worker is unavailable or dies.
|
|
9
|
+
|
|
10
|
+
Both are re-exported from the package root (`src/index.js`).
|
|
11
|
+
|
|
12
|
+
## Peer dependencies
|
|
13
|
+
|
|
14
|
+
`three-mesh-bvh` (`^0.9.10`) and `three` (`^0.184.0`) are **peer dependencies** — the host app must install them. `three-mesh-bvh` is geometry/math only (no renderer calls), so it is safe under the `three/webgpu` renderer and inside a worker; the module feeds it plain `BufferGeometry` data.
|
|
15
|
+
|
|
16
|
+
## Exports
|
|
17
|
+
|
|
18
|
+
### `buildBVH(objs = [], instanceGroups = []) → MeshBVH | null`
|
|
19
|
+
|
|
20
|
+
Merges every mesh under `objs` (world matrices baked, position attribute only) and returns a `MeshBVH` over the result, or `null` if there is no geometry.
|
|
21
|
+
|
|
22
|
+
- `objs`: array of `Object3D`s. Each is traversed (`updateMatrixWorld(true)` is called first) and every `Mesh` with a position attribute is baked at its **current** matrix:
|
|
23
|
+
- **Regular meshes** — geometry baked once at `matrixWorld`.
|
|
24
|
+
- **`InstancedMesh`** — the geometry is baked once **per instance** at `matrixWorld × instanceMatrix`.
|
|
25
|
+
- Meshes with `userData.noBVH` are skipped (see gotchas).
|
|
26
|
+
- `instanceGroups`: array of `{ geometry, matrices: Float32Array(count*16), count }` — explicit LOD0 geometry plus the **full** instance set, baked from world matrices. Used for distance-LOD scatters (e.g. rocks), so collision always uses LOD0 — never the far LOD — across all instances, regardless of what LOD is currently rendered.
|
|
27
|
+
|
|
28
|
+
Per-source geometry handling: the geometry is cloned, every attribute except `position` is deleted, the world matrix is applied, and indexed geometry is converted with `toNonIndexed()` so `mergeGeometries` doesn't mismatch indexed/non-indexed inputs.
|
|
29
|
+
|
|
30
|
+
### `buildBVHAsync(objs = [], instanceGroups = []) → Promise<MeshBVH | null>`
|
|
31
|
+
|
|
32
|
+
Same inputs and same collision result as `buildBVH()`, but the heavy work runs in a Web Worker. Returns a real `MeshBVH` that answers `.shapecast()` / `.raycastFirst()` identically — it's just built off the hot path.
|
|
33
|
+
|
|
34
|
+
Flow:
|
|
35
|
+
|
|
36
|
+
1. `extractParts()` gathers serializable data **without mutating or neutering the live geometry**: it copies each source `position` array and `index` array, and the **world** matrix of every instance, into fresh typed arrays. Parts are `{ positions, index|null, matrices, count }`.
|
|
37
|
+
2. The parts are posted to the worker with all buffers **transferred** (zero-copy; safe because they are copies).
|
|
38
|
+
3. The worker bakes every instance into one big position buffer, builds a `MeshBVH`, serializes it, and transfers the result back.
|
|
39
|
+
4. The main thread reconstructs a position-only `BufferGeometry` and calls `MeshBVH.deserialize(serialized, geom, { setIndex: true, indirect: … })`.
|
|
40
|
+
|
|
41
|
+
Fallback behavior:
|
|
42
|
+
|
|
43
|
+
- Worker construction fails (`new Worker` throws) → the module is marked broken and **every** call, current and future, uses synchronous `buildBVH()`.
|
|
44
|
+
- Worker fires `onerror` → all in-flight jobs resolve `null`, the module is marked broken; `buildBVHAsync` detects the death and rebuilds **synchronously** so the tile still collides. Later calls go straight to the sync path.
|
|
45
|
+
- Worker replies `ok: false` for a single job (an error thrown inside `buildFromParts`, or empty geometry) → that call resolves `null`. There is **no** sync retry in this case — a per-job failure is not treated as a dead worker.
|
|
46
|
+
- No parts extracted (no geometry) → resolves `null` without touching the worker.
|
|
47
|
+
|
|
48
|
+
### `colliderWireframe(bvh) → THREE.Mesh | null`
|
|
49
|
+
|
|
50
|
+
Dev visualization: a green (`0x00ff88`) wireframe `Mesh` of the merged collision geometry (`transparent`, `opacity: 0.4`, `depthTest: false`, `renderOrder: 999`), so the collider can be eyeballed without trusting the renderer's BVH helper. Returns `null` if `bvh` is falsy. Add it to the scene yourself; remember it shares the BVH's geometry, so don't dispose that geometry while the BVH is live.
|
|
51
|
+
|
|
52
|
+
## Worker protocol (`bvhWorker.js`)
|
|
53
|
+
|
|
54
|
+
Internal, but documented for maintenance. The main thread posts:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
{ id, parts: [{ positions: Float32Array,
|
|
58
|
+
index: Uint16Array|Uint32Array|null,
|
|
59
|
+
matrices: Float32Array(count*16), // WORLD transforms per instance
|
|
60
|
+
count }] }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The worker (`buildFromParts`) expands indexed parts to non-indexed, then bakes every instance of every part into **one** preallocated `Float32Array` using inlined matrix×point math — no per-instance geometry allocations (this mirrors `buildBVH` but avoids `clone()`/`mergeGeometries` overhead). It builds a `MeshBVH` (which creates a sequential index internally), serializes it with `{ cloneBuffers: false }`, and posts back:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
{ id, ok: true, serialized, position } // buffers transferred (zero-copy)
|
|
67
|
+
{ id, ok: false, empty: true } // no geometry
|
|
68
|
+
{ id, ok: false, error: "…" } // build threw
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Example
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
import { buildBVHAsync, colliderWireframe } from 'sindicate/world/collision.js';
|
|
75
|
+
|
|
76
|
+
// A streamed tile finished loading: buildings group + a rock scatter that
|
|
77
|
+
// renders with distance LODs but must always collide as LOD0.
|
|
78
|
+
const bvh = await buildBVHAsync(
|
|
79
|
+
[tile.buildingsGroup, tile.propsGroup], // regular + instanced meshes
|
|
80
|
+
[{ geometry: rockLOD0Geometry, // LOD0 only — never the far LOD
|
|
81
|
+
matrices: rockWorldMatrices, // Float32Array, count*16
|
|
82
|
+
count: rockCount }],
|
|
83
|
+
);
|
|
84
|
+
tile.collider = bvh; // may be null (empty tile) — moveCapsule must tolerate that
|
|
85
|
+
|
|
86
|
+
// Dev toggle: eyeball the collider.
|
|
87
|
+
if (DEBUG_COLLIDERS && bvh) scene.add(colliderWireframe(bvh));
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Gotchas
|
|
91
|
+
|
|
92
|
+
- **The BVH is baked once and frozen.** Anything that later moves would still be collided at the pose it had at boot — the door would swing open in front of you while its shut-pose triangles still stood in the doorway. Opt moving parts out with `userData.noBVH`. (The county's door leaves ride a `BatchedMesh` at the scene root, which the traversal never reaches, so the flag is belt and braces there; doors collide via the switchable slab in `world.collide()` — see `town.js` `doorsAt`.)
|
|
93
|
+
- **Instances are baked at their current matrices.** `updateMatrixWorld(true)` is called on each root before traversal, but instance matrices are read as-is — build the BVH only after all instance transforms are final.
|
|
94
|
+
- **Use `instanceGroups` for LOD scatters.** If you pass a distance-LOD `InstancedMesh` via `objs`, collision would use whatever LOD geometry that mesh happens to carry. Pass `{ geometry: LOD0, matrices, count }` instead so the collider is always the full-detail mesh across all instances.
|
|
95
|
+
- **Position-only, non-indexed.** All other attributes are stripped and indexed geometry is expanded before merging (indexed/non-indexed inputs can't be mixed in `mergeGeometries`). Don't expect UVs/normals or the original index on `bvh.geometry`.
|
|
96
|
+
- **Worker imports plain `'three'`, NOT `'three/webgpu'`.** The webgpu entry pulls in renderer/DOM code that has no `document`/`window` in a worker. `three-mesh-bvh` is pure math and worker-safe. The main-thread module uses `three/webgpu`; keep that split.
|
|
97
|
+
- **Why the worker exists:** the tile streamer used to bake+merge+build synchronously in `World.update()` — ~30–80 ms **per tile**, which was the constant town/exploration stutter (the `world` phase in the freeze log). Don't move this work back to the hot path.
|
|
98
|
+
- **`extractParts` copies before transferring.** Transferring the live geometry's buffers would neuter them (the rendered mesh would lose its vertex data). The copies are what gets transferred — keep it that way if you touch this code.
|
|
99
|
+
- **`buildBVHAsync` can return `null`** for empty geometry *and* for a per-job worker error. Callers must handle a null collider.
|
|
100
|
+
- **Worker fallback is one-way.** Once `_workerBroken` is set (construction failure or `onerror`), all subsequent builds are synchronous for the lifetime of the page; there is no retry/respawn.
|
|
101
|
+
- **Deserialization must match serialization.** The main thread passes `{ setIndex: true, indirect: Boolean(serialized.indirectBuffer) }` to `MeshBVH.deserialize`; the worker serializes with `{ cloneBuffers: false }` and transfers `position`, `serialized.roots`, and the index/indirect buffers. If you change either side, change both.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# vendor/FBXLoader
|
|
2
|
+
|
|
3
|
+
Vendored copy of three.js's `FBXLoader` (addons) with **one local patch**: `AnimationParser.parseAnimStacks` merges **all** animation layers in a stack into the clip, where stock three keeps only the first layer and discards the rest.
|
|
4
|
+
|
|
5
|
+
This fork exists so the engine does **not** depend on a `node_modules` postinstall mutation (the approach western/fable used — it breaks under CI caches/pnpm and was anchored to an exact three source line). Engine code changes only here.
|
|
6
|
+
|
|
7
|
+
## Why the patch exists
|
|
8
|
+
|
|
9
|
+
Synty animation FBX files frequently store their curves across multiple `AnimationLayer` nodes per `AnimationStack`. Stock FBXLoader assumes one layer per stack ("in practice there always seems to be one"), takes the first, and ignores the rest — so the resulting `AnimationClip` loses the tracks that live in later layers and multi-layer Synty packs play back as a T-pose. The patch (marked `/* synty-all-layers */`, in `parseAnimStacks`) collects the curve nodes from every child layer instead:
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
/* synty-all-layers */ const layer = [];
|
|
13
|
+
for ( const child of children ) {
|
|
14
|
+
const l = layersMap.get( child.ID );
|
|
15
|
+
// layer arrays are SPARSE — spread turns holes into undefined,
|
|
16
|
+
// which crashes generateTracks. Filter them out.
|
|
17
|
+
if ( l ) layer.push( ...l.filter( ( n ) => n !== undefined ) );
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Exports
|
|
22
|
+
|
|
23
|
+
- `FBXLoader` — class, extends `THREE.Loader`.
|
|
24
|
+
- `constructor( manager? )`
|
|
25
|
+
- `load( url, onLoad, onProgress, onError )` — loads and parses; `onLoad` receives a `Group`.
|
|
26
|
+
- `parse( FBXBuffer, path )` → `Group` — parses an `ArrayBuffer` (binary FBX >= 6400 or ASCII >= 7.0). Clips arrive on `group.animations`.
|
|
27
|
+
|
|
28
|
+
Re-exported from the engine root (`sindicate`), so games never import three's stock loader.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
import { FBXLoader } from 'sindicate'; // or '../vendor/FBXLoader.js'
|
|
34
|
+
|
|
35
|
+
const loader = new FBXLoader();
|
|
36
|
+
const group = await loader.loadAsync('assets/Chr_Cowboy_01.fbx');
|
|
37
|
+
group.scale.setScalar(0.01); // Synty FBX is authored in centimetres
|
|
38
|
+
mixer.clipAction(group.animations[0]).play(); // all layers merged — no T-pose
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
(In practice, load characters through `core/assets.js`, which wraps this loader with caching, atlas binding, and cm→m scaling.)
|
|
42
|
+
|
|
43
|
+
## Gotchas
|
|
44
|
+
|
|
45
|
+
- **Pin: three `^0.184`. Re-verify on every three upgrade.** The fork is a snapshot of the r184 loader plus the patch; upstream FBXLoader drifts. three upgrades are engine events: upgrade in Sindicate first, re-diff this file against the new stock loader, re-apply the patch, and run against western's bench pages before rolling to games.
|
|
46
|
+
- **The multi-layer warning is stale.** The stock `console.warn` ("multiple layers … Ignoring subsequent layers") still fires on multi-layer stacks, but the patched code *does* merge them. The message is wrong; the behavior is right.
|
|
47
|
+
- **Layer arrays are sparse — keep the filter.** `layerCurveNodes` is indexed by model position and has holes. A bare spread turns holes into `undefined` entries, which crash `generateTracks`. The `.filter((n) => n !== undefined)` is load-bearing; preserve it on any re-apply.
|
|
48
|
+
- **Main-thread parsing is slow.** FBX parsing hung batch scatter loads (the tropical-beach hang); the asset system dispatches to GLB twins where they exist and only falls back to this loader.
|
|
49
|
+
- **Names are sanitized.** FBXLoader converts spaces to underscores in node names — retarget bone tables must match the sanitized names.
|
|
50
|
+
- **Z-up→Y-up lives on container Object3Ds**, not in world transforms — code sampling bone transforms (retarget) must account for the container rotation.
|