@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.
- package/CHANGELOG.md +58 -0
- package/dist/GamePlayerShell.js +7 -0
- package/dist/Shell3dPresentation.js +5 -3
- package/dist/audio/AudioComponents.js +61 -51
- package/dist/audio/FeedbackHost.js +8 -4
- package/dist/audio/audioEngine.js +101 -24
- package/dist/audio/emitterTracking.d.ts +40 -0
- package/dist/audio/emitterTracking.js +153 -0
- package/dist/audio/eventFeedback.d.ts +15 -5
- package/dist/audio/eventFeedback.js +12 -7
- package/dist/audio/hapticsRoute.d.ts +28 -0
- package/dist/audio/hapticsRoute.js +59 -0
- package/dist/audio/loopParams.js +20 -10
- 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/settingsController.d.ts +10 -1
- package/dist/settings/settingsController.js +81 -69
- 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 +2 -2
- package/skills/jgengine-ui/api.md +45 -24
- package/skills/jgengine-ui/capabilities.md +8 -0
- package/skills/jgengine-ui/reference-primitives.md +2 -0
- package/skills/jgengine-world/api.md +7 -5
- 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;
|
|
46
|
-
*
|
|
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
|
-
/**
|
|
62
|
-
|
|
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
|
-
|
|
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;
|
|
58
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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,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;
|