@replayablejs/devtools 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/disabled-stats-DrzL4cm1.js +12 -0
- package/dist/disabled-stats-DrzL4cm1.js.map +1 -0
- package/dist/end-card-trigger-q4ww_XP2.d.ts +8 -0
- package/dist/endcard-trigger/disabled.d.ts +7 -0
- package/dist/endcard-trigger/disabled.js +10 -0
- package/dist/endcard-trigger/disabled.js.map +1 -0
- package/dist/endcard-trigger/enabled.d.ts +7 -0
- package/dist/endcard-trigger/enabled.js +68 -0
- package/dist/endcard-trigger/enabled.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/install-devtools-interaction-1Flxw89C.js +61 -0
- package/dist/install-devtools-interaction-1Flxw89C.js.map +1 -0
- package/dist/sound-control/disabled.d.ts +7 -0
- package/dist/sound-control/disabled.js +10 -0
- package/dist/sound-control/disabled.js.map +1 -0
- package/dist/sound-control/enabled.d.ts +7 -0
- package/dist/sound-control/enabled.js +91 -0
- package/dist/sound-control/enabled.js.map +1 -0
- package/dist/sound-control-D8U9dP87.d.ts +9 -0
- package/dist/stats/disabled.d.ts +7 -0
- package/dist/stats/disabled.js +10 -0
- package/dist/stats/disabled.js.map +1 -0
- package/dist/stats/enabled.d.ts +7 -0
- package/dist/stats/enabled.js +1081 -0
- package/dist/stats/enabled.js.map +1 -0
- package/dist/stats/webgl/disabled.d.ts +6 -0
- package/dist/stats/webgl/disabled.js +10 -0
- package/dist/stats/webgl/disabled.js.map +1 -0
- package/dist/stats/webgl/enabled.d.ts +14 -0
- package/dist/stats/webgl/enabled.js +62 -0
- package/dist/stats/webgl/enabled.js.map +1 -0
- package/dist/stats-CMNxuMXo.d.ts +13 -0
- package/package.json +62 -0
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
import { t as installDevtoolsInteraction } from "../install-devtools-interaction-1Flxw89C.js";
|
|
2
|
+
import { t as disabledStats } from "../disabled-stats-DrzL4cm1.js";
|
|
3
|
+
import { subscribeWebglContexts } from "./webgl/enabled.js";
|
|
4
|
+
import { playable } from "@replayablejs/runtime";
|
|
5
|
+
import { scaleLinear } from "d3-scale";
|
|
6
|
+
import { area, line } from "d3-shape";
|
|
7
|
+
//#region src/stats/lifecycle/run-stats-cleanup.ts
|
|
8
|
+
/**
|
|
9
|
+
* Attempts every cleanup in the supplied order, then reports failures.
|
|
10
|
+
* Callers clear retained ownership before invoking this function, so a failed
|
|
11
|
+
* cleanup cannot leave a stale subscription handle or block later destruction.
|
|
12
|
+
* A single failure keeps its original identity; multiple failures are preserved.
|
|
13
|
+
*/
|
|
14
|
+
function runStatsCleanup(actions) {
|
|
15
|
+
const errors = [];
|
|
16
|
+
for (const action of actions) try {
|
|
17
|
+
action();
|
|
18
|
+
} catch (error) {
|
|
19
|
+
errors.push(error);
|
|
20
|
+
}
|
|
21
|
+
if (errors.length === 1) throw errors[0];
|
|
22
|
+
if (errors.length > 1) throw new AggregateError(errors, "Failed to release every stats resource.");
|
|
23
|
+
}
|
|
24
|
+
/** Preserves the setup failure even when rolling back acquired resources also fails. */
|
|
25
|
+
function failStatsSetup(cause, cleanup) {
|
|
26
|
+
const errors = [cause];
|
|
27
|
+
try {
|
|
28
|
+
cleanup();
|
|
29
|
+
} catch (cleanupError) {
|
|
30
|
+
errors.push(cleanupError);
|
|
31
|
+
}
|
|
32
|
+
if (errors.length > 1) throw new AggregateError(errors, "Stats setup and cleanup both failed.", { cause });
|
|
33
|
+
throw cause;
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/stats/lifecycle/create-stats-lifecycle.ts
|
|
37
|
+
/**
|
|
38
|
+
* Connects measurement and presentation to Replayable's visibility and updates.
|
|
39
|
+
* Host visibility never overrides manual hide intent. This owns every runtime
|
|
40
|
+
* subscription and releases them together when the stats instance is destroyed.
|
|
41
|
+
*/
|
|
42
|
+
function createStatsLifecycle(sampler, view) {
|
|
43
|
+
let hostVisible = playable.state.visible;
|
|
44
|
+
let manuallyVisible = true;
|
|
45
|
+
let destroyed = false;
|
|
46
|
+
let removePostRenderListener;
|
|
47
|
+
const removeVisibilityListener = playable.on("visibilitychange", handleVisibilityChange);
|
|
48
|
+
try {
|
|
49
|
+
updateVisibility();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return failStatsSetup(error, destroy);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
show,
|
|
55
|
+
hide,
|
|
56
|
+
destroy
|
|
57
|
+
};
|
|
58
|
+
/** Requests stats visibility without overriding the host's visibility state. */
|
|
59
|
+
function show() {
|
|
60
|
+
if (destroyed || removePostRenderListener !== void 0) return;
|
|
61
|
+
manuallyVisible = true;
|
|
62
|
+
try {
|
|
63
|
+
updateVisibility();
|
|
64
|
+
} catch (error) {
|
|
65
|
+
manuallyVisible = false;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Releases stats' frame demand until explicitly shown again. */
|
|
70
|
+
function hide() {
|
|
71
|
+
if (destroyed || !manuallyVisible) return;
|
|
72
|
+
manuallyVisible = false;
|
|
73
|
+
updateVisibility();
|
|
74
|
+
}
|
|
75
|
+
/** Permanently releases subscriptions and DOM; later public calls are inert. */
|
|
76
|
+
function destroy() {
|
|
77
|
+
if (destroyed) return;
|
|
78
|
+
destroyed = true;
|
|
79
|
+
runStatsCleanup([
|
|
80
|
+
removeVisibilityListener,
|
|
81
|
+
stopSampling,
|
|
82
|
+
() => view.destroy()
|
|
83
|
+
]);
|
|
84
|
+
}
|
|
85
|
+
/** Applies host visibility without changing the caller's show/hide request. */
|
|
86
|
+
function handleVisibilityChange(visible) {
|
|
87
|
+
if (destroyed) return;
|
|
88
|
+
hostVisible = visible;
|
|
89
|
+
updateVisibility();
|
|
90
|
+
}
|
|
91
|
+
/** Ignore callbacks retained in runtime's dispatch snapshot after stopSampling(). */
|
|
92
|
+
function handlePostRender({ timestamp }) {
|
|
93
|
+
if (removePostRenderListener === void 0) return;
|
|
94
|
+
const sample = sampler.update(timestamp);
|
|
95
|
+
if (sample !== void 0) view.update(sample);
|
|
96
|
+
}
|
|
97
|
+
/** Stats run only when both the host and caller permit them. */
|
|
98
|
+
function updateVisibility() {
|
|
99
|
+
if (hostVisible && manuallyVisible) startSampling();
|
|
100
|
+
else stopSampling();
|
|
101
|
+
}
|
|
102
|
+
/** Fresh measurements and history start together; lifetime extrema survive. */
|
|
103
|
+
function startSampling() {
|
|
104
|
+
if (removePostRenderListener !== void 0) return;
|
|
105
|
+
try {
|
|
106
|
+
sampler.reset(performance.now());
|
|
107
|
+
sampler.start();
|
|
108
|
+
view.clearHistory();
|
|
109
|
+
view.show();
|
|
110
|
+
removePostRenderListener = playable.postRender.add(handlePostRender);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return failStatsSetup(error, stopSampling);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** The unsubscribe handle is also the single source of subscription state. */
|
|
116
|
+
function stopSampling() {
|
|
117
|
+
const unsubscribe = removePostRenderListener;
|
|
118
|
+
removePostRenderListener = void 0;
|
|
119
|
+
runStatsCleanup([
|
|
120
|
+
() => unsubscribe?.(),
|
|
121
|
+
sampler.stop,
|
|
122
|
+
() => view.hide()
|
|
123
|
+
]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/stats/measurement/create-stats-metrics.ts
|
|
128
|
+
/** Creates independent measurement instances for the enabled registry definitions. */
|
|
129
|
+
function createStatsMetrics(definitions) {
|
|
130
|
+
const metrics = definitions.map((definition) => ({
|
|
131
|
+
key: definition.key,
|
|
132
|
+
metric: definition.create()
|
|
133
|
+
}));
|
|
134
|
+
return {
|
|
135
|
+
start,
|
|
136
|
+
stop,
|
|
137
|
+
reset,
|
|
138
|
+
measure,
|
|
139
|
+
collect
|
|
140
|
+
};
|
|
141
|
+
/** CPU metrics need no subscription; external metrics acquire their shared source. */
|
|
142
|
+
function start() {
|
|
143
|
+
try {
|
|
144
|
+
for (const { metric } of metrics) metric.start();
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return failStatsSetup(error, stop);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Release sources before discarding a partial window; lifetime ranges survive. */
|
|
150
|
+
function stop() {
|
|
151
|
+
runStatsCleanup(metrics.flatMap(({ metric }) => [() => metric.stop(), () => metric.reset()]));
|
|
152
|
+
}
|
|
153
|
+
/** Delegates window cleanup; the collection does not own individual metric state. */
|
|
154
|
+
function reset() {
|
|
155
|
+
for (const { metric } of metrics) metric.reset();
|
|
156
|
+
}
|
|
157
|
+
/** Every enabled metric receives the same real, unclamped frame interval. */
|
|
158
|
+
function measure(deltaMilliseconds, frameTimestamp) {
|
|
159
|
+
for (const { metric } of metrics) metric.measure(deltaMilliseconds, frameTimestamp);
|
|
160
|
+
}
|
|
161
|
+
/** Preserves unavailable readings as undefined and leaves earlier snapshots untouched. */
|
|
162
|
+
function collect() {
|
|
163
|
+
const readings = /* @__PURE__ */ new Map();
|
|
164
|
+
for (const { key, metric } of metrics) readings.set(key, metric.collect());
|
|
165
|
+
return readings;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/stats/measurement/create-stats-sampling-clock.ts
|
|
170
|
+
const REFRESH_INTERVAL_MILLISECONDS = 500;
|
|
171
|
+
/** Delivers frames with real elapsed time (initially zero) and permits collection twice per second. */
|
|
172
|
+
function createStatsSamplingClock() {
|
|
173
|
+
let previousTimestamp;
|
|
174
|
+
let nextRefreshTimestamp = 0;
|
|
175
|
+
let hasFrameIntervals = false;
|
|
176
|
+
return {
|
|
177
|
+
reset,
|
|
178
|
+
advance
|
|
179
|
+
};
|
|
180
|
+
/** The first subsequent frame establishes a baseline, excluding all hidden time. */
|
|
181
|
+
function reset(timestamp) {
|
|
182
|
+
previousTimestamp = void 0;
|
|
183
|
+
nextRefreshTimestamp = timestamp + REFRESH_INTERVAL_MILLISECONDS;
|
|
184
|
+
hasFrameIntervals = false;
|
|
185
|
+
}
|
|
186
|
+
/** Delivers the frame before collection so the deadline-crossing frame belongs to this window. */
|
|
187
|
+
function advance(timestamp, onFrame) {
|
|
188
|
+
advanceFrame(timestamp, onFrame);
|
|
189
|
+
if (timestamp < nextRefreshTimestamp) return false;
|
|
190
|
+
nextRefreshTimestamp = timestamp + REFRESH_INTERVAL_MILLISECONDS;
|
|
191
|
+
if (!hasFrameIntervals) return false;
|
|
192
|
+
hasFrameIntervals = false;
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
/** Delivers the initial frame with zero elapsed time; repeated timestamps are ignored. */
|
|
196
|
+
function advanceFrame(timestamp, onFrame) {
|
|
197
|
+
const previous = previousTimestamp;
|
|
198
|
+
previousTimestamp = timestamp;
|
|
199
|
+
if (previous === void 0) {
|
|
200
|
+
onFrame(0);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const deltaMilliseconds = timestamp - previous;
|
|
204
|
+
if (deltaMilliseconds <= 0) return;
|
|
205
|
+
hasFrameIntervals = true;
|
|
206
|
+
onFrame(deltaMilliseconds);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region src/stats/measurement/create-stats-sampler.ts
|
|
211
|
+
/** Dispatches real frame intervals to enabled metrics and collects them twice per second. */
|
|
212
|
+
function createStatsSampler(definitions) {
|
|
213
|
+
const metrics = createStatsMetrics(definitions);
|
|
214
|
+
const clock = createStatsSamplingClock();
|
|
215
|
+
return {
|
|
216
|
+
start: metrics.start,
|
|
217
|
+
stop: metrics.stop,
|
|
218
|
+
reset,
|
|
219
|
+
update
|
|
220
|
+
};
|
|
221
|
+
/** Discards partial measurements and excludes the visibility gap, preserving extrema. */
|
|
222
|
+
function reset(timestamp) {
|
|
223
|
+
clock.reset(timestamp);
|
|
224
|
+
metrics.reset();
|
|
225
|
+
}
|
|
226
|
+
/** The clock delivers this frame's interval before permitting metric collection. */
|
|
227
|
+
function update(timestamp) {
|
|
228
|
+
if (!clock.advance(timestamp, (deltaMilliseconds) => {
|
|
229
|
+
metrics.measure(deltaMilliseconds, timestamp);
|
|
230
|
+
})) return;
|
|
231
|
+
return metrics.collect();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
//#endregion
|
|
235
|
+
//#region src/stats/presentation/create-stats-card-selection.ts
|
|
236
|
+
/** Owns available-card selection and cycling; it never renders or changes measurements. */
|
|
237
|
+
function createStatsCardSelection(cards) {
|
|
238
|
+
let current = cards.find((card) => card.available);
|
|
239
|
+
return {
|
|
240
|
+
get current() {
|
|
241
|
+
return current;
|
|
242
|
+
},
|
|
243
|
+
resolve,
|
|
244
|
+
cycle
|
|
245
|
+
};
|
|
246
|
+
/** Preserves selection while available; an empty collection has no selected card. */
|
|
247
|
+
function resolve() {
|
|
248
|
+
if (!current?.available) current = cards.find((card) => card.available);
|
|
249
|
+
}
|
|
250
|
+
/** Skips unavailable cards and wraps without recording or clearing their history. */
|
|
251
|
+
function cycle() {
|
|
252
|
+
const available = cards.filter((card) => card.available);
|
|
253
|
+
if (available.length === 0) return;
|
|
254
|
+
current = available[((current === void 0 ? -1 : available.indexOf(current)) + 1) % available.length];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/stats/presentation/create-stats-card-elements.ts
|
|
259
|
+
/** Builds an unmounted card with its graph behind the label, value, and range. */
|
|
260
|
+
function createStatsCardElements(metric, graph) {
|
|
261
|
+
const root = document.createElement("div");
|
|
262
|
+
root.className = "replayable-stats__card";
|
|
263
|
+
root.dataset.label = metric.label;
|
|
264
|
+
root.setAttribute("aria-label", metric.label);
|
|
265
|
+
if (metric.description !== "") root.title = metric.description;
|
|
266
|
+
const heading = document.createElement("span");
|
|
267
|
+
heading.className = "replayable-stats__label";
|
|
268
|
+
heading.textContent = metric.label;
|
|
269
|
+
const value = document.createElement("span");
|
|
270
|
+
value.className = "replayable-stats__value";
|
|
271
|
+
value.textContent = "—";
|
|
272
|
+
const range = document.createElement("span");
|
|
273
|
+
range.className = "replayable-stats__range";
|
|
274
|
+
range.hidden = true;
|
|
275
|
+
root.append(graph, heading, value, range);
|
|
276
|
+
return {
|
|
277
|
+
root,
|
|
278
|
+
value,
|
|
279
|
+
range
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
//#endregion
|
|
283
|
+
//#region src/stats/presentation/create-stats-graph-elements.ts
|
|
284
|
+
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
285
|
+
/** Creates an unmounted decorative SVG with its filled area behind the line. */
|
|
286
|
+
function createStatsGraphElements(width, height) {
|
|
287
|
+
const root = document.createElementNS(SVG_NAMESPACE, "svg");
|
|
288
|
+
root.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
|
289
|
+
root.setAttribute("aria-hidden", "true");
|
|
290
|
+
root.classList.add("replayable-stats__graph");
|
|
291
|
+
const fill = document.createElementNS(SVG_NAMESPACE, "path");
|
|
292
|
+
fill.classList.add("replayable-stats__graph-area");
|
|
293
|
+
const stroke = document.createElementNS(SVG_NAMESPACE, "path");
|
|
294
|
+
stroke.classList.add("replayable-stats__graph-line");
|
|
295
|
+
stroke.setAttribute("fill", "none");
|
|
296
|
+
root.append(fill, stroke);
|
|
297
|
+
return {
|
|
298
|
+
root,
|
|
299
|
+
fill,
|
|
300
|
+
stroke
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const GRAPH_WIDTH = 96;
|
|
304
|
+
const GRAPH_HEIGHT = 56;
|
|
305
|
+
const GRAPH_INSET = 2;
|
|
306
|
+
/**
|
|
307
|
+
* Creates one reusable line and filled area. D3 only calculates
|
|
308
|
+
* paths and coordinates; it does not manage DOM, transitions, or a frame loop.
|
|
309
|
+
*
|
|
310
|
+
* Observe samples before rendering. Keeping observation separate lets compact
|
|
311
|
+
* cards remember peaks without redrawing hidden graphs on every refresh.
|
|
312
|
+
*/
|
|
313
|
+
function createStatsGraph(initialCeiling) {
|
|
314
|
+
const elements = createStatsGraphElements(GRAPH_WIDTH, GRAPH_HEIGHT);
|
|
315
|
+
let ceiling = initialCeiling;
|
|
316
|
+
const xScale = scaleLinear().domain([0, 59]).range([GRAPH_INSET, 94]);
|
|
317
|
+
const yScale = scaleLinear().domain([0, ceiling]).range([54, GRAPH_INSET]);
|
|
318
|
+
const linePath = line().y((value) => yScale(value));
|
|
319
|
+
const areaPath = area().y0(yScale(0)).y1((value) => yScale(value));
|
|
320
|
+
return {
|
|
321
|
+
element: elements.root,
|
|
322
|
+
get ceiling() {
|
|
323
|
+
return ceiling;
|
|
324
|
+
},
|
|
325
|
+
observe,
|
|
326
|
+
render
|
|
327
|
+
};
|
|
328
|
+
/** Grows the scale for new peaks without redrawing a potentially hidden graph. */
|
|
329
|
+
function observe(value) {
|
|
330
|
+
while (value > ceiling) ceiling *= 2;
|
|
331
|
+
}
|
|
332
|
+
/** Updates existing paths from recorded history; fewer than two samples draw no trace. */
|
|
333
|
+
function render(history) {
|
|
334
|
+
yScale.domain([0, ceiling]);
|
|
335
|
+
if (history.length < 2) {
|
|
336
|
+
elements.fill.removeAttribute("d");
|
|
337
|
+
elements.stroke.removeAttribute("d");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const firstSlot = 60 - history.length;
|
|
341
|
+
linePath.x((_value, index) => xScale(firstSlot + index));
|
|
342
|
+
areaPath.x((_value, index) => xScale(firstSlot + index));
|
|
343
|
+
elements.stroke.setAttribute("d", linePath(history) ?? "");
|
|
344
|
+
elements.fill.setAttribute("d", areaPath(history) ?? "");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/stats/presentation/create-stats-card.ts
|
|
349
|
+
/** Owns one metric's recorded history and renders it into reusable card elements. */
|
|
350
|
+
function createStatsCard(metric) {
|
|
351
|
+
const graph = createStatsGraph(metric.ceiling);
|
|
352
|
+
const elements = createStatsCardElements(metric, graph.element);
|
|
353
|
+
const history = [];
|
|
354
|
+
let isAvailable = metric.initiallyAvailable;
|
|
355
|
+
let currentValue;
|
|
356
|
+
let currentRange;
|
|
357
|
+
return {
|
|
358
|
+
key: metric.key,
|
|
359
|
+
element: elements.root,
|
|
360
|
+
label: metric.label,
|
|
361
|
+
get available() {
|
|
362
|
+
return isAvailable;
|
|
363
|
+
},
|
|
364
|
+
record,
|
|
365
|
+
render,
|
|
366
|
+
clearHistory
|
|
367
|
+
};
|
|
368
|
+
/** Records data and graph peaks without redrawing, including while this card is hidden. */
|
|
369
|
+
function record(reading) {
|
|
370
|
+
isAvailable = reading !== void 0;
|
|
371
|
+
if (reading === void 0) {
|
|
372
|
+
clearHistory();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
currentValue = reading.value;
|
|
376
|
+
currentRange = reading.range;
|
|
377
|
+
history.push(reading.graphValue);
|
|
378
|
+
if (history.length > 60) history.shift();
|
|
379
|
+
graph.observe(reading.graphValue);
|
|
380
|
+
}
|
|
381
|
+
/** Draws stored values and history without recording another observation. */
|
|
382
|
+
function render() {
|
|
383
|
+
elements.value.textContent = currentValue?.toFixed(1) ?? "—";
|
|
384
|
+
elements.range.hidden = currentRange === void 0;
|
|
385
|
+
elements.range.textContent = formatRange(currentRange);
|
|
386
|
+
graph.render(history);
|
|
387
|
+
}
|
|
388
|
+
/** Discards the trace and value, preserving availability, lifetime range, and graph scale. */
|
|
389
|
+
function clearHistory() {
|
|
390
|
+
history.length = 0;
|
|
391
|
+
currentValue = void 0;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/** Formats lifetime extrema as a compact label, leaving an unavailable range empty. */
|
|
395
|
+
function formatRange(range) {
|
|
396
|
+
if (range === void 0) return "";
|
|
397
|
+
return `(${Math.round(range.min)}–${Math.round(range.max)})`;
|
|
398
|
+
}
|
|
399
|
+
//#endregion
|
|
400
|
+
//#region src/stats/presentation/create-stats-cards.ts
|
|
401
|
+
/** Owns card instances, recorded samples, and selection in registry order. */
|
|
402
|
+
function createStatsCards(definitions, display) {
|
|
403
|
+
const cards = definitions.map(createStatsCard);
|
|
404
|
+
const selection = createStatsCardSelection(cards);
|
|
405
|
+
const render = display === "compact" ? renderCompact : renderExpanded;
|
|
406
|
+
return {
|
|
407
|
+
elements: cards.map((card) => card.element),
|
|
408
|
+
get selectedLabel() {
|
|
409
|
+
return selection.current?.label;
|
|
410
|
+
},
|
|
411
|
+
record,
|
|
412
|
+
cycle: selection.cycle,
|
|
413
|
+
render,
|
|
414
|
+
clearHistory
|
|
415
|
+
};
|
|
416
|
+
/** Hidden cards still receive samples so cycling never loses their history. */
|
|
417
|
+
function record(sample) {
|
|
418
|
+
for (const card of cards) card.record(sample.get(card.key));
|
|
419
|
+
}
|
|
420
|
+
/** Displays only the selected available card; hidden cards retain their history. */
|
|
421
|
+
function renderCompact() {
|
|
422
|
+
selection.resolve();
|
|
423
|
+
for (const card of cards) renderCard(card, card === selection.current);
|
|
424
|
+
}
|
|
425
|
+
/** Displays every available card without resolving compact selection. */
|
|
426
|
+
function renderExpanded() {
|
|
427
|
+
for (const card of cards) renderCard(card, card.available);
|
|
428
|
+
}
|
|
429
|
+
/** Clears traces, preserving availability, scales, and selection. */
|
|
430
|
+
function clearHistory() {
|
|
431
|
+
for (const card of cards) card.clearHistory();
|
|
432
|
+
}
|
|
433
|
+
/** Applies visibility and avoids redrawing hidden cards in either display mode. */
|
|
434
|
+
function renderCard(card, visible) {
|
|
435
|
+
card.element.hidden = !visible;
|
|
436
|
+
if (visible) card.render();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
//#endregion
|
|
440
|
+
//#region src/stats/presentation/stats.scss?inline
|
|
441
|
+
var stats_default = ".replayable-stats{left:calc(2vmin + env(safe-area-inset-left,0px));bottom:calc(2vmin + env(safe-area-inset-bottom,0px));z-index:2147483647;max-width:calc(100% - 4vmin - env(safe-area-inset-left,0px) - env(safe-area-inset-right,0px));pointer-events:none;-webkit-user-select:none;user-select:none;font-variant-numeric:tabular-nums;color:#e6f5ff;flex-wrap:wrap;gap:1vmin;margin:0;padding:0;font:2.75vmin/1.4 ui-monospace,monospace;display:flex;position:fixed}.replayable-stats--compact[role=button]{pointer-events:auto;cursor:pointer;touch-action:none;-webkit-tap-highlight-color:transparent}.replayable-stats--compact[role=button]:focus-visible{outline-offset:.5vmin;outline:.25vmin solid #a7bdd1}.replayable-stats[hidden],.replayable-stats [hidden]{display:none}.replayable-stats__card{box-sizing:border-box;white-space:nowrap;background:#0c131ce6;border:.25vmin solid #435366;border-radius:1vmin;flex:0 0 24vmin;width:24vmin;height:14vmin;padding:1vmin 1.5vmin;position:relative;overflow:hidden}.replayable-stats__label,.replayable-stats__value{text-shadow:0 .25vmin .5vmin #0c131c;display:block;position:relative}.replayable-stats__label{color:#a7bdd1;font-size:2.5vmin}.replayable-stats__value{font-size:3vmin;position:absolute;bottom:.5vmin;left:1.5vmin}.replayable-stats__graph{width:100%;height:100%;position:absolute;inset:0}.replayable-stats__graph-area{fill:#5cbedc1f}.replayable-stats__graph-line{stroke:#70d3ef73;stroke-width:1px}.replayable-stats__range{color:#8095a8;text-shadow:0 .25vmin .5vmin #0c131c;font-size:1.75vmin;position:absolute;bottom:.5vmin;right:.5vmin}\n";
|
|
442
|
+
//#endregion
|
|
443
|
+
//#region src/stats/presentation/create-stats-shell-elements.ts
|
|
444
|
+
/** Builds the unmounted shell and stylesheet without installing input listeners. */
|
|
445
|
+
function createStatsShellElements(cardElements, display) {
|
|
446
|
+
const root = document.createElement("aside");
|
|
447
|
+
root.className = "replayable-stats";
|
|
448
|
+
root.setAttribute("aria-label", "Development stats");
|
|
449
|
+
root.classList.toggle("replayable-stats--compact", display === "compact");
|
|
450
|
+
root.append(...cardElements);
|
|
451
|
+
const style = document.createElement("style");
|
|
452
|
+
style.textContent = stats_default;
|
|
453
|
+
return {
|
|
454
|
+
root,
|
|
455
|
+
style
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region src/stats/presentation/create-stats-shell.ts
|
|
460
|
+
/** Mounts the shell, stylesheet, and optional compact input interception. */
|
|
461
|
+
function createStatsShell(cardElements, display, onCycleCard) {
|
|
462
|
+
const compact = display === "compact";
|
|
463
|
+
const { root, style } = createStatsShellElements(cardElements, display);
|
|
464
|
+
const updateAccessibility = compact ? updateCompactAccessibility : noop$4;
|
|
465
|
+
document.head.append(style);
|
|
466
|
+
document.body.append(root);
|
|
467
|
+
const removeInteraction = compact ? installDevtoolsInteraction(root, onCycleCard) : void 0;
|
|
468
|
+
return {
|
|
469
|
+
updateAccessibility,
|
|
470
|
+
show,
|
|
471
|
+
hide,
|
|
472
|
+
destroy
|
|
473
|
+
};
|
|
474
|
+
/** Describes selection without leaving an empty view as an invisible click target. */
|
|
475
|
+
function updateCompactAccessibility(label) {
|
|
476
|
+
if (label !== void 0) {
|
|
477
|
+
root.setAttribute("role", "button");
|
|
478
|
+
root.tabIndex = 0;
|
|
479
|
+
root.setAttribute("aria-label", `${label}. Show next stats card`);
|
|
480
|
+
} else {
|
|
481
|
+
root.removeAttribute("role");
|
|
482
|
+
root.removeAttribute("tabindex");
|
|
483
|
+
root.setAttribute("aria-label", "Development stats");
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/** Visibility alone does not change card data or selection. */
|
|
487
|
+
function show() {
|
|
488
|
+
root.hidden = false;
|
|
489
|
+
}
|
|
490
|
+
/** Hidden shells do not intercept input. */
|
|
491
|
+
function hide() {
|
|
492
|
+
root.hidden = true;
|
|
493
|
+
}
|
|
494
|
+
/** Removes this shell's input listeners, DOM, and stylesheet. */
|
|
495
|
+
function destroy() {
|
|
496
|
+
removeInteraction?.();
|
|
497
|
+
root.remove();
|
|
498
|
+
style.remove();
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/** Expanded accessibility is static and already initialized by the shell elements. */
|
|
502
|
+
function noop$4() {}
|
|
503
|
+
//#endregion
|
|
504
|
+
//#region src/stats/presentation/create-stats-view.ts
|
|
505
|
+
/** Coordinates metric cards and their mounted presentation shell. */
|
|
506
|
+
function createStatsView(definitions, display) {
|
|
507
|
+
const cards = createStatsCards(definitions, display);
|
|
508
|
+
const shell = createStatsShell(cards.elements, display, cycleCard);
|
|
509
|
+
render();
|
|
510
|
+
return {
|
|
511
|
+
update,
|
|
512
|
+
clearHistory,
|
|
513
|
+
show: shell.show,
|
|
514
|
+
hide: shell.hide,
|
|
515
|
+
destroy
|
|
516
|
+
};
|
|
517
|
+
/** Records a refresh before redrawing the visible cards. */
|
|
518
|
+
function update(sample) {
|
|
519
|
+
cards.record(sample);
|
|
520
|
+
render();
|
|
521
|
+
}
|
|
522
|
+
/** Clears traces after a measurement gap without changing selection. */
|
|
523
|
+
function clearHistory() {
|
|
524
|
+
cards.clearHistory();
|
|
525
|
+
render();
|
|
526
|
+
}
|
|
527
|
+
/** Changes the displayed card without recording another sample. */
|
|
528
|
+
function cycleCard() {
|
|
529
|
+
cards.cycle();
|
|
530
|
+
render();
|
|
531
|
+
}
|
|
532
|
+
/** Keeps shell accessibility in sync with resolved card selection. */
|
|
533
|
+
function render() {
|
|
534
|
+
cards.render();
|
|
535
|
+
shell.updateAccessibility(cards.selectedLabel);
|
|
536
|
+
}
|
|
537
|
+
/** Releases recorded histories and removes the shell without a final redraw. */
|
|
538
|
+
function destroy() {
|
|
539
|
+
shell.destroy();
|
|
540
|
+
cards.clearHistory();
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
//#endregion
|
|
544
|
+
//#region src/stats/measurement/create-stats-range.ts
|
|
545
|
+
/** Tracks extrema without storing samples or allocating on each measured frame. */
|
|
546
|
+
function createStatsRange() {
|
|
547
|
+
let min = Infinity;
|
|
548
|
+
let max = -Infinity;
|
|
549
|
+
return {
|
|
550
|
+
get current() {
|
|
551
|
+
return min === Infinity ? void 0 : {
|
|
552
|
+
min,
|
|
553
|
+
max
|
|
554
|
+
};
|
|
555
|
+
},
|
|
556
|
+
observe(value) {
|
|
557
|
+
if (!Number.isFinite(value) || value < 0) return;
|
|
558
|
+
min = Math.min(min, value);
|
|
559
|
+
max = Math.max(max, value);
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
//#endregion
|
|
564
|
+
//#region src/stats/measurement/metrics/create-fps-metric.ts
|
|
565
|
+
const MILLISECONDS_PER_SECOND = 1e3;
|
|
566
|
+
/** Counts complete frame intervals against real elapsed time, not instantaneous reciprocals. */
|
|
567
|
+
function createFpsMetric() {
|
|
568
|
+
const range = createStatsRange();
|
|
569
|
+
let intervalCount = 0;
|
|
570
|
+
let totalMilliseconds = 0;
|
|
571
|
+
return {
|
|
572
|
+
start: noop$3,
|
|
573
|
+
stop: noop$3,
|
|
574
|
+
measure,
|
|
575
|
+
collect,
|
|
576
|
+
reset
|
|
577
|
+
};
|
|
578
|
+
function measure(deltaMilliseconds) {
|
|
579
|
+
if (deltaMilliseconds === 0) return;
|
|
580
|
+
intervalCount += 1;
|
|
581
|
+
totalMilliseconds += deltaMilliseconds;
|
|
582
|
+
}
|
|
583
|
+
/** Lifetime extrema describe published FPS windows, not individual frames. */
|
|
584
|
+
function collect() {
|
|
585
|
+
if (intervalCount === 0) return;
|
|
586
|
+
const value = intervalCount * MILLISECONDS_PER_SECOND / totalMilliseconds;
|
|
587
|
+
range.observe(value);
|
|
588
|
+
reset();
|
|
589
|
+
return {
|
|
590
|
+
value,
|
|
591
|
+
graphValue: value,
|
|
592
|
+
range: range.current
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
function reset() {
|
|
596
|
+
intervalCount = 0;
|
|
597
|
+
totalMilliseconds = 0;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/** FPS uses supplied frame intervals and owns no external subscriptions. */
|
|
601
|
+
function noop$3() {}
|
|
602
|
+
//#endregion
|
|
603
|
+
//#region src/stats/measurement/metrics/create-frame-interval-metric.ts
|
|
604
|
+
/** Displays the average, graphs the peak, and retains extrema of individual intervals. */
|
|
605
|
+
function createFrameIntervalMetric() {
|
|
606
|
+
const range = createStatsRange();
|
|
607
|
+
let intervalCount = 0;
|
|
608
|
+
let totalMilliseconds = 0;
|
|
609
|
+
let peakMilliseconds = 0;
|
|
610
|
+
return {
|
|
611
|
+
start: noop$2,
|
|
612
|
+
stop: noop$2,
|
|
613
|
+
measure,
|
|
614
|
+
collect,
|
|
615
|
+
reset
|
|
616
|
+
};
|
|
617
|
+
function measure(deltaMilliseconds) {
|
|
618
|
+
if (deltaMilliseconds === 0) return;
|
|
619
|
+
intervalCount += 1;
|
|
620
|
+
totalMilliseconds += deltaMilliseconds;
|
|
621
|
+
peakMilliseconds = Math.max(peakMilliseconds, deltaMilliseconds);
|
|
622
|
+
range.observe(deltaMilliseconds);
|
|
623
|
+
}
|
|
624
|
+
function collect() {
|
|
625
|
+
if (intervalCount === 0) return;
|
|
626
|
+
const reading = {
|
|
627
|
+
value: totalMilliseconds / intervalCount,
|
|
628
|
+
graphValue: peakMilliseconds,
|
|
629
|
+
range: range.current
|
|
630
|
+
};
|
|
631
|
+
reset();
|
|
632
|
+
return reading;
|
|
633
|
+
}
|
|
634
|
+
function reset() {
|
|
635
|
+
intervalCount = 0;
|
|
636
|
+
totalMilliseconds = 0;
|
|
637
|
+
peakMilliseconds = 0;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/** Frame intervals are supplied by runtime; no external resources need acquisition. */
|
|
641
|
+
function noop$2() {}
|
|
642
|
+
//#endregion
|
|
643
|
+
//#region src/stats/measurement/metrics/create-js-heap-metric.ts
|
|
644
|
+
const BYTES_PER_MEGABYTE = 1e6;
|
|
645
|
+
/** Reads browser memory only on refresh; it has no per-frame or partial-window work. */
|
|
646
|
+
function createJsHeapMetric() {
|
|
647
|
+
const range = createStatsRange();
|
|
648
|
+
return {
|
|
649
|
+
start: noop$1,
|
|
650
|
+
stop: noop$1,
|
|
651
|
+
measure: noop$1,
|
|
652
|
+
reset: noop$1,
|
|
653
|
+
collect
|
|
654
|
+
};
|
|
655
|
+
function collect() {
|
|
656
|
+
const value = readJsHeap();
|
|
657
|
+
if (value === void 0) return;
|
|
658
|
+
range.observe(value);
|
|
659
|
+
return {
|
|
660
|
+
value,
|
|
661
|
+
graphValue: value,
|
|
662
|
+
range: range.current
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Reads the optional browser heap estimate only on collection.
|
|
668
|
+
* This is not total playable memory: it excludes GPU allocations and may
|
|
669
|
+
* include other work sharing the browser's JavaScript heap.
|
|
670
|
+
*/
|
|
671
|
+
function readJsHeap() {
|
|
672
|
+
if (!("memory" in performance)) return;
|
|
673
|
+
const { memory } = performance;
|
|
674
|
+
if (typeof memory !== "object" || memory === null || !("usedJSHeapSize" in memory)) return;
|
|
675
|
+
const bytes = memory.usedJSHeapSize;
|
|
676
|
+
if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) return;
|
|
677
|
+
return bytes / BYTES_PER_MEGABYTE;
|
|
678
|
+
}
|
|
679
|
+
/** Heap observations need no subscriptions, frame accumulation, or window reset. */
|
|
680
|
+
function noop$1() {}
|
|
681
|
+
//#endregion
|
|
682
|
+
//#region src/stats/webgl/observe-webgl-method.ts
|
|
683
|
+
/**
|
|
684
|
+
* Shadows a method on this context/extension only, leaving browser prototypes alone.
|
|
685
|
+
* Observation happens after a normal return: thrown calls retain their exception
|
|
686
|
+
* and are not counted. WebGL error flags are deliberately never queried.
|
|
687
|
+
*/
|
|
688
|
+
function observeWebglMethod(target, name, observe) {
|
|
689
|
+
const original = Reflect.get(target, name);
|
|
690
|
+
if (typeof original !== "function") return noop;
|
|
691
|
+
const method = original;
|
|
692
|
+
const descriptor = Object.getOwnPropertyDescriptor(target, name);
|
|
693
|
+
let active = true;
|
|
694
|
+
Object.defineProperty(target, name, {
|
|
695
|
+
configurable: true,
|
|
696
|
+
enumerable: descriptor?.enumerable ?? false,
|
|
697
|
+
writable: true,
|
|
698
|
+
value: observedMethod
|
|
699
|
+
});
|
|
700
|
+
return restore;
|
|
701
|
+
/** Preserve the receiver, argument identities, return value, and native exceptions. */
|
|
702
|
+
function observedMethod(...args) {
|
|
703
|
+
const result = Reflect.apply(method, this, args);
|
|
704
|
+
if (active) observe(args);
|
|
705
|
+
return result;
|
|
706
|
+
}
|
|
707
|
+
/** Never overwrite a wrapper installed by another tool after ours. */
|
|
708
|
+
function restore() {
|
|
709
|
+
if (!active) return;
|
|
710
|
+
active = false;
|
|
711
|
+
if (Object.getOwnPropertyDescriptor(target, name)?.value !== observedMethod) return;
|
|
712
|
+
if (descriptor === void 0) {
|
|
713
|
+
if (!Reflect.deleteProperty(target, name)) throw new TypeError(`Unable to remove the stats wrapper for ${name}.`);
|
|
714
|
+
} else Object.defineProperty(target, name, descriptor);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/** Missing WebGL2/extension methods need no instrumentation or cleanup. */
|
|
718
|
+
function noop() {}
|
|
719
|
+
//#endregion
|
|
720
|
+
//#region src/stats/webgl/create-webgl-tracker.ts
|
|
721
|
+
const trackers = /* @__PURE__ */ new WeakMap();
|
|
722
|
+
/**
|
|
723
|
+
* Instruments one renderer-created context, reusing it when renderers share it.
|
|
724
|
+
* No scheduling, context creation, GPU queries, or renderer knowledge lives here.
|
|
725
|
+
* The later sampling lifecycle owns this tracker and calls collect after rendering.
|
|
726
|
+
*/
|
|
727
|
+
function createWebglTracker(context) {
|
|
728
|
+
const existing = trackers.get(context);
|
|
729
|
+
if (existing !== void 0) return existing;
|
|
730
|
+
let drawCalls = 0;
|
|
731
|
+
let textureBinds = 0;
|
|
732
|
+
let programUses = 0;
|
|
733
|
+
const restoreMethods = [];
|
|
734
|
+
const tracker = {
|
|
735
|
+
collect,
|
|
736
|
+
reset,
|
|
737
|
+
destroy
|
|
738
|
+
};
|
|
739
|
+
try {
|
|
740
|
+
observeDrawMethods();
|
|
741
|
+
observe(context, "bindTexture", recordTextureBind);
|
|
742
|
+
observe(context, "useProgram", recordProgramUse);
|
|
743
|
+
observeExtensions();
|
|
744
|
+
} catch (error) {
|
|
745
|
+
return failStatsSetup(error, destroy);
|
|
746
|
+
}
|
|
747
|
+
trackers.set(context, tracker);
|
|
748
|
+
return tracker;
|
|
749
|
+
/** Instanced submissions count once, regardless of how many instances they draw. */
|
|
750
|
+
function observeDrawMethods() {
|
|
751
|
+
for (const name of [
|
|
752
|
+
"drawArrays",
|
|
753
|
+
"drawElements",
|
|
754
|
+
"drawRangeElements",
|
|
755
|
+
"drawArraysInstanced",
|
|
756
|
+
"drawElementsInstanced"
|
|
757
|
+
]) observe(context, name, recordDraw);
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Retrieve extension objects once so already-cached objects are instrumented too.
|
|
761
|
+
* This enables supported extensions, but never creates a WebGL context. Native
|
|
762
|
+
* extension objects are reused by getExtension; unsupported ones return null.
|
|
763
|
+
* A function copied/bound before installation cannot be intercepted retroactively.
|
|
764
|
+
* https://developer.mozilla.org/en-US/docs/Web/API/WEBGL_multi_draw
|
|
765
|
+
*/
|
|
766
|
+
function observeExtensions() {
|
|
767
|
+
const instancing = context.getExtension("ANGLE_instanced_arrays");
|
|
768
|
+
if (typeof instancing === "object" && instancing !== null) {
|
|
769
|
+
observe(instancing, "drawArraysInstancedANGLE", recordDraw);
|
|
770
|
+
observe(instancing, "drawElementsInstancedANGLE", recordDraw);
|
|
771
|
+
}
|
|
772
|
+
const multiDraw = context.getExtension("WEBGL_multi_draw");
|
|
773
|
+
if (typeof multiDraw === "object" && multiDraw !== null) {
|
|
774
|
+
observe(multiDraw, "multiDrawArraysWEBGL", (args) => recordMultiDraw(args[5]));
|
|
775
|
+
observe(multiDraw, "multiDrawElementsWEBGL", (args) => recordMultiDraw(args[6]));
|
|
776
|
+
observe(multiDraw, "multiDrawArraysInstancedWEBGL", (args) => recordMultiDraw(args[7]));
|
|
777
|
+
observe(multiDraw, "multiDrawElementsInstancedWEBGL", (args) => recordMultiDraw(args[8]));
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/** Keeps installation and cleanup paired for both context and extension methods. */
|
|
781
|
+
function observe(target, name, record) {
|
|
782
|
+
restoreMethods.push(observeWebglMethod(target, name, record));
|
|
783
|
+
}
|
|
784
|
+
/** One ordinary API submission, including zero-count draws; no GPU success claim. */
|
|
785
|
+
function recordDraw() {
|
|
786
|
+
drawCalls += 1;
|
|
787
|
+
}
|
|
788
|
+
/** Reads the signature's drawcount, ignoring extra arguments as WebGL does. */
|
|
789
|
+
function recordMultiDraw(count) {
|
|
790
|
+
if (typeof count === "number" && Number.isInteger(count) && count > 0) drawCalls += count;
|
|
791
|
+
}
|
|
792
|
+
/** Repeated bindings and null unbindings are still API calls. */
|
|
793
|
+
function recordTextureBind() {
|
|
794
|
+
textureBinds += 1;
|
|
795
|
+
}
|
|
796
|
+
/** Counts useProgram calls, not unique programs or successful shader switches. */
|
|
797
|
+
function recordProgramUse() {
|
|
798
|
+
programUses += 1;
|
|
799
|
+
}
|
|
800
|
+
/** Allocate only at the caller's sampling boundary, never a snapshot per GL call. */
|
|
801
|
+
function collect() {
|
|
802
|
+
const counts = {
|
|
803
|
+
drawCalls,
|
|
804
|
+
textureBinds,
|
|
805
|
+
programUses
|
|
806
|
+
};
|
|
807
|
+
reset();
|
|
808
|
+
return counts;
|
|
809
|
+
}
|
|
810
|
+
/** Discards a partial frame without changing the installed wrappers. */
|
|
811
|
+
function reset() {
|
|
812
|
+
drawCalls = 0;
|
|
813
|
+
textureBinds = 0;
|
|
814
|
+
programUses = 0;
|
|
815
|
+
}
|
|
816
|
+
/** Idempotent cleanup; stale owners cannot remove a newer tracker. */
|
|
817
|
+
function destroy() {
|
|
818
|
+
const restores = restoreMethods.splice(0).reverse();
|
|
819
|
+
reset();
|
|
820
|
+
if (trackers.get(context) === tracker) trackers.delete(context);
|
|
821
|
+
runStatsCleanup(restores);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
//#endregion
|
|
825
|
+
//#region src/stats/webgl/create-webgl-context-sampling.ts
|
|
826
|
+
/** Owns instrumentation and loss listeners for one registered, renderer-created context. */
|
|
827
|
+
function createWebglContextSampling(context) {
|
|
828
|
+
let tracker;
|
|
829
|
+
let destroyed = false;
|
|
830
|
+
try {
|
|
831
|
+
context.canvas.addEventListener("webglcontextlost", handleContextLost);
|
|
832
|
+
context.canvas.addEventListener("webglcontextrestored", handleContextRestored);
|
|
833
|
+
handleContextRestored();
|
|
834
|
+
} catch (error) {
|
|
835
|
+
return failStatsSetup(error, destroy);
|
|
836
|
+
}
|
|
837
|
+
return {
|
|
838
|
+
collect,
|
|
839
|
+
destroy
|
|
840
|
+
};
|
|
841
|
+
/** Lost contexts are unavailable, not zero-work frames. */
|
|
842
|
+
function collect() {
|
|
843
|
+
return tracker?.collect();
|
|
844
|
+
}
|
|
845
|
+
/** Leave preventDefault/restoration policy to the renderer; discard partial counts. */
|
|
846
|
+
function handleContextLost() {
|
|
847
|
+
const previousTracker = tracker;
|
|
848
|
+
tracker = void 0;
|
|
849
|
+
previousTracker?.destroy();
|
|
850
|
+
}
|
|
851
|
+
/** Reacquire extension objects and wrappers after restoration, with fresh counters. */
|
|
852
|
+
function handleContextRestored() {
|
|
853
|
+
if (!destroyed && tracker === void 0 && !context.isContextLost()) tracker = createWebglTracker(context);
|
|
854
|
+
}
|
|
855
|
+
/** Removes listeners before releasing wrappers; does not destroy the canvas or GL context. */
|
|
856
|
+
function destroy() {
|
|
857
|
+
if (destroyed) return;
|
|
858
|
+
destroyed = true;
|
|
859
|
+
runStatsCleanup([
|
|
860
|
+
() => context.canvas.removeEventListener("webglcontextlost", handleContextLost),
|
|
861
|
+
() => context.canvas.removeEventListener("webglcontextrestored", handleContextRestored),
|
|
862
|
+
handleContextLost
|
|
863
|
+
]);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
//#endregion
|
|
867
|
+
//#region src/stats/webgl/subscribe-webgl-frames.ts
|
|
868
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
869
|
+
const contexts = /* @__PURE__ */ new Map();
|
|
870
|
+
let collectedTimestamp;
|
|
871
|
+
let removeContextListener;
|
|
872
|
+
/**
|
|
873
|
+
* Acquires frame sampling until the returned release is called (hide/destroy).
|
|
874
|
+
* The first consumer installs instrumentation; the last release restores it.
|
|
875
|
+
* Stats lifecycle releases consumers when hidden; this source owns no visibility policy.
|
|
876
|
+
* Merely importing this module allocates no DOM, wrappers, or subscriptions.
|
|
877
|
+
*/
|
|
878
|
+
function subscribeWebglFrames(listener) {
|
|
879
|
+
const deliver = (timestamp, counts) => listener(timestamp, counts);
|
|
880
|
+
listeners.add(deliver);
|
|
881
|
+
if (listeners.size === 1) try {
|
|
882
|
+
startSampling();
|
|
883
|
+
} catch (error) {
|
|
884
|
+
return failStatsSetup(error, release);
|
|
885
|
+
}
|
|
886
|
+
return release;
|
|
887
|
+
/** Idempotent release; other visible stats instances keep their shared tracker. */
|
|
888
|
+
function release() {
|
|
889
|
+
if (!listeners.delete(deliver) || listeners.size !== 0) return;
|
|
890
|
+
stopSampling();
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
/** Replay existing contexts before the next update, or observe later registrations. */
|
|
894
|
+
function startSampling() {
|
|
895
|
+
if (removeContextListener !== void 0) return;
|
|
896
|
+
removeContextListener = subscribeWebglContexts(updateContexts);
|
|
897
|
+
}
|
|
898
|
+
/** Release every owned wrapper and context reference, invalidating the previous frame. */
|
|
899
|
+
function stopSampling() {
|
|
900
|
+
collectedTimestamp = void 0;
|
|
901
|
+
const unsubscribe = removeContextListener;
|
|
902
|
+
removeContextListener = void 0;
|
|
903
|
+
const samplings = [...contexts.values()];
|
|
904
|
+
contexts.clear();
|
|
905
|
+
runStatsCleanup([() => unsubscribe?.(), ...samplings.map((sampling) => sampling.destroy)]);
|
|
906
|
+
}
|
|
907
|
+
/** Registry snapshots already deduplicate contexts shared by Pixi and Three. */
|
|
908
|
+
function updateContexts(registered) {
|
|
909
|
+
const removals = [];
|
|
910
|
+
for (const [context, sampling] of contexts) if (!registered.includes(context)) {
|
|
911
|
+
contexts.delete(context);
|
|
912
|
+
removals.push(sampling.destroy);
|
|
913
|
+
}
|
|
914
|
+
runStatsCleanup(removals);
|
|
915
|
+
for (const context of registered) if (!contexts.has(context)) contexts.set(context, createWebglContextSampling(context));
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Called by metric sampling after rendering, never by an independent scheduler.
|
|
919
|
+
* Motion's frame timestamp is shared across stats instances, unlike their local
|
|
920
|
+
* performance.now() readings. It makes collection/reset happen once per frame.
|
|
921
|
+
* Delivery updates all subscribed metrics before any instance collects its readings.
|
|
922
|
+
*/
|
|
923
|
+
function collectWebglFrame(timestamp) {
|
|
924
|
+
if (removeContextListener === void 0 || collectedTimestamp === timestamp) return;
|
|
925
|
+
collectedTimestamp = timestamp;
|
|
926
|
+
let available = false;
|
|
927
|
+
let drawCalls = 0;
|
|
928
|
+
let textureBinds = 0;
|
|
929
|
+
let programUses = 0;
|
|
930
|
+
for (const sampling of contexts.values()) {
|
|
931
|
+
const counts = sampling.collect();
|
|
932
|
+
if (counts !== void 0) {
|
|
933
|
+
available = true;
|
|
934
|
+
drawCalls += counts.drawCalls;
|
|
935
|
+
textureBinds += counts.textureBinds;
|
|
936
|
+
programUses += counts.programUses;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
const counts = available ? Object.freeze({
|
|
940
|
+
drawCalls,
|
|
941
|
+
textureBinds,
|
|
942
|
+
programUses
|
|
943
|
+
}) : void 0;
|
|
944
|
+
for (const listener of [...listeners]) if (listeners.has(listener)) listener(timestamp, counts);
|
|
945
|
+
}
|
|
946
|
+
//#endregion
|
|
947
|
+
//#region src/stats/measurement/metrics/create-webgl-metric.ts
|
|
948
|
+
/** One counter's frame average, graph peak, and lifetime per-frame extrema. */
|
|
949
|
+
function createWebglMetric(key) {
|
|
950
|
+
const range = createStatsRange();
|
|
951
|
+
let unsubscribeFromFrames;
|
|
952
|
+
let frames = 0;
|
|
953
|
+
let total = 0;
|
|
954
|
+
let peak = 0;
|
|
955
|
+
return {
|
|
956
|
+
start,
|
|
957
|
+
stop,
|
|
958
|
+
measure,
|
|
959
|
+
collect,
|
|
960
|
+
reset
|
|
961
|
+
};
|
|
962
|
+
/** The source collects once per frame even when all three metrics subscribe. */
|
|
963
|
+
function start() {
|
|
964
|
+
unsubscribeFromFrames ??= subscribeWebglFrames(recordFrame);
|
|
965
|
+
}
|
|
966
|
+
/** Releases frame sampling and clears this metric's subscription state. */
|
|
967
|
+
function stop() {
|
|
968
|
+
const unsubscribe = unsubscribeFromFrames;
|
|
969
|
+
unsubscribeFromFrames = void 0;
|
|
970
|
+
unsubscribe?.();
|
|
971
|
+
}
|
|
972
|
+
/** All metrics use the same post-render measurement path; the source deduplicates frames. */
|
|
973
|
+
function measure(_deltaMilliseconds, frameTimestamp) {
|
|
974
|
+
collectWebglFrame(frameTimestamp);
|
|
975
|
+
}
|
|
976
|
+
/** A missing/lost context invalidates this window rather than publishing stale work. */
|
|
977
|
+
function recordFrame(_timestamp, counts) {
|
|
978
|
+
if (counts === void 0) {
|
|
979
|
+
reset();
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
const count = counts[key];
|
|
983
|
+
frames += 1;
|
|
984
|
+
total += count;
|
|
985
|
+
peak = Math.max(peak, count);
|
|
986
|
+
range.observe(count);
|
|
987
|
+
}
|
|
988
|
+
/** Average includes idle zero-work frames; graph and extrema retain individual peaks. */
|
|
989
|
+
function collect() {
|
|
990
|
+
if (frames === 0) return;
|
|
991
|
+
const reading = {
|
|
992
|
+
value: total / frames,
|
|
993
|
+
graphValue: peak,
|
|
994
|
+
range: range.current
|
|
995
|
+
};
|
|
996
|
+
reset();
|
|
997
|
+
return reading;
|
|
998
|
+
}
|
|
999
|
+
/** Discard partial counts without clearing the metric's lifetime range. */
|
|
1000
|
+
function reset() {
|
|
1001
|
+
frames = 0;
|
|
1002
|
+
total = 0;
|
|
1003
|
+
peak = 0;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
//#endregion
|
|
1007
|
+
//#region src/stats/stats-metrics.ts
|
|
1008
|
+
/**
|
|
1009
|
+
* The single built-in registry: order, presentation, and measurement factory.
|
|
1010
|
+
* Add a metric implementation here; sampler, lifecycle, and cards need no edits.
|
|
1011
|
+
* A new authored toggle also belongs in config's strict stats schema and runtime type.
|
|
1012
|
+
*/
|
|
1013
|
+
const STATS_METRICS = [
|
|
1014
|
+
{
|
|
1015
|
+
key: "fps",
|
|
1016
|
+
label: "FPS",
|
|
1017
|
+
description: "",
|
|
1018
|
+
ceiling: 120,
|
|
1019
|
+
initiallyAvailable: true,
|
|
1020
|
+
create: createFpsMetric
|
|
1021
|
+
},
|
|
1022
|
+
{
|
|
1023
|
+
key: "frameInterval",
|
|
1024
|
+
label: "Frame · ms",
|
|
1025
|
+
description: "",
|
|
1026
|
+
ceiling: 100,
|
|
1027
|
+
initiallyAvailable: true,
|
|
1028
|
+
create: createFrameIntervalMetric
|
|
1029
|
+
},
|
|
1030
|
+
{
|
|
1031
|
+
key: "jsHeap",
|
|
1032
|
+
label: "JS heap · MB",
|
|
1033
|
+
description: "Approximate browser-reported JS heap; not total playable memory.",
|
|
1034
|
+
ceiling: 64,
|
|
1035
|
+
initiallyAvailable: false,
|
|
1036
|
+
create: createJsHeapMetric
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
key: "drawCalls",
|
|
1040
|
+
label: "Draw calls",
|
|
1041
|
+
description: "Submitted draws per frame; instanced draws count once, multi-draw counts each draw.",
|
|
1042
|
+
ceiling: 100,
|
|
1043
|
+
initiallyAvailable: false,
|
|
1044
|
+
create: () => createWebglMetric("drawCalls")
|
|
1045
|
+
},
|
|
1046
|
+
{
|
|
1047
|
+
key: "textureBinds",
|
|
1048
|
+
label: "Texture binds",
|
|
1049
|
+
description: "bindTexture calls per frame, including repeated bindings and null unbindings.",
|
|
1050
|
+
ceiling: 100,
|
|
1051
|
+
initiallyAvailable: false,
|
|
1052
|
+
create: () => createWebglMetric("textureBinds")
|
|
1053
|
+
},
|
|
1054
|
+
{
|
|
1055
|
+
key: "programUses",
|
|
1056
|
+
label: "Program uses",
|
|
1057
|
+
description: "useProgram calls per frame, not unique programs or shaders.",
|
|
1058
|
+
ceiling: 100,
|
|
1059
|
+
initiallyAvailable: false,
|
|
1060
|
+
create: () => createWebglMetric("programUses")
|
|
1061
|
+
}
|
|
1062
|
+
];
|
|
1063
|
+
//#endregion
|
|
1064
|
+
//#region src/create-stats.ts
|
|
1065
|
+
/** Creates optional development stats after await playable.ready(). */
|
|
1066
|
+
function createStats() {
|
|
1067
|
+
const config = playable.config.devtools.stats;
|
|
1068
|
+
if (config === false) return disabledStats;
|
|
1069
|
+
const metrics = STATS_METRICS.filter((metric) => config[metric.key]);
|
|
1070
|
+
if (metrics.length === 0) return disabledStats;
|
|
1071
|
+
requireRuntimeReady();
|
|
1072
|
+
return createStatsLifecycle(createStatsSampler(metrics), createStatsView(metrics, config.display));
|
|
1073
|
+
}
|
|
1074
|
+
/** Requires await playable.ready() before stats can allocate DOM or subscribe. */
|
|
1075
|
+
function requireRuntimeReady() {
|
|
1076
|
+
playable.state;
|
|
1077
|
+
}
|
|
1078
|
+
//#endregion
|
|
1079
|
+
export { createStats };
|
|
1080
|
+
|
|
1081
|
+
//# sourceMappingURL=enabled.js.map
|