@jgengine/shell 0.31.0 → 0.33.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 (62) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/GamePlayerShell.js +7 -0
  3. package/dist/Shell3dPresentation.js +6 -4
  4. package/dist/ShellChrome.d.ts +3 -1
  5. package/dist/ShellChrome.js +3 -2
  6. package/dist/ShellHudPresentation.js +1 -1
  7. package/dist/audio/AudioComponents.js +61 -51
  8. package/dist/audio/CaptionOverlay.d.ts +15 -0
  9. package/dist/audio/CaptionOverlay.js +50 -0
  10. package/dist/audio/FeedbackHost.d.ts +14 -0
  11. package/dist/audio/FeedbackHost.js +46 -0
  12. package/dist/audio/audioEngine.d.ts +25 -0
  13. package/dist/audio/audioEngine.js +267 -32
  14. package/dist/audio/audioProbe.d.ts +22 -0
  15. package/dist/audio/audioProbe.js +20 -0
  16. package/dist/audio/busMix.d.ts +4 -0
  17. package/dist/audio/busMix.js +7 -0
  18. package/dist/audio/captions.d.ts +48 -0
  19. package/dist/audio/captions.js +77 -0
  20. package/dist/audio/emitterTracking.d.ts +40 -0
  21. package/dist/audio/emitterTracking.js +153 -0
  22. package/dist/audio/eventFeedback.d.ts +137 -0
  23. package/dist/audio/eventFeedback.js +273 -0
  24. package/dist/audio/gestureResume.d.ts +10 -0
  25. package/dist/audio/gestureResume.js +25 -0
  26. package/dist/audio/hapticsRoute.d.ts +28 -0
  27. package/dist/audio/hapticsRoute.js +59 -0
  28. package/dist/audio/loopParams.js +20 -10
  29. package/dist/audio/playableAudio.d.ts +1 -1
  30. package/dist/audio/playableAudio.js +3 -1
  31. package/dist/devtools/FrameTelemetry.d.ts +1 -1
  32. package/dist/devtools/FrameTelemetry.js +4 -2
  33. package/dist/devtools/agentBridge.js +13 -0
  34. package/dist/devtools/frameSampler.js +4 -0
  35. package/dist/drivers/CanvasCoverFrameloop.d.ts +13 -0
  36. package/dist/drivers/CanvasCoverFrameloop.js +22 -0
  37. package/dist/drivers/FrameRateLimiter.d.ts +2 -1
  38. package/dist/drivers/FrameRateLimiter.js +6 -3
  39. package/dist/postfx/PostProcessing.d.ts +78 -2
  40. package/dist/postfx/PostProcessing.js +149 -76
  41. package/dist/postfx/postfxOverlay.d.ts +14 -4
  42. package/dist/postfx/postfxOverlay.js +90 -10
  43. package/dist/postfx/shadowPrepass.d.ts +6 -2
  44. package/dist/postfx/shadowPrepass.js +7 -3
  45. package/dist/render/characterLod.js +6 -0
  46. package/dist/render/useFootIk.js +54 -15
  47. package/dist/render/useModelAnimation.js +22 -5
  48. package/dist/settings/appliedSettings.js +3 -0
  49. package/dist/settings/settingsController.d.ts +10 -1
  50. package/dist/settings/settingsController.js +94 -67
  51. package/dist/vision/RevealVision.d.ts +3 -1
  52. package/dist/vision/RevealVision.js +34 -15
  53. package/dist/world/WorldItems.js +11 -3
  54. package/package.json +4 -4
  55. package/skills/jgengine/api.md +3 -2
  56. package/skills/jgengine-ui/SKILL.md +1 -0
  57. package/skills/jgengine-ui/api.md +94 -24
  58. package/skills/jgengine-ui/capabilities.md +40 -0
  59. package/skills/jgengine-ui/reference-primitives.md +2 -0
  60. package/skills/jgengine-ui/reference.md +1 -1
  61. package/skills/jgengine-world/api.md +18 -8
  62. package/skills/jgengine-world/capabilities.md +4 -0
@@ -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,6 +1,6 @@
1
1
  import type { AudioBusDef, SoundDef } from "@jgengine/core/audio/audioFalloff";
2
2
  import type { PlayableGame } from "../registry.js";
3
- /** A playable's sound catalog and buses with the default UI cues merged in. @internal */
3
+ /** A playable's sound catalog and buses with the default UI and gameplay feedback cues merged in. @internal */
4
4
  export interface ResolvedPlayableAudio {
5
5
  sounds: Record<string, SoundDef>;
6
6
  buses: Record<string, AudioBusDef> | undefined;
@@ -1,10 +1,12 @@
1
+ import { withFeedbackCueSounds } from "@jgengine/core/audio/feedbackCues";
1
2
  import { withUiCueSounds } from "@jgengine/core/audio/uiCues";
2
3
  const cache = new WeakMap();
3
4
  /** Resolve once per playable so the engine, the volume bridge and the settings rows share one bus list. @internal */
4
5
  export function resolvePlayableAudio(playable) {
5
6
  let resolved = cache.get(playable);
6
7
  if (resolved === undefined) {
7
- resolved = withUiCueSounds(playable.audio?.sounds, playable.audio?.buses, playable.audio?.uiCues);
8
+ const ui = withUiCueSounds(playable.audio?.sounds, playable.audio?.buses, playable.audio?.uiCues);
9
+ resolved = withFeedbackCueSounds(ui.sounds, ui.buses, playable.audio?.feedbackCues);
8
10
  cache.set(playable, resolved);
9
11
  }
10
12
  return resolved;
@@ -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;
@@ -6,23 +6,39 @@ import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
6
6
  import { GTAOPass } from "three/examples/jsm/postprocessing/GTAOPass.js";
7
7
  import { BokehPass } from "three/examples/jsm/postprocessing/BokehPass.js";
8
8
  import { SMAAPass } from "three/examples/jsm/postprocessing/SMAAPass.js";
9
- import { DEFAULT_AO } from "@jgengine/core/render/postProcessing";
10
- import { adaptExposure, DEFAULT_AUTO_EXPOSURE, exposureForKey, keyLuminanceFromSamples } from "@jgengine/core/render/colorPipeline";
9
+ import { DEFAULT_AO, } from "@jgengine/core/render/postProcessing";
10
+ import { adaptExposure, DEFAULT_AUTO_EXPOSURE, exposureForKey, keyLuminanceFromSamples, } from "@jgengine/core/render/colorPipeline";
11
11
  import { ExposureMeterPass } from "./autoExposure.js";
12
12
  import { createGradePass } from "./gradeShader.js";
13
+ import { releasePostfxOverlayTracking } from "./postfxOverlay.js";
13
14
  import { StylizePass } from "./stylizePass.js";
14
15
  import { renderShadowlessPrepass } from "./shadowPrepass.js";
15
- /**
16
- * GTAO renders the scene with an `overrideMaterial` normal/depth prepass, which stamps
17
- * transparent overlay quads (additive VFX sprites, telegraphs, debug gizmos) into the AO
18
- * buffers as opaque squares — the AO composite then multiplies them toward black. Hide
19
- * marked overlay subtrees for the whole pass: the beauty buffer it composites over was
20
- * already rendered with them in.
21
- */
16
+ /** Point three's GTAO pass at the prepass that already hid its points and lines. @internal */
17
+ export function skipGtaoVisibilityWalk(pass) {
18
+ const hooks = pass;
19
+ if (typeof hooks._overrideVisibility !== "function" || typeof hooks._restoreVisibility !== "function")
20
+ return false;
21
+ const restore = hooks._restoreVisibility;
22
+ hooks._overrideVisibility = () => { };
23
+ hooks._restoreVisibility = () => {
24
+ // three's restore only touches its own cache, which stays empty while the walk is skipped.
25
+ restore.call(pass);
26
+ };
27
+ return true;
28
+ }
22
29
  class OverlayAwareGTAOPass extends GTAOPass {
23
30
  hiddenOverlays = [];
31
+ constructor(scene, camera, width, height) {
32
+ super(scene, camera, width, height);
33
+ skipGtaoVisibilityWalk(this);
34
+ }
24
35
  render(renderer, writeBuffer, readBuffer, deltaTime, maskActive) {
25
- renderShadowlessPrepass(renderer, this.scene, this.hiddenOverlays, () => super.render(renderer, writeBuffer, readBuffer, deltaTime, maskActive));
36
+ const rendersGbuffer = this._renderGBuffer !== false;
37
+ if (!rendersGbuffer) {
38
+ super.render(renderer, writeBuffer, readBuffer, deltaTime, maskActive);
39
+ return;
40
+ }
41
+ renderShadowlessPrepass(renderer, this.scene, this.hiddenOverlays, () => super.render(renderer, writeBuffer, readBuffer, deltaTime, maskActive), true);
26
42
  }
27
43
  }
28
44
  /** DOF's depth prepass has the same `overrideMaterial` overlay-stamping problem as GTAO. */
@@ -65,6 +81,125 @@ export function resolveAoStage(ao, tierAllowsAo) {
65
81
  return null;
66
82
  return ao ?? DEFAULT_AO;
67
83
  }
84
+ /** The stages one chain asks for, before any pass is built. Pure data. @internal */
85
+ export function planPostStages(config, quality = "high", stages) {
86
+ const aa = config.aa === undefined ? "smaa" : config.aa;
87
+ const tierStages = stages ?? { ao: quality === "high", bloom: true, dof: quality === "high", smaa: true };
88
+ const ao = resolveAoStage(config.ao, tierStages.ao);
89
+ const authoredDof = tierStages.dof && config.dof !== undefined && config.dof !== false ? config.dof : null;
90
+ const bloom = tierStages.bloom && config.bloom !== false ? (config.bloom ?? {}) : null;
91
+ const smaa = tierStages.smaa && aa === "smaa";
92
+ return {
93
+ samples: aa === false ? 0 : 2,
94
+ ao,
95
+ aoSamples: quality === "ultra" ? 16 : quality === "high" ? 12 : 8,
96
+ aoGbuffer: smaa || aa === false ? "beauty" : "prepass",
97
+ bloom,
98
+ dof: authoredDof === null ? null : { ...authoredDof, aperture: authoredDof.aperture ?? (quality === "high" ? 0.00025 : 0.00012) },
99
+ smaa,
100
+ autoExposure: config.autoExposure === false ? null : { ...DEFAULT_AUTO_EXPOSURE, ...(config.autoExposure ?? {}) },
101
+ stylize: config.stylize !== undefined && config.stylize !== false ? config.stylize : null,
102
+ grade: config.grade !== false ? (config.grade ?? {}) : null,
103
+ exposure: config.exposure ?? 1,
104
+ };
105
+ }
106
+ /**
107
+ * The toggle stages a chain built from `config` can contain on its most permissive tier, with the
108
+ * player's own toggles ignored. A row for any other stage switches a pass the chain never builds, so
109
+ * nothing the player picks reaches the screen.
110
+ * @internal
111
+ */
112
+ export function availablePostfxStages(config) {
113
+ const plan = planPostStages(config, "high", { ao: true, bloom: true, dof: true, smaa: true });
114
+ const stages = [];
115
+ if (plan.ao !== null)
116
+ stages.push("ao");
117
+ if (plan.bloom !== null)
118
+ stages.push("bloom");
119
+ if (plan.dof !== null)
120
+ stages.push("dof");
121
+ if (plan.smaa)
122
+ stages.push("smaa");
123
+ return stages;
124
+ }
125
+ /**
126
+ * Build the composer for a plan. A stage the plan left out adds no pass, so it draws nothing and
127
+ * allocates no render target.
128
+ * @internal
129
+ */
130
+ export function buildPostGraph(gl, scene, camera, plan, width, height) {
131
+ const beautyDepth = plan.ao !== null && plan.aoGbuffer === "beauty" ? new THREE.DepthTexture(width, height) : null;
132
+ const target = new THREE.WebGLRenderTarget(width, height, {
133
+ type: THREE.HalfFloatType,
134
+ samples: beautyDepth === null ? plan.samples : 0,
135
+ ...(beautyDepth === null ? {} : { depthTexture: beautyDepth }),
136
+ });
137
+ const composer = new EffectComposer(gl, target);
138
+ const built = [];
139
+ composer.addPass(new RenderPass(scene, camera));
140
+ if (plan.ao !== null) {
141
+ const ao = new OverlayAwareGTAOPass(scene, camera, width, height);
142
+ if (beautyDepth !== null)
143
+ ao.setGBuffer(beautyDepth);
144
+ ao.blendIntensity = plan.ao.blend ?? 1;
145
+ ao.updateGtaoMaterial({
146
+ radius: plan.ao.radius ?? 1.8,
147
+ distanceExponent: 1,
148
+ thickness: plan.ao.distanceFalloff ?? 3.6,
149
+ scale: plan.ao.intensity ?? 2.4,
150
+ samples: plan.aoSamples,
151
+ });
152
+ composer.addPass(ao);
153
+ built.push("ao");
154
+ }
155
+ if (plan.bloom !== null) {
156
+ composer.addPass(new UnrealBloomPass(new THREE.Vector2(width, height), plan.bloom.strength ?? 0.32, plan.bloom.radius ?? 0.55, plan.bloom.threshold ?? 0.85));
157
+ built.push("bloom");
158
+ }
159
+ if (plan.dof !== null) {
160
+ const dof = new OverlayAwareBokehPass(scene, camera, {
161
+ focus: plan.dof.focus ?? 18,
162
+ aperture: plan.dof.aperture ?? 0.00025,
163
+ maxblur: plan.dof.maxBlur ?? 0.01,
164
+ });
165
+ composer.addPass(dof);
166
+ built.push("dof");
167
+ }
168
+ // SMAA must run before OutputPass (linear-sRGB). Default on for post chains.
169
+ if (plan.smaa) {
170
+ composer.addPass(new SMAAPass());
171
+ built.push("smaa");
172
+ }
173
+ // Clamped auto-exposure meters the pre-tone-map buffer, so the meter sits before OutputPass and
174
+ // hands the adaptation to the renderer's exposure — the one value the sky swatches also read.
175
+ const authoredExposure = plan.exposure;
176
+ if (plan.autoExposure !== null) {
177
+ const autoExposure = plan.autoExposure;
178
+ let current = authoredExposure;
179
+ let sampled = false;
180
+ composer.addPass(new ExposureMeterPass((samples, seconds) => {
181
+ const target = authoredExposure * exposureForKey(keyLuminanceFromSamples(samples), autoExposure);
182
+ // The first sample lands on its own target; fading in from the authored exposure would
183
+ // open every load on a visible exposure slideshow.
184
+ current = sampled ? adaptExposure(current, target, seconds, autoExposure.adaptationSeconds) : target;
185
+ sampled = true;
186
+ gl.toneMappingExposure = quantizeExposure(current);
187
+ }));
188
+ built.push("exposure");
189
+ }
190
+ composer.addPass(new OutputPass());
191
+ if (plan.stylize !== null) {
192
+ composer.addPass(new StylizePass(scene, camera, plan.stylize));
193
+ built.push("stylize");
194
+ }
195
+ let grade = null;
196
+ if (plan.grade !== null) {
197
+ grade = createGradePass(plan.grade);
198
+ composer.addPass(grade);
199
+ built.push("grade");
200
+ }
201
+ return { composer, grade, stages: built };
202
+ }
68
203
  /**
69
204
  * Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
70
205
  * (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
@@ -73,7 +208,8 @@ export function resolveAoStage(ao, tierAllowsAo) {
73
208
  *
74
209
  * `stages` (resolved from the player's graphics profile) controls expensive passes
75
210
  * independently. Medium keeps AO and DOF with reduced AO sampling; low can disable
76
- * them while retaining cheaper bloom, SMAA, tone mapping, and grade stages.
211
+ * them while retaining cheaper bloom, SMAA, tone mapping, and grade stages. A stage
212
+ * that is off adds no pass at all, so it costs no draw and allocates no target.
77
213
  */
78
214
  export function PostProcessing({ config, quality = "high", stages }) {
79
215
  const gl = useThree((s) => s.gl);
@@ -84,75 +220,12 @@ export function PostProcessing({ config, quality = "high", stages }) {
84
220
  useEffect(() => {
85
221
  const width = Math.max(1, size.width);
86
222
  const height = Math.max(1, size.height);
87
- const aa = config.aa === undefined ? "smaa" : config.aa;
88
- const msaaSamples = aa === false ? 0 : 2;
89
- const target = new THREE.WebGLRenderTarget(width, height, {
90
- type: THREE.HalfFloatType,
91
- samples: msaaSamples,
92
- });
93
- const composer = new EffectComposer(gl, target);
94
- composer.addPass(new RenderPass(scene, camera));
95
- const resolvedStages = stages ?? { ao: quality === "high", bloom: true, dof: quality === "high", smaa: true };
96
- const aoStage = resolveAoStage(config.ao, resolvedStages.ao);
97
- if (aoStage !== null) {
98
- const ao = new OverlayAwareGTAOPass(scene, camera, width, height);
99
- ao.blendIntensity = aoStage.blend ?? 1;
100
- ao.updateGtaoMaterial({
101
- radius: aoStage.radius ?? 1.8,
102
- distanceExponent: 1,
103
- thickness: aoStage.distanceFalloff ?? 3.6,
104
- scale: aoStage.intensity ?? 2.4,
105
- samples: quality === "ultra" ? 16 : quality === "high" ? 12 : 8,
106
- });
107
- composer.addPass(ao);
108
- }
109
- if (resolvedStages.bloom && config.bloom !== false) {
110
- const b = config.bloom ?? {};
111
- composer.addPass(new UnrealBloomPass(new THREE.Vector2(width, height), b.strength ?? 0.32, b.radius ?? 0.55, b.threshold ?? 0.85));
112
- }
113
- if (resolvedStages.dof && config.dof !== undefined && config.dof !== false) {
114
- const d = config.dof;
115
- const dof = new OverlayAwareBokehPass(scene, camera, {
116
- focus: d.focus ?? 18,
117
- aperture: d.aperture ?? (quality === "high" ? 0.00025 : 0.00012),
118
- maxblur: d.maxBlur ?? 0.01,
119
- });
120
- composer.addPass(dof);
121
- }
122
- // SMAA must run before OutputPass (linear-sRGB). Default on for post chains.
123
- if (resolvedStages.smaa && aa === "smaa") {
124
- composer.addPass(new SMAAPass());
125
- }
126
- // Clamped auto-exposure meters the pre-tone-map buffer, so the meter sits before OutputPass and
127
- // hands the adaptation to the renderer's exposure — the one value the sky swatches also read.
128
- const autoExposure = config.autoExposure === false ? null : { ...DEFAULT_AUTO_EXPOSURE, ...(config.autoExposure ?? {}) };
129
- const authoredExposure = config.exposure ?? 1;
130
- if (autoExposure !== null) {
131
- let current = authoredExposure;
132
- let sampled = false;
133
- composer.addPass(new ExposureMeterPass((samples, seconds) => {
134
- const target = authoredExposure * exposureForKey(keyLuminanceFromSamples(samples), autoExposure);
135
- // The first sample lands on its own target; fading in from the authored exposure would
136
- // open every load on a visible exposure slideshow.
137
- current = sampled ? adaptExposure(current, target, seconds, autoExposure.adaptationSeconds) : target;
138
- sampled = true;
139
- gl.toneMappingExposure = quantizeExposure(current);
140
- }));
141
- }
142
- composer.addPass(new OutputPass());
143
- if (config.stylize !== undefined && config.stylize !== false) {
144
- composer.addPass(new StylizePass(scene, camera, config.stylize));
145
- }
146
- let grade = null;
147
- if (config.grade !== false) {
148
- grade = createGradePass(config.grade ?? {});
149
- composer.addPass(grade);
150
- }
151
- const graph = { composer, grade };
223
+ const graph = buildPostGraph(gl, scene, camera, planPostStages(config, quality, stages), width, height);
152
224
  syncSize(graph, width, height, gl.getPixelRatio());
153
225
  builtRef.current = graph;
154
226
  return () => {
155
227
  disposeGraph(graph);
228
+ releasePostfxOverlayTracking(scene);
156
229
  builtRef.current = null;
157
230
  };
158
231
  }, [gl, scene, camera, config, quality, stages]);