sindicate 0.2.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/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/engine.js +284 -0
- package/src/index.js +13 -1
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/entity.js +524 -0
- package/src/systems/fistCurl.js +38 -0
- package/src/systems/footsteps.js +161 -0
- package/src/systems/glass.js +67 -0
- package/src/systems/herd.js +277 -0
- package/src/systems/horse.js +361 -0
- package/src/systems/mountedTraveller.js +518 -0
- package/src/systems/nav.js +343 -0
- package/src/systems/npc.js +880 -0
- package/src/systems/pathFollow.js +107 -0
- package/src/systems/platform.js +484 -0
- package/src/systems/riding.js +580 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/trainCamera.js +414 -0
- package/src/systems/traveller.js +254 -0
- package/src/systems/tumbleweeds.js +92 -0
- package/src/systems/vat.js +327 -0
- package/src/systems/vfx.js +472 -0
- package/src/world/blobShadows.js +109 -0
- package/src/world/clouds.js +61 -0
- package/src/world/flora.js +87 -0
- package/src/world/footprints.js +117 -0
- package/src/world/lampField.js +58 -0
- package/src/world/lampGlow.js +92 -0
- package/src/world/scatter.js +1077 -0
- package/src/world/sky.js +363 -0
- package/src/world/tileManager.js +197 -0
- package/src/world/walkHumps.js +74 -0
- package/src/world/weather.js +312 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# grass.js — Streaming Instanced Grass (`GrassField`)
|
|
2
|
+
|
|
3
|
+
`src/world/grass.js`
|
|
4
|
+
|
|
5
|
+
Lightweight procedural grass: each instance is a **clump of 4 blades** (~6 triangles per blade, vertex-colour gradient, pointed tips, "rounded cross-section" normals) rendered as instanced tiles streamed in a ring around the player. Wind, trampling, dryness patches, translucency, wetness darkening, and snow tipping are all evaluated on the GPU in a TSL `MeshStandardNodeMaterial`. Only a few thousand clumps exist or render at any time.
|
|
6
|
+
|
|
7
|
+
Blades flatten under the player and nearby NPCs/enemies and spring back over ~3 seconds, driven by a timestamped trail of recent positions uploaded as a uniform array.
|
|
8
|
+
|
|
9
|
+
## Exports
|
|
10
|
+
|
|
11
|
+
### `class GrassField`
|
|
12
|
+
|
|
13
|
+
#### `new GrassField(scene, opts)`
|
|
14
|
+
|
|
15
|
+
| Option | Type | Default | Meaning |
|
|
16
|
+
| --- | --- | --- | --- |
|
|
17
|
+
| `accept` | `(x, z) => boolean` | **required** | Placement predicate. Called once per candidate clump position (post-jitter). Return `false` to skip — this is how grass avoids roads, water, buildings, pads. |
|
|
18
|
+
| `heightAt` | `(x, z) => number` | **required** | Terrain height sampler. Called on a coarse 17×17 grid per tile, plus exact per-blade calls only on steep cells (see gotchas). |
|
|
19
|
+
| `tile` | `number` | `16` | Tile edge length in metres. |
|
|
20
|
+
| `spacing` | `number` | `0.135` | Grid spacing between clump candidates within a tile. |
|
|
21
|
+
| `jitter` | `number` | `0.11` | ± random offset applied to each candidate position. |
|
|
22
|
+
| `sMin` / `sMax` | `number` | `0.22` / `0.40` | Per-clump uniform scale range. |
|
|
23
|
+
| `showR` | `number` | `60` | Tiles whose centre is within this radius of the player get built. Also drives the alpha-hash dissolve band at the render edge (`showR - 14 … showR - 3`). |
|
|
24
|
+
| `hideR` | `number` | `72` | Tiles whose centre passes beyond this radius are returned to the pool. Must exceed `showR` (hysteresis so tiles don't thrash at the boundary). |
|
|
25
|
+
|
|
26
|
+
Both `accept` and `heightAt` are **injected** — `GrassField` knows nothing about the terrain implementation. Whatever fbm/pad/road logic the world uses stays on the caller's side.
|
|
27
|
+
|
|
28
|
+
The constructor also pre-builds the entire **resident mesh pool** (see gotchas): `poolN = ceil(π · hideR² / tile²) + 12` `InstancedMesh` objects, each with capacity `tileCap = ceil((tile / spacing + 2)²)` clumps, all added to the scene immediately with `visible = false`.
|
|
29
|
+
|
|
30
|
+
#### Instance properties of note
|
|
31
|
+
|
|
32
|
+
- `uTrail` — `uniformArray` of 28 `vec3(x, z, timestamp)` trample samples (20 player slots + 8 entity slots).
|
|
33
|
+
- `uNow`, `uPlayer` — `uniform(float)` clock and `uniform(vec3)` player position feeding the GPU trample gate.
|
|
34
|
+
- `geo` — the shared clump `BufferGeometry` (position/normal/color, indexed).
|
|
35
|
+
- `mat` — the shared `MeshStandardNodeMaterial` (one material for every pool mesh).
|
|
36
|
+
- `tiles` — `Map<key, { mesh, cx, cz }>` of live tiles.
|
|
37
|
+
- `maxBuildsPerUpdate` — hardcoded `1`. Raising it stacks multi-ms tile builds into one frame and produces visible gallop hitches; leave it.
|
|
38
|
+
|
|
39
|
+
#### `update(px, pz)`
|
|
40
|
+
|
|
41
|
+
Call every frame (or every few frames) with the player's world X/Z. Returns tiles beyond `hideR` to the pool and builds **at most one** missing tile within `showR` per call. Tile builds are budgeted, so streaming catches up over successive frames rather than spiking one.
|
|
42
|
+
|
|
43
|
+
#### `tickTrample(dt, sources)`
|
|
44
|
+
|
|
45
|
+
Call every frame. `sources` is an array of `{x, z}` world positions — **the player must be first**. Advances the GPU clock, writes `uPlayer` from `sources[0]`, and every 0.15 s samples positions into the trail ring:
|
|
46
|
+
|
|
47
|
+
- `sources[0]` → the 20 dedicated player slots (a crowd of NPCs can never evict the player's path).
|
|
48
|
+
- `sources[1..]` → the 8 shared entity slots.
|
|
49
|
+
|
|
50
|
+
Each trail sample flattens blades within `TRAMPLE_R = 0.9` m; the flatten holds ~1 s then eases out by ~3 s. Trampled blades also shorten, droop their tips outward along the sample direction, and stop swaying.
|
|
51
|
+
|
|
52
|
+
### Module constants (not exported)
|
|
53
|
+
|
|
54
|
+
`TRAIL_P = 20`, `TRAIL_E = 8`, `TRAMPLE_R = 0.9`, `PLAYER_GATE = 36` — the trample loop only runs at all for blades within 36 m of the player, bounding its per-vertex cost.
|
|
55
|
+
|
|
56
|
+
## Weather / wind integration
|
|
57
|
+
|
|
58
|
+
The material reads the shared module-level singletons from `world/weatherUniforms.js` — no wiring needed, they are the same nodes regardless of build order:
|
|
59
|
+
|
|
60
|
+
- `uWindDir` (`vec2`, unit XZ heading the wind blows *toward*) and `uWindStr` (`0` calm … `~1` gusting) — written each frame by `world/wind.js`. Gust fronts are a tileable noise field advected downwind, squared so lulls rest and fronts sweep whole meadows; strength = local gust front × global wind strength. A small per-blade sine jiggle desynchronises neighbours inside a front. One vertex texture fetch total.
|
|
61
|
+
- `uWet` (`0` dry … `1` soaked) — darkens grass colour to 70 % when soaked.
|
|
62
|
+
- `uCover` (`0` bare … `1` snow) — blends blade **tips** toward near-white `vec3(0.95, 0.96, 1.0)`, weighted by blade height, so snow reads as sitting on top of the sward.
|
|
63
|
+
- `uSunDir` — key light direction; drives the light-through-the-blade translucency term (`dot(view, sun)⁴`, scaled by blade height).
|
|
64
|
+
|
|
65
|
+
Other look features baked into the material: broad noise-patch dryness (green → straw gold), cheap root AO (`y`-based), and a distance blend of the blade normal toward the **baked terrain normal** (per-clump `aTNorm` attribute) so far meadows light like the hillside they grow on — kills far-field sparkle.
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
import * as THREE from 'three/webgpu';
|
|
71
|
+
import { GrassField } from 'sindicate';
|
|
72
|
+
|
|
73
|
+
const grass = new GrassField(scene, {
|
|
74
|
+
accept: (x, z) => !roads.covers(x, z) && terrain.heightAt(x, z) > WATER_LEVEL + 0.15,
|
|
75
|
+
heightAt: (x, z) => terrain.heightAt(x, z),
|
|
76
|
+
tile: 16,
|
|
77
|
+
showR: 60,
|
|
78
|
+
hideR: 72,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// per frame:
|
|
82
|
+
grass.update(player.position.x, player.position.z);
|
|
83
|
+
grass.tickTrample(dt, [
|
|
84
|
+
player.position, // player MUST be first
|
|
85
|
+
...enemies.map((e) => e.position), // nearby entities after
|
|
86
|
+
]);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Gotchas (hard-won — do not "clean up")
|
|
90
|
+
|
|
91
|
+
- **Resident mesh pool = zero mesh churn.** Grass tiles *never* create meshes at runtime. On three r184 WebGPU, every new `InstancedMesh` **object** costs ~3 pipeline-cache entries, and adding a mesh to the scene re-records the render bundle — per-tile mesh churn while moving was most of the remaining travel freezes (geo:+3 / 45–60 ms render records). The fixed pool is built at boot so its pipelines compile in the boot warm; a streamed-in tile checks a mesh out and rewrites its instance buffers, a streamed-out tile sets `visible = false`, `count = 0` and returns it — **no `scene.remove`, no `dispose`**. Invisible pool slots skip the render-list walk; per-tile bounding spheres keep frustum culling working.
|
|
92
|
+
- **Pool dry → tile simply skipped.** If a transient burst empties the pool, `_buildTile` leaves the tile unbuilt; the next `update()` naturally retries. A tile that overflows `tileCap` logs an error and drops the excess clumps.
|
|
93
|
+
- **`maxBuildsPerUpdate = 1` is deliberate.** More than one multi-ms tile build in a frame reads as a hitch when travelling fast.
|
|
94
|
+
- **Coarse height grid, exact fallback.** Calling `heightAt` (fbm + pads + road scan) per blade was ~14 k full samples per tile — *the* gallop-streaming hitch. Instead 17×17 real samples are bilinearly interpolated; cells whose corners disagree by > 0.35 m (river banks, cliff benches) fall back to exact `heightAt` so blades never float on rough ground. Terrain normals come from finite differences on the same grid — zero extra `heightAt` calls.
|
|
95
|
+
- **Flat number array in `_buildTile`.** An object per blade was megabytes of short-lived GC churn per tile, felt as periodic pauses while streaming at speed. Clump data is packed 8 floats per clump into one array.
|
|
96
|
+
- **Instance matrices are world-space** (meshes sit at the origin, `matrixAutoUpdate = false`), which is exactly why instance-transformed `normalLocal` can be treated as world space in the terrain-normal blend. Repositioning pool meshes would silently break far-field lighting.
|
|
97
|
+
- **Trample loop needs a `Fn()` context.** TSL `Loop`/`If` require a stack context, so the entire `positionNode` calculation lives inside one `Fn`. The loop is also gated by `PLAYER_GATE` (36 m) so distant blades pay nothing.
|
|
98
|
+
- **Player trail is partitioned by design.** Slots 0–19 belong to the player, 20–27 to entities; without the split, a crowd of NPCs evicted the player's own footsteps.
|
|
99
|
+
- **Cantilever dip.** Sway shortens the blade (`y -= lean² · 0.3`) — a bent blade must get *shorter*, not longer, or tips stretch unnaturally in gusts. Trampled tips droop outward bounded by their own height (no summed push), so blades lie over rather than launching sideways.
|
|
100
|
+
- **Instanced buffers are born zero-filled** (`instanceMatrix` of a fresh `InstancedMesh`) which is warm-flash safe: nothing garbage renders during the boot pipeline warm.
|
|
101
|
+
- **`name = 'scatter:grass'` matters.** `'grass'` is in the collision BVH EXCLUDE regex — pool meshes never enter collision. Renaming them would make ~50 permanent scene meshes collidable.
|
|
102
|
+
- **Blade normals point mostly up** (~45° tilt, `y = 1.05` pre-normalise) — noon sun must still light the sward; fully sideways cross-section normals go dark at midday.
|
|
103
|
+
- **Edge dissolve is fragment-stage on purpose.** `opacityNode` uses camera distance with `alphaHash` — doing the fade in the vertex/position stage created a circular dependency.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Input
|
|
2
|
+
|
|
3
|
+
Unified input for Sindicate: keyboard + mouse + gamepad behind **one API** with per-frame edge detection. Game code never talks to a device — it reads key codes (`'KeyE'`, `'Space'`) and mouse button indices (`0`, `2`); the gamepad folds itself into that same vocabulary through a bindings table the game passes in. Source:
|
|
4
|
+
|
|
5
|
+
| File | Exports |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `src/core/input/index.js` | `Input` (facade), re-exports `DEFAULT_PAD_BINDINGS` |
|
|
8
|
+
| `src/core/input/keyboard.js` | `KeyboardDevice` |
|
|
9
|
+
| `src/core/input/mouse.js` | `MouseDevice` |
|
|
10
|
+
| `src/core/input/gamepad.js` | `GamepadDevice` |
|
|
11
|
+
| `src/core/input/bindings.js` | `DEFAULT_PAD_BINDINGS` |
|
|
12
|
+
|
|
13
|
+
The frame contract, straight from the source header:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
const input = new Input(canvas, { padBindings: MY_LAYOUT });
|
|
17
|
+
input.pollGamepad(ui.anyPanelOpen); // once per frame, BEFORE game reads
|
|
18
|
+
if (input.hit('KeyE')) … // true for keyboard E or bound pad button
|
|
19
|
+
input.endFrame(); // last thing in the frame
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## The device split
|
|
23
|
+
|
|
24
|
+
`Input` is a thin facade over three device objects it constructs and owns:
|
|
25
|
+
|
|
26
|
+
- **`KeyboardDevice`** — `keys` (held `Set` of `event.code`) + `pressed` (this-frame edge `Set`). Filters `e.repeat`. Clears `keys` on window `blur` (a button held across alt-tab stayed "held" forever — stuck block/bow).
|
|
27
|
+
- **`MouseDevice(domElement)`** — `mouse = { x, y, dx, dy, buttons }` (buttons is a bitmask, `1 << button`), `mousePressed` edge `Set`, `wheel` (accumulated `Math.sign(deltaY)` per event), `pointerLocked`, and pointer-lock management. `dx`/`dy` only accumulate while locked. Clears `buttons` on `blur` for the same alt-tab reason. Suppresses the context menu on the element.
|
|
28
|
+
- **`GamepadDevice(bindings = DEFAULT_PAD_BINDINGS)`** — polled (not event-driven) via `navigator.getGamepads()`; uses the **first connected** pad. Translates buttons/sticks into `gpKeys`/`gpPressed` (key-code vocabulary), `gpButtons`/`gpMousePressed` (virtual mouse buttons), plus two analog vectors: `stickLook` and `moveStick`.
|
|
29
|
+
|
|
30
|
+
The facade exposes the devices' live objects as direct fields — `input.keys`, `input.pressed`, `input.mouse`, `input.mousePressed`, `input.gpKeys`, `input.gpPressed`, `input.gpMousePressed`, `input.stickLook`, `input.moveStick`. These are **the same references** the devices own (existing code reads the fields directly; sharing the objects keeps reads and writes coherent either way).
|
|
31
|
+
|
|
32
|
+
## `class Input`
|
|
33
|
+
|
|
34
|
+
### `new Input(domElement, { padBindings } = {})`
|
|
35
|
+
|
|
36
|
+
`domElement` receives mouse events and is the pointer-lock target (typically the game canvas). `padBindings` is optional data in the format below; omitted, the gamepad uses `DEFAULT_PAD_BINDINGS`.
|
|
37
|
+
|
|
38
|
+
### Unified reads
|
|
39
|
+
|
|
40
|
+
| Signature | Returns | Meaning |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `down(code)` | `boolean` | Key (or bound pad input) currently held — `keys.has(code) \|\| gpKeys.has(code)`. |
|
|
43
|
+
| `hit(code)` | `boolean` | Key went down **this frame** (edge) — `pressed.has(code) \|\| gpPressed.has(code)`. |
|
|
44
|
+
| `mouseHit(btn)` | `boolean` | Mouse button (or bound trigger) edge this frame. |
|
|
45
|
+
| `held(btn)` | `boolean` | Mouse button held — bit test on `mouse.buttons \| gpButtons`, so it merges real mouse and virtual pad buttons. |
|
|
46
|
+
|
|
47
|
+
### Edge consumption
|
|
48
|
+
|
|
49
|
+
| Signature | Meaning |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `consume(code)` | Delete a just-pressed code from **both** `pressed` and `gpPressed` so a single press isn't acted on twice — e.g. the UI confirms a dialogue option with E, then world-interaction must not see that same E and immediately re-open dialogue. |
|
|
52
|
+
| `consumeMouse(btn)` | Same for a mouse button: slow-mo eats the LMB that looses its volley so the gun state machine doesn't ALSO read that click as a hip shot. |
|
|
53
|
+
|
|
54
|
+
### Analog vectors (read directly)
|
|
55
|
+
|
|
56
|
+
| Field | Range | Meaning |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `moveStick` | `{ x, y }`, each −1..1 | Left stick, analog, **radial** deadzone rescaled from the zone edge. Movement code reads this so a light push walks and a full push runs — the digital move-key faking (below) was all-or-nothing on the key threshold. |
|
|
59
|
+
| `stickLook` | `{ x, y }`, each −1..1 | Right stick, deadzoned + response-curved. Kept **separate** from `mouse.dx/dy` so the camera can apply it rate-based (framerate-independent) — mouse deltas are per-frame amounts, stick is a rate. |
|
|
60
|
+
|
|
61
|
+
### Pointer lock / lifecycle
|
|
62
|
+
|
|
63
|
+
| Signature | Meaning |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `requestLock()` | Request pointer lock on the element (no-op if already locked). |
|
|
66
|
+
| `releaseLock()` | Exit pointer lock (no-op if not locked). |
|
|
67
|
+
| `pollGamepad(uiOpen = false)` | Poll the pad and fold it into input state. **Once per frame, before any game reads.** Pass `true` while a modal UI panel owns the screen (see gotchas). |
|
|
68
|
+
| `endFrame()` | Clear keyboard/mouse edge sets and per-frame mouse deltas/wheel. **Last thing in the frame.** |
|
|
69
|
+
|
|
70
|
+
### Getters
|
|
71
|
+
|
|
72
|
+
| Getter | Meaning |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `wheel` | Accumulated wheel steps this frame (`+1`/`−1` per event, summed; reset by `endFrame`). |
|
|
75
|
+
| `pointerLocked` | `true` while the element holds pointer lock. |
|
|
76
|
+
| `gpActive` | Pad produced meaningful input within the last **1500 ms** (any axis nonzero after deadzone, or any button value > 0.3). |
|
|
77
|
+
| `acting` | `pointerLocked \|\| gpActive` — the gate for camera/combat: enabled when the mouse is captured **or** a gamepad is in active use. |
|
|
78
|
+
|
|
79
|
+
## `padBindings` data format
|
|
80
|
+
|
|
81
|
+
Bindings are **data**, not code — a game passes its own layout to `new Input(dom, { padBindings })`. `DEFAULT_PAD_BINDINGS` is a third-person-action layout for the standard gamepad mapping (PS names): Cross(0) jump · Circle(1) dodge · Square(2) interact · Triangle(3) swap · R1(5) reload · L2(6) sprint · Share(8) tab-panel · Options(9) pause · L3(10) ability · D-pad UP(12) inventory · LEFT(14) finisher · RIGHT(15) slot 1 · D-pad DOWN(13)/touchpad(16,17) map · R2(7) fire (LMB) · L1(4) aim-hold (RMB).
|
|
82
|
+
|
|
83
|
+
### `buttonKeys: { [padButtonIndex]: keyCode }`
|
|
84
|
+
|
|
85
|
+
Pad button index → the key code the rest of the game reads via `down()`/`hit()`. Uses the button's digital `.pressed`. Default:
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
{ 0: 'Space', 1: 'AltLeft', 2: 'KeyE', 3: 'KeyQ', 5: 'KeyR', 6: 'ShiftLeft',
|
|
89
|
+
8: 'Tab', 9: 'Escape', 10: 'ControlLeft', 12: 'KeyI', 14: 'KeyF', 15: 'Digit1' }
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### `comboKeys: [{ key, buttons }]`
|
|
93
|
+
|
|
94
|
+
Several pad buttons OR'd into **one** key in a single write. This exists because routing them through `buttonKeys` makes them fight: the un-pressed ones clear the key the pressed one just set, so the key re-fires every frame — a map-flicker bug this engine actually shipped once. If two pad buttons should produce the same key code, they **must** go here, not in `buttonKeys`. Default: `[{ key: 'KeyM', buttons: [13, 16, 17] }]`.
|
|
95
|
+
|
|
96
|
+
### `axisMouse: [{ mouseButton, button, threshold }]`
|
|
97
|
+
|
|
98
|
+
Analog trigger/bumper → virtual mouse button, thresholded on the button's analog `.value` (not `.pressed`). Feeds `gpButtons`/`gpMousePressed`, so it surfaces through `mouseHit()`/`held()`. Default: R2 (button 7, threshold 0.4) → mouse button 0 (fire); L1 (button 4, threshold 0.4) → mouse button 2 (aim — a hold, read via `held(2)`).
|
|
99
|
+
|
|
100
|
+
### `moveKeys: { up, down, left, right, threshold }`
|
|
101
|
+
|
|
102
|
+
Left stick → **digital** move key codes, for consumers that only want on/off: jump run-up, dodge direction, animation gates. Analog movement should read `moveStick` instead. Default: WASD with `threshold: 0.2`. (The compared axis values are pre-filtered by `curves.stickAxisDeadzone`, so the effective trip point is the larger of the two.)
|
|
103
|
+
|
|
104
|
+
### `curves: { moveDeadzone, lookDeadzone, lookExponent, stickAxisDeadzone }`
|
|
105
|
+
|
|
106
|
+
Response tuning:
|
|
107
|
+
|
|
108
|
+
- `moveDeadzone` (0.10) — **radial** (magnitude, not per-axis) deadzone for `moveStick`, rescaled from the zone edge so motion ramps from 0 the instant you clear it. A per-axis threshold is "dead to one side, then a lurch to full".
|
|
109
|
+
- `lookDeadzone` (0.06) — small radial deadzone for `stickLook`.
|
|
110
|
+
- `lookExponent` (2) — past the look deadzone, magnitude is raised to this power: slow near centre for fine aim, fast at the edge to whip the camera round (the RDR2 feel).
|
|
111
|
+
- `stickAxisDeadzone` (0.22) — per-axis deadzone applied to the raw axes used for digital move keys and pad-activity detection.
|
|
112
|
+
|
|
113
|
+
## Usage
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
import { Input, DEFAULT_PAD_BINDINGS } from './core/input/index.js';
|
|
117
|
+
|
|
118
|
+
const input = new Input(canvas, { padBindings: DEFAULT_PAD_BINDINGS });
|
|
119
|
+
canvas.addEventListener('click', () => input.requestLock());
|
|
120
|
+
|
|
121
|
+
function frame(dt) {
|
|
122
|
+
input.pollGamepad(ui.anyPanelOpen); // FIRST — before any input reads
|
|
123
|
+
|
|
124
|
+
if (input.acting) {
|
|
125
|
+
// camera: mouse delta is per-frame; stick is a rate — scale by dt
|
|
126
|
+
camera.yaw -= input.mouse.dx * MOUSE_SENS + input.stickLook.x * STICK_RATE * dt;
|
|
127
|
+
camera.pitch -= input.mouse.dy * MOUSE_SENS + input.stickLook.y * STICK_RATE * dt;
|
|
128
|
+
|
|
129
|
+
// movement: analog stick wins, else digital keys (either device)
|
|
130
|
+
let mx = input.moveStick.x, my = input.moveStick.y;
|
|
131
|
+
if (!mx && !my) {
|
|
132
|
+
mx = (input.down('KeyD') ? 1 : 0) - (input.down('KeyA') ? 1 : 0);
|
|
133
|
+
my = (input.down('KeyS') ? 1 : 0) - (input.down('KeyW') ? 1 : 0);
|
|
134
|
+
}
|
|
135
|
+
player.move(mx, my, input.down('ShiftLeft'));
|
|
136
|
+
|
|
137
|
+
if (input.mouseHit(0)) player.fire(); // click or R2 past threshold
|
|
138
|
+
player.aiming = input.held(2); // RMB or L1 held
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (input.hit('KeyE')) {
|
|
142
|
+
if (ui.handleInteract()) input.consume('KeyE'); // don't let the world see the same press
|
|
143
|
+
else world.tryInteract(player);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
input.endFrame(); // LAST — clears kb/mouse edges, deltas, wheel
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Gotchas
|
|
151
|
+
|
|
152
|
+
- **Call order is a contract.** `pollGamepad()` once per frame *before* any game code reads input; `endFrame()` as the *last* thing in the frame. Read edges between the two. Calling `endFrame()` early silently eats that frame's `hit()`/`mouseHit()` results.
|
|
153
|
+
- **Gamepad edges are cleared by `poll()`, keyboard/mouse edges by `endFrame()`.** `gpPressed`/`gpMousePressed` are wiped at the top of each `pollGamepad()`; `pressed`/`mousePressed` are wiped by `endFrame()`. Poll exactly once per frame — polling twice destroys pad edges for the second half of the frame.
|
|
154
|
+
- **UI-modal suppression (`pollGamepad(true)`).** While a modal panel owns the screen the pad drives the UI — the panel polls the raw pad itself — NOT the game. Passing `uiOpen = true` clears `gpKeys`/`gpButtons` and zeroes both sticks so a press doesn't leak into movement/fire/aim behind the panel, but still refreshes the activity timestamp so `gpActive`/`acting` stay true.
|
|
155
|
+
- **Edge priming on the first frame back from a panel.** The button the player is still holding — the one that dismissed the panel — would otherwise register as a fresh press the instant the game mappings switch back on (close-dialogue is ALSO jump → the player jumped as the talk ended). On that one frame the device *adopts* held state (`down()` is true) but suppresses the edge (`hit()` stays false); only a genuine release-and-repress acts.
|
|
156
|
+
- **Pointer-lock re-acquire swallow.** On `pointerlockchange`, if lock was just acquired, `mousePressed` is cleared and `mouse.buttons` zeroed: the click that re-acquires the lock (e.g. after closing dialogue) must not also register as an attack.
|
|
157
|
+
- **Alt-tab clears held state.** Both keyboard `keys` and `mouse.buttons` are cleared on window `blur`, because a button held across alt-tab stayed "held" forever (stuck block/bow). No `keyup`/`mouseup` arrives for it — don't "fix" this by removing the blur handlers.
|
|
158
|
+
- **Same key on two pad buttons ⇒ `comboKeys`, never duplicate `buttonKeys` entries.** Multiple buttons writing one key via `buttonKeys` fight each other and re-fire the key every frame (the shipped map-flicker bug). `comboKeys` ORs them in a single write.
|
|
159
|
+
- **Movement should read `moveStick`, not the WASD fakes.** The digital move keys exist for on/off consumers; reading them for movement makes an analog stick all-or-nothing at the key threshold.
|
|
160
|
+
- **`stickLook` is a rate, `mouse.dx/dy` is a per-frame delta.** Scale stick look by `dt`; do not scale mouse deltas by `dt`. They are deliberately not merged.
|
|
161
|
+
- **`held()` merges devices at the bit level** (`mouse.buttons | gpButtons`), so a pad trigger past its `axisMouse` threshold is indistinguishable from a held physical button — which is the point.
|
|
162
|
+
- **`mouse.dx/dy` accumulate only while pointer-locked**; `mouse.x/y` always track the cursor. `wheel` accumulates event *signs* (direction steps), not raw `deltaY` pixels.
|
|
163
|
+
- **`gpActive` is a 1500 ms recency window**, keyed off any axis (post-deadzone) or any button value > 0.3 — a pad that is merely connected but idle does not count, which keeps the mouse re-lock behavior working alongside a plugged-in pad.
|
|
164
|
+
- **`consume()`/`consumeMouse()` clear both device edge sets** — use them whenever one press could be interpreted by two systems in the same frame (UI confirm vs. world interact; slow-mo volley vs. hip shot).
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Renderer (`src/core/renderer.js`)
|
|
2
|
+
|
|
3
|
+
WebGPU renderer with automatic WebGL2 fallback, plus the post-processing pipeline builder that owns the game's single "screen grade" slot. Built on `three/webgpu` (`THREE.WebGPURenderer`), whose WebGL2 backend kicks in automatically when WebGPU is unavailable — one code path serves both backends. Re-exported from `src/index.js`.
|
|
4
|
+
|
|
5
|
+
## Backend selection: WebGPU → WebGL2 (`?webgl`)
|
|
6
|
+
|
|
7
|
+
- Default: WebGPU when the browser supports it.
|
|
8
|
+
- Automatic fallback: three's `WebGPURenderer` silently drops to its WebGL2 backend when WebGPU is unavailable.
|
|
9
|
+
- Forced fallback: add `?webgl` to the URL to force the WebGL2 backend (useful for A/B testing and debugging backend-specific issues).
|
|
10
|
+
- The chosen backend is logged at boot: `[renderer] backend: WebGPU` (or `WebGL2`), with `· TRAA` appended when TRAA is on.
|
|
11
|
+
|
|
12
|
+
## Exports
|
|
13
|
+
|
|
14
|
+
### `wantsTRAA()`
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
export function wantsTRAA(): boolean
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Returns `true` when the URL contains `?traa`. TRAA (temporal reprojection anti-aliasing) is **opt-in only** — every quality tier runs the plain render path + MSAA by default. See the gotchas for why.
|
|
21
|
+
|
|
22
|
+
### `createRenderer(container)`
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
export async function createRenderer(container: HTMLElement): Promise<{
|
|
26
|
+
renderer: THREE.WebGPURenderer,
|
|
27
|
+
backend: 'WebGPU' | 'WebGL2',
|
|
28
|
+
}>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Creates and initialises the renderer, appends its canvas to `container`, and installs a `resize` listener that calls `renderer.setSize(window.innerWidth, window.innerHeight)`.
|
|
32
|
+
|
|
33
|
+
Configuration applied, in order:
|
|
34
|
+
|
|
35
|
+
- `antialias` (MSAA) comes from the active quality preset (`getQuality().antialias`) **and is forced off when TRAA is on** — TRAANode requires MSAA disabled; TRAA provides the anti-aliasing instead.
|
|
36
|
+
- `forceWebGL` set from the `?webgl` query param.
|
|
37
|
+
- `await renderer.init()` — creation is async; you must await it.
|
|
38
|
+
- `setPixelRatio(Math.min(window.devicePixelRatio, q.pixelRatio))` — capped by the quality preset.
|
|
39
|
+
- `renderer.shadowMap.enabled = false` — shadow mapping is retired at **every** preset (see gotchas for the measured history).
|
|
40
|
+
- `renderer.toneMapping = THREE.ACESFilmicToneMapping`, `toneMappingExposure = 1.05`.
|
|
41
|
+
|
|
42
|
+
`backend` is derived from `renderer.backend?.isWebGPUBackend`.
|
|
43
|
+
|
|
44
|
+
### `createPostProcessing(renderer, scene, camera, options)`
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
export function createPostProcessing(
|
|
48
|
+
renderer: THREE.WebGPURenderer,
|
|
49
|
+
scene: THREE.Scene,
|
|
50
|
+
camera: THREE.Camera,
|
|
51
|
+
{ traa = false, grade = null }: {
|
|
52
|
+
traa?: boolean, // pass wantsTRAA() here
|
|
53
|
+
grade?: TSLFn | null, // Fn(([color, amount]) => Node) — see grade-slot contract
|
|
54
|
+
} = {},
|
|
55
|
+
): THREE.PostProcessing // with .gradeAmount: UniformNode<number> | null added
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Builds the post-processing pipeline. The returned `PostProcessing`'s `.render()` **replaces** `renderer.render(scene, camera)` in the main loop.
|
|
59
|
+
|
|
60
|
+
Pipeline shape:
|
|
61
|
+
|
|
62
|
+
- A `pass(scene, camera)` scene pass is always created.
|
|
63
|
+
- With `traa: true`, the pass gets a velocity MRT (`mrt({ output, velocity })`) so TRAA can reproject the previous frame, and the beauty node is `traa(output, depth, velocity, camera)`.
|
|
64
|
+
- Without TRAA, the beauty node is the pass's plain `output` texture.
|
|
65
|
+
- The grade (if any) is composed **last**, then `outputColorTransform` stays at its default (`true`): the chain renders linear, and ACES tone mapping + sRGB are applied once at the end.
|
|
66
|
+
|
|
67
|
+
MSAA is **not** lost by moving into a pass: `PassNode` takes its render target's sample count from `renderer.samples`, which is 4 whenever the renderer was created with `antialias` — so MSAA still runs, inside the pass.
|
|
68
|
+
|
|
69
|
+
## The screen grade slot
|
|
70
|
+
|
|
71
|
+
The whole screen look is ONE game-supplied TSL `Fn` driven by ONE uniform. The contract:
|
|
72
|
+
|
|
73
|
+
- **Signature:** `grade = Fn(([color, amount]) => Node)` — it is called exactly once at build time as `grade(beautyNode, amountUniform)` and must return the final colour node. `color` is the (possibly TRAA-resolved) scene colour; `amount` is a float uniform, 0..1.
|
|
74
|
+
- **`gradeAmount` uniform:** when a grade is supplied, the returned object gets `post.gradeAmount` — a `uniform(0)` node. This is the game's one live dial: write `post.gradeAmount.value` each frame (in this codebase, `deadeye.js` drives it — the source comment calls it "deadEyeAmount", but the actual property is `gradeAmount`). With no grade, `post.gradeAmount` is `null`.
|
|
75
|
+
- **Identity at 0:** the grade must be *mathematically* identity at `amount = 0`, so the idle cost is a single fullscreen `mix`. Do not gate on `amount` with branches; author it so amount 0 passes colour through exactly.
|
|
76
|
+
- **Linear space:** the grade runs in LINEAR space — `outputColorTransform` stays `true`, so ACES tone map + sRGB apply AFTER it. Author luminance recolours, not gamma-space curves, so the look survives the tone map.
|
|
77
|
+
- **Build at boot:** the pipeline is now built ALWAYS, grade included, even if the grade sits at 0 for the whole session. Swapping render paths (or `outputNode`) at runtime compiles a fresh pipeline on first use — a multi-second WebGPU freeze mid-play. Building at boot pays that cost on the loading screen instead.
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
import { Fn, vec4, mix, luminance } from 'three/tsl';
|
|
83
|
+
import { createRenderer, createPostProcessing, wantsTRAA } from './core/renderer.js';
|
|
84
|
+
|
|
85
|
+
const { renderer, backend } = await createRenderer(document.getElementById('game'));
|
|
86
|
+
|
|
87
|
+
// Dead-Eye-style desaturating grade. mix(color, grey, 0) === color,
|
|
88
|
+
// so it is exact identity at amount 0 — one fullscreen mix when idle.
|
|
89
|
+
const grade = Fn(([color, amount]) => {
|
|
90
|
+
const grey = luminance(color.rgb);
|
|
91
|
+
return vec4(mix(color.rgb, grey, amount), color.a);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const post = createPostProcessing(renderer, scene, camera, {
|
|
95
|
+
traa: wantsTRAA(),
|
|
96
|
+
grade,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function frame(dt) {
|
|
100
|
+
// The one live dial — write .value, never rebuild the pipeline.
|
|
101
|
+
post.gradeAmount.value = deadEyeActive ? 1 : 0;
|
|
102
|
+
post.render(); // replaces renderer.render(scene, camera)
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Gotchas
|
|
107
|
+
|
|
108
|
+
- **Never swap `outputNode` (or render paths) at runtime.** The first frame with a new node graph compiles a fresh pipeline — a multi-second WebGPU freeze, exactly the hazard this codebase documents. Everything the game will ever show must be in the graph at boot; runtime variation goes through uniforms (`gradeAmount`).
|
|
109
|
+
- **TRAA and MSAA are mutually exclusive.** TRAANode requires MSAA off; `createRenderer` handles this by dropping `antialias` when `wantsTRAA()` is true. Don't re-enable one while the other runs.
|
|
110
|
+
- **TRAA is off by default because it ~doubles per-frame GPU cost** (full-screen velocity MRT + temporal resolve pass). Its ONLY unique job is making the dithered LOD dissolve read as a smooth fade instead of a screen door — it jitters the camera sub-pixel each frame and accumulates, averaging the dither stipple into a gradient over time (the GTA / Unreal "dither + temporal AA" trick). MSAA already antialiases edges, cheaper, so every tier now runs plain render + MSAA at ~2× the fps. Opt in with `?traa` if the LOD stipple bothers you. (It was previously on for Medium + High.)
|
|
111
|
+
- **Shadow mapping is retired at every preset** (re-landed solo after a batch revert). The shadow pass is a SECOND full scene render per frame — ~7.5ms CPU measured — and three r184's node-based `ShadowNode` only honours per-**light** `shadow.autoUpdate`/`needsUpdate`, so the renderer-level throttle that ran for months was silently ignored (it rendered EVERY frame). Characters get instanced blob discs instead (`world/blobShadows.js`). Measured: 35 → 47.6 fps. Related: mutating shadow maps live corrupts the WebGPU backend's shadow state (see `quality.js`), so don't try to toggle them at runtime either.
|
|
112
|
+
- **Moving rendering into `PostProcessing` does not lose MSAA.** `PassNode` inherits its target's sample count from `renderer.samples` (4 when the renderer was created with `antialias`), so MSAA runs inside the pass. The grade is the last link either way, with or without TRAA layered in.
|
|
113
|
+
- **`antialias` is fixed at renderer creation** — the quality preset's `antialias` flag only applies on next reload; `pixelRatio` changes apply live (via `applyQuality` in `quality.js`).
|
|
114
|
+
- **The grade runs pre-tonemap, in linear.** If a look computes correctly in Photoshop but shifts in game, that's the ACES tone map applied after your math — recolour by luminance rather than applying gamma-space curves.
|
|
115
|
+
- **The resize listener only calls `setSize`.** Pixel-ratio changes (e.g. quality downgrades from the `AutoTuner`) go through `applyQuality`, which is itself a big hitch — that's why the auto-tuner requires two consecutive bad fps windows before acting.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# retarget — bind-pose-delta animation retargeting
|
|
2
|
+
|
|
3
|
+
`src/core/retarget.js`
|
|
4
|
+
|
|
5
|
+
Plays animation clips authored on one skeleton on characters rigged with a *different* skeleton. The canonical case: clips from the Synty ANIMATION pack rig (`Root/Hips/Spine_01/Shoulder_L…`) driving Synty POLYGON character rigs (`Pelvis/spine_01/UpperArm_L…`). The rigs share structure and proportions, but bone names differ and the bone-local frames differ (Unreal-style axis convention on the POLYGON side).
|
|
6
|
+
|
|
7
|
+
The method is **bind-pose deltas**:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Δb = Wb_animBind⁻¹ · Wb_targetBind (world-rotation delta per bone)
|
|
11
|
+
q'b(t) = Δparent(b)⁻¹ · qb(t) · Δb (applied to every quaternion key)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This makes the target's world deformation match the source clip exactly, regardless of local frame conventions.
|
|
15
|
+
|
|
16
|
+
The module is consumed automatically by `Animator` (`src/core/anim.js`), which builds one retarget context per source rig per character and runs every registered clip through `retargetClip`. You only touch this module directly at boot (registering source rigs) or when adding a foreign animation pack.
|
|
17
|
+
|
|
18
|
+
## Why "source rigs" exist at all
|
|
19
|
+
|
|
20
|
+
Clips are authored against a specific skeleton's **bind pose**, and a clip only plays correctly on a rig with that same bind. Unity hides this — its Humanoid/Mecanim avatar retargets through muscle-space, so any humanoid clip plays on any humanoid rig. three.js has no such layer: it applies raw bone quaternions. So a pack whose bind differs — the Wild West gunslinger pack's PreviewRig is ~90–107° off on Hips/Spine vs the Synty anim rig, despite **identical bone names** — must declare its own source bind, or it plays as a folded-in-half mess.
|
|
21
|
+
|
|
22
|
+
One global source bind was the original bug: every clip was assumed to be authored on the Synty rig. The fix is the named-rig registry (`addSourceRig`) plus per-clip rig keys (see `Animator`'s `clipRigs` / `setClipDefaults({ rigs })`).
|
|
23
|
+
|
|
24
|
+
**When a game needs a custom source rig:** any time it imports clips from a pack whose skeleton bind pose differs from an already-registered rig — even if the bone names match. Register the pack's own preview/reference model as a rig under a new key, and map its clips to that key.
|
|
25
|
+
|
|
26
|
+
## Exports
|
|
27
|
+
|
|
28
|
+
### `addSourceRig(key, root, map = null)`
|
|
29
|
+
|
|
30
|
+
Register a source rig by key. `root` is the loaded model whose **current loaded pose is taken as its bind pose** (captured from skinned-mesh inverse-bind matrices, so a posed model is still safe — see Gotchas). `map` is an optional bone-name table translating this rig's bone names onto ANIM-rig names (e.g. `MALBERS_TO_ANIM`); pass `null` when the rig already uses ANIM-rig names.
|
|
31
|
+
|
|
32
|
+
The **first** rig registered becomes the default for clips that don't name a rig.
|
|
33
|
+
|
|
34
|
+
### `setSourceRig(root, map = null)`
|
|
35
|
+
|
|
36
|
+
Back-compat single-source API. Equivalent to `addSourceRig('anim', root, map)` and forces `'anim'` to be the default rig key. Use this to register the Synty animation rig.
|
|
37
|
+
|
|
38
|
+
### `detectRig(root)` → `'anim' | 'polygon'`
|
|
39
|
+
|
|
40
|
+
Classifies a target skeleton: returns `'polygon'` if any bone is named `Pelvis`, else `'anim'`. Used internally by `buildRetargetContext` to decide whether the `ANIM_TO_POLYGON` renaming step applies.
|
|
41
|
+
|
|
42
|
+
### `buildRetargetContext(targetRoot, rigKey = null)` → context
|
|
43
|
+
|
|
44
|
+
Builds a per-character retarget context against the source rig named by `rigKey` (default: the default rig). **Throws** if that rig was never registered:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
source rig '<key>' not registered — call setSourceRig()/addSourceRig() first
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Returned object:
|
|
51
|
+
|
|
52
|
+
| Field | Meaning |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `rig` | `'anim'` or `'polygon'` (from `detectRig`) |
|
|
55
|
+
| `deltas` | `Map` of source bone name → `{ delta, targetName, parentFix }` |
|
|
56
|
+
| `posScale` | scale factor for root translation keys (source→target hip-height ratio) |
|
|
57
|
+
| `bindMatches` | `true` if every bone's bind-delta is ~identity (clip can play as-is) |
|
|
58
|
+
| `needsRootFix` | `true` if any bone needed the target-parent root fix (see Gotchas) |
|
|
59
|
+
| `posQ` | quaternion converting source-container-frame position vectors into the target's frame |
|
|
60
|
+
| `sourceBind` | the source rig's captured bind map (used by `retargetClip` for parent lookup) |
|
|
61
|
+
| `rigKey` | the resolved source rig key |
|
|
62
|
+
| `key` | cache-key string: `${rigKey}:${rig}:${posScale}:${boneCount}:${bindMatches}:${bindHash}` |
|
|
63
|
+
|
|
64
|
+
Contexts are cheap; characters sharing a rig signature *could* share one, but the engine just builds one per loaded character master (per source rig).
|
|
65
|
+
|
|
66
|
+
### `retargetClip(clip, ctx)` → `THREE.AnimationClip`
|
|
67
|
+
|
|
68
|
+
Retargets a clip using a context from `buildRetargetContext`. Behavior:
|
|
69
|
+
|
|
70
|
+
- **Short-circuit:** if `ctx.rig === 'anim'` and `ctx.bindMatches` and `!ctx.needsRootFix` and `posScale ≈ 1`, the original clip is returned untouched — identity rig, full bone coverage, matching units.
|
|
71
|
+
- **Cache:** results are cached by `${clip.uuid}:${ctx.key}` in a module-level map; repeated calls are free.
|
|
72
|
+
- **Quaternion tracks:** each key is transformed `Δparent⁻¹ · q(t) · Δb`, and the track is renamed to the target bone.
|
|
73
|
+
- **Position tracks:** only the root (`Hips`) translation is kept — it drives bob/crouch — rotated by `ctx.posQ` into the target's frame, then rescaled by `posScale` into the target rig's units. All other position tracks are dropped.
|
|
74
|
+
- **Scale tracks:** dropped.
|
|
75
|
+
- **Tracks for bones not in `ctx.deltas`** (e.g. bow-prop joints riding along in a clip) are dropped.
|
|
76
|
+
|
|
77
|
+
### `MALBERS_TO_ANIM` (const)
|
|
78
|
+
|
|
79
|
+
Bone-map table: Malbers "Human" rig → ANIM rig (`R_Pelvis → Hips`, `R_L_UpperArm → Shoulder_L`, …). Used for the climb/ladder clips, which are authored on the Malbers skeleton. It maps onto ANIM-rig names deliberately: the internal `ANIM_TO_POLYGON` step then carries the result onto polygon-rigged characters automatically, so **one map serves both anim- and polygon-rigged targets**. Pass it as the `map` argument when registering the Malbers rig:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
addSourceRig('malbers', malbersModel, MALBERS_TO_ANIM);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Notable entries:
|
|
86
|
+
|
|
87
|
+
- `R_CG: 'Root'` — the Malbers root above the pelvis. Without it the pelvis loses parent compensation and the body pitches 90°.
|
|
88
|
+
- Bone names use underscores (`R_L_Clavicle`, not `"R_L Clavicle"`) because FBXLoader sanitizes spaces to underscores on load.
|
|
89
|
+
|
|
90
|
+
### Not exported (but you should know they exist)
|
|
91
|
+
|
|
92
|
+
- **`ANIM_TO_POLYGON`** — module-private table mapping ANIM rig bone names → POLYGON rig names (`Hips → Pelvis`, `Shoulder_L → UpperArm_L`, finger chains, etc.). Identity entries are implied for rigs (e.g. the Knights pack) that reuse the source names. Applied automatically inside `buildRetargetContext` when the target is detected as `'polygon'`.
|
|
93
|
+
- **`captureBind(root)`** — module-private. Captures a rig's bind pose as a map of bone name → `{ worldQ, localP, parentName }` (plus a `chainQ` container rotation on the map itself). The world rotations come from the SkinnedMesh **inverse-bind matrices** (`boneInverses[i]⁻¹` *is* the bone's world matrix at bind), which are pose-independent ground truth, then get folded into the model-root frame via the skeleton container's rotation. Bones with no skin weight fall back to their live world quaternion. The details here are the source of most of the Gotchas below.
|
|
94
|
+
|
|
95
|
+
## Usage
|
|
96
|
+
|
|
97
|
+
Boot-time setup and manual retargeting (the `Animator` class does the last two steps for you):
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
import {
|
|
101
|
+
setSourceRig, addSourceRig, MALBERS_TO_ANIM,
|
|
102
|
+
buildRetargetContext, retargetClip,
|
|
103
|
+
} from 'sindicate';
|
|
104
|
+
|
|
105
|
+
// 1. Register source rigs once, at boot, after loading the reference models.
|
|
106
|
+
// First/explicit 'anim' registration becomes the default.
|
|
107
|
+
setSourceRig(syntyAnimRigModel); // Synty ANIMATION pack rig
|
|
108
|
+
addSourceRig('malbers', malbersHuman, MALBERS_TO_ANIM); // climb/ladder clips
|
|
109
|
+
addSourceRig('western', gunslingerPreviewRig); // same names, different bind!
|
|
110
|
+
|
|
111
|
+
// 2. Per character, build a context against the rig the clip was authored on.
|
|
112
|
+
const ctx = buildRetargetContext(characterModel); // default ('anim')
|
|
113
|
+
const climbCtx = buildRetargetContext(characterModel, 'malbers');
|
|
114
|
+
|
|
115
|
+
// 3. Retarget clips (cached — cheap to call repeatedly).
|
|
116
|
+
const mixer = new THREE.AnimationMixer(characterModel);
|
|
117
|
+
mixer.clipAction(retargetClip(runClip, ctx)).play();
|
|
118
|
+
mixer.clipAction(retargetClip(climbClip, climbCtx));
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
In the engine proper you normally do step 1 only, then let `Animator` handle 2–3, telling it which clips come from which rig via `setClipDefaults({ rigs: { climb: 'malbers', quickdraw: 'western' } })`.
|
|
122
|
+
|
|
123
|
+
## Gotchas
|
|
124
|
+
|
|
125
|
+
Hard-won lessons preserved from the source. Most of these are bug histories — do not "simplify" them away.
|
|
126
|
+
|
|
127
|
+
- **Bind pose must come from inverse-bind matrices, not the live pose.** Reading `getWorldQuaternion()` off live bones silently poisons every delta if the model is captured mid-animation — e.g. a shared master that got posed before this clone. That is exactly why the `SK_Chr_Soldier_*` guards held their arms out: their arm deltas were ~30° off true bind. `boneInverses[i]⁻¹` is pose-independent ground truth.
|
|
128
|
+
|
|
129
|
+
- **`boneInverses` are in the skeleton *container's* space, not world.** FBXLoader parks the Z-up→Y-up conversion on container `Object3D`s for some packs (Wild West gunslinger: root Group −90° X, PreviewRig Group −180° Z) and on a Root *bone* for others (Synty: containers identity). Comparing a bind captured in one frame against a bind captured in the other tilts every delta by that rotation — the character animates lying on his back. `captureBind` folds the container's world rotation in so both rigs are captured in the model-root frame. This is a static transform, so it stays safe for posed shared masters.
|
|
130
|
+
|
|
131
|
+
- **…and the container rotation is measured relative to `root`, not the world.** `getWorldQuaternion()` also picks up wherever the character happens to be *standing* — and a Character's model hangs under a root Group rotated to its **heading**. The bind captured for a townsman facing south had his heading folded into it, and every delta was off by exactly that angle: measured across the town, the chain rotation came out at precisely |heading| for every NPC (Silas at heading π captured a 180° "bind" and stood with his arms straight up; the player and the barkeep face heading 0, which is the only reason they ever looked right).
|
|
132
|
+
|
|
133
|
+
- **Root fix: identity is the wrong fallback for source bones with no parent.** Packs whose root bone is Hips (no Root — e.g. the gunslinger rig) have no parent delta for Hips. But the *target's* Hips hangs under a Root bone carrying its own bind rotation, which then never cancels. The correct fallback is the **target parent's inverse bind world rotation** (`parentFix`), derived from `q_local = W_parent⁻¹ · q_src(t) · Δb`. With identity, the whole character renders upside down / rolled by the root's bind.
|
|
134
|
+
|
|
135
|
+
- **Root translation is unit-sensitive.** Position tracks are authored in the source rig's units (cm). A metre-scale pack (e.g. the Modular Fantasy Hero) needs them rescaled or the pelvis flies 87 m up. `posScale` is the target/source hip-height ratio — and note this is why the identity short-circuit in `retargetClip` also checks `posScale ≈ 1`: a metre-scale rig with matching bone names still needs the rescale pass.
|
|
136
|
+
|
|
137
|
+
- **`posScale` of zero pinned characters to the floor.** A target whose Hips sits at its parent's origin (`localP.y === 0` — true of the western Synty rigs, where Root and Hips are coincident) used to yield `posScale = 0`, multiplying every `Hips.position` key by zero: the pelvis got pinned to the root and the character sank into the ground. Rescale only happens when *both* hip heights are real and the ratio is sane (`0.01 < posScale < 100`); otherwise 1:1.
|
|
138
|
+
|
|
139
|
+
- **Hips translation is a vector in the source container's frame.** Rotating only the quaternion tracks left it in the pack's Z-up space: the pelvis got driven to the floor (hipsY 0) while the body above it posed correctly. `retargetClip` rotates the position keys by `ctx.posQ` *then* rescales.
|
|
140
|
+
|
|
141
|
+
- **Same bone names ≠ same bind pose — twice over.** (1) The gunslinger pack: identical names, ~90–107° different bind, hence named source rigs. (2) The Fantasy Rivals bosses each carry a custom rest stance (bent legs, etc.) on anim-rig bone names — the clip is only usable as-is when the bind *also* matches (`bindMatches` = every delta ~identity, `|w| ≈ 1`).
|
|
142
|
+
|
|
143
|
+
- **The cache key folds in a hash of the bind-deltas.** Every custom-posed boss otherwise produces the *same* key (same rig, same bone count), so they'd share one retargeted clip — the first one computed — and wear each other's skeleton. The hash makes each distinct bind pose unique while still letting identical rigs (e.g. many goblins) share the cache.
|
|
144
|
+
|
|
145
|
+
- **Registration order matters.** The first `addSourceRig` call sets the default rig key; `setSourceRig` forces the default to `'anim'`. Register the Synty rig before anything constructs an `Animator`, or `buildRetargetContext` throws.
|
|
146
|
+
|
|
147
|
+
- **Dropped tracks are intentional.** Non-Hips position tracks, all scale tracks, and tracks for bones with no delta entry (prop joints riding in a clip) are discarded, not errors.
|
|
148
|
+
|
|
149
|
+
- **FBXLoader sanitizes bone names.** Spaces become underscores (`"R_L Clavicle"` loads as `R_L_Clavicle`). Bone-map tables must use the *loaded* names.
|