@jgengine/shell 0.32.0 → 0.34.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/GamePlayerShell.js +7 -0
  3. package/dist/Shell3dPresentation.js +5 -3
  4. package/dist/audio/AudioComponents.js +61 -51
  5. package/dist/audio/FeedbackHost.js +8 -4
  6. package/dist/audio/audioEngine.js +101 -24
  7. package/dist/audio/emitterTracking.d.ts +40 -0
  8. package/dist/audio/emitterTracking.js +153 -0
  9. package/dist/audio/eventFeedback.d.ts +15 -5
  10. package/dist/audio/eventFeedback.js +12 -7
  11. package/dist/audio/hapticsRoute.d.ts +28 -0
  12. package/dist/audio/hapticsRoute.js +59 -0
  13. package/dist/audio/loopParams.js +20 -10
  14. package/dist/devtools/FrameTelemetry.d.ts +1 -1
  15. package/dist/devtools/FrameTelemetry.js +4 -2
  16. package/dist/devtools/agentBridge.js +13 -0
  17. package/dist/devtools/frameSampler.js +4 -0
  18. package/dist/drivers/CanvasCoverFrameloop.d.ts +13 -0
  19. package/dist/drivers/CanvasCoverFrameloop.js +22 -0
  20. package/dist/drivers/FrameRateLimiter.d.ts +2 -1
  21. package/dist/drivers/FrameRateLimiter.js +6 -3
  22. package/dist/postfx/PostProcessing.d.ts +78 -2
  23. package/dist/postfx/PostProcessing.js +149 -76
  24. package/dist/postfx/postfxOverlay.d.ts +14 -4
  25. package/dist/postfx/postfxOverlay.js +90 -10
  26. package/dist/postfx/shadowPrepass.d.ts +6 -2
  27. package/dist/postfx/shadowPrepass.js +7 -3
  28. package/dist/render/characterLod.js +6 -0
  29. package/dist/render/useFootIk.js +54 -15
  30. package/dist/render/useModelAnimation.js +22 -5
  31. package/dist/settings/settingsController.d.ts +10 -1
  32. package/dist/settings/settingsController.js +81 -69
  33. package/dist/vision/RevealVision.d.ts +3 -1
  34. package/dist/vision/RevealVision.js +34 -15
  35. package/dist/world/WorldItems.js +11 -3
  36. package/package.json +4 -4
  37. package/skills/jgengine/api.md +2 -2
  38. package/skills/jgengine-ui/api.md +45 -24
  39. package/skills/jgengine-ui/capabilities.md +8 -0
  40. package/skills/jgengine-ui/reference-primitives.md +2 -0
  41. package/skills/jgengine-world/api.md +7 -5
  42. package/skills/jgengine-world/reference.md +1 -1
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Track one sound map's positional loops.
3
+ *
4
+ * Membership is reconciled only when `sources.ids()` returns a new array, and each frame then reads
5
+ * just the tracked emitters — so a game with hundreds of props and a handful of sounding ones pays
6
+ * for the handful, and a still frame allocates nothing. An emitter that gains a sounding key through
7
+ * a rename is picked up on the next membership change, not per frame; catalog ids never change, and a
8
+ * spawned entity already carries the name its sound map is keyed by.
9
+ * @internal
10
+ */
11
+ export function createEmitterTracker(options) {
12
+ const entries = [];
13
+ const byId = new Map();
14
+ const pose = { key: "", position: [0, 0, 0] };
15
+ const target = { x: 0, y: 0, z: 0 };
16
+ let soundsRef;
17
+ let idsRef = null;
18
+ let reconcileNext = false;
19
+ function stopEntry(entry) {
20
+ entry.handle?.stop();
21
+ entry.handle = null;
22
+ }
23
+ function removeAt(index) {
24
+ const entry = entries[index];
25
+ stopEntry(entry);
26
+ byId.delete(entry.id);
27
+ const last = entries.pop();
28
+ if (index < entries.length) {
29
+ entries[index] = last;
30
+ last.index = index;
31
+ }
32
+ }
33
+ function clear() {
34
+ for (let index = 0; index < entries.length; index += 1)
35
+ stopEntry(entries[index]);
36
+ entries.length = 0;
37
+ byId.clear();
38
+ }
39
+ function playAt(sound, x, y, z) {
40
+ target.x = x;
41
+ target.y = y;
42
+ target.z = z;
43
+ return options.play(sound, target);
44
+ }
45
+ function start(id, key, sound) {
46
+ const position = pose.position;
47
+ const entry = {
48
+ id,
49
+ key,
50
+ sound,
51
+ handle: playAt(sound, position[0], position[1], position[2]),
52
+ index: entries.length,
53
+ x: position[0],
54
+ y: position[1],
55
+ z: position[2],
56
+ };
57
+ entries.push(entry);
58
+ byId.set(id, entry);
59
+ }
60
+ function retune(entry, sound) {
61
+ if (sound === undefined) {
62
+ removeAt(entry.index);
63
+ return false;
64
+ }
65
+ entry.key = pose.key;
66
+ if (sound !== entry.sound) {
67
+ stopEntry(entry);
68
+ entry.sound = sound;
69
+ entry.handle = playAt(sound, pose.position[0], pose.position[1], pose.position[2]);
70
+ }
71
+ applyPose(entry);
72
+ return true;
73
+ }
74
+ function applyPose(entry) {
75
+ const position = pose.position;
76
+ if (position[0] === entry.x && position[1] === entry.y && position[2] === entry.z)
77
+ return;
78
+ entry.x = position[0];
79
+ entry.y = position[1];
80
+ entry.z = position[2];
81
+ if (entry.handle !== null) {
82
+ target.x = entry.x;
83
+ target.y = entry.y;
84
+ target.z = entry.z;
85
+ entry.handle.setPosition(target);
86
+ }
87
+ }
88
+ function reconcile(sounds) {
89
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
90
+ const entry = entries[index];
91
+ if (!options.sources.read(entry.id, pose))
92
+ removeAt(index);
93
+ }
94
+ for (const id of options.sources.ids()) {
95
+ if (byId.has(id) || !options.sources.read(id, pose))
96
+ continue;
97
+ const sound = sounds[pose.key];
98
+ if (sound !== undefined)
99
+ start(id, pose.key, sound);
100
+ }
101
+ }
102
+ return {
103
+ step() {
104
+ const sounds = options.sounds();
105
+ if (sounds === undefined) {
106
+ if (entries.length > 0)
107
+ clear();
108
+ soundsRef = undefined;
109
+ idsRef = null;
110
+ return;
111
+ }
112
+ if (sounds !== soundsRef) {
113
+ soundsRef = sounds;
114
+ reconcileNext = true;
115
+ }
116
+ const ids = options.sources.ids();
117
+ if (ids !== idsRef) {
118
+ idsRef = ids;
119
+ reconcileNext = true;
120
+ }
121
+ if (reconcileNext) {
122
+ reconcileNext = false;
123
+ reconcile(sounds);
124
+ }
125
+ // Backwards so a retune that drops an entry does not shift the ones still to visit.
126
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
127
+ const entry = entries[index];
128
+ if (!options.sources.read(entry.id, pose)) {
129
+ removeAt(index);
130
+ continue;
131
+ }
132
+ if (pose.key !== entry.key) {
133
+ if (!retune(entry, sounds[pose.key]))
134
+ continue;
135
+ }
136
+ else {
137
+ applyPose(entry);
138
+ }
139
+ // A stolen or failed voice is retried while its emitter lives, as the per-frame rebuild did.
140
+ if (entry.handle === null)
141
+ entry.handle = playAt(entry.sound, entry.x, entry.y, entry.z);
142
+ }
143
+ },
144
+ count() {
145
+ return entries.length;
146
+ },
147
+ dispose() {
148
+ clear();
149
+ soundsRef = undefined;
150
+ idsRef = null;
151
+ },
152
+ };
153
+ }
@@ -42,8 +42,9 @@ export type AnyFeedbackBinding = {
42
42
  }[FeedbackEventName];
43
43
  /**
44
44
  * The default mapping: each engine-announced gameplay event to the cue it raises. Damage to the
45
- * local player reads as hurt, damage anywhere else as a hit; a wave start has no event of its own,
46
- * so a game announces it through `notify("wave")` or `attachWaveFeedback`.
45
+ * local player reads as hurt, damage anywhere else as a hit; an event that carries a `position`
46
+ * (damage, death) pans and attenuates its cue, and one without still plays centred. A wave start has
47
+ * no event of its own, so a game announces it through `notify("wave")` or `attachWaveFeedback`.
47
48
  */
48
49
  export declare const DEFAULT_FEEDBACK_BINDINGS: readonly AnyFeedbackBinding[];
49
50
  /** A resolved trigger: pooled per binding, so a busy event stream allocates nothing to decide. */
@@ -58,11 +59,20 @@ export interface ResolvedFeedback {
58
59
  }
59
60
  /** Everything the feedback seam reaches outside itself. Injected so it runs headless. */
60
61
  export interface FeedbackDeps {
61
- /** Play one catalog sound through the game's audio seam. */
62
- play(sound: string): void;
62
+ /**
63
+ * Play one catalog sound through the game's audio seam. `position` is the event's own position
64
+ * when it carries one, so the engine pans and attenuates the cue; omit it and the cue plays
65
+ * centred at full gain.
66
+ */
67
+ play(sound: string, position?: readonly [number, number, number]): void;
63
68
  /** Add camera trauma `0..1`; called with `shake x motionScale`. */
64
69
  shake?(amount: number): void;
65
- haptics?(pulse: HapticPulse): void; /** Show a caption line for `cue`; only called when captions are on. */
70
+ /**
71
+ * One-shot rumble for the local player. Wire it to `createHapticsRoute` so a gamepad takes the
72
+ * pulse when one is connected and a phone feels it through vibration otherwise.
73
+ */
74
+ haptics?(pulse: HapticPulse): void;
75
+ /** Show a caption line for `cue`; only called when captions are on. */
66
76
  caption?(text: string, cue: FeedbackCue): void;
67
77
  /** The local player, for `scope: "local"`. */
68
78
  localUserId(): string | null;
@@ -54,8 +54,9 @@ export const DEFAULT_FEEDBACK_REACTIONS = {
54
54
  };
55
55
  /**
56
56
  * The default mapping: each engine-announced gameplay event to the cue it raises. Damage to the
57
- * local player reads as hurt, damage anywhere else as a hit; a wave start has no event of its own,
58
- * so a game announces it through `notify("wave")` or `attachWaveFeedback`.
57
+ * local player reads as hurt, damage anywhere else as a hit; an event that carries a `position`
58
+ * (damage, death) pans and attenuates its cue, and one without still plays centred. A wave start has
59
+ * no event of its own, so a game announces it through `notify("wave")` or `attachWaveFeedback`.
59
60
  */
60
61
  export const DEFAULT_FEEDBACK_BINDINGS = [
61
62
  { event: "combat.damage", cue: "hurt", scope: "local", detail: (event) => `${Math.round(event.amount)}` },
@@ -138,8 +139,11 @@ export function createEventFeedback(deps, options = {}) {
138
139
  return true;
139
140
  }
140
141
  }
141
- function fire(trigger, captionText) {
142
- deps.play(trigger.sound);
142
+ function fire(trigger, captionText, position) {
143
+ if (position === undefined)
144
+ deps.play(trigger.sound);
145
+ else
146
+ deps.play(trigger.sound, position);
143
147
  if (trigger.haptics !== undefined)
144
148
  deps.haptics?.(trigger.haptics);
145
149
  if (trigger.shake > 0) {
@@ -150,7 +154,7 @@ export function createEventFeedback(deps, options = {}) {
150
154
  if (captionsOn && captionText !== undefined)
151
155
  deps.caption?.(captionText, trigger.cue);
152
156
  }
153
- function react(cue, captionText) {
157
+ function react(cue, captionText, position) {
154
158
  const reaction = reactions[cue];
155
159
  const trigger = cueTriggers[cue];
156
160
  const time = now();
@@ -161,7 +165,7 @@ export function createEventFeedback(deps, options = {}) {
161
165
  }
162
166
  lastPlayed[cue] = time;
163
167
  const text = captionText ?? trigger.caption ?? reaction.caption;
164
- fire(trigger, text);
168
+ fire(trigger, text, position);
165
169
  return true;
166
170
  }
167
171
  return {
@@ -176,7 +180,8 @@ export function createEventFeedback(deps, options = {}) {
176
180
  const detail = binding.detail?.(payload);
177
181
  const base = binding.caption ?? reactions[binding.cue].caption;
178
182
  const captionText = base === undefined ? detail : detail === undefined ? base : `${base} ${detail}`;
179
- return react(binding.cue, captionText) ? binding.cue : null;
183
+ const position = payload.position;
184
+ return react(binding.cue, captionText, position) ? binding.cue : null;
180
185
  }
181
186
  return null;
182
187
  },
@@ -0,0 +1,28 @@
1
+ import type { HapticPulse } from "@jgengine/core/input/haptics";
2
+ /** Where a pulse actually landed: a gamepad, the device's own motor, or nowhere. */
3
+ export type HapticsSink = "gamepad" | "vibration" | "none";
4
+ /** The browser reach of {@link createHapticsRoute}; injected so the route runs headless. */
5
+ export interface HapticsRouteOptions {
6
+ /** Connected pads. Default `navigator.getGamepads`. */
7
+ gamepads?(): ArrayLike<Gamepad | null>;
8
+ /** Device vibration, called with the pulse length in ms. Default `navigator.vibrate`; `null` for a device without one. */
9
+ vibrate?: ((ms: number) => void) | null;
10
+ /** Where `gamepadconnected`/`gamepaddisconnected` arrive. Default `window`; `null` reads pads per pulse. */
11
+ events?: Pick<Window, "addEventListener" | "removeEventListener"> | null;
12
+ }
13
+ /** Route haptic pulses to whatever the player can feel them on. */
14
+ export interface HapticsRoute {
15
+ /** Send one pulse: the gamepad if a pad is connected, else the phone's motor, else nothing. */
16
+ deliver(pulse: HapticPulse): HapticsSink;
17
+ /** Drop the connection listeners. Safe twice. */
18
+ dispose(): void;
19
+ }
20
+ /**
21
+ * One pulse in, whichever motor the player has: a connected gamepad takes it through the haptic
22
+ * channels the gamepad loop already drives, and a phone with no pad falls back to
23
+ * `navigator.vibrate`. Pad presence is cached and refreshed on connection events, so a hit never
24
+ * asks the browser for its pad list. Reduced motion does not silence this: see `eventFeedback`.
25
+ *
26
+ * @capability haptics-route send a feedback pulse to the gamepad when one is connected, else to the phone's vibration motor
27
+ */
28
+ export declare function createHapticsRoute(pad: (pulse: HapticPulse) => void, options?: HapticsRouteOptions): HapticsRoute;
@@ -0,0 +1,59 @@
1
+ function browserPads() {
2
+ return typeof navigator === "undefined" || navigator.getGamepads === undefined ? [] : navigator.getGamepads();
3
+ }
4
+ function hasVibratingPad(pads) {
5
+ for (let index = 0; index < pads.length; index += 1) {
6
+ const pad = pads[index];
7
+ if (pad !== null && pad !== undefined && pad.connected && pad.vibrationActuator != null)
8
+ return true;
9
+ }
10
+ return false;
11
+ }
12
+ function browserVibrate() {
13
+ if (typeof navigator === "undefined" || navigator.vibrate === undefined)
14
+ return null;
15
+ return (ms) => {
16
+ navigator.vibrate(ms);
17
+ };
18
+ }
19
+ function browserEvents() {
20
+ return typeof window === "undefined" ? null : window;
21
+ }
22
+ /**
23
+ * One pulse in, whichever motor the player has: a connected gamepad takes it through the haptic
24
+ * channels the gamepad loop already drives, and a phone with no pad falls back to
25
+ * `navigator.vibrate`. Pad presence is cached and refreshed on connection events, so a hit never
26
+ * asks the browser for its pad list. Reduced motion does not silence this: see `eventFeedback`.
27
+ *
28
+ * @capability haptics-route send a feedback pulse to the gamepad when one is connected, else to the phone's vibration motor
29
+ */
30
+ export function createHapticsRoute(pad, options = {}) {
31
+ const readPads = options.gamepads ?? browserPads;
32
+ const vibrate = options.vibrate === undefined ? browserVibrate() : options.vibrate;
33
+ const events = options.events === undefined ? browserEvents() : options.events;
34
+ let padPresent = hasVibratingPad(readPads());
35
+ const refresh = () => {
36
+ padPresent = hasVibratingPad(readPads());
37
+ };
38
+ const readPerPulse = events === null;
39
+ events?.addEventListener("gamepadconnected", refresh);
40
+ events?.addEventListener("gamepaddisconnected", refresh);
41
+ return {
42
+ deliver(pulse) {
43
+ if (readPerPulse)
44
+ refresh();
45
+ if (padPresent) {
46
+ pad(pulse);
47
+ return "gamepad";
48
+ }
49
+ if (vibrate === null || Math.max(0, pulse.ms) === 0)
50
+ return "none";
51
+ vibrate(Math.round(pulse.ms));
52
+ return "vibration";
53
+ },
54
+ dispose() {
55
+ events?.removeEventListener("gamepadconnected", refresh);
56
+ events?.removeEventListener("gamepaddisconnected", refresh);
57
+ },
58
+ };
59
+ }
@@ -54,18 +54,28 @@ const MAX_LISTENER_SPEED = 400;
54
54
  */
55
55
  export function stepListenerVelocity(track, position, dt) {
56
56
  const last = track.last;
57
- track.last = { x: position.x, y: position.y, z: position.z };
58
- if (last === null || !(dt > 0))
57
+ if (last === null) {
58
+ track.last = { x: position.x, y: position.y, z: position.z };
59
59
  return track.velocity;
60
- const vx = (position.x - last.x) / dt;
61
- const vy = (position.y - last.y) / dt;
62
- const vz = (position.z - last.z) / dt;
63
- if (Math.hypot(vx, vy, vz) > MAX_LISTENER_SPEED) {
64
- track.velocity = { x: 0, y: 0, z: 0 };
60
+ }
61
+ const dx = position.x - last.x;
62
+ const dy = position.y - last.y;
63
+ const dz = position.z - last.z;
64
+ last.x = position.x;
65
+ last.y = position.y;
66
+ last.z = position.z;
67
+ if (!(dt > 0))
65
68
  return track.velocity;
69
+ const v = track.velocity;
70
+ if (Math.hypot(dx, dy, dz) / dt > MAX_LISTENER_SPEED) {
71
+ v.x = 0;
72
+ v.y = 0;
73
+ v.z = 0;
74
+ return v;
66
75
  }
67
76
  const blend = 1 - Math.exp(-dt / 0.05);
68
- const v = track.velocity;
69
- track.velocity = { x: v.x + (vx - v.x) * blend, y: v.y + (vy - v.y) * blend, z: v.z + (vz - v.z) * blend };
70
- return track.velocity;
77
+ v.x += (dx / dt - v.x) * blend;
78
+ v.y += (dy / dt - v.y) * blend;
79
+ v.z += (dz / dt - v.z) * blend;
80
+ return v;
71
81
  }
@@ -1,4 +1,4 @@
1
- import type { PerfQuality } from "@jgengine/core/devtools/devtools";
1
+ import { type PerfQuality } from "@jgengine/core/devtools/devtools";
2
2
  /**
3
3
  * Mounts the in-page frame sampler for the live 3D scene: it wraps this renderer's
4
4
  * context for GPU-object counts, reads renderer and scene numbers on demand, and
@@ -1,5 +1,6 @@
1
1
  import { useEffect } from "react";
2
2
  import { useThree } from "@react-three/fiber";
3
+ import { devtools } from "@jgengine/core/devtools/devtools";
3
4
  import { readUrlParam } from "@jgengine/core/devtools/urlFlags";
4
5
  import { attachGlCounters, installFrameTelemetry, probeReportRoute, reportGameId, TELEMETRY_FLAG, } from "./frameSampler.js";
5
6
  /**
@@ -35,12 +36,13 @@ export function FrameTelemetry({ game, quality }) {
35
36
  quality: () => quality,
36
37
  viewport: () => `${window.innerWidth}x${window.innerHeight}`,
37
38
  gpu: () => readGpuLabel(gl.getContext()),
38
- render: () => ({
39
+ render: () => devtools.render.latest() ?? {
40
+ // Falling back to the live counter is a mid-frame read: it holds the passes drawn so far.
39
41
  drawCalls: gl.info.render.calls,
40
42
  triangles: gl.info.render.triangles,
41
43
  geometries: gl.info.memory.geometries,
42
44
  textures: gl.info.memory.textures,
43
- }),
45
+ },
44
46
  scene: () => scene,
45
47
  gl: () => counters,
46
48
  residentPrograms: () => gl.info.programs?.length ?? null,
@@ -1,5 +1,6 @@
1
1
  import { devtools } from "@jgengine/core/devtools/devtools";
2
2
  import { getSaveEndpoint } from "@jgengine/core/devtools/saveEndpoint";
3
+ import { canvasCover } from "@jgengine/react/canvasCover";
3
4
  import { isHudAnchor, listActiveHudLayouts, } from "@jgengine/core/ui/hudLayout";
4
5
  import { listHudPanelTypes, resolvePanelResize, resizePanelSize, } from "@jgengine/core/ui/hudDocument";
5
6
  import { buildFullReport, buildLeanReport, summarizeLeanReport } from "./devtoolsReports.js";
@@ -125,6 +126,7 @@ function panelLayoutSnapshot(placement, extras) {
125
126
  export function installAgentBridge(options) {
126
127
  const { playable, devtoolsEnabled, isDevtoolsOpen, setDevtoolsOpen } = options;
127
128
  const getContext = options.getContext ?? (() => null);
129
+ let coverRelease = null;
128
130
  const handle = (request) => {
129
131
  const method = typeof request?.method === "string" ? request.method : "";
130
132
  switch (method) {
@@ -317,6 +319,15 @@ export function installAgentBridge(options) {
317
319
  }
318
320
  return { ok: false, error: `no HUD panel "${id}" — canvas_state lists ids` };
319
321
  }
322
+ case "canvas_cover": {
323
+ if (request.covered === true) {
324
+ coverRelease ??= canvasCover.hold();
325
+ return { ok: true, covered: true, holders: canvasCover.holders() };
326
+ }
327
+ coverRelease?.();
328
+ coverRelease = null;
329
+ return { ok: true, covered: canvasCover.covered(), holders: canvasCover.holders() };
330
+ }
320
331
  case "canvas_list_panel_types":
321
332
  return {
322
333
  ok: true,
@@ -368,6 +379,8 @@ export function installAgentBridge(options) {
368
379
  };
369
380
  window.__jgengineAgent = { handle };
370
381
  return () => {
382
+ coverRelease?.();
383
+ coverRelease = null;
371
384
  if (window.__jgengineAgent?.handle === handle)
372
385
  delete window.__jgengineAgent;
373
386
  };
@@ -110,6 +110,10 @@ export function buildFrameReport(inputs) {
110
110
  frame: scaled,
111
111
  render: {
112
112
  draws: inputs.render?.drawCalls ?? null,
113
+ // Only the CDP harness wraps the draw calls and samples a whole frame; this sampler reads
114
+ // whatever the renderer probe last recorded, so it cannot state a percentile.
115
+ drawsFrame: null,
116
+ drawsFrames: 0,
113
117
  triangles: inputs.render?.triangles ?? null,
114
118
  geometries: inputs.render?.geometries ?? null,
115
119
  textures: inputs.render?.textures ?? null,
@@ -0,0 +1,13 @@
1
+ /** The frameloop the shell asked for; the cover only ever overrides it with `"never"`. */
2
+ type CanvasFrameloop = "always" | "demand" | "never";
3
+ /**
4
+ * Stops R3F's loop while a surface covers the canvas: the world, its shadow passes and postfx
5
+ * stop drawing, and the last drawn frame stays on the canvas under the surface. Input, audio and
6
+ * the DOM HUD live outside the loop and keep running. Lifting the cover restores the loop, which
7
+ * draws the untouched scene again.
8
+ * @internal
9
+ */
10
+ export declare function CanvasCoverFrameloop({ authored }: {
11
+ authored: CanvasFrameloop;
12
+ }): null;
13
+ export {};
@@ -0,0 +1,22 @@
1
+ import { useThree } from "@react-three/fiber";
2
+ import { useEffect } from "react";
3
+ import { useCanvasCovered } from "@jgengine/react/canvasCover";
4
+ /**
5
+ * Stops R3F's loop while a surface covers the canvas: the world, its shadow passes and postfx
6
+ * stop drawing, and the last drawn frame stays on the canvas under the surface. Input, audio and
7
+ * the DOM HUD live outside the loop and keep running. Lifting the cover restores the loop, which
8
+ * draws the untouched scene again.
9
+ * @internal
10
+ */
11
+ export function CanvasCoverFrameloop({ authored }) {
12
+ const covered = useCanvasCovered();
13
+ const frameloop = useThree((state) => state.frameloop);
14
+ const setFrameloop = useThree((state) => state.setFrameloop);
15
+ useEffect(() => {
16
+ const wanted = covered ? "never" : authored;
17
+ if (frameloop === wanted)
18
+ return;
19
+ setFrameloop(wanted);
20
+ }, [covered, authored, frameloop, setFrameloop]);
21
+ return null;
22
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Drives the Canvas at a capped frame rate. Mount only with `frameloop="never"`: it runs its
3
3
  * own requestAnimationFrame loop and advances R3F on the refreshes `paceFrame` admits, so
4
- * simulation, useFrame and rendering all run at the cap with correct deltas.
4
+ * simulation, useFrame and rendering all run at the cap with correct deltas. Under a canvas
5
+ * cover it advances nothing, so a covered canvas draws no frames even at a cap.
5
6
  * @internal shell-internal; driven by the player's frame-rate limit setting.
6
7
  */
7
8
  export declare function FrameRateLimiter({ fps }: {
@@ -1,17 +1,20 @@
1
1
  import { useThree } from "@react-three/fiber";
2
2
  import { useEffect } from "react";
3
3
  import { paceFrame } from "@jgengine/core/settings/frameRateLimit";
4
+ import { useCanvasCovered } from "@jgengine/react/canvasCover";
4
5
  /**
5
6
  * Drives the Canvas at a capped frame rate. Mount only with `frameloop="never"`: it runs its
6
7
  * own requestAnimationFrame loop and advances R3F on the refreshes `paceFrame` admits, so
7
- * simulation, useFrame and rendering all run at the cap with correct deltas.
8
+ * simulation, useFrame and rendering all run at the cap with correct deltas. Under a canvas
9
+ * cover it advances nothing, so a covered canvas draws no frames even at a cap.
8
10
  * @internal shell-internal; driven by the player's frame-rate limit setting.
9
11
  */
10
12
  export function FrameRateLimiter({ fps }) {
13
+ const covered = useCanvasCovered();
11
14
  const advance = useThree((state) => state.advance);
12
15
  const get = useThree((state) => state.get);
13
16
  useEffect(() => {
14
- if (fps <= 0)
17
+ if (fps <= 0 || covered)
15
18
  return;
16
19
  // R3F's "never" mode reads the timestamp as seconds on its own clock; continue that clock.
17
20
  const base = get().clock.elapsedTime;
@@ -34,6 +37,6 @@ export function FrameRateLimiter({ fps }) {
34
37
  // Hand the clock back to R3F's own loop in milliseconds so its next delta is one frame.
35
38
  get().clock.oldTime = performance.now();
36
39
  };
37
- }, [fps, advance, get]);
40
+ }, [fps, advance, get, covered]);
38
41
  return null;
39
42
  }
@@ -1,9 +1,62 @@
1
+ import * as THREE from "three";
1
2
  import { EffectComposer, type ShaderPass } from "three-stdlib";
2
- import { type AoConfig, type PostProcessingConfig } from "@jgengine/core/render/postProcessing";
3
+ import { type AoConfig, type BloomConfig, type DofConfig, type GradeConfig, type PostProcessingConfig, type StylizeConfig } from "@jgengine/core/render/postProcessing";
4
+ import { type AutoExposureConfig } from "@jgengine/core/render/colorPipeline";
3
5
  import type { GraphicsQuality } from "@jgengine/core/settings/settingsModel";
6
+ /**
7
+ * GTAO renders the scene with an `overrideMaterial` normal/depth prepass when it has no GBuffer,
8
+ * which stamps transparent overlay quads (additive VFX sprites, telegraphs, debug gizmos) into the
9
+ * AO buffers as opaque squares — the AO composite then multiplies them toward black. Hide marked
10
+ * overlay subtrees for the whole pass: the beauty buffer it composites over was already rendered
11
+ * with them in. A chain whose AO reads the beauty pass's own depth buffer draws no prepass, so
12
+ * nothing is stamped and the cached hide list is empty.
13
+ */
14
+ /**
15
+ * GTAO's own `_overrideVisibility` walks the whole scene to hide points and lines before its normal
16
+ * render. `renderShadowlessPrepass` already hides the same set from a cached list, so that walk is
17
+ * skipped here; three only fills its cache with objects it hid, so leaving it empty restores nothing.
18
+ * The hooks are optional: a three version without them leaves the walk in place.
19
+ * @internal
20
+ */
21
+ export type GtaoVisibilityHooks = {
22
+ _overrideVisibility?: () => void;
23
+ _restoreVisibility?: () => void;
24
+ };
25
+ /** Point three's GTAO pass at the prepass that already hid its points and lines. @internal */
26
+ export declare function skipGtaoVisibilityWalk(pass: object): boolean;
27
+ /** A post chain stage that costs its own draws; the plan names the ones a chain actually builds. @internal */
28
+ export type PostfxStage = "ao" | "bloom" | "dof" | "smaa" | "exposure" | "stylize" | "grade";
29
+ /** What one chain will build, resolved from the game's config, the tier and the player's toggles. @internal */
30
+ export interface PostfxStagePlan {
31
+ /** MSAA samples on the composer's beauty target: 2 unless AA is off or MSAA-only. */
32
+ samples: number;
33
+ ao: AoConfig | null;
34
+ aoSamples: number;
35
+ /**
36
+ * Where AO's depth and normals come from. `"beauty"` reads the composer's own depth buffer, so the
37
+ * stage draws nothing; `"prepass"` renders the scene again for a normal buffer, which is only
38
+ * needed when the beauty target is multisampled and its depth cannot be sampled.
39
+ *
40
+ * The prepass was the whole price of the stage: 537 extra draws and 2.6-2.9 ms/frame in Harbor
41
+ * Heat, 2,105 extra draws in Drift Foundry (1600x900, RTX 3070). The beauty target is therefore
42
+ * single-sampled whenever AO reads it, so an `aa: "smaa"` chain keeps SMAA's edge resolve without
43
+ * the extra MSAA one; `aa: "msaa"` has no other antialiasing and keeps the prepass.
44
+ */
45
+ aoGbuffer: "beauty" | "prepass";
46
+ bloom: BloomConfig | null;
47
+ dof: DofConfig | null;
48
+ smaa: boolean;
49
+ autoExposure: AutoExposureConfig | null;
50
+ stylize: StylizeConfig | null;
51
+ grade: GradeConfig | null;
52
+ /** The authored exposure the meter corrects around. */
53
+ exposure: number;
54
+ }
4
55
  interface BuiltGraph {
5
56
  composer: EffectComposer;
6
57
  grade: ShaderPass | null;
58
+ /** Stages this chain actually built, in pass order. A stage absent here costs no draw. */
59
+ stages: readonly PostfxStage[];
7
60
  }
8
61
  /**
9
62
  * Rounds an adapted exposure to 1/32 of a stop. Without it the sky's display-colour cache (which keys
@@ -17,6 +70,28 @@ export declare function disposeGraph(built: BuiltGraph): void;
17
70
  export declare function syncSize(built: BuiltGraph, width: number, height: number, pixelRatio: number): void;
18
71
  /** AO the chain runs: authored config, {@link DEFAULT_AO} when unset, null when off or the tier skips it. @internal */
19
72
  export declare function resolveAoStage(ao: PostProcessingConfig["ao"], tierAllowsAo: boolean): AoConfig | null;
73
+ /** The stages one chain asks for, before any pass is built. Pure data. @internal */
74
+ export declare function planPostStages(config: PostProcessingConfig, quality?: GraphicsQuality, stages?: {
75
+ ao: boolean;
76
+ bloom: boolean;
77
+ dof: boolean;
78
+ smaa: boolean;
79
+ }): PostfxStagePlan;
80
+ /** The stage toggles a settings surface may offer; each is a pass a chain can either build or skip. @internal */
81
+ export type PostfxToggleStage = "ao" | "bloom" | "dof" | "smaa";
82
+ /**
83
+ * The toggle stages a chain built from `config` can contain on its most permissive tier, with the
84
+ * player's own toggles ignored. A row for any other stage switches a pass the chain never builds, so
85
+ * nothing the player picks reaches the screen.
86
+ * @internal
87
+ */
88
+ export declare function availablePostfxStages(config: PostProcessingConfig): readonly PostfxToggleStage[];
89
+ /**
90
+ * Build the composer for a plan. A stage the plan left out adds no pass, so it draws nothing and
91
+ * allocates no render target.
92
+ * @internal
93
+ */
94
+ export declare function buildPostGraph(gl: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.Camera, plan: PostfxStagePlan, width: number, height: number): BuiltGraph;
20
95
  /**
21
96
  * Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
22
97
  * (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
@@ -25,7 +100,8 @@ export declare function resolveAoStage(ao: PostProcessingConfig["ao"], tierAllow
25
100
  *
26
101
  * `stages` (resolved from the player's graphics profile) controls expensive passes
27
102
  * independently. Medium keeps AO and DOF with reduced AO sampling; low can disable
28
- * them while retaining cheaper bloom, SMAA, tone mapping, and grade stages.
103
+ * them while retaining cheaper bloom, SMAA, tone mapping, and grade stages. A stage
104
+ * that is off adds no pass at all, so it costs no draw and allocates no target.
29
105
  */
30
106
  export declare function PostProcessing({ config, quality, stages }: {
31
107
  config: PostProcessingConfig;