@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.
- package/CHANGELOG.md +67 -0
- package/dist/GamePlayerShell.js +7 -0
- package/dist/Shell3dPresentation.js +6 -4
- package/dist/ShellChrome.d.ts +3 -1
- package/dist/ShellChrome.js +3 -2
- package/dist/ShellHudPresentation.js +1 -1
- package/dist/audio/AudioComponents.js +61 -51
- package/dist/audio/CaptionOverlay.d.ts +15 -0
- package/dist/audio/CaptionOverlay.js +50 -0
- package/dist/audio/FeedbackHost.d.ts +14 -0
- package/dist/audio/FeedbackHost.js +46 -0
- package/dist/audio/audioEngine.d.ts +25 -0
- package/dist/audio/audioEngine.js +267 -32
- package/dist/audio/audioProbe.d.ts +22 -0
- package/dist/audio/audioProbe.js +20 -0
- package/dist/audio/busMix.d.ts +4 -0
- package/dist/audio/busMix.js +7 -0
- package/dist/audio/captions.d.ts +48 -0
- package/dist/audio/captions.js +77 -0
- package/dist/audio/emitterTracking.d.ts +40 -0
- package/dist/audio/emitterTracking.js +153 -0
- package/dist/audio/eventFeedback.d.ts +137 -0
- package/dist/audio/eventFeedback.js +273 -0
- package/dist/audio/gestureResume.d.ts +10 -0
- package/dist/audio/gestureResume.js +25 -0
- package/dist/audio/hapticsRoute.d.ts +28 -0
- package/dist/audio/hapticsRoute.js +59 -0
- package/dist/audio/loopParams.js +20 -10
- package/dist/audio/playableAudio.d.ts +1 -1
- package/dist/audio/playableAudio.js +3 -1
- package/dist/devtools/FrameTelemetry.d.ts +1 -1
- package/dist/devtools/FrameTelemetry.js +4 -2
- package/dist/devtools/agentBridge.js +13 -0
- package/dist/devtools/frameSampler.js +4 -0
- package/dist/drivers/CanvasCoverFrameloop.d.ts +13 -0
- package/dist/drivers/CanvasCoverFrameloop.js +22 -0
- package/dist/drivers/FrameRateLimiter.d.ts +2 -1
- package/dist/drivers/FrameRateLimiter.js +6 -3
- package/dist/postfx/PostProcessing.d.ts +78 -2
- package/dist/postfx/PostProcessing.js +149 -76
- package/dist/postfx/postfxOverlay.d.ts +14 -4
- package/dist/postfx/postfxOverlay.js +90 -10
- package/dist/postfx/shadowPrepass.d.ts +6 -2
- package/dist/postfx/shadowPrepass.js +7 -3
- package/dist/render/characterLod.js +6 -0
- package/dist/render/useFootIk.js +54 -15
- package/dist/render/useModelAnimation.js +22 -5
- package/dist/settings/appliedSettings.js +3 -0
- package/dist/settings/settingsController.d.ts +10 -1
- package/dist/settings/settingsController.js +94 -67
- package/dist/vision/RevealVision.d.ts +3 -1
- package/dist/vision/RevealVision.js +34 -15
- package/dist/world/WorldItems.js +11 -3
- package/package.json +4 -4
- package/skills/jgengine/api.md +3 -2
- package/skills/jgengine-ui/SKILL.md +1 -0
- package/skills/jgengine-ui/api.md +94 -24
- package/skills/jgengine-ui/capabilities.md +40 -0
- package/skills/jgengine-ui/reference-primitives.md +2 -0
- package/skills/jgengine-ui/reference.md +1 -1
- package/skills/jgengine-world/api.md +18 -8
- 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
|
+
}
|
package/dist/audio/loopParams.js
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
57
|
+
if (last === null) {
|
|
58
|
+
track.last = { x: position.x, y: position.y, z: position.z };
|
|
59
59
|
return track.velocity;
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
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]);
|