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.
- package/docs/api/quality.md +159 -0
- package/package.json +1 -1
- package/src/core/anim.js +184 -0
- package/src/core/assets.js +596 -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 +6 -1
|
@@ -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
|
+
}
|
package/src/core/quality.js
CHANGED
|
@@ -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
|
-
|
|
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 —
|
|
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';
|