@pix3/vite-plugin 2.0.0-alpha.0 → 2.0.0-alpha.1
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 +201 -201
- package/README.md +40 -38
- package/dist/build/player-modules.d.ts +24 -0
- package/dist/build/player-modules.js +50 -0
- package/dist/editor-page.js +23 -23
- package/dist/index.js +13 -1
- package/dist/module-paths.js +1 -1
- package/dist/player/debug/context.d.ts +9 -0
- package/dist/player/debug/context.js +1 -0
- package/dist/player/debug/device-info.d.ts +15 -0
- package/dist/player/debug/device-info.js +36 -0
- package/dist/player/debug/frame-stats.d.ts +79 -0
- package/dist/player/debug/frame-stats.js +159 -0
- package/dist/player/debug/index.d.ts +10 -0
- package/dist/player/debug/index.js +20 -0
- package/dist/player/debug/long-frames.d.ts +46 -0
- package/dist/player/debug/long-frames.js +79 -0
- package/dist/player/debug/overlay.d.ts +25 -0
- package/dist/player/debug/overlay.js +320 -0
- package/dist/player/debug/session.d.ts +92 -0
- package/dist/player/debug/session.js +156 -0
- package/dist/player/dev/editor-link.d.ts +16 -0
- package/dist/player/dev/editor-link.js +125 -0
- package/dist/player/index.d.ts +7 -0
- package/dist/player/index.js +18 -1
- package/dist/vite-config.js +6 -3
- package/package.json +60 -60
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { openEditor } from '../dev/editor-link';
|
|
2
|
+
import { LONG_FRAME_MS } from './frame-stats';
|
|
3
|
+
/**
|
|
4
|
+
* The debug overlay's DOM: a panel over the game's top-right corner, in a shadow root so the
|
|
5
|
+
* game's CSS cannot reach it (and its CSS cannot reach the game). The chart repaints at 8 Hz and
|
|
6
|
+
* the text at 2 Hz (each text refresh closes an averaging window), from timers rather than the game's frame listener, so a slow page shows a
|
|
7
|
+
* slow overlay instead of a slower game.
|
|
8
|
+
*/
|
|
9
|
+
const CHART_MS = 125;
|
|
10
|
+
const TEXT_EVERY = 4;
|
|
11
|
+
const CHART_CEILING_MS = 50;
|
|
12
|
+
const COLLAPSED_KEY = 'pix3:debug:collapsed';
|
|
13
|
+
const STYLE = `
|
|
14
|
+
:host { all: initial; }
|
|
15
|
+
.panel {
|
|
16
|
+
position: fixed; top: 8px; right: 8px; z-index: 2147483647;
|
|
17
|
+
width: min(320px, calc(100vw - 16px)); max-height: calc(100vh - 16px);
|
|
18
|
+
overflow: hidden auto; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.25) transparent;
|
|
19
|
+
box-sizing: border-box; padding: 8px 10px;
|
|
20
|
+
font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
21
|
+
color: #e8e8e8; background: rgba(18, 18, 20, 0.88); border: 1px solid rgba(255,255,255,0.12);
|
|
22
|
+
border-radius: 6px; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
|
23
|
+
user-select: text;
|
|
24
|
+
}
|
|
25
|
+
.panel.collapsed { width: auto; }
|
|
26
|
+
.panel.collapsed .body { display: none; }
|
|
27
|
+
header { display: flex; align-items: center; gap: 6px; }
|
|
28
|
+
.title { font-weight: 600; color: #ffcf33; margin-right: auto; white-space: nowrap; }
|
|
29
|
+
.fps { font-weight: 600; min-width: 4.5em; text-align: right; white-space: nowrap; }
|
|
30
|
+
button {
|
|
31
|
+
font: inherit; color: inherit; background: rgba(255,255,255,0.08);
|
|
32
|
+
border: 1px solid rgba(255,255,255,0.16); border-radius: 4px; padding: 1px 7px; cursor: pointer;
|
|
33
|
+
}
|
|
34
|
+
button:hover:not(:disabled) { background: rgba(255,255,255,0.16); }
|
|
35
|
+
button:disabled { opacity: 0.4; cursor: default; }
|
|
36
|
+
button[aria-pressed="true"] { background: rgba(255,207,51,0.25); border-color: #ffcf33; }
|
|
37
|
+
canvas { display: block; width: 100%; height: 64px; margin-top: 6px; }
|
|
38
|
+
.legend { display: flex; flex-wrap: wrap; gap: 2px 10px; color: #a0a0a0; margin-top: 2px; }
|
|
39
|
+
.legend > span { white-space: nowrap; }
|
|
40
|
+
.legend i { display: inline-block; width: 8px; height: 8px; margin-right: 4px; vertical-align: -1px; }
|
|
41
|
+
section { margin-top: 8px; }
|
|
42
|
+
h3 { all: unset; display: block; color: #a0a0a0; text-transform: uppercase; font-size: 10px;
|
|
43
|
+
letter-spacing: 0.06em; border-bottom: 1px solid rgba(255,255,255,0.08); margin-bottom: 3px; }
|
|
44
|
+
.row { display: flex; justify-content: space-between; gap: 8px; }
|
|
45
|
+
.row > :first-child { min-width: 0; color: #a0a0a0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
46
|
+
.row > :last-child { flex: none; max-width: 70%; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
47
|
+
.warn { color: #ffb454; }
|
|
48
|
+
.bad { color: #ff6b6b; }
|
|
49
|
+
.muted { color: #707070; }
|
|
50
|
+
.error { color: #ff6b6b; word-break: break-word; margin-top: 2px; }
|
|
51
|
+
.error small { display: block; color: #a0a0a0; }
|
|
52
|
+
pre { margin: 0; white-space: pre-wrap; word-break: break-word; max-height: 160px; overflow: auto; }
|
|
53
|
+
`;
|
|
54
|
+
const COLORS = {
|
|
55
|
+
logic: '#ffcf33',
|
|
56
|
+
render: '#4ea1ff',
|
|
57
|
+
other: 'rgba(255,255,255,0.22)',
|
|
58
|
+
grid: 'rgba(255,255,255,0.18)',
|
|
59
|
+
over: '#ff6b6b',
|
|
60
|
+
};
|
|
61
|
+
const ms = (value, digits = 1) => value === null ? '–' : `${value.toFixed(digits)} ms`;
|
|
62
|
+
const num = (value) => value === null || value === undefined ? '–' : value.toLocaleString('en-US');
|
|
63
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g, ch => `&#${ch.charCodeAt(0)};`);
|
|
64
|
+
const row = (label, value, cls = '') => `<div class="row"><span>${escapeHtml(label)}</span><span class="${cls}">${value}</span></div>`;
|
|
65
|
+
const section = (title, body) => `<section><h3>${escapeHtml(title)}</h3>${body}</section>`;
|
|
66
|
+
const readCollapsed = () => {
|
|
67
|
+
try {
|
|
68
|
+
return localStorage.getItem(COLLAPSED_KEY) === '1';
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const writeCollapsed = (collapsed) => {
|
|
75
|
+
try {
|
|
76
|
+
localStorage.setItem(COLLAPSED_KEY, collapsed ? '1' : '0');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Per-viewer convenience only.
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
export class DebugOverlay {
|
|
83
|
+
session;
|
|
84
|
+
editorUrl;
|
|
85
|
+
host = document.createElement('div');
|
|
86
|
+
root;
|
|
87
|
+
panel;
|
|
88
|
+
canvas;
|
|
89
|
+
fps;
|
|
90
|
+
sections;
|
|
91
|
+
pauseButton;
|
|
92
|
+
stepButton;
|
|
93
|
+
collapseButton;
|
|
94
|
+
timer = null;
|
|
95
|
+
ticks = 0;
|
|
96
|
+
constructor(session, editorUrl = null) {
|
|
97
|
+
this.session = session;
|
|
98
|
+
this.editorUrl = editorUrl;
|
|
99
|
+
this.host.className = 'pix3-debug-overlay';
|
|
100
|
+
const root = this.host.attachShadow({ mode: 'open' });
|
|
101
|
+
this.root = root;
|
|
102
|
+
root.innerHTML = `<style>${STYLE}</style>
|
|
103
|
+
<div class="panel" part="panel">
|
|
104
|
+
<header>
|
|
105
|
+
<span class="title">Pix3 debug</span>
|
|
106
|
+
<span class="fps">– fps</span>
|
|
107
|
+
<button type="button" data-action="pause" aria-pressed="false" title="Freeze the game (manual time mode)">Pause</button>
|
|
108
|
+
<button type="button" data-action="step" title="Advance one frame while paused" disabled>Step</button>
|
|
109
|
+
${editorUrl ? '<button type="button" data-action="editor" title="Open this project in the Pix3 editor">Editor</button>' : ''}
|
|
110
|
+
<button type="button" data-action="collapse" title="Collapse">Hide</button>
|
|
111
|
+
</header>
|
|
112
|
+
<div class="body">
|
|
113
|
+
<canvas></canvas>
|
|
114
|
+
<div class="legend"><span><i style="background:${COLORS.logic}"></i>logic</span><span><i style="background:${COLORS.render}"></i>render</span><span><i style="background:${COLORS.other}"></i>other</span><span class="muted">lines: 16.7 / 33.3 ms</span></div>
|
|
115
|
+
<div class="sections"></div>
|
|
116
|
+
<section><button type="button" data-action="reset" title="Reset percentiles, long frames and the program count">Reset stats</button></section>
|
|
117
|
+
</div>
|
|
118
|
+
</div>`;
|
|
119
|
+
const pick = (selector) => root.querySelector(selector);
|
|
120
|
+
this.panel = pick('.panel');
|
|
121
|
+
this.canvas = pick('canvas');
|
|
122
|
+
this.fps = pick('.fps');
|
|
123
|
+
this.sections = pick('.sections');
|
|
124
|
+
this.pauseButton = pick('[data-action="pause"]');
|
|
125
|
+
this.stepButton = pick('[data-action="step"]');
|
|
126
|
+
this.collapseButton = pick('[data-action="collapse"]');
|
|
127
|
+
root.addEventListener('click', this.onClick);
|
|
128
|
+
// Keep the game's own listeners (a tap-to-jump on `window`) out of the panel's clicks.
|
|
129
|
+
for (const type of ['pointerdown', 'pointerup', 'touchstart', 'touchend', 'wheel', 'keydown']) {
|
|
130
|
+
this.panel.addEventListener(type, event => event.stopPropagation());
|
|
131
|
+
}
|
|
132
|
+
this.setCollapsed(readCollapsed());
|
|
133
|
+
}
|
|
134
|
+
mount() {
|
|
135
|
+
document.body.appendChild(this.host);
|
|
136
|
+
this.render();
|
|
137
|
+
this.timer = setInterval(() => this.tick(), CHART_MS);
|
|
138
|
+
}
|
|
139
|
+
dispose() {
|
|
140
|
+
if (this.timer !== null)
|
|
141
|
+
clearInterval(this.timer);
|
|
142
|
+
this.timer = null;
|
|
143
|
+
this.host.remove();
|
|
144
|
+
}
|
|
145
|
+
/** The timer: the chart every tick; every {@link TEXT_EVERY}th, a new window and the text. */
|
|
146
|
+
tick() {
|
|
147
|
+
this.ticks += 1;
|
|
148
|
+
const withText = this.ticks % TEXT_EVERY === 0;
|
|
149
|
+
// Only the timer closes windows: a click must not cut one short (an empty window reads "–").
|
|
150
|
+
if (withText)
|
|
151
|
+
this.session.rollWindow();
|
|
152
|
+
if (!this.panel.classList.contains('collapsed'))
|
|
153
|
+
drawChart(this.canvas, this.session.ring);
|
|
154
|
+
if (withText)
|
|
155
|
+
this.render();
|
|
156
|
+
}
|
|
157
|
+
render() {
|
|
158
|
+
const snapshot = this.session.snapshot();
|
|
159
|
+
this.renderHeader(snapshot);
|
|
160
|
+
if (this.panel.classList.contains('collapsed'))
|
|
161
|
+
return;
|
|
162
|
+
drawChart(this.canvas, this.session.ring);
|
|
163
|
+
this.sections.innerHTML = renderSections(snapshot);
|
|
164
|
+
}
|
|
165
|
+
renderHeader(snapshot) {
|
|
166
|
+
const fps = snapshot.window.fps;
|
|
167
|
+
this.fps.textContent = snapshot.paused
|
|
168
|
+
? 'paused'
|
|
169
|
+
: fps === null
|
|
170
|
+
? '– fps'
|
|
171
|
+
: `${fps.toFixed(0)} fps`;
|
|
172
|
+
this.fps.className = `fps ${fps !== null && fps < 50 && !snapshot.paused ? 'warn' : ''}`;
|
|
173
|
+
this.pauseButton.textContent = snapshot.paused ? 'Resume' : 'Pause';
|
|
174
|
+
this.pauseButton.setAttribute('aria-pressed', String(snapshot.paused));
|
|
175
|
+
this.stepButton.disabled = !snapshot.paused;
|
|
176
|
+
}
|
|
177
|
+
onClick = (event) => {
|
|
178
|
+
const action = event.target
|
|
179
|
+
?.closest('[data-action]')
|
|
180
|
+
?.getAttribute('data-action');
|
|
181
|
+
switch (action) {
|
|
182
|
+
case 'pause':
|
|
183
|
+
if (this.session.paused)
|
|
184
|
+
this.session.resume();
|
|
185
|
+
else
|
|
186
|
+
this.session.pause();
|
|
187
|
+
break;
|
|
188
|
+
case 'step':
|
|
189
|
+
this.session.step();
|
|
190
|
+
break;
|
|
191
|
+
case 'reset':
|
|
192
|
+
this.session.resetStats();
|
|
193
|
+
break;
|
|
194
|
+
case 'editor':
|
|
195
|
+
if (this.editorUrl) {
|
|
196
|
+
const button = this.root.querySelector('[data-action="editor"]');
|
|
197
|
+
void openEditor(this.editorUrl).then(outcome => {
|
|
198
|
+
if (outcome !== 'already-open')
|
|
199
|
+
return;
|
|
200
|
+
button.textContent = 'Editor is open';
|
|
201
|
+
setTimeout(() => (button.textContent = 'Editor'), 3000);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
case 'collapse':
|
|
206
|
+
this.setCollapsed(!this.panel.classList.contains('collapsed'));
|
|
207
|
+
break;
|
|
208
|
+
default:
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
this.render();
|
|
212
|
+
};
|
|
213
|
+
setCollapsed(collapsed) {
|
|
214
|
+
this.panel.classList.toggle('collapsed', collapsed);
|
|
215
|
+
this.collapseButton.textContent = collapsed ? 'Show' : 'Hide';
|
|
216
|
+
this.collapseButton.title = collapsed ? 'Expand' : 'Collapse';
|
|
217
|
+
writeCollapsed(collapsed);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Stacked bars, oldest left: logic, render, then the rest of the frame (`dt` − work). */
|
|
221
|
+
const drawChart = (canvas, ring) => {
|
|
222
|
+
const dpr = window.devicePixelRatio || 1;
|
|
223
|
+
const width = Math.max(1, Math.round(canvas.clientWidth * dpr));
|
|
224
|
+
const height = Math.max(1, Math.round(canvas.clientHeight * dpr));
|
|
225
|
+
if (canvas.width !== width)
|
|
226
|
+
canvas.width = width;
|
|
227
|
+
if (canvas.height !== height)
|
|
228
|
+
canvas.height = height;
|
|
229
|
+
const g = canvas.getContext('2d');
|
|
230
|
+
if (!g)
|
|
231
|
+
return;
|
|
232
|
+
g.clearRect(0, 0, width, height);
|
|
233
|
+
const y = (valueMs) => height - (Math.min(valueMs, CHART_CEILING_MS) / CHART_CEILING_MS) * height;
|
|
234
|
+
const bar = width / ring.capacity;
|
|
235
|
+
const offset = ring.capacity - ring.length;
|
|
236
|
+
for (let i = 0; i < ring.length; i += 1) {
|
|
237
|
+
const at = ring.indexOf(i);
|
|
238
|
+
const x = (offset + i) * bar;
|
|
239
|
+
const w = Math.max(1, bar - (bar > 2 ? 0.5 * dpr : 0));
|
|
240
|
+
const logic = ring.logicMs[at];
|
|
241
|
+
const render = ring.renderMs[at];
|
|
242
|
+
const frame = Math.max(ring.frameMs[at], logic + render);
|
|
243
|
+
g.fillStyle = COLORS.other;
|
|
244
|
+
g.fillRect(x, y(frame), w, height - y(frame));
|
|
245
|
+
g.fillStyle = COLORS.render;
|
|
246
|
+
g.fillRect(x, y(logic + render), w, y(logic) - y(logic + render));
|
|
247
|
+
g.fillStyle = COLORS.logic;
|
|
248
|
+
g.fillRect(x, y(logic), w, height - y(logic));
|
|
249
|
+
if (frame > LONG_FRAME_MS) {
|
|
250
|
+
g.fillStyle = COLORS.over;
|
|
251
|
+
g.fillRect(x, 0, w, 2 * dpr);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
g.fillStyle = COLORS.grid;
|
|
255
|
+
for (const line of [1000 / 60, 1000 / 30])
|
|
256
|
+
g.fillRect(0, Math.round(y(line)), width, dpr);
|
|
257
|
+
};
|
|
258
|
+
const renderSections = (s) => {
|
|
259
|
+
const w = s.window;
|
|
260
|
+
const st = s.stability;
|
|
261
|
+
const parts = [];
|
|
262
|
+
parts.push(section('Frame', row('frame (avg)', ms(w.frameMs)) +
|
|
263
|
+
row('logic / render', `${ms(w.logicMs, 2)} / ${ms(w.renderMs, 2)}`) +
|
|
264
|
+
row('other (not the game)', ms(w.unaccountedMs)) +
|
|
265
|
+
row('rAF lateness', ms(w.rafLatenessMs, 2)) +
|
|
266
|
+
row('p50 / p95 / p99', `${ms(st.p50Ms, 0)} / ${ms(st.p95Ms, 0)} / ${ms(st.p99Ms, 0)}`) +
|
|
267
|
+
row(`> ${LONG_FRAME_MS.toFixed(0)} ms`, `${num(st.longCount)} of ${num(st.sampleCount)}${st.longPercent === null ? '' : ` (${st.longPercent.toFixed(1)}%)`}`, st.longPercent !== null && st.longPercent > 1 ? 'warn' : '') +
|
|
268
|
+
row('worst', ms(st.worstMs), st.worstMs !== null && st.worstMs > 50 ? 'bad' : '') +
|
|
269
|
+
row('frame #', num(s.frameNumber))));
|
|
270
|
+
if (w.activities.length > 0) {
|
|
271
|
+
parts.push(section('Activities (self, per frame)', w.activities
|
|
272
|
+
.slice(0, 8)
|
|
273
|
+
.map(activity => row(activity.label, ms(activity.ms, 2)))
|
|
274
|
+
.join('')));
|
|
275
|
+
}
|
|
276
|
+
const r = s.renderer;
|
|
277
|
+
parts.push(section('Renderer', r
|
|
278
|
+
? row('draw calls', num(r.calls)) +
|
|
279
|
+
row('triangles', num(r.triangles)) +
|
|
280
|
+
row('geometries / textures', `${num(r.geometries)} / ${num(r.textures)}`) +
|
|
281
|
+
row('programs', `${num(r.programs)}${s.programsLinked > 0 ? ` (+${s.programsLinked} linked)` : ''}`, s.programsLinked > 0 ? 'warn' : '')
|
|
282
|
+
: '<div class="muted">no frame rendered yet</div>'));
|
|
283
|
+
const lf = s.longFrames;
|
|
284
|
+
parts.push(section('Long frames (browser)', lf.supported
|
|
285
|
+
? row('count', num(lf.count), lf.count > 0 ? 'warn' : '') +
|
|
286
|
+
lf.worst
|
|
287
|
+
.map(frame => row(frame.script ?? 'no JS (GC, compositing, vsync)', ms(frame.durationMs, 0), frame.durationMs > 100 ? 'bad' : 'warn'))
|
|
288
|
+
.join('')
|
|
289
|
+
: '<div class="muted">not reported by this browser</div>'));
|
|
290
|
+
parts.push(section(`Audio (${s.audio.active})`, s.audio.playbacks.length > 0
|
|
291
|
+
? s.audio.playbacks
|
|
292
|
+
.map(playback => row(playback.label, `${playback.bus}${playback.loop ? ' · loop' : ''} · ${(playback.elapsedMs / 1000).toFixed(1)} s`))
|
|
293
|
+
.join('')
|
|
294
|
+
: '<div class="muted">nothing playing</div>'));
|
|
295
|
+
parts.push(section(`Errors (${s.errors.count})`, s.errors.recent.length > 0
|
|
296
|
+
? s.errors.recent
|
|
297
|
+
.slice(-4)
|
|
298
|
+
.reverse()
|
|
299
|
+
.map(error => `<div class="error">${escapeHtml(error.message)}${error.where ? `<small>${escapeHtml(error.where)}</small>` : ''}</div>`)
|
|
300
|
+
.join('')
|
|
301
|
+
: '<div class="muted">none</div>'));
|
|
302
|
+
if (s.game) {
|
|
303
|
+
let text;
|
|
304
|
+
try {
|
|
305
|
+
text = JSON.stringify(s.game.snapshot, null, 1) ?? 'null';
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
text = '(not JSON-serialisable)';
|
|
309
|
+
}
|
|
310
|
+
parts.push(section(`Game · ${s.game.name}`, `<pre>${escapeHtml(text.slice(0, 4000))}</pre>`));
|
|
311
|
+
}
|
|
312
|
+
const d = s.device;
|
|
313
|
+
parts.push(section('Device', row('scene', escapeHtml(s.scene ?? '–')) +
|
|
314
|
+
row('status', escapeHtml(s.status), s.status === 'failed' ? 'bad' : '') +
|
|
315
|
+
row('GPU', escapeHtml(d.gpu ?? '–')) +
|
|
316
|
+
row('screen / viewport', `${d.screen} / ${d.viewport} @${d.devicePixelRatio}x`) +
|
|
317
|
+
row('memory / cores', `${d.memoryGb === null ? '–' : `${d.memoryGb} GB`} / ${num(d.cores)}`) +
|
|
318
|
+
row('JS heap', s.heapMb === null ? '–' : `${s.heapMb.toFixed(1)} MB`)));
|
|
319
|
+
return parts.join('');
|
|
320
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type RuntimeRendererStatsSnapshot } from '@pix3/runtime';
|
|
2
|
+
import type { Pix3DebugContext } from './context';
|
|
3
|
+
import { type DeviceInfo } from './device-info';
|
|
4
|
+
import { FrameRing, type FrameStability, type FrameWindow } from './frame-stats';
|
|
5
|
+
import { type LongFrameStats } from './long-frames';
|
|
6
|
+
/**
|
|
7
|
+
* The debug mode's model, DOM-free: subscribes to the runner's frame stats, keeps the numbers the
|
|
8
|
+
* overlay draws, and owns the pause / step controls. `snapshot()` is also what
|
|
9
|
+
* `__PIX3_PLAYER__.debug.snapshot()` answers, so an agent reads the same numbers a human sees.
|
|
10
|
+
*/
|
|
11
|
+
interface DebugError {
|
|
12
|
+
readonly at: number;
|
|
13
|
+
readonly message: string;
|
|
14
|
+
/** `phase · node · component` for a script error, null for a page error. */
|
|
15
|
+
readonly where: string | null;
|
|
16
|
+
}
|
|
17
|
+
export interface DebugSnapshot {
|
|
18
|
+
readonly status: string;
|
|
19
|
+
readonly scene: string | null;
|
|
20
|
+
readonly frameNumber: number;
|
|
21
|
+
readonly paused: boolean;
|
|
22
|
+
/** The last closed window (≈0.5 s): FPS, the frame split, activities. */
|
|
23
|
+
readonly window: FrameWindow;
|
|
24
|
+
/** Since the session started or the last reset. */
|
|
25
|
+
readonly stability: FrameStability;
|
|
26
|
+
/** Counters of the last rendered frame. */
|
|
27
|
+
readonly renderer: RuntimeRendererStatsSnapshot | null;
|
|
28
|
+
/** WebGL programs linked since the session started or the last reset (each one a stall). */
|
|
29
|
+
readonly programsLinked: number;
|
|
30
|
+
readonly audio: {
|
|
31
|
+
readonly active: number;
|
|
32
|
+
readonly playbacks: readonly {
|
|
33
|
+
readonly label: string;
|
|
34
|
+
readonly bus: string;
|
|
35
|
+
readonly loop: boolean;
|
|
36
|
+
readonly elapsedMs: number;
|
|
37
|
+
}[];
|
|
38
|
+
};
|
|
39
|
+
readonly longFrames: LongFrameStats;
|
|
40
|
+
readonly heapMb: number | null;
|
|
41
|
+
readonly device: DeviceInfo;
|
|
42
|
+
readonly errors: {
|
|
43
|
+
readonly count: number;
|
|
44
|
+
readonly recent: readonly DebugError[];
|
|
45
|
+
};
|
|
46
|
+
/** The game's own `registerGameDebug` provider snapshot, when it registered one. */
|
|
47
|
+
readonly game: {
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly snapshot: unknown;
|
|
50
|
+
} | null;
|
|
51
|
+
}
|
|
52
|
+
export declare class DebugSession {
|
|
53
|
+
private readonly context;
|
|
54
|
+
readonly ring: FrameRing;
|
|
55
|
+
private readonly histogram;
|
|
56
|
+
private readonly accumulator;
|
|
57
|
+
private readonly longFrames;
|
|
58
|
+
private window;
|
|
59
|
+
private renderer;
|
|
60
|
+
private wantRendererStats;
|
|
61
|
+
private programsAtReset;
|
|
62
|
+
private frameNumber;
|
|
63
|
+
private readonly errors;
|
|
64
|
+
private errorCount;
|
|
65
|
+
private pageErrorsSeen;
|
|
66
|
+
/** The time mode pause replaced, or null while the overlay has not paused the game. */
|
|
67
|
+
private pausedFrom;
|
|
68
|
+
private readonly disposers;
|
|
69
|
+
constructor(context: Pix3DebugContext);
|
|
70
|
+
start(): void;
|
|
71
|
+
dispose(): void;
|
|
72
|
+
/** Close the current averaging window (the overlay calls this at its text cadence). */
|
|
73
|
+
rollWindow(): void;
|
|
74
|
+
/** Forget the session's percentiles, long frames and program count; keep the errors. */
|
|
75
|
+
resetStats(): void;
|
|
76
|
+
get paused(): boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Freeze the game in `manual` time mode rather than `SceneRunner.pause()`: a paused runner
|
|
79
|
+
* cannot `stepFrames`. Audio is muted by the mode (`muteAudio`), not suspended.
|
|
80
|
+
*/
|
|
81
|
+
pause(): void;
|
|
82
|
+
resume(): void;
|
|
83
|
+
/** One tick at the paused mode's `fixedDeltaSec`; a no-op unless the overlay paused the game. */
|
|
84
|
+
step(): void;
|
|
85
|
+
snapshot(): DebugSnapshot;
|
|
86
|
+
private readonly onFrame;
|
|
87
|
+
private readonly onScriptError;
|
|
88
|
+
/** `__PIX3_PLAYER__.errors` (window errors, rejections, a failed boot), new ones only. */
|
|
89
|
+
private collectPageErrors;
|
|
90
|
+
private pushError;
|
|
91
|
+
}
|
|
92
|
+
export {};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { getGameDebug, registerScriptErrorSink, } from '@pix3/runtime';
|
|
2
|
+
import { readDeviceInfo, readHeapMb } from './device-info';
|
|
3
|
+
import { EMPTY_WINDOW, FrameIntervalHistogram, FrameRing, FrameWindowAccumulator, } from './frame-stats';
|
|
4
|
+
import { LongFrameObserver } from './long-frames';
|
|
5
|
+
const KEEP_ERRORS = 20;
|
|
6
|
+
const SHOWN_PLAYBACKS = 6;
|
|
7
|
+
export class DebugSession {
|
|
8
|
+
context;
|
|
9
|
+
ring = new FrameRing();
|
|
10
|
+
histogram = new FrameIntervalHistogram();
|
|
11
|
+
accumulator = new FrameWindowAccumulator();
|
|
12
|
+
longFrames = new LongFrameObserver();
|
|
13
|
+
window = EMPTY_WINDOW;
|
|
14
|
+
renderer = null;
|
|
15
|
+
wantRendererStats = true;
|
|
16
|
+
programsAtReset = null;
|
|
17
|
+
frameNumber = 0;
|
|
18
|
+
errors = [];
|
|
19
|
+
errorCount = 0;
|
|
20
|
+
pageErrorsSeen = 0;
|
|
21
|
+
/** The time mode pause replaced, or null while the overlay has not paused the game. */
|
|
22
|
+
pausedFrom = null;
|
|
23
|
+
disposers = [];
|
|
24
|
+
constructor(context) {
|
|
25
|
+
this.context = context;
|
|
26
|
+
}
|
|
27
|
+
start() {
|
|
28
|
+
this.disposers.push(this.context.runner.subscribeFrameStats(this.onFrame));
|
|
29
|
+
// The player registers no script-error sink of its own; the editor's lives in another page.
|
|
30
|
+
this.disposers.push(registerScriptErrorSink(this.onScriptError));
|
|
31
|
+
this.longFrames.start();
|
|
32
|
+
this.disposers.push(() => this.longFrames.stop());
|
|
33
|
+
}
|
|
34
|
+
dispose() {
|
|
35
|
+
if (this.pausedFrom)
|
|
36
|
+
this.resume();
|
|
37
|
+
for (const dispose of this.disposers.splice(0))
|
|
38
|
+
dispose();
|
|
39
|
+
}
|
|
40
|
+
/** Close the current averaging window (the overlay calls this at its text cadence). */
|
|
41
|
+
rollWindow() {
|
|
42
|
+
this.window = this.accumulator.close();
|
|
43
|
+
this.wantRendererStats = true;
|
|
44
|
+
}
|
|
45
|
+
/** Forget the session's percentiles, long frames and program count; keep the errors. */
|
|
46
|
+
resetStats() {
|
|
47
|
+
this.histogram.reset();
|
|
48
|
+
this.ring.clear();
|
|
49
|
+
this.longFrames.reset();
|
|
50
|
+
this.programsAtReset = this.renderer?.programs ?? null;
|
|
51
|
+
}
|
|
52
|
+
get paused() {
|
|
53
|
+
return this.pausedFrom !== null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Freeze the game in `manual` time mode rather than `SceneRunner.pause()`: a paused runner
|
|
57
|
+
* cannot `stepFrames`. Audio is muted by the mode (`muteAudio`), not suspended.
|
|
58
|
+
*/
|
|
59
|
+
pause() {
|
|
60
|
+
const runner = this.context.runner;
|
|
61
|
+
if (this.pausedFrom || !runner.running)
|
|
62
|
+
return;
|
|
63
|
+
const current = runner.getTimeMode();
|
|
64
|
+
if (current.mode === 'manual')
|
|
65
|
+
return; // the game drives its own ticks
|
|
66
|
+
this.pausedFrom = current;
|
|
67
|
+
runner.setTimeMode({ mode: 'manual', fixedDeltaSec: current.fixedDeltaSec, muteAudio: true });
|
|
68
|
+
}
|
|
69
|
+
resume() {
|
|
70
|
+
if (!this.pausedFrom)
|
|
71
|
+
return;
|
|
72
|
+
const previous = this.pausedFrom;
|
|
73
|
+
this.pausedFrom = null;
|
|
74
|
+
this.context.runner.setTimeMode(previous);
|
|
75
|
+
}
|
|
76
|
+
/** One tick at the paused mode's `fixedDeltaSec`; a no-op unless the overlay paused the game. */
|
|
77
|
+
step() {
|
|
78
|
+
if (this.pausedFrom)
|
|
79
|
+
this.context.runner.stepFrames(1);
|
|
80
|
+
}
|
|
81
|
+
snapshot() {
|
|
82
|
+
this.collectPageErrors();
|
|
83
|
+
const { audio, state } = this.context;
|
|
84
|
+
const playbacks = audio.getActivePlaybackSnapshot();
|
|
85
|
+
const programs = this.renderer?.programs ?? null;
|
|
86
|
+
if (programs !== null && this.programsAtReset === null)
|
|
87
|
+
this.programsAtReset = programs;
|
|
88
|
+
const provider = getGameDebug();
|
|
89
|
+
return {
|
|
90
|
+
status: state.status,
|
|
91
|
+
scene: state.scene,
|
|
92
|
+
frameNumber: this.frameNumber,
|
|
93
|
+
paused: this.paused,
|
|
94
|
+
window: this.window,
|
|
95
|
+
stability: this.histogram.snapshot(),
|
|
96
|
+
renderer: this.renderer,
|
|
97
|
+
programsLinked: programs === null ? 0 : programs - (this.programsAtReset ?? programs),
|
|
98
|
+
audio: {
|
|
99
|
+
active: playbacks.length,
|
|
100
|
+
playbacks: playbacks.slice(0, SHOWN_PLAYBACKS).map(playback => ({
|
|
101
|
+
label: playback.label,
|
|
102
|
+
bus: playback.bus,
|
|
103
|
+
loop: playback.loop,
|
|
104
|
+
elapsedMs: playback.elapsedMs,
|
|
105
|
+
})),
|
|
106
|
+
},
|
|
107
|
+
longFrames: this.longFrames.stats(),
|
|
108
|
+
heapMb: readHeapMb(),
|
|
109
|
+
device: readDeviceInfo(),
|
|
110
|
+
errors: { count: this.errorCount, recent: [...this.errors] },
|
|
111
|
+
game: provider ? { name: provider.name, snapshot: readGameSnapshot(provider) } : null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
onFrame = (sample) => {
|
|
115
|
+
this.frameNumber = sample.frameNumber;
|
|
116
|
+
const frameMs = sample.dt * 1000;
|
|
117
|
+
this.histogram.record(frameMs);
|
|
118
|
+
this.ring.push(sample.logicMs, sample.renderMs, frameMs);
|
|
119
|
+
this.accumulator.add(sample);
|
|
120
|
+
// The counters are reset at every tick's start, so only a tick that painted has them; one
|
|
121
|
+
// read per window, not per frame.
|
|
122
|
+
if (this.wantRendererStats && sample.renderMs > 0) {
|
|
123
|
+
this.wantRendererStats = false;
|
|
124
|
+
this.renderer = this.context.renderer.getStatsSnapshot();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
onScriptError = (error) => {
|
|
128
|
+
const where = [error.phase, error.nodeName ?? error.nodeId, error.componentType]
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.join(' · ');
|
|
131
|
+
this.pushError({ at: Date.now(), message: error.message, where });
|
|
132
|
+
};
|
|
133
|
+
/** `__PIX3_PLAYER__.errors` (window errors, rejections, a failed boot), new ones only. */
|
|
134
|
+
collectPageErrors() {
|
|
135
|
+
const errors = this.context.state.errors;
|
|
136
|
+
for (; this.pageErrorsSeen < errors.length; this.pageErrorsSeen += 1) {
|
|
137
|
+
this.pushError({ at: Date.now(), message: errors[this.pageErrorsSeen], where: null });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
pushError(error) {
|
|
141
|
+
this.errorCount += 1;
|
|
142
|
+
this.errors.push(error);
|
|
143
|
+
if (this.errors.length > KEEP_ERRORS)
|
|
144
|
+
this.errors.shift();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const readGameSnapshot = (provider) => {
|
|
148
|
+
if (!provider.snapshot)
|
|
149
|
+
return null;
|
|
150
|
+
try {
|
|
151
|
+
return provider.snapshot();
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
155
|
+
}
|
|
156
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "Pix3 editor" link over the game in dev (`virtual:pix3/dev`; not on `?clean`, not in a
|
|
3
|
+
* frame, not in a build). A second editor tab would open read-only (the writer lock), so the link
|
|
4
|
+
* first asks over a `BroadcastChannel` whether an editor of this dev server is open: if one
|
|
5
|
+
* answers, the page says so and tries to bring its opener forward; if none does, this tab becomes
|
|
6
|
+
* the editor. The editor answers in `packages/editor-core/src/host/editor-presence.ts`.
|
|
7
|
+
*/
|
|
8
|
+
/** Keep in step with `editor-presence.ts` in editor-core. */
|
|
9
|
+
export declare const EDITOR_PRESENCE_CHANNEL = "pix3-editor-presence";
|
|
10
|
+
export type OpenEditorOutcome = 'navigated' | 'already-open';
|
|
11
|
+
/** True when an editor tab of this origin answers within {@link ANSWER_MS}. */
|
|
12
|
+
export declare const isEditorOpen: (timeoutMs?: number) => Promise<boolean>;
|
|
13
|
+
/** Go to the editor: this tab becomes it, unless one is already open. */
|
|
14
|
+
export declare const openEditor: (editorUrl: string) => Promise<OpenEditorOutcome>;
|
|
15
|
+
/** Mount the link; returns the teardown. Hidden for the rest of the tab's session by its close button. */
|
|
16
|
+
export declare function mountEditorLink(editorUrl: string): () => void;
|