sindicate 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -2
- package/docs/api/README.md +28 -0
- package/docs/api/anim.md +125 -0
- package/docs/api/assets.md +208 -0
- package/docs/api/collision.md +101 -0
- package/docs/api/fbxloader.md +50 -0
- package/docs/api/grass.md +103 -0
- package/docs/api/input.md +164 -0
- package/docs/api/quality.md +159 -0
- package/docs/api/renderer.md +115 -0
- package/docs/api/retarget.md +149 -0
- package/docs/api/shatter.md +121 -0
- package/docs/api/utils-decals.md +188 -0
- package/docs/api/water.md +143 -0
- package/docs/api/wind-weather-uniforms.md +105 -0
- package/docs/contracts/render.md +32 -0
- package/docs/contracts/time-feel.md +52 -0
- package/package.json +1 -1
- package/src/core/anim.js +184 -0
- package/src/core/assets.js +596 -0
- package/src/core/engine.js +284 -0
- package/src/core/input/bindings.js +30 -0
- package/src/core/input/gamepad.js +92 -0
- package/src/core/input/index.js +62 -0
- package/src/core/input/keyboard.js +16 -0
- package/src/core/input/mouse.js +48 -0
- package/src/core/quality.js +5 -1
- package/src/core/renderer.js +104 -0
- package/src/core/retarget.js +282 -0
- package/src/index.js +19 -2
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/entity.js +524 -0
- package/src/systems/fistCurl.js +38 -0
- package/src/systems/footsteps.js +161 -0
- package/src/systems/glass.js +67 -0
- package/src/systems/herd.js +277 -0
- package/src/systems/horse.js +361 -0
- package/src/systems/mountedTraveller.js +518 -0
- package/src/systems/nav.js +343 -0
- package/src/systems/npc.js +880 -0
- package/src/systems/pathFollow.js +107 -0
- package/src/systems/platform.js +484 -0
- package/src/systems/riding.js +580 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/trainCamera.js +414 -0
- package/src/systems/traveller.js +254 -0
- package/src/systems/tumbleweeds.js +92 -0
- package/src/systems/vat.js +327 -0
- package/src/systems/vfx.js +472 -0
- package/src/world/blobShadows.js +109 -0
- package/src/world/clouds.js +61 -0
- package/src/world/flora.js +87 -0
- package/src/world/footprints.js +117 -0
- package/src/world/lampField.js +58 -0
- package/src/world/lampGlow.js +92 -0
- package/src/world/scatter.js +1077 -0
- package/src/world/sky.js +363 -0
- package/src/world/tileManager.js +197 -0
- package/src/world/walkHumps.js +74 -0
- package/src/world/weather.js +312 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// The Engine base: frame loop, the two clocks (rawDt vs scaled dt), the timeScale
|
|
2
|
+
// contributor chain, game-time timers, feel (hitstop/shake), the culling frustum,
|
|
3
|
+
// the single render path, warm-up, and the freeze profiler / perf census.
|
|
4
|
+
// Contracts: docs/contracts/time-feel.md and docs/contracts/render.md.
|
|
5
|
+
//
|
|
6
|
+
// const engine = new Engine({ renderer, scene, camera, input,
|
|
7
|
+
// post, // PostProcessing from createPostProcessing (or null)
|
|
8
|
+
// isPaused: () => ui.anyPanelOpen, // holds sim: engine timers + game's gated blocks
|
|
9
|
+
// isUiModal: () => ui.anyPanelOpen, // pad drives the UI, suppress game mappings
|
|
10
|
+
// })
|
|
11
|
+
// engine.addTimeScale(() => slowMo.scale) // multiplicative, 1 = inactive
|
|
12
|
+
// engine.on('preScale', (ctx) => …) // rawDt phase — decide THIS frame's scale
|
|
13
|
+
// engine.on('frame', (ctx) => …) // ordered main-phase hooks: ctx = { dt,
|
|
14
|
+
// // rawDt, paused, stamp, engine }
|
|
15
|
+
// engine.onFps((fps) => …) // 1s-window fps readout
|
|
16
|
+
// engine.start() // hands the loop to the renderer
|
|
17
|
+
import * as THREE from 'three/webgpu';
|
|
18
|
+
|
|
19
|
+
export class Engine {
|
|
20
|
+
constructor({ renderer, scene, camera, input, post = null, isPaused = () => false, isUiModal = () => false }) {
|
|
21
|
+
this.renderer = renderer;
|
|
22
|
+
this.scene = scene;
|
|
23
|
+
this.camera = camera;
|
|
24
|
+
this.input = input;
|
|
25
|
+
this.post = post;
|
|
26
|
+
this.isPaused = isPaused;
|
|
27
|
+
this.isUiModal = isUiModal;
|
|
28
|
+
|
|
29
|
+
// time
|
|
30
|
+
this.rawDt = 0;
|
|
31
|
+
this.worldDtScale = 1; // exposed: the dev drive-bus asserts on it
|
|
32
|
+
this.playSeconds = 0; // unpaused world time (save-slot playtime readout)
|
|
33
|
+
this._timers = [];
|
|
34
|
+
this._timeScales = [];
|
|
35
|
+
this._clockLast = performance.now();
|
|
36
|
+
|
|
37
|
+
// feel — hit-stop is a built-in contributor: combat refreshes the window, the
|
|
38
|
+
// world crawls at 0.12× while it runs (cheap, famous crunch)
|
|
39
|
+
this.hitstopT = 0;
|
|
40
|
+
this.addTimeScale(() => {
|
|
41
|
+
if (this.hitstopT > 0) { this.hitstopT -= this._frameDt; return 0.12; }
|
|
42
|
+
return 1;
|
|
43
|
+
});
|
|
44
|
+
this.shakeT = 0;
|
|
45
|
+
|
|
46
|
+
// culling frustum — built by buildCullFrustum() once per unpaused frame; MAY BE
|
|
47
|
+
// NULL and null means "don't cull" (a stale frustum mis-culls; see render contract)
|
|
48
|
+
this.cullFrustum = null;
|
|
49
|
+
|
|
50
|
+
this._hooks = { preScale: [], frame: [] };
|
|
51
|
+
this._fpsCb = null;
|
|
52
|
+
this._fps = { acc: 0, n: 0, t: 0 };
|
|
53
|
+
this._perf = null;
|
|
54
|
+
this._stampKeys = [];
|
|
55
|
+
this.__post = undefined; // set false for a plain-render A/B
|
|
56
|
+
|
|
57
|
+
this.tick = this._tick.bind(this);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- registration -------------------------------------------------------------------
|
|
61
|
+
on(phase, fn) {
|
|
62
|
+
if (!this._hooks[phase]) throw new Error(`Engine.on: unknown phase '${phase}'`);
|
|
63
|
+
this._hooks[phase].push(fn);
|
|
64
|
+
return fn;
|
|
65
|
+
}
|
|
66
|
+
addTimeScale(fn) { this._timeScales.push(fn); return fn; }
|
|
67
|
+
onFps(cb) { this._fpsCb = cb; }
|
|
68
|
+
|
|
69
|
+
// ---- time & feel --------------------------------------------------------------------
|
|
70
|
+
// schedule fn after `sec` seconds of GAME time — freezes with the pause menu (a pause
|
|
71
|
+
// can never eat a landing), stretches under slow-mo. Errors are caught, never thrown
|
|
72
|
+
// into the loop.
|
|
73
|
+
after(sec, fn) { this._timers.push({ t: sec, fn }); }
|
|
74
|
+
wait(sec) { return new Promise((r) => this.after(sec, r)); }
|
|
75
|
+
hitstop(sec) { this.hitstopT = Math.max(this.hitstopT, sec); }
|
|
76
|
+
shake(amount) { this.shakeT = Math.max(this.shakeT, amount); }
|
|
77
|
+
// camera shake — call at the point in the frame where the camera is FINAL (after every
|
|
78
|
+
// camera writer), not earlier: shake applied before an override is shake thrown away.
|
|
79
|
+
applyShake(dt) {
|
|
80
|
+
if (this.shakeT > 0) {
|
|
81
|
+
this.shakeT = Math.max(0, this.shakeT - dt * 1.8);
|
|
82
|
+
this.camera.position.x += (Math.random() - 0.5) * this.shakeT * 0.75;
|
|
83
|
+
this.camera.position.y += (Math.random() - 0.5) * this.shakeT * 0.75;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- render path --------------------------------------------------------------------
|
|
88
|
+
// THE render path — one function so boot warm-up and the tick go through the SAME
|
|
89
|
+
// pipelines (a settle that warmed the plain path would leave the post path to compile
|
|
90
|
+
// its first frame in gameplay). engine.__post = false drops to plain render for an A/B.
|
|
91
|
+
renderFrame() {
|
|
92
|
+
if (this.post && this.__post !== false) this.post.render();
|
|
93
|
+
else this.renderer.render(this.scene, this.camera);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Night dimming etc. via tone-map exposure. CRITICAL: only WRITE when it actually
|
|
97
|
+
// changes — writing renderer.toneMappingExposure every frame forces a WebGPU
|
|
98
|
+
// output-pipeline rebuild on some GPUs → freezes. The gate lives here so games can
|
|
99
|
+
// "set" it every frame safely.
|
|
100
|
+
setExposure(v) {
|
|
101
|
+
if (Math.abs(v - (this._lastExposure ?? -1)) > 0.008) { this.renderer.toneMappingExposure = v; this._lastExposure = v; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Build the per-frame culling frustum. Call ONCE per unpaused frame, after every
|
|
105
|
+
// camera writer that matters to culling. Consumers read engine.cullFrustum and MUST
|
|
106
|
+
// treat null as visible; a game may null it deliberately for a frame (paused dialogue
|
|
107
|
+
// ticking one NPC against a stale camera) — the next build repairs it.
|
|
108
|
+
buildCullFrustum() {
|
|
109
|
+
this.camera.updateMatrixWorld();
|
|
110
|
+
(this._camMWI ??= new THREE.Matrix4()).copy(this.camera.matrixWorld).invert();
|
|
111
|
+
(this._camProj ??= new THREE.Matrix4()).multiplyMatrices(this.camera.projectionMatrix, this._camMWI);
|
|
112
|
+
(this.cullFrustum ??= new THREE.Frustum()).setFromProjectionMatrix(this._camProj);
|
|
113
|
+
return this.cullFrustum;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---- warm-up (PERF LAW) -------------------------------------------------------------
|
|
117
|
+
// Force every hidden / frustum-culled / count-0-instanced mesh to compile its GPU
|
|
118
|
+
// pipeline NOW (at load) instead of on first sight — first-sight compile is the
|
|
119
|
+
// multi-frame freeze you get spinning the camera onto something you hadn't looked at.
|
|
120
|
+
// compileAsync, NOT render(): compiles off the main thread (render() blocked ~20s and
|
|
121
|
+
// tripped Chrome's 'page unresponsive'). Still awaited at boot — no travel freezes.
|
|
122
|
+
async warmRenderHidden(tag, root = this.scene) {
|
|
123
|
+
const warm = [];
|
|
124
|
+
root.traverse((o) => {
|
|
125
|
+
const mesh = o.isMesh || o.isInstancedMesh;
|
|
126
|
+
const hidI = mesh && o.isInstancedMesh && o.count === 0 && (o.instanceMatrix?.array?.length ?? 0) >= 16;
|
|
127
|
+
if (!(o.visible === false || hidI || (mesh && o.frustumCulled))) return;
|
|
128
|
+
warm.push({ o, vis: o.visible, cnt: o.isInstancedMesh ? o.count : null, fc: mesh ? o.frustumCulled : null });
|
|
129
|
+
o.visible = true; if (mesh) o.frustumCulled = false; if (hidI) o.count = 1;
|
|
130
|
+
});
|
|
131
|
+
const t0 = performance.now();
|
|
132
|
+
if (warm.length) { try { await this.renderer.compileAsync(this.scene, this.camera); } catch (e) { /* warm */ } }
|
|
133
|
+
console.log(`[warm:${tag}] ${Math.round(performance.now() - t0)}ms over ${warm.length} hidden/culled objs (off-thread compile)`);
|
|
134
|
+
for (const w of warm) { w.o.visible = w.vis; if (w.cnt !== null) w.o.count = w.cnt; if (w.fc !== null) w.o.frustumCulled = w.fc; }
|
|
135
|
+
return warm.length;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- the loop -----------------------------------------------------------------------
|
|
139
|
+
start() {
|
|
140
|
+
this._clockLast = performance.now();
|
|
141
|
+
this.renderer.setAnimationLoop(this.tick);
|
|
142
|
+
// auto perf log — first at 6s (past load hitches), then periodic
|
|
143
|
+
setTimeout(() => { this.perfReport(); this._perfTimer = setInterval(() => this.perfReport(), 15000); }, 6000);
|
|
144
|
+
}
|
|
145
|
+
stop() {
|
|
146
|
+
this.renderer.setAnimationLoop(null);
|
|
147
|
+
if (this._perfTimer) clearInterval(this._perfTimer);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_tick() {
|
|
151
|
+
const now = performance.now();
|
|
152
|
+
let dt = Math.min((now - this._clockLast) / 1000, 0.1);
|
|
153
|
+
this._clockLast = now;
|
|
154
|
+
const rawDt = dt; // wall-clock — the fps meter reads THIS (scaled dt inflates it during hit-stop)
|
|
155
|
+
this.rawDt = rawDt; // published for systems that must NOT slow down (camera look, aim IK)
|
|
156
|
+
|
|
157
|
+
this.input?.pollGamepad(this.isUiModal()); // panels drive the pad themselves; suppress game mappings
|
|
158
|
+
|
|
159
|
+
const paused = this.isPaused();
|
|
160
|
+
const stamps = (this._stamps = { t0: performance.now() });
|
|
161
|
+
const stamp = (key) => { stamps[key] = performance.now(); if (!(key in (this._perf ?? {}))) this._stampKeys.includes(key) || this._stampKeys.push(key); };
|
|
162
|
+
const ctx = { dt: 0, rawDt, paused, stamp, engine: this };
|
|
163
|
+
|
|
164
|
+
// PRE-SCALE (rawDt): systems that produce this frame's timeScale run first, so the
|
|
165
|
+
// scale they return lands on THIS frame (a slow-mo meter timed in slow-mo drains 3×
|
|
166
|
+
// slower the instant it activates).
|
|
167
|
+
for (const fn of this._hooks.preScale) fn(ctx);
|
|
168
|
+
|
|
169
|
+
// world-dt multiplier chain — every contribution lands on ONE dt, which then drives
|
|
170
|
+
// mixers, physics, AI, projectiles, VFX.
|
|
171
|
+
this._frameDt = dt;
|
|
172
|
+
let scale = 1;
|
|
173
|
+
for (const fn of this._timeScales) scale *= fn();
|
|
174
|
+
this.worldDtScale = scale;
|
|
175
|
+
dt *= scale;
|
|
176
|
+
ctx.dt = dt;
|
|
177
|
+
|
|
178
|
+
// game-time bookkeeping — first thing inside the pause gate, before any sim hook
|
|
179
|
+
if (!paused) {
|
|
180
|
+
this.playSeconds += dt;
|
|
181
|
+
for (let i = this._timers.length - 1; i >= 0; i--) {
|
|
182
|
+
const tm = this._timers[i];
|
|
183
|
+
tm.t -= dt;
|
|
184
|
+
if (tm.t <= 0) { this._timers.splice(i, 1); try { tm.fn(); } catch (e) { console.warn('[after]', e); } }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// MAIN PHASE — the game's ordered hooks. Pause-gating of individual blocks is the
|
|
189
|
+
// hook's business (ctx.paused); ordering between hooks is registration order.
|
|
190
|
+
for (const fn of this._hooks.frame) fn(ctx);
|
|
191
|
+
|
|
192
|
+
this.renderFrame();
|
|
193
|
+
stamps.render = performance.now();
|
|
194
|
+
|
|
195
|
+
this._accumulatePerf(stamps);
|
|
196
|
+
this._freezeProbe(stamps);
|
|
197
|
+
|
|
198
|
+
// fps meter — 1s window (a calmer readout; real spikes still show in the freeze log)
|
|
199
|
+
const f = this._fps;
|
|
200
|
+
f.acc += rawDt; f.n++; f.t += rawDt;
|
|
201
|
+
if (f.t > 1) {
|
|
202
|
+
this._fpsCb?.(Math.round(f.n / f.acc));
|
|
203
|
+
f.acc = 0; f.n = 0; f.t = 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.input?.endFrame();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---- profiling ----------------------------------------------------------------------
|
|
210
|
+
_accumulatePerf(stamps) {
|
|
211
|
+
const P = (this._perfAcc ??= { n: 0, frame: 0, phases: {} });
|
|
212
|
+
P.n++; P.frame += stamps.render - stamps.t0;
|
|
213
|
+
let prev = stamps.t0;
|
|
214
|
+
for (const k of this._stampKeys) {
|
|
215
|
+
if (!(k in stamps)) continue;
|
|
216
|
+
P.phases[k] = (P.phases[k] ?? 0) + (stamps[k] - prev);
|
|
217
|
+
prev = stamps[k];
|
|
218
|
+
}
|
|
219
|
+
P.phases.render = (P.phases.render ?? 0) + (stamps.render - prev);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// FREEZE PROFILER: a SILENT ring buffer of slow frames + their phase breakdown, with
|
|
223
|
+
// frame-level cause attribution sampled EVERY frame so a freeze's deltas reveal WHAT
|
|
224
|
+
// it did: pipe = compiled-pipeline count (grows = a shader compiled this frame = a
|
|
225
|
+
// warm-coverage gap); geo/tex = uploads (a streamed tile). __dumpFreezeLog() downloads.
|
|
226
|
+
_freezeProbe(stamps) {
|
|
227
|
+
const frame = stamps.render - stamps.t0;
|
|
228
|
+
const _pipe = this.renderer._pipelines?.caches?.size ?? -1;
|
|
229
|
+
const _geo = this.renderer.info?.memory?.geometries ?? -1;
|
|
230
|
+
const _tex = this.renderer.info?.memory?.textures ?? -1;
|
|
231
|
+
const last = (this._fzLast ??= { pipe: _pipe, geo: _geo, tex: _tex });
|
|
232
|
+
if (frame > 55) {
|
|
233
|
+
const b = { ms: Math.round(frame), v: 5 };
|
|
234
|
+
let prev = stamps.t0;
|
|
235
|
+
for (const k of this._stampKeys) { if (k in stamps) { b[k] = +(stamps[k] - prev).toFixed(1); prev = stamps[k]; } }
|
|
236
|
+
b.render = +(stamps.render - prev).toFixed(1);
|
|
237
|
+
b.calls = this.renderer.info?.render?.drawCalls ?? -1;
|
|
238
|
+
b.tris = this.renderer.info?.render?.triangles ?? -1;
|
|
239
|
+
b.pipe = _pipe - last.pipe; b.geo = _geo - last.geo; b.tex = _tex - last.tex; b.pipeTotal = _pipe;
|
|
240
|
+
b.t = Math.round(stamps.render);
|
|
241
|
+
Object.assign(b, this._freezeExtra?.() ?? {});
|
|
242
|
+
const log = (window.__freezeLog ??= []); log.push(b); if (log.length > 300) log.shift();
|
|
243
|
+
if (import.meta.env?.DEV) { try { fetch('/__perf', { method: 'POST', body: JSON.stringify({ FREEZE: b }) }); } catch (e) { /* sink offline */ } }
|
|
244
|
+
window.__dumpFreezeLog ??= () => {
|
|
245
|
+
const a = document.createElement('a');
|
|
246
|
+
a.href = URL.createObjectURL(new Blob([JSON.stringify(window.__freezeLog, null, 2)], { type: 'application/json' }));
|
|
247
|
+
a.download = 'freeze-log.json'; a.click();
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
last.pipe = _pipe; last.geo = _geo; last.tex = _tex;
|
|
251
|
+
}
|
|
252
|
+
// game hook: extra fields for freeze records / perf reports (e.g. sky hour, player pos)
|
|
253
|
+
setPerfExtras({ freeze = null, report = null } = {}) { this._freezeExtra = freeze; this._reportExtra = report; }
|
|
254
|
+
|
|
255
|
+
// One-shot perf census: frame-time by phase, GPU load, what's resident/visible, exact
|
|
256
|
+
// render state. Prints to console when window.__perflog is set; always returns it.
|
|
257
|
+
perfReport() {
|
|
258
|
+
if (document.hidden) return null; // backgrounded tab: rAF paused, the sample is noise
|
|
259
|
+
const r = this.renderer, info = r.info;
|
|
260
|
+
let meshes = 0, skinned = 0, skinnedVis = 0, instanced = 0, instCount = 0, lights = 0, litOn = 0, sprites = 0;
|
|
261
|
+
this.scene.traverse((o) => {
|
|
262
|
+
if (o.isInstancedMesh) { instanced++; instCount += o.count; }
|
|
263
|
+
else if (o.isSkinnedMesh) { skinned++; if (o.visible && o.parent?.visible !== false) skinnedVis++; }
|
|
264
|
+
else if (o.isMesh) meshes++;
|
|
265
|
+
if (o.isSprite) sprites++;
|
|
266
|
+
if (o.isLight) { lights++; if (o.intensity > 0) litOn++; }
|
|
267
|
+
});
|
|
268
|
+
const P = this._perfAcc ?? { n: 1, frame: 0, phases: {} };
|
|
269
|
+
const n = Math.max(1, P.n), fps = Math.round(1000 / (P.frame / n || 16));
|
|
270
|
+
const phases = {};
|
|
271
|
+
for (const k of Object.keys(P.phases)) phases[k] = +(P.phases[k] / n).toFixed(2);
|
|
272
|
+
const rep = {
|
|
273
|
+
fps, frameMs: +(P.frame / n).toFixed(2), phases,
|
|
274
|
+
gpu: { calls: info.render?.drawCalls, ktris: +((info.render?.triangles ?? 0) / 1000).toFixed(0), geometries: info.memory?.geometries, textures: info.memory?.textures },
|
|
275
|
+
scene: { meshes, skinned, skinnedVisible: skinnedVis, instancedMeshes: instanced, instances: instCount, lights, litOn, sprites },
|
|
276
|
+
render: { postProcessing: !!this.post, shadows: r.shadowMap?.enabled, pixelRatio: +r.getPixelRatio().toFixed(2), cameraFar: this.camera.far },
|
|
277
|
+
...(this._reportExtra?.() ?? {}),
|
|
278
|
+
};
|
|
279
|
+
if (window.__perflog) console.log(`%c[PERF] ${fps}fps · frame ${rep.frameMs}ms`, 'color:#4dd; font-weight:700', rep);
|
|
280
|
+
this._perfAcc = { n: 0, frame: 0, phases: {} };
|
|
281
|
+
if (import.meta.env?.DEV) { try { fetch('/__perf', { method: 'POST', body: JSON.stringify(rep) }); } catch (e) { /* sink offline */ } }
|
|
282
|
+
return rep;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Gamepad bindings are DATA a game passes in: new Input(dom, { padBindings }).
|
|
2
|
+
// This default is a sane third-person-action layout (standard mapping, PS names):
|
|
3
|
+
// Cross(0) jump · Circle(1) dodge · Square(2) interact · Triangle(3) swap ·
|
|
4
|
+
// R1(5) reload · L2(6) sprint · Share(8) tab-panel · Options(9) pause ·
|
|
5
|
+
// L3(10) ability · D-pad UP(12) inventory · LEFT(14) finisher · RIGHT(15) slot 1 ·
|
|
6
|
+
// D-pad DOWN(13)/touchpad(16,17) map · R2(7) fire (LMB) · L1(4) aim-hold (RMB).
|
|
7
|
+
export const DEFAULT_PAD_BINDINGS = {
|
|
8
|
+
// pad button index -> key code the rest of the game reads via down()/hit()
|
|
9
|
+
buttonKeys: {
|
|
10
|
+
0: 'Space', 1: 'AltLeft', 2: 'KeyE', 3: 'KeyQ', 5: 'KeyR', 6: 'ShiftLeft',
|
|
11
|
+
8: 'Tab', 9: 'Escape', 10: 'ControlLeft', 12: 'KeyI', 14: 'KeyF', 15: 'Digit1',
|
|
12
|
+
},
|
|
13
|
+
// several buttons -> ONE key must be OR'd in a single write: routing them through
|
|
14
|
+
// buttonKeys makes them fight — the un-pressed ones clear the key the pressed one
|
|
15
|
+
// just set, so it re-fires every frame (a map-flicker bug this engine shipped once).
|
|
16
|
+
comboKeys: [{ key: 'KeyM', buttons: [13, 16, 17] }],
|
|
17
|
+
// analog trigger/bumper -> virtual mouse button (value-thresholded, not .pressed)
|
|
18
|
+
axisMouse: [
|
|
19
|
+
{ mouseButton: 0, button: 7, threshold: 0.4 }, // R2 = fire
|
|
20
|
+
{ mouseButton: 2, button: 4, threshold: 0.4 }, // L1 = aim (held; read via held(2))
|
|
21
|
+
],
|
|
22
|
+
// left stick -> digital move keys (for consumers that only want on/off:
|
|
23
|
+
// jump run-up, dodge direction, anim gates — analog movement reads moveStick)
|
|
24
|
+
moveKeys: { up: 'KeyW', down: 'KeyS', left: 'KeyA', right: 'KeyD', threshold: 0.2 },
|
|
25
|
+
// response tuning. moveDeadzone is RADIAL and rescaled from the zone edge (a
|
|
26
|
+
// per-axis threshold is "dead to one side, then a lurch to full"). lookDeadzone
|
|
27
|
+
// is small + the magnitude is raised to lookExponent past it: slow near centre
|
|
28
|
+
// for fine aim, fast at the edge to whip the camera (the RDR2 feel).
|
|
29
|
+
curves: { moveDeadzone: 0.10, lookDeadzone: 0.06, lookExponent: 2, stickAxisDeadzone: 0.22 },
|
|
30
|
+
};
|
|
@@ -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
|
+
}
|