sindicate 0.1.0 → 0.2.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.
@@ -0,0 +1,92 @@
1
+ // Gamepad device (PS5/Xbox via the standard mapping), folded into the same
2
+ // key/button vocabulary as keyboard+mouse so game code reads ONE API.
3
+ // Bindings are data (see bindings.js) — a game passes its own layout in.
4
+ import { DEFAULT_PAD_BINDINGS } from './bindings.js';
5
+
6
+ export class GamepadDevice {
7
+ constructor(bindings = DEFAULT_PAD_BINDINGS) {
8
+ this.bindings = bindings;
9
+ this.gpKeys = new Set();
10
+ this.gpPressed = new Set();
11
+ this.gpButtons = 0; // virtual mouse-button bitmask
12
+ this.gpMousePressed = new Set();
13
+ this._gpLastT = -1e9;
14
+ this._gpPrime = false;
15
+ // right-stick look, deadzoned + curved (range −1..1). Kept separate from the
16
+ // mouse delta so the camera can apply it rate-based (framerate-independent).
17
+ this.stickLook = { x: 0, y: 0 };
18
+ // left-stick MOVE, analog (range −1..1, radial deadzone). Digital move-key faking
19
+ // still fires for on/off consumers, but MOVEMENT reads this so a light push walks
20
+ // and a full push runs — an analog stick was all-or-nothing on the key threshold.
21
+ this.moveStick = { x: 0, y: 0 };
22
+ }
23
+
24
+ // Poll once per frame and fold the pad into the input state.
25
+ poll(uiOpen = false) {
26
+ const pads = typeof navigator !== 'undefined' && navigator.getGamepads ? navigator.getGamepads() : null;
27
+ let gp = null;
28
+ if (pads) for (const p of pads) { if (p && p.connected) { gp = p; break; } }
29
+ this.gpPressed.clear();
30
+ this.gpMousePressed.clear();
31
+ if (!gp) { this.gpKeys.clear(); this.gpButtons = 0; this.stickLook.x = 0; this.stickLook.y = 0; this.moveStick.x = 0; this.moveStick.y = 0; return; }
32
+ const B = this.bindings, C = B.curves;
33
+ const DZ = C.stickAxisDeadzone;
34
+ const ax = (i) => { const v = gp.axes[i] || 0; return Math.abs(v) < DZ ? 0 : v; };
35
+ const lx = ax(0), ly = ax(1), rx = ax(2), ry = ax(3);
36
+ const bv = (i) => (gp.buttons[i] ? gp.buttons[i].value : 0);
37
+ const bp = (i) => !!(gp.buttons[i] && gp.buttons[i].pressed);
38
+ // LEFT STICK, ANALOG MOVE — radial deadzone (magnitude, not per-axis), rescaled so
39
+ // motion ramps from 0 the instant you clear the zone.
40
+ const rlx = gp.axes[0] || 0, rly = gp.axes[1] || 0;
41
+ const mMag = Math.hypot(rlx, rly), MDZ = C.moveDeadzone;
42
+ if (mMag > MDZ) { const s = ((mMag - MDZ) / (1 - MDZ)) / mMag; this.moveStick.x = rlx * s; this.moveStick.y = rly * s; }
43
+ else { this.moveStick.x = 0; this.moveStick.y = 0; }
44
+ // While a modal panel owns the screen the pad drives the UI — the panel polls the
45
+ // raw pad itself — NOT the game. Suppress the game mappings so a press doesn't leak
46
+ // into movement/fire/aim behind the panel; still mark the pad "active".
47
+ if (uiOpen) {
48
+ this.gpKeys.clear(); this.gpButtons = 0;
49
+ this.moveStick.x = this.moveStick.y = 0; this.stickLook.x = this.stickLook.y = 0;
50
+ if (gp.buttons.some((b) => b && b.value > 0.3) || lx || ly || rx || ry) this._gpLastT = (typeof performance !== 'undefined' ? performance.now() : 0);
51
+ this._gpPrime = true; // next game frame: a button still held from driving the UI must NOT fire a fresh edge
52
+ return;
53
+ }
54
+ // FIRST FRAME BACK FROM A PANEL: the button the player is still holding — the one that
55
+ // dismissed the panel — would otherwise register as a fresh press the instant the game
56
+ // mappings switch back on (close-dialogue is ALSO jump → the player jumped as the talk
57
+ // ended). On this one frame PRIME the held state but suppress the edge, so only a
58
+ // genuine release-and-repress acts.
59
+ const prime = this._gpPrime; this._gpPrime = false;
60
+ // left stick -> digital move keys
61
+ const MK = B.moveKeys, MT = MK.threshold;
62
+ this._gpKey(MK.up, ly < -MT, prime); this._gpKey(MK.down, ly > MT, prime);
63
+ this._gpKey(MK.left, lx < -MT, prime); this._gpKey(MK.right, lx > MT, prime);
64
+ // RIGHT STICK -> LOOK: small radial deadzone, then magnitude raised to lookExponent —
65
+ // slow near centre (fine aim), fast at the edge (whip the camera round).
66
+ const rrx = gp.axes[2] || 0, rry = gp.axes[3] || 0, rMag = Math.hypot(rrx, rry), RDZ = C.lookDeadzone;
67
+ if (rMag > RDZ) {
68
+ const t = (rMag - RDZ) / (1 - RDZ);
69
+ const curved = Math.pow(t, C.lookExponent);
70
+ this.stickLook.x = (rrx / rMag) * curved;
71
+ this.stickLook.y = (rry / rMag) * curved;
72
+ } else { this.stickLook.x = 0; this.stickLook.y = 0; }
73
+ // buttons -> keys, per the game's bindings table
74
+ for (const i in B.buttonKeys) this._gpKey(B.buttonKeys[i], bp(+i), prime);
75
+ for (const combo of B.comboKeys) this._gpKey(combo.key, combo.buttons.some(bp), prime);
76
+ for (const am of B.axisMouse) this._gpBtn(am.mouseButton, bv(am.button) > am.threshold, prime);
77
+ // mark active if any meaningful input this frame (keeps the mouse re-lock fix
78
+ // working when a pad is merely connected but idle)
79
+ if (lx || ly || rx || ry || gp.buttons.some((b) => b && b.value > 0.3)) this._gpLastT = (typeof performance !== 'undefined' ? performance.now() : 0);
80
+ }
81
+ _gpKey(code, on, prime) {
82
+ const was = this.gpKeys.has(code);
83
+ if (on && !was) { this.gpKeys.add(code); if (!prime) this.gpPressed.add(code); } // prime: adopt the held state without firing an edge
84
+ else if (!on && was) this.gpKeys.delete(code);
85
+ }
86
+ _gpBtn(btn, on, prime) {
87
+ const bit = 1 << btn, was = (this.gpButtons & bit) !== 0;
88
+ if (on && !was) { this.gpButtons |= bit; if (!prime) this.gpMousePressed.add(btn); }
89
+ else if (!on && was) this.gpButtons &= ~bit;
90
+ }
91
+ get active() { return (typeof performance !== 'undefined' ? performance.now() : 0) - this._gpLastT < 1500; }
92
+ }
@@ -0,0 +1,62 @@
1
+ // Unified input: keyboard + mouse + gamepad behind ONE API with per-frame edge
2
+ // detection. Game code never talks to a device — it reads key codes and mouse
3
+ // buttons; the gamepad folds itself into that same vocabulary via a bindings
4
+ // table (data the game passes in — see bindings.js).
5
+ //
6
+ // const input = new Input(canvas, { padBindings: MY_LAYOUT })
7
+ // input.pollGamepad(ui.anyPanelOpen) // once per frame, BEFORE game reads
8
+ // if (input.hit('KeyE')) … // true for keyboard E or bound pad button
9
+ // input.endFrame() // last thing in the frame
10
+ import { KeyboardDevice } from './keyboard.js';
11
+ import { MouseDevice } from './mouse.js';
12
+ import { GamepadDevice } from './gamepad.js';
13
+ export { DEFAULT_PAD_BINDINGS } from './bindings.js';
14
+
15
+ export class Input {
16
+ constructor(domElement, { padBindings } = {}) {
17
+ this.dom = domElement;
18
+ this._kb = new KeyboardDevice();
19
+ this._ms = new MouseDevice(domElement);
20
+ this._gp = new GamepadDevice(padBindings);
21
+ // aliases: existing code reads these fields directly, and the devices own the
22
+ // live objects — same references, so writes/reads stay coherent either way.
23
+ this.keys = this._kb.keys;
24
+ this.pressed = this._kb.pressed;
25
+ this.mouse = this._ms.mouse;
26
+ this.mousePressed = this._ms.mousePressed;
27
+ this.gpKeys = this._gp.gpKeys;
28
+ this.gpPressed = this._gp.gpPressed;
29
+ this.gpMousePressed = this._gp.gpMousePressed;
30
+ this.stickLook = this._gp.stickLook;
31
+ this.moveStick = this._gp.moveStick;
32
+ }
33
+ get wheel() { return this._ms.wheel; }
34
+ get pointerLocked() { return this._ms.pointerLocked; }
35
+ get _gpButtons() { return this._gp.gpButtons; }
36
+
37
+ requestLock() { this._ms.requestLock(); }
38
+ releaseLock() { this._ms.releaseLock(); }
39
+ pollGamepad(uiOpen = false) { this._gp.poll(uiOpen); }
40
+
41
+ get gpActive() { return this._gp.active; }
42
+ // camera/combat enabled when mouse is captured OR a gamepad is in active use
43
+ get acting() { return this.pointerLocked || this.gpActive; }
44
+
45
+ down(code) { return this.keys.has(code) || this.gpKeys.has(code); }
46
+ hit(code) { return this.pressed.has(code) || this.gpPressed.has(code); }
47
+ mouseHit(btn) { return this.mousePressed.has(btn) || this.gpMousePressed.has(btn); }
48
+ held(btn) { return (((this.mouse.buttons | this._gp.gpButtons) >> btn) & 1) !== 0; }
49
+
50
+ // Drop a just-pressed code so a single button press isn't acted on twice — e.g.
51
+ // the UI confirms a dialogue option with E, then world-interaction must not see
52
+ // that same E and immediately re-open dialogue.
53
+ consume(code) { this.pressed.delete(code); this.gpPressed.delete(code); }
54
+ // Same, for a mouse button: slow-mo eats the LMB that looses its volley so the gun
55
+ // state machine doesn't ALSO read that click as a hip shot.
56
+ consumeMouse(btn) { this.mousePressed.delete(btn); this.gpMousePressed.delete(btn); }
57
+
58
+ endFrame() {
59
+ this._kb.endFrame();
60
+ this._ms.endFrame();
61
+ }
62
+ }
@@ -0,0 +1,16 @@
1
+ // Keyboard device: held-key set + per-frame edge set.
2
+ export class KeyboardDevice {
3
+ constructor() {
4
+ this.keys = new Set();
5
+ this.pressed = new Set(); // cleared each frame by the Input facade
6
+ window.addEventListener('keydown', (e) => {
7
+ if (e.repeat) return;
8
+ this.keys.add(e.code);
9
+ this.pressed.add(e.code);
10
+ });
11
+ window.addEventListener('keyup', (e) => this.keys.delete(e.code));
12
+ // a button held across alt-tab stayed 'held' forever (stuck block/bow)
13
+ window.addEventListener('blur', () => this.keys.clear());
14
+ }
15
+ endFrame() { this.pressed.clear(); }
16
+ }
@@ -0,0 +1,48 @@
1
+ // Mouse device: buttons/movement/wheel + pointer-lock management.
2
+ export class MouseDevice {
3
+ constructor(domElement) {
4
+ this.dom = domElement;
5
+ this.mouse = { x: 0, y: 0, dx: 0, dy: 0, buttons: 0 };
6
+ this.mousePressed = new Set(); // button indices pressed this frame
7
+ this.wheel = 0;
8
+ this.pointerLocked = false;
9
+
10
+ domElement.addEventListener('mousedown', (e) => {
11
+ this.mouse.buttons |= (1 << e.button);
12
+ this.mousePressed.add(e.button);
13
+ });
14
+ window.addEventListener('mouseup', (e) => {
15
+ this.mouse.buttons &= ~(1 << e.button);
16
+ });
17
+ window.addEventListener('mousemove', (e) => {
18
+ if (this.pointerLocked) {
19
+ this.mouse.dx += e.movementX;
20
+ this.mouse.dy += e.movementY;
21
+ }
22
+ this.mouse.x = e.clientX;
23
+ this.mouse.y = e.clientY;
24
+ });
25
+ window.addEventListener('wheel', (e) => { this.wheel += Math.sign(e.deltaY); }, { passive: true });
26
+ // a button held across alt-tab stayed 'held' forever
27
+ window.addEventListener('blur', () => { this.mouse.buttons = 0; });
28
+
29
+ document.addEventListener('pointerlockchange', () => {
30
+ const wasLocked = this.pointerLocked;
31
+ this.pointerLocked = document.pointerLockElement === domElement;
32
+ // the click that re-acquires the lock (e.g. after closing dialogue) must not
33
+ // also register as an attack — drop any pending mouse press on lock-acquire.
34
+ if (this.pointerLocked && !wasLocked) { this.mousePressed.clear(); this.mouse.buttons = 0; }
35
+ });
36
+ domElement.addEventListener('contextmenu', (e) => e.preventDefault());
37
+ }
38
+
39
+ requestLock() { if (!this.pointerLocked) this.dom.requestPointerLock?.(); }
40
+ releaseLock() { if (this.pointerLocked) document.exitPointerLock?.(); }
41
+
42
+ endFrame() {
43
+ this.mousePressed.clear();
44
+ this.mouse.dx = 0;
45
+ this.mouse.dy = 0;
46
+ this.wheel = 0;
47
+ }
48
+ }
@@ -1,6 +1,10 @@
1
1
  // Graphics quality presets + auto-downgrade for weaker machines.
2
2
  // pixelRatio and culling apply live; antialias applies on next reload.
3
- const KEY = 'aldenvale_quality';
3
+ // The localStorage key is per-game — register it at boot BEFORE the first
4
+ // getQuality/getQualityName read, or two games sharing an origin (dev!)
5
+ // will read each other's settings.
6
+ let KEY = 'sindicate_quality';
7
+ export function setQualityStorageKey(key) { KEY = key; }
4
8
 
5
9
  export const PRESETS = {
6
10
  low: {
@@ -0,0 +1,104 @@
1
+ // Renderer: WebGPU with automatic WebGL2 fallback.
2
+ // three's WebGPURenderer falls back to its WebGL2 backend when WebGPU
3
+ // is unavailable, so one code path serves both.
4
+ import * as THREE from 'three/webgpu';
5
+ import { pass, mrt, output, velocity, uniform } from 'three/tsl';
6
+ import { traa } from 'three/addons/tsl/display/TRAANode.js';
7
+ import { getQuality, getQualityName } from './quality.js';
8
+
9
+ // TRAA (temporal reprojection AA) is what makes the dithered LOD dissolve read
10
+ // as a SMOOTH fade instead of a screen door: it jitters the camera sub-pixel
11
+ // each frame and accumulates, so the dither's on/off stipple averages into a
12
+ // gradient over time (the GTA / Unreal "dither + temporal AA" trick). It also
13
+ // antialiases the whole image — so MSAA is turned OFF when TRAA runs, since the
14
+ // two don't combine.
15
+ // COST: TRAA adds a full-screen velocity MRT + a temporal-resolve pass — roughly
16
+ // DOUBLES the per-frame GPU cost. Its ONLY unique job is smoothing the dithered LOD
17
+ // dissolve (MSAA already antialiases edges, cheaper), so it's OFF by default now —
18
+ // every tier runs the plain render path + MSAA and gets ~2× the fps. Opt in with
19
+ // ?traa if the LOD stipple ever bothers you. (Was: on for Medium+High.)
20
+ export function wantsTRAA() {
21
+ return new URLSearchParams(location.search).has('traa');
22
+ }
23
+
24
+ export async function createRenderer(container) {
25
+ // ?webgl forces the WebGL2 backend (also used when WebGPU is unavailable)
26
+ const forceWebGL = new URLSearchParams(location.search).has('webgl');
27
+ const q = getQuality();
28
+ const traaOn = wantsTRAA();
29
+ // MSAA must be disabled when TRAA is in use (TRAANode requirement); TRAA
30
+ // provides the anti-aliasing instead.
31
+ const renderer = new THREE.WebGPURenderer({ antialias: q.antialias && !traaOn, forceWebGL });
32
+ await renderer.init();
33
+
34
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, q.pixelRatio));
35
+ renderer.setSize(window.innerWidth, window.innerHeight);
36
+ // Shadow mapping retired at every preset (re-landed solo after the batch revert): the pass
37
+ // is a SECOND full scene render per frame — ~7.5ms CPU measured — and r184's node-based
38
+ // ShadowNode only honours per-LIGHT shadow.autoUpdate/needsUpdate, so the renderer-level
39
+ // throttle we ran for months was silently ignored (it rendered EVERY frame). Characters
40
+ // get instanced blob discs instead (world/blobShadows.js). Measured: 35 → 47.6fps.
41
+ renderer.shadowMap.enabled = false;
42
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
43
+ renderer.toneMappingExposure = 1.05;
44
+ container.appendChild(renderer.domElement);
45
+
46
+ const backend = renderer.backend?.isWebGPUBackend ? 'WebGPU' : 'WebGL2';
47
+ console.log(`[renderer] backend: ${backend}${traaOn ? ' · TRAA' : ''}`);
48
+
49
+ window.addEventListener('resize', () => {
50
+ renderer.setSize(window.innerWidth, window.innerHeight);
51
+ });
52
+
53
+ return { renderer, backend };
54
+ }
55
+
56
+ // ---- SCREEN GRADE SLOT --------------------------------------------------------------
57
+ // The whole screen look, as ONE game-supplied TSL Fn `grade([color, amount])` driven by
58
+ // ONE uniform (post.gradeAmount 0..1). The grade must be mathematically identity at
59
+ // amount 0 so it costs a single fullscreen mix when idle. It runs in LINEAR space
60
+ // (outputColorTransform stays true: ACES tone map + sRGB apply AFTER it) — author
61
+ // luminance recolours, not gamma-space curves, so the look survives the tone map.
62
+
63
+ // Build the post-processing pipeline. The returned PostProcessing's .render() replaces
64
+ // renderer.render(scene, camera) in the main loop, and it is now built ALWAYS — the Dead
65
+ // Eye grade needs a full-screen pass, and swapping render paths at RUNTIME would compile a
66
+ // fresh pipeline the first time Ctrl was pressed (a multi-second WebGPU freeze, exactly the
67
+ // hazard this codebase documents). Building it at boot pays that cost on the loading screen.
68
+ //
69
+ // AA is NOT lost: PassNode takes its render target's sample count from renderer.samples,
70
+ // which is 4 whenever the renderer was created with antialias — so MSAA still runs, inside
71
+ // the pass. TRAA (?traa) still layers on top when asked for; the grade is the last link
72
+ // either way.
73
+ //
74
+ // post.deadEyeAmount is the live uniform — deadeye.js writes .value each frame.
75
+ export function createPostProcessing(renderer, scene, camera, { traa: traaOn = false, grade = null } = {}) {
76
+ const postProcessing = new THREE.PostProcessing(renderer);
77
+ const scenePass = pass(scene, camera);
78
+ let beauty;
79
+ if (traaOn) {
80
+ // TRAA needs a velocity (motion-vector) MRT so it can reproject the previous frame
81
+ scenePass.setMRT(mrt({ output, velocity }));
82
+ beauty = traa(
83
+ scenePass.getTextureNode('output'),
84
+ scenePass.getTextureNode('depth'),
85
+ scenePass.getTextureNode('velocity'),
86
+ camera,
87
+ );
88
+ } else {
89
+ beauty = scenePass.getTextureNode('output');
90
+ }
91
+ // outputColorTransform stays at its default (true): the chain renders linear and tone
92
+ // mapping + sRGB are applied once at the end. The grade (if any) is composed at BOOT —
93
+ // never swap outputNode at runtime, that compiles a fresh pipeline mid-play (a
94
+ // multi-second WebGPU freeze, exactly the hazard this codebase documents).
95
+ if (grade) {
96
+ const amount = uniform(0);
97
+ postProcessing.outputNode = grade(beauty, amount);
98
+ postProcessing.gradeAmount = amount; // the game's one live dial (write .value)
99
+ } else {
100
+ postProcessing.outputNode = beauty;
101
+ postProcessing.gradeAmount = null;
102
+ }
103
+ return postProcessing;
104
+ }
@@ -0,0 +1,282 @@
1
+ // Retargeting from the ANIMATION-pack rig (PolygonSyntyCharacter:
2
+ // Root/Hips/Spine_01/Shoulder_L…) onto Synty POLYGON character rigs.
3
+ //
4
+ // The rigs share structure and proportions, but bone names differ
5
+ // (Pelvis/spine_01/UpperArm_L…) and the bone-local frames differ
6
+ // (Unreal-style axis convention). We retarget with bind-pose deltas:
7
+ //
8
+ // Δb = Wb_animBind⁻¹ · Wb_targetBind (world-rotation delta)
9
+ // q'b(t) = Δparent(b)⁻¹ · qb(t) · Δb (per quaternion key)
10
+ //
11
+ // which makes the target's world deformation match the source clip
12
+ // exactly, regardless of local frame conventions.
13
+ import * as THREE from 'three/webgpu';
14
+
15
+ // ANIMATION rig bone -> POLYGON rig bone. Identity entries are implied
16
+ // for rigs (e.g. Knights pack) that reuse the source names.
17
+ const ANIM_TO_POLYGON = {
18
+ Hips: 'Pelvis',
19
+ Spine_01: 'spine_01', Spine_02: 'spine_02', Spine_03: 'spine_03',
20
+ Neck: 'neck_01', Head: 'head', Eyes: 'eyes', Eyebrows: 'eyebrows',
21
+ Clavicle_L: 'clavicle_l', Shoulder_L: 'UpperArm_L', Elbow_L: 'lowerarm_l', Hand_L: 'Hand_L',
22
+ Clavicle_R: 'clavicle_r', Shoulder_R: 'UpperArm_R', Elbow_R: 'lowerarm_r', Hand_R: 'Hand_R',
23
+ Thumb_01: 'thumb_01_l', Thumb_02: 'thumb_02_l', Thumb_03: 'thumb_03_l',
24
+ IndexFinger_01: 'indexFinger_01_l', IndexFinger_02: 'indexFinger_02_l',
25
+ IndexFinger_03: 'indexFinger_03_l', IndexFinger_04: 'indexFinger_04_l',
26
+ Finger_01: 'finger_01_l', Finger_02: 'finger_02_l', Finger_03: 'finger_03_l', Finger_04: 'finger_04_l',
27
+ Thumb_01_1: 'thumb_01_r', Thumb_02_1: 'thumb_02_r', Thumb_03_1: 'thumb_03_r',
28
+ IndexFinger_01_1: 'indexFinger_01_r', IndexFinger_02_1: 'indexFinger_02_r',
29
+ IndexFinger_03_1: 'indexFinger_03_r', IndexFinger_04_1: 'indexFinger_04_r',
30
+ Finger_01_1: 'finger_01_r', Finger_02_1: 'finger_02_r', Finger_03_1: 'finger_03_r', Finger_04_1: 'finger_04_r',
31
+ UpperLeg_L: 'Thigh_L', LowerLeg_L: 'calf_l', Ankle_L: 'Foot_L', Ball_L: 'ball_l', Toes_L: 'toes_l',
32
+ UpperLeg_R: 'Thigh_R', LowerLeg_R: 'calf_r', Ankle_R: 'Foot_R', Ball_R: 'ball_r', Toes_R: 'toes_r',
33
+ };
34
+
35
+ // Named SOURCE RIGS. Clips are authored against a specific skeleton's BIND POSE, and a clip
36
+ // only plays correctly on a rig with that same bind. Unity hides this — its Humanoid/Mecanim
37
+ // avatar retargets muscle-space, so any humanoid clip plays on any humanoid rig. three.js has
38
+ // no such layer: it applies raw bone quaternions. So a pack whose bind differs (the Wild West
39
+ // gunslinger pack's PreviewRig is ~90-107 deg off on Hips/Spine vs the Synty anim rig, despite
40
+ // IDENTICAL bone names) must declare its own source bind, or it plays as a folded-in-half mess.
41
+ // One global source bind was the bug: every clip was assumed to be authored on the Synty rig.
42
+ const sourceRigs = new Map(); // key -> { bind, map }
43
+ let defaultRigKey = null;
44
+
45
+ // Malbers "Human" rig -> the ANIMATION rig. Used for the climb/ladder clips, which are authored on the
46
+ // Malbers skeleton, not the Synty animation rig. We map onto ANIM-rig names (Hips/Spine_01/Shoulder_L…);
47
+ // the existing ANIM_TO_POLYGON step in mapName() then carries it onto polygon characters automatically, so
48
+ // ONE map serves both anim- and polygon-rigged targets. NOTE: FBXLoader sanitizes spaces->underscores, so
49
+ // the bones load as R_L_Clavicle (not "R_L Clavicle").
50
+ export const MALBERS_TO_ANIM = {
51
+ R_CG: 'Root', // Malbers root (above pelvis) -> anim Root; without it the pelvis loses parent compensation and the body pitches 90°
52
+ R_Pelvis: 'Hips', R_Spine: 'Spine_01', R_Spine1: 'Spine_02', R_Spine2: 'Spine_03',
53
+ R_Neck: 'Neck', R_Head: 'Head',
54
+ R_L_Clavicle: 'Clavicle_L', R_L_UpperArm: 'Shoulder_L', R_L_Forearm: 'Elbow_L', R_L_Hand: 'Hand_L',
55
+ R_R_Clavicle: 'Clavicle_R', R_R_UpperArm: 'Shoulder_R', R_R_Forearm: 'Elbow_R', R_R_Hand: 'Hand_R',
56
+ R_L_Thigh: 'UpperLeg_L', R_L_Calf: 'LowerLeg_L', R_L_Foot: 'Ankle_L',
57
+ R_R_Thigh: 'UpperLeg_R', R_R_Calf: 'LowerLeg_R', R_R_Foot: 'Ankle_R',
58
+ };
59
+
60
+ const _bindP = new THREE.Vector3(), _bindS = new THREE.Vector3();
61
+ const _posV = new THREE.Vector3();
62
+
63
+ function captureBind(root) {
64
+ root.updateMatrixWorld(true);
65
+ // TRUE bind-pose world rotations from the SkinnedMesh inverse-bind matrices (boneInverses[i]⁻¹
66
+ // IS the bone's world matrix at bind), which are pose-independent ground truth. Reading the
67
+ // live getWorldQuaternion() instead silently poisons every delta if the model is captured
68
+ // mid-animation — e.g. a shared master that got posed before this clone (which is exactly why
69
+ // the SK_Chr_Soldier_* guards held their arms out: their arm deltas were ~30° off true bind).
70
+ const bindQ = new Map(); // bone name -> world quaternion at bind
71
+ root.traverse((o) => {
72
+ if (!o.isSkinnedMesh || !o.skeleton) return;
73
+ const { bones, boneInverses } = o.skeleton;
74
+ for (let i = 0; i < bones.length; i++) {
75
+ const b = bones[i];
76
+ if (!b || !boneInverses[i] || bindQ.has(b.name)) continue;
77
+ const q = new THREE.Quaternion();
78
+ boneInverses[i].clone().invert().decompose(_bindP, q, _bindS);
79
+ bindQ.set(b.name, q);
80
+ }
81
+ });
82
+ // OBJECT-CHAIN ROTATION. boneInverses are expressed in the SKELETON's container space —
83
+ // NOT world. FBXLoader parks the Z-up->Y-up conversion on the container Object3Ds for some
84
+ // packs (the Wild West gunslinger rig: root Group -90deg X, PreviewRig Group -180deg Z) and
85
+ // on a Root BONE for others (Synty: containers identity). Comparing a bind captured in one
86
+ // frame against a bind captured in the other tilts every delta by that rotation — the
87
+ // character animates lying on his back. Fold the container's world rotation in so both rigs
88
+ // are captured in the MODEL-ROOT frame. Identity for Synty-family rigs (no behaviour change);
89
+ // it is a static transform, so this stays safe for posed shared masters (unlike reading
90
+ // getWorldQuaternion off a live bone).
91
+ root.updateMatrixWorld(true);
92
+ let firstBone = null;
93
+ root.traverse((o) => { if (!firstBone && o.isBone) firstBone = o; });
94
+ let rootBone = firstBone;
95
+ while (rootBone?.parent?.isBone) rootBone = rootBone.parent;
96
+ const container = rootBone?.parent ?? null;
97
+ // ...MEASURED RELATIVE TO `root`, NOT TO THE WORLD. getWorldQuaternion() also picks up
98
+ // wherever the character happens to be STANDING — and a Character's model hangs under a root
99
+ // Group that is rotated to its HEADING. So the bind captured for a townsman facing south had
100
+ // his heading folded into it, and every delta computed from it was off by exactly that angle:
101
+ // measured across the town, chainQ came out at precisely |heading| for every NPC (Silas at
102
+ // heading π captured a 180° "bind" and stood with his arms straight up; the player and the
103
+ // barkeep face heading 0, which is the only reason they ever looked right). The container's
104
+ // rotation is only meaningful in the MODEL-ROOT frame — which is what this always meant to be.
105
+ const rootQinv = root.getWorldQuaternion(new THREE.Quaternion()).invert();
106
+ const chainQ = container
107
+ ? rootQinv.multiply(container.getWorldQuaternion(new THREE.Quaternion()))
108
+ : new THREE.Quaternion();
109
+
110
+ const map = new Map();
111
+ map.chainQ = chainQ; // container rotation — position tracks live in this frame too
112
+ root.traverse((o) => {
113
+ if (!o.isBone) return;
114
+ const bq = bindQ.get(o.name);
115
+ const worldQ = bq
116
+ ? chainQ.clone().multiply(bq) // container space -> model-root frame
117
+ : o.getWorldQuaternion(new THREE.Quaternion()); // fallback: bones with no skin weight
118
+ map.set(o.name, {
119
+ worldQ,
120
+ localP: o.position.clone(),
121
+ parentName: o.parent?.isBone ? o.parent.name : null,
122
+ });
123
+ });
124
+ return map;
125
+ }
126
+
127
+ // Register a source rig by key (its loaded pose = its bind pose). The FIRST one registered
128
+ // becomes the default for clips that don't name a rig.
129
+ export function addSourceRig(key, root, map = null) {
130
+ sourceRigs.set(key, { bind: captureBind(root), map });
131
+ if (!defaultRigKey) defaultRigKey = key;
132
+ }
133
+
134
+ // Back-compat: the original single-source API — registers the 'anim' (Synty) rig.
135
+ export function setSourceRig(root, map = null) {
136
+ addSourceRig('anim', root, map);
137
+ defaultRigKey = 'anim';
138
+ }
139
+
140
+ export function detectRig(root) {
141
+ let rig = 'anim';
142
+ root.traverse((o) => { if (o.isBone && o.name === 'Pelvis') rig = 'polygon'; });
143
+ return rig;
144
+ }
145
+
146
+ // Per-character retarget context: bone deltas keyed by SOURCE bone name.
147
+ // Characters sharing a rig signature can share a context, but contexts are
148
+ // cheap; we just build one per loaded character master.
149
+ export function buildRetargetContext(targetRoot, rigKey = null) {
150
+ const key = rigKey ?? defaultRigKey;
151
+ const srcRig = sourceRigs.get(key);
152
+ if (!srcRig) throw new Error(`source rig '${key}' not registered — call setSourceRig()/addSourceRig() first`);
153
+ const sourceBind = srcRig.bind, sourceMap = srcRig.map;
154
+ const rig = detectRig(targetRoot);
155
+ const targetBind = captureBind(targetRoot);
156
+ const deltas = new Map(); // source bone name -> THREE.Quaternion
157
+ const mapName = (src) => {
158
+ let n = sourceMap ? (sourceMap[src] || src) : src; // foreign source -> anim-rig name
159
+ if (rig === 'polygon' && ANIM_TO_POLYGON[n]) return ANIM_TO_POLYGON[n]; // ...then anim -> polygon for polygon targets
160
+ return n; // identity mapping (Knights-style rigs, or shared names)
161
+ };
162
+ for (const [srcName, src] of sourceBind) {
163
+ const tgtName = mapName(srcName);
164
+ const tgt = targetBind.get(tgtName);
165
+ if (!tgt) continue;
166
+ const delta = src.worldQ.clone().invert().multiply(tgt.worldQ);
167
+ // ROOT FIX. Each bone is compensated through its PARENT's delta. A source bone with no
168
+ // parent (packs whose root bone is Hips — no Root — e.g. the Wild West gunslinger rig)
169
+ // has no parent delta, and the old code fell back to IDENTITY. But the TARGET's Hips
170
+ // hangs under a Root bone carrying its own bind rotation, which then never cancels:
171
+ // want W_tgt(t) = W_src(t) · Δb , and W_tgt = W_parent · q_local
172
+ // so q_local = W_parent⁻¹ · q_src(t) · Δb
173
+ // i.e. the fallback must be the TARGET PARENT's inverse bind world rotation, not identity.
174
+ // With identity the whole character renders upside down / rolled by the root's bind.
175
+ let parentFix = null;
176
+ if (!src.parentName) {
177
+ const tp = tgt.parentName ? targetBind.get(tgt.parentName) : null;
178
+ if (tp) parentFix = tp.worldQ.clone().invert();
179
+ }
180
+ deltas.set(srcName, { delta, targetName: tgtName, parentFix });
181
+ }
182
+
183
+ // Root translation tracks are authored in the source rig's units (cm).
184
+ // Packs authored in metres need them rescaled or the pelvis flies 87m up.
185
+ const srcHips = sourceBind.get('Hips');
186
+ const tgtRootName = rig === 'polygon' ? 'Pelvis' : 'Hips';
187
+ const tgtRoot = targetBind.get(tgtRootName);
188
+ let posScale = 1;
189
+ if (srcHips && tgtRoot && Math.abs(srcHips.localP.y) > 0.001 && Math.abs(tgtRoot.localP.y) > 0.001) {
190
+ posScale = tgtRoot.localP.y / srcHips.localP.y;
191
+ }
192
+ // A TARGET whose Hips sits at its parent's origin (localP.y === 0 — true of the western
193
+ // Synty rigs, where Root and Hips are coincident) used to yield posScale = 0, which
194
+ // multiplied every Hips.position key by ZERO: the pelvis got pinned to the root and the
195
+ // character sank into the ground. Only rescale when BOTH hip heights are real; else 1:1.
196
+ if (!(posScale > 0.01 && posScale < 100)) posScale = 1;
197
+
198
+ // Same bone NAMES don't guarantee the same bind POSE: the Fantasy Rivals bosses each carry a
199
+ // custom rest stance (bent legs, etc.) on anim-rig bone names. The clip can only be used as-is
200
+ // when the bind pose ALSO matches — otherwise their legs break. bindMatches = every bone's
201
+ // bind-delta is ~identity (|w|≈1).
202
+ // Also fold a hash of the bind-deltas into the cache key: every custom-posed boss otherwise
203
+ // produces the SAME key (same rig/boneCount), so they'd share ONE retargeted clip — the first
204
+ // one computed — and wear each other's skeleton. The hash makes each distinct bind pose unique
205
+ // while still letting identical rigs (e.g. many goblins) share the cache.
206
+ let bindMatches = true, h = 0;
207
+ for (const e of deltas.values()) {
208
+ const d = e.delta;
209
+ if (Math.abs(Math.abs(d.w) - 1) > 0.002) bindMatches = false;
210
+ h = (Math.imul(h, 31) + ((d.x * 1e3) | 0) + Math.imul((d.y * 1e3) | 0, 7) + Math.imul((d.z * 1e3) | 0, 13)) | 0;
211
+ }
212
+
213
+ const needsRootFix = [...deltas.values()].some((e) => e.parentFix);
214
+ // position tracks are VECTORS in the source container's frame — convert them the same way
215
+ const posQ = (targetBind.chainQ ?? new THREE.Quaternion()).clone().invert()
216
+ .multiply(sourceBind.chainQ ?? new THREE.Quaternion());
217
+ return { rig, deltas, posScale, bindMatches, needsRootFix, posQ, sourceBind, rigKey: key, key: `${key}:${rig}:${posScale.toFixed(3)}:${deltas.size}:${bindMatches ? 1 : 0}:${h}` };
218
+ }
219
+
220
+ const clipCache = new Map(); // `${clip.uuid}:${ctx.key}` -> retargeted clip
221
+
222
+ // Retarget a clip recorded on the ANIMATION rig to the target context.
223
+ export function retargetClip(clip, ctx) {
224
+ // Identity rig with full bone coverage AND matching units (cm): use clip as-is.
225
+ // A metre-scale rig (e.g. the Modular Fantasy Hero) needs the root-position rescale below,
226
+ // so don't short-circuit it even though its bone names already match the source.
227
+ if (ctx.rig === 'anim' && ctx.bindMatches && !ctx.needsRootFix && Math.abs(ctx.posScale - 1) < 0.05) return clip;
228
+ const cacheKey = `${clip.uuid}:${ctx.key}`;
229
+ if (clipCache.has(cacheKey)) return clipCache.get(cacheKey);
230
+
231
+ const qa = new THREE.Quaternion();
232
+ const tracks = [];
233
+ for (const track of clip.tracks) {
234
+ const dot = track.name.lastIndexOf('.');
235
+ const node = track.name.slice(0, dot);
236
+ const prop = track.name.slice(dot + 1);
237
+ const entry = ctx.deltas.get(node);
238
+ if (!entry) continue; // not a character bone (e.g. bow-prop joints) -> drop
239
+
240
+ if (prop === 'quaternion') {
241
+ const src = ctx.sourceBind.get(node);
242
+ const parentEntry = src.parentName ? ctx.deltas.get(src.parentName) : null;
243
+ // parented bone → cancel the parent's delta; source-root bone → cancel the TARGET
244
+ // parent's bind world rotation (see ROOT FIX in buildRetargetContext)
245
+ const dParentInv = parentEntry ? parentEntry.delta.clone().invert()
246
+ : (entry.parentFix ? entry.parentFix.clone() : null);
247
+ const dBone = entry.delta;
248
+ const values = new Float32Array(track.values.length);
249
+ for (let i = 0; i < track.values.length; i += 4) {
250
+ qa.fromArray(track.values, i);
251
+ if (dParentInv) qa.premultiply(dParentInv);
252
+ qa.multiply(dBone);
253
+ qa.toArray(values, i);
254
+ }
255
+ const t = new THREE.QuaternionKeyframeTrack(`${entry.targetName}.${prop}`, Array.from(track.times), Array.from(values));
256
+ tracks.push(t);
257
+ } else if (prop === 'position') {
258
+ // Keep only the root (Hips) translation — drives bob/crouch —
259
+ // rescaled into the target rig's units.
260
+ if (node !== 'Hips') continue;
261
+ const t = track.clone();
262
+ t.name = `${entry.targetName}.${prop}`;
263
+ // The hips translation is a VECTOR in the source container's frame. Rotating only the
264
+ // quaternion tracks left it in the pack's Z-up space: the pelvis got driven to the
265
+ // floor (hipsY 0) while the body above it posed correctly. Rotate, then rescale.
266
+ const v = t.values;
267
+ const identityQ = Math.abs(ctx.posQ.w) > 0.999999;
268
+ if (!identityQ) {
269
+ for (let i = 0; i < v.length; i += 3) {
270
+ _posV.set(v[i], v[i + 1], v[i + 2]).applyQuaternion(ctx.posQ);
271
+ v[i] = _posV.x; v[i + 1] = _posV.y; v[i + 2] = _posV.z;
272
+ }
273
+ }
274
+ if (ctx.posScale !== 1) for (let i = 0; i < v.length; i++) v[i] *= ctx.posScale;
275
+ tracks.push(t);
276
+ }
277
+ // scale tracks dropped
278
+ }
279
+ const out = new THREE.AnimationClip(clip.name, clip.duration, tracks);
280
+ clipCache.set(cacheKey, out);
281
+ return out;
282
+ }
package/src/index.js CHANGED
@@ -9,7 +9,12 @@
9
9
  // facet contracts) land in later steps; step 1 files are byte-identical to
10
10
  // their proven originals on purpose.
11
11
 
12
- // core — math/utils, quality presets + auto-tuner, decals
12
+ // core — rendering, assets, animation, retargeting, input, quality, decals, utils
13
+ export { createRenderer, createPostProcessing, wantsTRAA } from './core/renderer.js';
14
+ export * from './core/assets.js';
15
+ export * from './core/anim.js';
16
+ export * from './core/retarget.js';
17
+ export { Input, DEFAULT_PAD_BINDINGS } from './core/input/index.js';
13
18
  export * from './core/utils.js';
14
19
  export { PRESETS, getQuality, getQualityName, setQualityName, applyQuality, AutoTuner } from './core/quality.js';
15
20
  export { DecalField } from './core/decalField.js';