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,159 @@
1
+ # quality
2
+
3
+ Graphics quality presets plus an automatic downgrade path for weaker machines.
4
+
5
+ Source: `src/core/quality.js`
6
+
7
+ The module owns three things:
8
+
9
+ 1. **`PRESETS`** — the `low` / `medium` / `high` tier definitions.
10
+ 2. **Persistence** — the selected tier name is stored in `localStorage` under a configurable key (see `setQualityStorageKey`).
11
+ 3. **Runtime application** — `applyQuality` pushes everything that can change live onto a running game, and `AutoTuner` steps the tier down when measured FPS says the machine can't keep up.
12
+
13
+ Note the split between live and reload-only settings: `pixelRatio` and culling values apply live; `antialias` only takes effect on the next reload (the renderer is created with it).
14
+
15
+ ## Exports
16
+
17
+ ### `PRESETS`
18
+
19
+ ```js
20
+ export const PRESETS = { low: {...}, medium: {...}, high: {...} };
21
+ ```
22
+
23
+ Each preset is a plain object with the following fields:
24
+
25
+ | Field | Type | Controls |
26
+ |---|---|---|
27
+ | `label` | string | Display name for UI (`'Low'`, `'Medium'`, `'High'`). |
28
+ | `pixelRatio` | number | Upper bound on render pixel ratio. `applyQuality` uses `Math.min(window.devicePixelRatio, pixelRatio)`. |
29
+ | `antialias` | boolean | Renderer antialiasing. **Reload-only** — read at renderer creation, not applied live. |
30
+ | `shadows` | boolean | Shadow mapping. Currently `false` on every tier — see gotchas. |
31
+ | `shadowSize` | number | Shadow map resolution (512 / 1024 / 2048). Vestigial while `shadows` is false. |
32
+ | `entityCull` | number | Distance in metres beyond which characters are hidden and frozen. |
33
+ | `animHalfRate` | number | Distance beyond which characters animate every other frame. |
34
+ | `animMidRate` | number | Distance beyond which characters animate every 3rd frame (every 4th near the cull edge). |
35
+ | `maxLamps` | number | Maximum active lamp lights. |
36
+ | `fogScale` | number | Fog distance multiplier; smaller pulls fog in, which means less overdraw. Forwarded to `world.sky.fogScale`. |
37
+ | `cameraFar` | number | Camera far plane. `applyQuality` sets it and calls `updateProjectionMatrix()`. |
38
+
39
+ Tier values as of now:
40
+
41
+ | Field | low | medium | high |
42
+ |---|---|---|---|
43
+ | `pixelRatio` | 1 | 1.5 | 1.5 |
44
+ | `antialias` | false | true | true |
45
+ | `shadows` | false | false | false |
46
+ | `entityCull` | 45 | 70 | 110 |
47
+ | `animHalfRate` | 22 | 32 | 55 |
48
+ | `animMidRate` | 33 | 48 | 80 |
49
+ | `maxLamps` | 2 | 6 | 15 |
50
+ | `fogScale` | 0.45 | 0.8 | 1 |
51
+ | `cameraFar` | 200 | 420 | 800 |
52
+
53
+ ### `setQualityStorageKey(key)`
54
+
55
+ ```js
56
+ export function setQualityStorageKey(key)
57
+ ```
58
+
59
+ Replaces the `localStorage` key (default `'sindicate_quality'`) used to persist the tier name. **Call this at boot, before the first `getQuality`/`getQualityName` read.** The key must be per-game: two games served from the same origin — which is exactly the dev setup — will otherwise read and overwrite each other's quality setting.
60
+
61
+ ### `getQualityName()`
62
+
63
+ ```js
64
+ export function getQualityName() // -> 'low' | 'medium' | 'high'
65
+ ```
66
+
67
+ Reads the stored tier name from `localStorage`. Returns `'medium'` when nothing valid is stored — a safe default; the auto-tuner adjusts downward from there if needed.
68
+
69
+ ### `getQuality()`
70
+
71
+ ```js
72
+ export function getQuality() // -> preset object
73
+ ```
74
+
75
+ Convenience: `PRESETS[getQualityName()]`.
76
+
77
+ ### `setQualityName(name)`
78
+
79
+ ```js
80
+ export function setQualityName(name)
81
+ ```
82
+
83
+ Persists `name` to `localStorage`. Silently ignores names that aren't a key of `PRESETS`. Persistence only — does not touch the renderer; use `applyQuality` for that.
84
+
85
+ ### `applyQuality(game, name)`
86
+
87
+ ```js
88
+ export function applyQuality(game, name)
89
+ ```
90
+
91
+ Persists the tier (via `setQualityName`) and applies everything that can change live:
92
+
93
+ - `renderer.setPixelRatio(Math.min(window.devicePixelRatio, q.pixelRatio))` and `renderer.setSize(innerWidth, innerHeight)`
94
+ - `camera.far = q.cameraFar` + `camera.updateProjectionMatrix()`
95
+ - `game.quality = q`, `game.qualityName = name`
96
+ - `game.__traa = true` — a live A/B override only; TRAA now exists solely when opted in via `?traa` (`renderer.wantsTRAA`), and the default is the plain render path on every tier
97
+ - if `game.world` exists: `world.quality = q`, and `world.sky.fogScale = q.fogScale` when a sky exists
98
+
99
+ It deliberately does **not** touch shadows — see gotchas.
100
+
101
+ ### `AutoTuner`
102
+
103
+ ```js
104
+ export class AutoTuner {
105
+ constructor(game)
106
+ update(dt) // call once per frame with delta time in seconds
107
+ }
108
+ ```
109
+
110
+ Rolling FPS monitor that steps quality *down* (never up) when the machine can't keep up. Behavior of `update(dt)`:
111
+
112
+ 1. **Grace period** — the first ~6 seconds are ignored entirely, so loading hitches don't count.
113
+ 2. **5-second windows** — frames and elapsed time accumulate; every 5 seconds the average FPS for the window is computed and the accumulators reset.
114
+ 3. **Downgrade rule** — if the window averaged under 24 FPS *and* a lower tier exists (`high → medium → low`), a bad-window counter increments. Only after **two consecutive bad windows** does it call `applyQuality(game, nextLowerTier)` and show a toast via `game.ui?.toast(...)` ("Performance: graphics set to … (change in Esc menu)").
115
+ 4. **Settling** — any window at or above 24 FPS sets `done = true` and the tuner stops evaluating permanently ("stable — stop fiddling"). Note this also means a downgrade that fixes the frame rate ends tuning; it will not later re-upgrade.
116
+
117
+ ## Usage example
118
+
119
+ ```js
120
+ import {
121
+ setQualityStorageKey, getQualityName, getQuality,
122
+ applyQuality, AutoTuner, PRESETS,
123
+ } from './core/quality.js';
124
+
125
+ // Boot — BEFORE any getQuality read, so this game doesn't share
126
+ // settings with other games on the same origin during dev.
127
+ setQualityStorageKey('mygame_quality');
128
+
129
+ const name = getQualityName(); // 'medium' on first run
130
+ const q = getQuality();
131
+ const game = createGame({ antialias: q.antialias }); // reload-only setting
132
+
133
+ applyQuality(game, name); // live settings: pixelRatio, far plane, fog…
134
+
135
+ const tuner = new AutoTuner(game);
136
+
137
+ // In the frame loop:
138
+ function tick(dt) {
139
+ tuner.update(dt); // may auto-drop to a lower tier
140
+ // ...
141
+ }
142
+
143
+ // Settings menu:
144
+ for (const [key, preset] of Object.entries(PRESETS)) {
145
+ addMenuOption(preset.label, () => applyQuality(game, key));
146
+ }
147
+ ```
148
+
149
+ ## Gotchas
150
+
151
+ - **Register the storage key first.** `setQualityStorageKey` must run at boot before the first `getQuality`/`getQualityName` read. The key is per-game on purpose: two games sharing an origin (the normal dev situation) will read each other's settings under the default key.
152
+ - **`antialias` is reload-only.** `pixelRatio` and culling apply live; antialiasing only changes on the next reload.
153
+ - **Shadow mapping is retired on every tier** — and it was retired *again* after being re-landed solo. The shadow pass is a **second full scene render every frame** (~7.5 ms), and three.js r184 ignores the renderer-level throttle flags that would have amortized it. Characters get blob discs instead (`world/blobShadows.js`). Don't re-enable `shadows: true` without re-checking that cost.
154
+ - **Never mutate shadow maps live.** Shadows are configured once at startup (`renderer.js` / `sky.js`). Mutating shadow state at runtime corrupts the WebGPU backend's shadow state and the sun renders everything as shadowed — the world goes dark at noon. This is why `applyQuality` doesn't touch shadows.
155
+ - **High tier caps `pixelRatio` at 1.5, not 2.** Full retina cost ~1.5 ms/frame for an imperceptible gain at TRAA.
156
+ - **`game.__traa` in `applyQuality` is a live A/B override only.** TRAA is opt-in via the `?traa` query flag (`renderer.wantsTRAA`); the default render path is plain on every tier.
157
+ - **AutoTuner requires two consecutive bad 5-second windows before downgrading.** `applyQuality`'s `setPixelRatio` + `setSize` is itself a big hitch, so a single loading burst must not trigger a mid-play downgrade.
158
+ - **AutoTuner is one-way and one-shot.** It only steps down, and the first stable window (≥ 24 FPS) sets `done` and stops it for good. It never restores a higher tier — users do that from the Esc menu (the toast tells them so).
159
+ - **`getQualityName` falls back to `'medium'`**, not `'low'` — a deliberate safe default, since the auto-tuner will pull weak machines down from there.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sindicate",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Sindicate — open-world game engine for three.js WebGPU. Streaming worlds, characters, mounts & vehicles, missions, crowds.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,184 @@
1
+ // Character animation state machine over THREE.AnimationMixer,
2
+ // with automatic retargeting per character rig.
3
+ import * as THREE from 'three/webgpu';
4
+ import { buildRetargetContext, retargetClip } from './retarget.js';
5
+ // Per-clip timing (start/speed/end trims) and the clip->source-rig table are GAME
6
+ // DATA, not engine code — a game registers them once at boot:
7
+ // setClipDefaults({ timing: CLIP_TIMING, rigs: CLIP_RIGS })
8
+ // Unregistered, clips play untrimmed and every Animator needs explicit clipRigs.
9
+ let CLIP_TIMING = {};
10
+ let CLIP_RIGS = null;
11
+ export function setClipDefaults({ timing = null, rigs = null } = {}) {
12
+ if (timing) CLIP_TIMING = timing;
13
+ if (rigs) CLIP_RIGS = rigs;
14
+ }
15
+
16
+ export class Animator {
17
+ get needsRetarget() { return true; } // RawAnimator says no (see the bottom of this file)
18
+
19
+ // clipRigs: optional { clipName -> sourceRigKey }. A clip is authored against ONE
20
+ // skeleton's bind pose; clips from a foreign pack (different bind, same bone names)
21
+ // must retarget from THAT bind or they play folded in half. One ctx per source rig.
22
+ constructor(root, clips, clipRigs = null) {
23
+ this.mixer = new THREE.AnimationMixer(root);
24
+ this.root = root;
25
+ this.clipRigs = clipRigs ?? CLIP_RIGS;
26
+ this._ctxByRig = new Map();
27
+ // A RawAnimator's clips target the model's OWN skeleton, so it needs no retarget context —
28
+ // and building one THREW if no source rig had been registered yet. In the game that was
29
+ // invisible (the player registers the Synty rig long before a horse loads); anywhere else —
30
+ // a bench that only wants a horse — it was a hard crash on construction. Ask, don't assume.
31
+ this.ctx = this.needsRetarget ? this._ctxFor(null) : null; // (other code reads .ctx)
32
+ this.actions = {};
33
+ this.current = null;
34
+ this.currentName = null;
35
+ this._overlay = null;
36
+ for (const [name, clip] of Object.entries(clips)) this.register(name, clip);
37
+
38
+ // completion callbacks are PER-ACTION (action._onceDone), not a shared slot: with a
39
+ // single slot, any play({once}) stomped the PREVIOUS one-shot's pending callback —
40
+ // e.g. a bow release during a dodge wiped the dodge's busy=false and soft-locked
41
+ // the player. Per-action, each one-shot can only ever replace its own callback.
42
+ this.mixer.addEventListener('finished', (e) => {
43
+ const cb = e.action._onceDone;
44
+ e.action._onceDone = null;
45
+ cb?.();
46
+ });
47
+ }
48
+
49
+ _ctxFor(rigKey) {
50
+ const k = rigKey ?? '__default';
51
+ let c = this._ctxByRig.get(k);
52
+ if (!c) { c = buildRetargetContext(this.root, rigKey); this._ctxByRig.set(k, c); }
53
+ return c;
54
+ }
55
+
56
+ register(name, clip) {
57
+ const ctx = this._ctxFor(this.clipRigs?.[name] ?? null);
58
+ const action = this.mixer.clipAction(retargetClip(clip, ctx));
59
+ this.actions[name] = action;
60
+ }
61
+
62
+ // Build a bone-masked copy of an already-registered clip (keeps only tracks whose
63
+ // bone passes keepFn). Used to split locomotion across body halves — e.g. legs
64
+ // from `walk` + torso/arms from `blockLoop` so you can walk with the guard up.
65
+ registerMasked(name, srcName, keepFn) {
66
+ const src = this.actions[srcName];
67
+ if (!src) return;
68
+ const clip = src.getClip().clone();
69
+ clip.tracks = clip.tracks.filter((t) => keepFn(t.name.replace(/\.(position|quaternion|scale)$/, '')));
70
+ clip.name = name;
71
+ this.actions[name] = this.mixer.clipAction(clip);
72
+ }
73
+
74
+ // A persistent overlay action that plays ALONGSIDE the base FSM action (play()
75
+ // only fades `this.current`, never the overlay). Non-overlapping tracks compose.
76
+ //
77
+ // WEIGHTS MUST SUM TO 1 ON EVERY BONE, ALWAYS. three's PropertyMixer fills any
78
+ // cumulative-weight deficit from the binding's ORIGINAL saved value — captured at the
79
+ // mixer's first activation, i.e. the model's BIND POSE. So a weight gap does not blend
80
+ // between two poses: it blends toward a T-POSE. A 0.12s overlay fadeOut racing a 0.18s
81
+ // base fadeIn dipped the sum to ~0.58 and flashed the arms 42% toward T-pose on every
82
+ // aim release. Hence: overlay swaps of the SAME mask crossfade (sum stays 1); starting
83
+ // or dropping an overlay is INSTANT, and the caller switches the base to/from the
84
+ // complementary mask in the SAME frame. A hard pose cut is survivable; a T-pose is not.
85
+ setOverlay(name, opts = {}) {
86
+ const next = name ? this.actions[name] : null;
87
+ const prev = this._overlay;
88
+ if (prev === next && !opts.restart) return next;
89
+ if (next) {
90
+ next.reset(); // reset() calls stopFading() — so it MUST come first
91
+ if (opts.once) { next.setLoop(THREE.LoopOnce, 1); next.clampWhenFinished = true; }
92
+ else next.setLoop(THREE.LoopRepeat, Infinity);
93
+ next.enabled = true;
94
+ next.setEffectiveWeight(1);
95
+ next.play();
96
+ // same-mask swap (aim -> fire -> aim): crossFadeFrom keeps the pair summing to 1
97
+ if (prev && prev !== next && opts.fade) next.crossFadeFrom(prev, opts.fade, false);
98
+ else if (prev && prev !== next) { prev.stop(); prev.setEffectiveWeight(0); }
99
+ } else if (prev) {
100
+ // DROPPING the overlay. stop() zeroes its weight instantly — but the base is still the
101
+ // LOWER-only mask for a beat, so the upper body is left with ZERO cumulative weight and
102
+ // three fills it from the bind pose: a ~0.2s T-POSE flash at the end of every one-shot.
103
+ // Instead fade it out over EXACTLY the duration the caller fades its full-body base in
104
+ // (opts.fade). Upper: overlay(1-t) + base(t) = 1. Lower: lowerBase(1-t) + base(t) = 1.
105
+ const d = opts.fade ?? 0;
106
+ if (d > 0) prev.fadeOut(d);
107
+ else { prev.stop(); prev.setEffectiveWeight(0); }
108
+ }
109
+ this._overlay = next;
110
+ return next;
111
+ }
112
+
113
+ // play(name) — loop. play(name, {once, fade, timeScale, onDone}) — one-shot.
114
+ // startFrac (0..1) skips into the clip past wind-up. CLIP_TIMING (tuned via
115
+ // /anim.html) supplies per-clip start/speed DEFAULTS; explicit opts override.
116
+ play(name, opts = {}) {
117
+ const action = this.actions[name];
118
+ if (!action) return null;
119
+ const tm = CLIP_TIMING[name] || {};
120
+ const once = opts.once ?? false;
121
+ const fade = opts.fade ?? 0.18;
122
+ const restart = opts.restart ?? false;
123
+ const onDone = opts.onDone ?? null;
124
+ const startFrac = opts.startFrac ?? tm.start ?? 0;
125
+ const timeScale = opts.timeScale ?? tm.speed ?? 1;
126
+ if (this.currentName === name && !restart && !once) return action;
127
+
128
+ action.reset();
129
+ if (startFrac > 0) action.time = action.getClip().duration * startFrac;
130
+ action.timeScale = timeScale;
131
+ if (once) {
132
+ action.setLoop(THREE.LoopOnce, 1);
133
+ action.clampWhenFinished = true;
134
+ action._onceDone = onDone;
135
+ } else {
136
+ action.setLoop(THREE.LoopRepeat, Infinity);
137
+ action._onceDone = null; // re-played as a loop: drop any stale one-shot callback
138
+ }
139
+ action.fadeIn(fade).play();
140
+ if (this.current && this.current !== action) this.current.fadeOut(fade);
141
+ this.current = action;
142
+ this.currentName = name;
143
+ this._curTiming = tm; // for end-trim handling in update()
144
+ this._curOnce = once;
145
+ return action;
146
+ }
147
+
148
+ stopAll() {
149
+ this.mixer.stopAllAction();
150
+ this.current = null;
151
+ this.currentName = null;
152
+ this._overlay = null;
153
+ }
154
+
155
+ update(dt) {
156
+ this.mixer.update(dt);
157
+ // CLIP_TIMING end-trim: cut the tail of the clip. One-shots clamp at `end`
158
+ // (and fire onDone there); loops cycle back to `start`.
159
+ const a = this.current, tm = this._curTiming;
160
+ if (a && tm && tm.end != null && tm.end < 1) {
161
+ const dur = a.getClip().duration;
162
+ if (a.time >= tm.end * dur) {
163
+ if (this._curOnce) {
164
+ a.time = tm.end * dur;
165
+ a.paused = true;
166
+ const cb = a._onceDone; a._onceDone = null; cb?.();
167
+ } else {
168
+ a.time = (tm.start ?? 0) * dur;
169
+ }
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ // Self-animated rigs (PolyPerfect creatures, like the horse): the clips target the
176
+ // model's OWN skeleton, so registration skips the Synty retarget entirely — everything
177
+ // else (play/overlay/once-callbacks/end-trim) behaves identically.
178
+ export class RawAnimator extends Animator {
179
+ get needsRetarget() { return false; } // its clips already speak this skeleton's language
180
+
181
+ register(name, clip) {
182
+ this.actions[name] = this.mixer.clipAction(clip);
183
+ }
184
+ }