@rydr/game-sdk 1.16.0 → 1.17.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.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * perf-marks — lightweight timing instrumentation, shared so any rydr game's
3
+ * marks feed the same {@link PerfOverlay} panel.
4
+ *
5
+ * Toggle with `window.__PERF__ = true` (or `?perf=1`, handled by the overlay).
6
+ * When disabled every call is a cheap no-op, so it is safe to sprinkle through
7
+ * hot-path code.
8
+ *
9
+ * Usage:
10
+ * ```ts
11
+ * perfMark('frame.update'); // start a region
12
+ * … work …
13
+ * perfMeasure('frame.update'); // end the region, record elapsed ms
14
+ * window.__perfReport(); // { label: { avg, max, n } }
15
+ * ```
16
+ *
17
+ * Browser-only (uses `performance.now()` + `window`). Part of the optional
18
+ * `@rydr/game-sdk/three` entry point.
19
+ */
20
+ const SAMPLES = 60;
21
+ const marks = new Map();
22
+ const histories = new Map();
23
+ function enabled() {
24
+ return typeof window !== "undefined" && window.__PERF__ === true;
25
+ }
26
+ /** Start a timing region under `label`. No-op unless `window.__PERF__` is set. */
27
+ export function perfMark(label) {
28
+ if (!enabled())
29
+ return;
30
+ marks.set(label, performance.now());
31
+ }
32
+ /**
33
+ * End the region opened by `perfMark(label)` and push the elapsed ms into a
34
+ * rolling ring buffer. No-op if no matching mark or perf is disabled.
35
+ */
36
+ export function perfMeasure(label) {
37
+ if (!enabled())
38
+ return;
39
+ const start = marks.get(label);
40
+ if (start == null)
41
+ return;
42
+ const ms = performance.now() - start;
43
+ let hist = histories.get(label);
44
+ if (!hist) {
45
+ hist = [];
46
+ histories.set(label, hist);
47
+ }
48
+ hist.push(ms);
49
+ if (hist.length > SAMPLES)
50
+ hist.shift();
51
+ }
52
+ /** Snapshot every recorded label as `{ avg, max, n }`. null when perf is off. */
53
+ export function perfReport() {
54
+ if (!enabled())
55
+ return null;
56
+ const out = {};
57
+ for (const [label, hist] of histories) {
58
+ if (!hist.length)
59
+ continue;
60
+ const avg = hist.reduce((a, b) => a + b, 0) / hist.length;
61
+ const max = Math.max(...hist);
62
+ out[label] = { avg: +avg.toFixed(2), max: +max.toFixed(2), n: hist.length };
63
+ }
64
+ return out;
65
+ }
66
+ /**
67
+ * Rolling average for a label, WITHOUT the `__PERF__` guard — the overlay reads
68
+ * these to render its phase breakdown regardless of global logging state.
69
+ */
70
+ export function perfAvg(label) {
71
+ const hist = histories.get(label);
72
+ if (!hist || !hist.length)
73
+ return 0;
74
+ return hist.reduce((a, b) => a + b, 0) / hist.length;
75
+ }
76
+ /** Rolling peak for a label. Same no-guard rule as {@link perfAvg}. */
77
+ export function perfPeak(label) {
78
+ const hist = histories.get(label);
79
+ if (!hist || !hist.length)
80
+ return 0;
81
+ return Math.max(...hist);
82
+ }
83
+ if (typeof window !== "undefined") {
84
+ window.__perfReport = perfReport;
85
+ }
86
+ //# sourceMappingURL=perf-marks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"perf-marks.js","sourceRoot":"","sources":["../../src/three/perf-marks.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,104 @@
1
+ /**
2
+ * perf-overlay — a shared, collapsible Three.js performance HUD for every rydr
3
+ * game. Shows FPS / CPU / GPU (via WebGL2 timer queries) / `renderer.info`, a
4
+ * `perfMark` phase breakdown, live entity counters, a copy-report button, and a
5
+ * generic draw-call audit. Default-off; zero cost when disabled.
6
+ *
7
+ * This is the SDK's first browser-UI module (the core protocol/client stay
8
+ * isomorphic). It is intentionally **self-contained**: it injects nothing
9
+ * global, depends on no CSS variables, and builds its own top-right overlay
10
+ * stack — so any Three.js game gets the panel by constructing it.
11
+ *
12
+ * Core is generic; each game extends it through a plain options object
13
+ * ({@link PerfOverlayOptions}) — `counters`, `phases`, `buttons`, `scene` — no
14
+ * formal capability interface (the surface is too small to warrant one).
15
+ *
16
+ * Enable: `?perf=1` on the page (or parent frame, for iframed games) or
17
+ * `window.__PERF__ = true`. Toggle the panel with the backtick key.
18
+ *
19
+ * Per-frame call order (mirrors a typical render loop):
20
+ * ```ts
21
+ * perf.frameStart();
22
+ * update();
23
+ * perf.gpuBegin(); render(); perf.gpuEnd();
24
+ * perf.frameEnd(dtSeconds);
25
+ * ```
26
+ */
27
+ import type * as THREE from "three";
28
+ /** Per-game extension surface — all optional. */
29
+ export interface PerfOverlayOptions {
30
+ /** Scene to drive the generic "Audit draw calls" button. Omit to hide it. */
31
+ scene?: THREE.Object3D;
32
+ /** Live entity counts → rendered as panel rows (e.g. `{ enemies, projectiles }`). */
33
+ counters?: () => Record<string, number>;
34
+ /** `perfMark` labels to surface as an avg/max breakdown (e.g. `['update','render']`). */
35
+ phases?: string[];
36
+ /** Extra action buttons (e.g. a renderer-specific "Toggle bloom"). */
37
+ buttons?: PerfButtonSpec[];
38
+ /** Pixel offset of the overlay stack from the top-right corner. Default
39
+ * `{ top: 8, right: 8 }`. Raise `right` to clear other top-right chrome
40
+ * (e.g. a host hamburger menu). */
41
+ offset?: {
42
+ top?: number;
43
+ right?: number;
44
+ };
45
+ }
46
+ /** A custom panel button. */
47
+ export interface PerfButtonSpec {
48
+ label: string;
49
+ /** CSS background tint; defaults to the neutral blue. */
50
+ color?: string;
51
+ onClick: () => void;
52
+ }
53
+ /**
54
+ * Bucket a scene's renderable objects (≈ draw calls) by top-level child, plus a
55
+ * material/geometry composition of the biggest bucket. Renderer-agnostic — the
56
+ * same audit the tower-defense game used, generalized over any `scene`.
57
+ */
58
+ export declare function auditScene(scene: THREE.Object3D): string;
59
+ /**
60
+ * The performance overlay. Construct once with the canvas host + renderer, drive
61
+ * it from the render loop, and pass game specifics via {@link PerfOverlayOptions}.
62
+ */
63
+ export declare class PerfOverlay {
64
+ private readonly renderer;
65
+ private readonly opts;
66
+ private readonly gl;
67
+ private readonly ext;
68
+ private readonly fps;
69
+ private readonly frameMs;
70
+ private readonly cpuMs;
71
+ private readonly gpuMs;
72
+ private prevFrameTime;
73
+ private cpuStart;
74
+ private readonly gpuPool;
75
+ private readonly gpuPending;
76
+ private gpuCurrent;
77
+ private lastInfo;
78
+ private domTick;
79
+ private readonly DOM_INTERVAL;
80
+ private visible;
81
+ private readonly stack;
82
+ private readonly pill;
83
+ private readonly panel;
84
+ private readonly pre;
85
+ private readonly reportOut;
86
+ private readonly onKey;
87
+ constructor(host: HTMLElement, renderer: THREE.WebGLRenderer, opts?: PerfOverlayOptions);
88
+ frameStart(): void;
89
+ gpuBegin(): void;
90
+ gpuEnd(): void;
91
+ /** End of frame. `dt` is seconds since last frame (drives the DOM throttle). */
92
+ frameEnd(dt: number): void;
93
+ toggle(): void;
94
+ destroy(): void;
95
+ private applyVisibility;
96
+ private makeButton;
97
+ private buildButtons;
98
+ private dumpReport;
99
+ private initGpuExt;
100
+ private pollGpu;
101
+ private updatePill;
102
+ private updateDom;
103
+ }
104
+ //# sourceMappingURL=perf-overlay.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"perf-overlay.d.ts","sourceRoot":"","sources":["../../src/three/perf-overlay.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAC;AAIpC,iDAAiD;AACjD,MAAM,WAAW,kBAAkB;IACjC,6EAA6E;IAC7E,KAAK,CAAC,EAAE,KAAK,CAAC,QAAQ,CAAC;IACvB,qFAAqF;IACrF,QAAQ,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxC,yFAAyF;IACzF,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,sEAAsE;IACtE,OAAO,CAAC,EAAE,cAAc,EAAE,CAAC;IAC3B;;wCAEoC;IACpC,MAAM,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3C;AAED,6BAA6B;AAC7B,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,yDAAyD;IACzD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AA6DD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,KAAK,CAAC,QAAQ,GAAG,MAAM,CA6FxD;AAED;;;GAGG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsB;IAC/C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAqB;IAC1C,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAiD;IACpE,OAAO,CAAC,QAAQ,CAAC,GAAG,CAA+B;IAEnD,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAgB;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgB;IACxC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgB;IACtC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgB;IAEtC,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,QAAQ,CAAK;IAErB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAoB;IAC5C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAoB;IAC/C,OAAO,CAAC,UAAU,CAA2B;IAE7C,OAAO,CAAC,QAAQ,CAAkF;IAElG,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IAEtC,OAAO,CAAC,OAAO,CAAU;IACzB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiB;IACvC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAoB;IACzC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiB;IACvC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAiB;IACrC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAiB;IAC3C,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA6B;gBAEvC,IAAI,EAAE,WAAW,EAAE,QAAQ,EAAE,KAAK,CAAC,aAAa,EAAE,IAAI,GAAE,kBAAuB;IA2E3F,UAAU,IAAI,IAAI;IAWlB,QAAQ,IAAI,IAAI;IAShB,MAAM,IAAI,IAAI;IAQd,gFAAgF;IAChF,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAsB1B,MAAM,IAAI,IAAI;IAMd,OAAO,IAAI,IAAI;IAgBf,OAAO,CAAC,eAAe;IAKvB,OAAO,CAAC,UAAU;IAYlB,OAAO,CAAC,YAAY;IAuBpB,OAAO,CAAC,UAAU;IA0BlB,OAAO,CAAC,UAAU;IAMlB,OAAO,CAAC,OAAO;IAkBf,OAAO,CAAC,UAAU;IAKlB,OAAO,CAAC,SAAS;CA6BlB"}
@@ -0,0 +1,446 @@
1
+ /**
2
+ * perf-overlay — a shared, collapsible Three.js performance HUD for every rydr
3
+ * game. Shows FPS / CPU / GPU (via WebGL2 timer queries) / `renderer.info`, a
4
+ * `perfMark` phase breakdown, live entity counters, a copy-report button, and a
5
+ * generic draw-call audit. Default-off; zero cost when disabled.
6
+ *
7
+ * This is the SDK's first browser-UI module (the core protocol/client stay
8
+ * isomorphic). It is intentionally **self-contained**: it injects nothing
9
+ * global, depends on no CSS variables, and builds its own top-right overlay
10
+ * stack — so any Three.js game gets the panel by constructing it.
11
+ *
12
+ * Core is generic; each game extends it through a plain options object
13
+ * ({@link PerfOverlayOptions}) — `counters`, `phases`, `buttons`, `scene` — no
14
+ * formal capability interface (the surface is too small to warrant one).
15
+ *
16
+ * Enable: `?perf=1` on the page (or parent frame, for iframed games) or
17
+ * `window.__PERF__ = true`. Toggle the panel with the backtick key.
18
+ *
19
+ * Per-frame call order (mirrors a typical render loop):
20
+ * ```ts
21
+ * perf.frameStart();
22
+ * update();
23
+ * perf.gpuBegin(); render(); perf.gpuEnd();
24
+ * perf.frameEnd(dtSeconds);
25
+ * ```
26
+ */
27
+ import { perfReport, perfAvg, perfPeak } from "./perf-marks.js";
28
+ // ── Ring buffer (Float32) ──────────────────────────────────────
29
+ class Ring {
30
+ buf;
31
+ i = 0;
32
+ n = 0;
33
+ constructor(len) {
34
+ this.buf = new Float32Array(len);
35
+ }
36
+ push(v) {
37
+ this.buf[this.i % this.buf.length] = v;
38
+ this.i++;
39
+ if (this.n < this.buf.length)
40
+ this.n++;
41
+ }
42
+ avg() {
43
+ if (!this.n)
44
+ return 0;
45
+ let s = 0;
46
+ for (let i = 0; i < this.n; i++)
47
+ s += this.buf[i];
48
+ return s / this.n;
49
+ }
50
+ max() {
51
+ if (!this.n)
52
+ return 0;
53
+ let m = 0;
54
+ for (let i = 0; i < this.n; i++)
55
+ if (this.buf[i] > m)
56
+ m = this.buf[i];
57
+ return m;
58
+ }
59
+ min() {
60
+ if (!this.n)
61
+ return 0;
62
+ let m = Infinity;
63
+ for (let i = 0; i < this.n; i++)
64
+ if (this.buf[i] < m)
65
+ m = this.buf[i];
66
+ return m === Infinity ? 0 : m;
67
+ }
68
+ }
69
+ // ── Formatting helpers ─────────────────────────────────────────
70
+ const f1 = (v, w = 5) => v.toFixed(1).padStart(w);
71
+ const f0 = (v, w = 4) => Math.round(v).toString().padStart(w);
72
+ function kFmt(v, w = 6) {
73
+ if (v >= 10000)
74
+ return ((v / 1000).toFixed(0) + "k").padStart(w);
75
+ if (v >= 1000)
76
+ return ((v / 1000).toFixed(1) + "k").padStart(w);
77
+ return Math.round(v).toString().padStart(w);
78
+ }
79
+ const MONO = "11px/1.6 ui-monospace, 'SF Mono', Menlo, Consolas, monospace";
80
+ /**
81
+ * Bucket a scene's renderable objects (≈ draw calls) by top-level child, plus a
82
+ * material/geometry composition of the biggest bucket. Renderer-agnostic — the
83
+ * same audit the tower-defense game used, generalized over any `scene`.
84
+ */
85
+ export function auditScene(scene) {
86
+ const isRenderable = (o) => o.isMesh === true || o.isLine === true || o.isPoints === true || o.isSprite === true;
87
+ const buckets = [];
88
+ let total = 0;
89
+ for (const child of scene.children) {
90
+ let visible = 0;
91
+ let hidden = 0;
92
+ child.traverse((o) => {
93
+ if (!isRenderable(o))
94
+ return;
95
+ if (o.visible && child.visible)
96
+ visible++;
97
+ else
98
+ hidden++;
99
+ });
100
+ if (visible + hidden === 0)
101
+ continue;
102
+ total += visible;
103
+ buckets.push({ name: child.name || child.type || "unnamed", visible, hidden });
104
+ }
105
+ buckets.sort((a, b) => b.visible - a.visible);
106
+ const lines = buckets.map((b) => `${b.name.slice(0, 22).padEnd(22)} ${String(b.visible).padStart(5)} vis ${String(b.hidden).padStart(5)} hidden`);
107
+ // Composition of the biggest bucket: unique geometries vs. materials, and the
108
+ // most-repeated geometry+material pair (merge-by-material vs. instancing cue).
109
+ const top = buckets[0];
110
+ let detail = "";
111
+ if (top) {
112
+ const topChild = scene.children.find((c) => (c.name || c.type || "unnamed") === top.name);
113
+ if (topChild) {
114
+ const geos = new Set();
115
+ const matInfo = new Map();
116
+ const pairCounts = new Map();
117
+ topChild.traverse((o) => {
118
+ const m = o;
119
+ if (!m.isMesh || !o.visible || !m.geometry)
120
+ return;
121
+ geos.add(m.geometry.uuid);
122
+ const matList = Array.isArray(m.material) ? m.material : m.material ? [m.material] : [];
123
+ for (const mm of matList) {
124
+ const entry = matInfo.get(mm.uuid);
125
+ if (entry)
126
+ entry.count++;
127
+ else
128
+ matInfo.set(mm.uuid, { mat: mm, count: 1 });
129
+ }
130
+ const pairKey = `${m.geometry.uuid}|${matList.map((mm) => mm.uuid).join("+")}`;
131
+ pairCounts.set(pairKey, (pairCounts.get(pairKey) ?? 0) + 1);
132
+ });
133
+ let maxRepeat = 0;
134
+ for (const n of pairCounts.values())
135
+ if (n > maxRepeat)
136
+ maxRepeat = n;
137
+ const describe = (mat) => {
138
+ const color = mat.color ? "#" + mat.color.getHexString() : "—";
139
+ const maps = [];
140
+ if (mat.map)
141
+ maps.push("map");
142
+ if (mat.normalMap)
143
+ maps.push("normal");
144
+ if (mat.emissive && mat.emissiveMap)
145
+ maps.push("emissiveMap");
146
+ if (mat.emissive && mat.emissive.getHex() !== 0)
147
+ maps.push("emissive");
148
+ if (mat.transparent)
149
+ maps.push("transparent");
150
+ const name = mat.name || "(unnamed)";
151
+ return `${name.slice(0, 18).padEnd(18)} ${(mat.type ?? "").replace("Material", "").slice(0, 10).padEnd(10)} ${color} ${maps.join(",") || "flat"}`;
152
+ };
153
+ const matLines = [...matInfo.values()]
154
+ .sort((a, b) => b.count - a.count)
155
+ .map((e) => ` ${String(e.count).padStart(5)}× ${describe(e.mat)}`);
156
+ detail =
157
+ `[${top.name}] composition:\n` +
158
+ ` unique geometries: ${geos.size}\n` +
159
+ ` unique materials: ${matInfo.size}\n` +
160
+ ` most-repeated geo+mat pair: ${maxRepeat}×\n` +
161
+ ` materials (count × name / type / color / maps):\n` +
162
+ matLines.join("\n") +
163
+ `\n\n`;
164
+ }
165
+ }
166
+ return detail + `~${total} visible draw calls (scene.children = ${scene.children.length})\n` + lines.join("\n");
167
+ }
168
+ /**
169
+ * The performance overlay. Construct once with the canvas host + renderer, drive
170
+ * it from the render loop, and pass game specifics via {@link PerfOverlayOptions}.
171
+ */
172
+ export class PerfOverlay {
173
+ renderer;
174
+ opts;
175
+ gl;
176
+ ext;
177
+ fps = new Ring(60);
178
+ frameMs = new Ring(60);
179
+ cpuMs = new Ring(60);
180
+ gpuMs = new Ring(30);
181
+ prevFrameTime = 0;
182
+ cpuStart = 0;
183
+ gpuPool = [];
184
+ gpuPending = [];
185
+ gpuCurrent = null;
186
+ lastInfo = { calls: 0, tris: 0, lines: 0, geos: 0, texs: 0, prgs: 0 };
187
+ domTick = 0;
188
+ DOM_INTERVAL = 1 / 8; // 8 Hz — 60 Hz numbers are unreadable
189
+ visible;
190
+ stack;
191
+ pill;
192
+ panel;
193
+ pre;
194
+ reportOut;
195
+ onKey;
196
+ constructor(host, renderer, opts = {}) {
197
+ this.renderer = renderer;
198
+ this.opts = opts;
199
+ this.gl = renderer.getContext();
200
+ this.ext = this.initGpuExt();
201
+ // Visibility: `?perf=1` on this frame OR the parent (games run iframed),
202
+ // or `window.__PERF__`. Showing the panel enables sampling.
203
+ let parentSearch = "";
204
+ try {
205
+ if (window.parent !== window)
206
+ parentSearch = window.parent.location.search;
207
+ }
208
+ catch {
209
+ /* cross-origin parent — ignore */
210
+ }
211
+ const urlFlag = new URLSearchParams(location.search).get("perf") === "1" ||
212
+ new URLSearchParams(parentSearch).get("perf") === "1";
213
+ this.visible = urlFlag || window.__PERF__ === true;
214
+ if (this.visible)
215
+ window.__PERF__ = true;
216
+ // ── Self-contained top-right overlay stack ──
217
+ const offTop = opts.offset?.top ?? 8;
218
+ const offRight = opts.offset?.right ?? 8;
219
+ this.stack = document.createElement("div");
220
+ this.stack.style.cssText = `
221
+ position: absolute; top: ${offTop}px; right: ${offRight}px; z-index: 99999;
222
+ display: flex; flex-direction: column; align-items: flex-end; gap: 6px;
223
+ pointer-events: none;`;
224
+ host.appendChild(this.stack);
225
+ this.pill = this.makeButton("PERF", "rgba(7,9,20,0.82)");
226
+ this.pill.addEventListener("click", () => this.toggle());
227
+ this.stack.appendChild(this.pill);
228
+ this.panel = document.createElement("div");
229
+ this.panel.style.cssText = `
230
+ background: rgba(7,9,20,0.90);
231
+ border: 1px solid rgba(255,255,255,0.12);
232
+ backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
233
+ border-radius: 5px; padding: 8px 11px 7px;
234
+ pointer-events: auto; user-select: none;`;
235
+ this.stack.appendChild(this.panel);
236
+ const titleRow = document.createElement("div");
237
+ titleRow.style.cssText = "display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;";
238
+ const titleText = document.createElement("span");
239
+ titleText.textContent = "PERF";
240
+ titleText.style.cssText = `font:600 9px/1.8 ${MONO};letter-spacing:0.12em;color:rgba(120,180,255,0.6);`;
241
+ const closeBtn = document.createElement("button");
242
+ closeBtn.textContent = "×";
243
+ closeBtn.style.cssText = `background:none;border:none;font:11px/1 ${MONO};color:rgba(255,255,255,0.35);cursor:pointer;padding:0 0 0 10px;pointer-events:auto;`;
244
+ closeBtn.addEventListener("click", () => this.toggle());
245
+ titleRow.append(titleText, closeBtn);
246
+ this.panel.appendChild(titleRow);
247
+ this.pre = document.createElement("pre");
248
+ this.pre.style.cssText = `margin:0;font:${MONO};color:rgba(220,230,255,0.85);white-space:pre;`;
249
+ this.panel.appendChild(this.pre);
250
+ this.buildButtons();
251
+ this.reportOut = document.createElement("pre");
252
+ this.reportOut.style.cssText = `margin:4px 0 0;font:10px/1.5 ${MONO};color:rgba(180,255,205,0.9);white-space:pre;max-height:240px;overflow:auto;`;
253
+ this.panel.appendChild(this.reportOut);
254
+ this.onKey = (e) => {
255
+ if (e.key === "`")
256
+ this.toggle();
257
+ };
258
+ document.addEventListener("keydown", this.onKey);
259
+ this.applyVisibility();
260
+ }
261
+ // ── Public per-frame API ─────────────────────────────────────
262
+ frameStart() {
263
+ const now = performance.now();
264
+ if (this.prevFrameTime) {
265
+ const frameMs = now - this.prevFrameTime;
266
+ this.frameMs.push(frameMs);
267
+ if (frameMs > 0)
268
+ this.fps.push(1000 / frameMs);
269
+ }
270
+ this.prevFrameTime = now;
271
+ this.cpuStart = now;
272
+ }
273
+ gpuBegin() {
274
+ if (!this.ext)
275
+ return;
276
+ const gl = this.gl;
277
+ const q = this.gpuPool.pop() ?? gl.createQuery();
278
+ if (!q)
279
+ return;
280
+ this.gpuCurrent = q;
281
+ gl.beginQuery(this.ext.TIME_ELAPSED_EXT, q);
282
+ }
283
+ gpuEnd() {
284
+ if (!this.ext || !this.gpuCurrent)
285
+ return;
286
+ const gl = this.gl;
287
+ gl.endQuery(this.ext.TIME_ELAPSED_EXT);
288
+ this.gpuPending.push(this.gpuCurrent);
289
+ this.gpuCurrent = null;
290
+ }
291
+ /** End of frame. `dt` is seconds since last frame (drives the DOM throttle). */
292
+ frameEnd(dt) {
293
+ this.cpuMs.push(performance.now() - this.cpuStart);
294
+ if (this.ext)
295
+ this.pollGpu();
296
+ const ri = this.renderer.info;
297
+ this.lastInfo = {
298
+ calls: ri.render.calls,
299
+ tris: ri.render.triangles,
300
+ lines: ri.render.lines,
301
+ geos: ri.memory.geometries,
302
+ texs: ri.memory.textures,
303
+ prgs: ri.programs?.length ?? 0,
304
+ };
305
+ this.domTick -= dt;
306
+ if (this.domTick <= 0) {
307
+ this.domTick = this.DOM_INTERVAL;
308
+ this.updatePill();
309
+ if (this.visible)
310
+ this.updateDom();
311
+ }
312
+ }
313
+ toggle() {
314
+ this.visible = !this.visible;
315
+ window.__PERF__ = this.visible;
316
+ this.applyVisibility();
317
+ }
318
+ destroy() {
319
+ document.removeEventListener("keydown", this.onKey);
320
+ this.stack.remove();
321
+ const gl = this.gl;
322
+ if (this.ext) {
323
+ for (const q of this.gpuPool)
324
+ gl.deleteQuery(q);
325
+ for (const q of this.gpuPending)
326
+ gl.deleteQuery(q);
327
+ if (this.gpuCurrent) {
328
+ gl.endQuery(this.ext.TIME_ELAPSED_EXT);
329
+ gl.deleteQuery(this.gpuCurrent);
330
+ }
331
+ }
332
+ }
333
+ // ── Internals ────────────────────────────────────────────────
334
+ applyVisibility() {
335
+ this.panel.style.display = this.visible ? "block" : "none";
336
+ this.pill.style.display = this.visible ? "none" : "block";
337
+ }
338
+ makeButton(label, bg) {
339
+ const b = document.createElement("button");
340
+ b.textContent = label;
341
+ b.style.cssText = `
342
+ margin: 6px 6px 0 0; padding: 4px 9px;
343
+ font: ${MONO}; color: rgba(225,235,255,0.95);
344
+ background: ${bg};
345
+ border: 1px solid rgba(255,255,255,0.18); border-radius: 4px;
346
+ cursor: pointer; pointer-events: auto; user-select: none;`;
347
+ return b;
348
+ }
349
+ buildButtons() {
350
+ const reportBtn = this.makeButton("Log perf report", "rgba(80,120,200,0.28)");
351
+ reportBtn.addEventListener("click", () => this.dumpReport(reportBtn));
352
+ this.panel.appendChild(reportBtn);
353
+ if (this.opts.scene) {
354
+ const scene = this.opts.scene;
355
+ const auditBtn = this.makeButton("Audit draw calls", "rgba(120,180,120,0.28)");
356
+ auditBtn.addEventListener("click", () => {
357
+ const text = auditScene(scene);
358
+ console.log("[perf] draw-call audit\n" + text);
359
+ this.reportOut.textContent = "✓ LOGGED IN CONSOLE\n\n" + text;
360
+ });
361
+ this.panel.appendChild(auditBtn);
362
+ }
363
+ for (const spec of this.opts.buttons ?? []) {
364
+ const btn = this.makeButton(spec.label, spec.color ?? "rgba(200,120,80,0.28)");
365
+ btn.addEventListener("click", spec.onClick);
366
+ this.panel.appendChild(btn);
367
+ }
368
+ }
369
+ dumpReport(btn) {
370
+ if (!window.__PERF__) {
371
+ window.__PERF__ = true;
372
+ this.reportOut.textContent = "Perf sampling enabled — run a few seconds, then click again.";
373
+ return;
374
+ }
375
+ const r = perfReport();
376
+ if (!r || !Object.keys(r).length) {
377
+ this.reportOut.textContent = "No samples yet — run a few seconds, then click again.";
378
+ return;
379
+ }
380
+ const text = Object.entries(r)
381
+ .sort((a, b) => b[1].avg - a[1].avg)
382
+ .map(([label, e]) => `${label.padEnd(16)} avg ${e.avg.toFixed(2).padStart(7)}ms max ${e.max.toFixed(2).padStart(7)}ms n${e.n}`)
383
+ .join("\n");
384
+ console.log("[perf] report\n" + text + "\n\n" + JSON.stringify(r, null, 2));
385
+ this.reportOut.textContent = "✓ LOGGED IN CONSOLE\n\n" + text;
386
+ btn.textContent = "Logged ✓";
387
+ setTimeout(() => {
388
+ btn.textContent = "Log perf report";
389
+ }, 1500);
390
+ }
391
+ initGpuExt() {
392
+ if (typeof WebGL2RenderingContext === "undefined")
393
+ return null;
394
+ if (!(this.gl instanceof WebGL2RenderingContext))
395
+ return null;
396
+ return this.gl.getExtension("EXT_disjoint_timer_query_webgl2");
397
+ }
398
+ pollGpu() {
399
+ const gl = this.gl;
400
+ const ext = this.ext;
401
+ const disjoint = gl.getParameter(ext.GPU_DISJOINT_EXT);
402
+ for (let i = this.gpuPending.length - 1; i >= 0; i--) {
403
+ const q = this.gpuPending[i];
404
+ if (!gl.getQueryParameter(q, gl.QUERY_RESULT_AVAILABLE))
405
+ continue;
406
+ if (!disjoint) {
407
+ const ns = gl.getQueryParameter(q, gl.QUERY_RESULT);
408
+ this.gpuMs.push(ns / 1_000_000);
409
+ }
410
+ this.gpuPool.push(q);
411
+ const last = this.gpuPending.length - 1;
412
+ if (i !== last)
413
+ this.gpuPending[i] = this.gpuPending[last];
414
+ this.gpuPending.pop();
415
+ }
416
+ }
417
+ updatePill() {
418
+ const gpuStr = this.ext ? `${this.gpuMs.avg().toFixed(1)}ms` : "n/a";
419
+ this.pill.textContent = `${Math.round(this.fps.avg())} fps • GPU ${gpuStr} • CPU ${this.cpuMs.avg().toFixed(1)}ms`;
420
+ }
421
+ updateDom() {
422
+ const { calls, tris, geos, texs, prgs } = this.lastInfo;
423
+ const gpuStr = this.ext ? `${f1(this.gpuMs.avg())}ms ↑${f1(this.gpuMs.max())}ms` : " n/a";
424
+ const rows = [
425
+ `FPS ${f1(this.fps.avg())} lo ${f0(this.fps.min(), 3)} frame ${f1(this.frameMs.avg())}ms`,
426
+ `CPU ${f1(this.cpuMs.avg())}ms ↑${f1(this.cpuMs.max())}ms GPU ${gpuStr}`,
427
+ ];
428
+ for (const label of this.opts.phases ?? []) {
429
+ rows.push(` ${label.padEnd(16)} ${f1(perfAvg(label))}ms ↑${f1(perfPeak(label))}ms`);
430
+ }
431
+ rows.push("", `calls ${f0(calls, 4)} tris ${kFmt(tris)}`, `geos ${f0(geos, 4)} tex ${f0(texs, 4)} prgs ${f0(prgs, 3)}`);
432
+ const counters = this.opts.counters?.();
433
+ if (counters) {
434
+ rows.push("");
435
+ const entries = Object.entries(counters);
436
+ for (let i = 0; i < entries.length; i += 2) {
437
+ const a = entries[i];
438
+ const b = entries[i + 1];
439
+ const left = `${a[0].slice(0, 7).padEnd(7)} ${f0(a[1], 4)}`;
440
+ rows.push(b ? `${left} ${b[0].slice(0, 7).padEnd(7)} ${f0(b[1], 4)}` : left);
441
+ }
442
+ }
443
+ this.pre.textContent = rows.join("\n");
444
+ }
445
+ }
446
+ //# sourceMappingURL=perf-overlay.js.map