webgpu-profiler 0.1.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,438 @@
1
+ import {
2
+ useEffect,
3
+ useRef,
4
+ useState,
5
+ useSyncExternalStore,
6
+ type CSSProperties,
7
+ type ReactNode,
8
+ } from "react";
9
+ import {
10
+ profileMemory,
11
+ reportToText,
12
+ type MemoryReport,
13
+ type TextureEntry,
14
+ type BufferEntry,
15
+ } from "../profiler.js";
16
+ import {
17
+ getActiveInstrumentation,
18
+ subscribeActiveInstrumentation,
19
+ type DeviceInstrumentation,
20
+ } from "../instrument.js";
21
+
22
+ /**
23
+ * Viewport-pinned overlay that polls {@link profileMemory} on a timer and
24
+ * renders a copyable breakdown of all GPU memory the page has allocated
25
+ * through `device.createBuffer` / `device.createTexture`.
26
+ *
27
+ * Self-contained (no external CSS, no design-token dependency). Default
28
+ * styling is a dark transparent panel. Re-skin via `style` / `className`.
29
+ *
30
+ * Each section (Textures / Render targets / Buffers) is independently
31
+ * scrollable, so the HUD never overflows the viewport.
32
+ */
33
+
34
+ export interface MemoryHUDProps {
35
+ /**
36
+ * Optional. When omitted, the HUD reads the active instrumentation set
37
+ * by `instrumentDevice()` or `autoInstrument()` via subscription. Pass
38
+ * explicitly if you're managing multiple devices.
39
+ */
40
+ instrumentation?: DeviceInstrumentation | null;
41
+ refreshMs?: number;
42
+ corner?: "top-right" | "top-left" | "bottom-right" | "bottom-left";
43
+ className?: string;
44
+ style?: CSSProperties;
45
+ }
46
+
47
+ /**
48
+ * React hook that subscribes to the active instrumentation. Re-renders
49
+ * the calling component when `instrumentDevice` or `autoInstrument` sets
50
+ * (or clears) the global handle.
51
+ */
52
+ function useActiveInstrumentation(): DeviceInstrumentation | null {
53
+ return useSyncExternalStore(
54
+ subscribeActiveInstrumentation,
55
+ getActiveInstrumentation,
56
+ () => null,
57
+ );
58
+ }
59
+
60
+ const DEFAULT_REFRESH_MS = 1000;
61
+ const SECTION_MAX_HEIGHT = 240;
62
+ const MIN_WIDTH = 360;
63
+ const NAME_MAX_CHARS = 26;
64
+
65
+ const CORNERS: Record<NonNullable<MemoryHUDProps["corner"]>, CSSProperties> = {
66
+ "top-right": { top: 8, right: 8 },
67
+ "top-left": { top: 8, left: 8 },
68
+ "bottom-right": { bottom: 8, right: 8 },
69
+ "bottom-left": { bottom: 8, left: 8 },
70
+ };
71
+
72
+ function mb(bytes: number): string {
73
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
74
+ }
75
+
76
+ /** Pre-truncate names that would otherwise blow the layout; the CSS
77
+ * ellipsis is a safety net for edges this helper misses. */
78
+ function shortName(name: string, maxLen = NAME_MAX_CHARS): string {
79
+ if (name.length <= maxLen) return name;
80
+ return name.slice(0, maxLen - 1) + "…";
81
+ }
82
+
83
+ // Default theme. Override via `style` prop or className.
84
+ const C = {
85
+ bg: "rgba(8, 12, 16, 0.92)",
86
+ border: "rgba(255, 255, 255, 0.1)",
87
+ hover: "rgba(255, 255, 255, 0.06)",
88
+ textPrimary: "rgba(255, 255, 255, 0.9)",
89
+ textSecondary: "rgba(255, 255, 255, 0.65)",
90
+ textMuted: "rgba(255, 255, 255, 0.4)",
91
+ } as const;
92
+
93
+ const monoFont =
94
+ 'ui-monospace, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
95
+
96
+ // One-time global style injection for focus-visible. The HUD otherwise
97
+ // uses inline styles only, so consumers can override via `style` / `className`
98
+ // without specificity wars. SSR-safe via the `document` guard.
99
+ const FOCUS_STYLE_ID = "webgpu-profiler-focus";
100
+ function ensureFocusStyle(): void {
101
+ if (typeof document === "undefined") return;
102
+ if (document.getElementById(FOCUS_STYLE_ID)) return;
103
+ const el = document.createElement("style");
104
+ el.id = FOCUS_STYLE_ID;
105
+ el.textContent = `
106
+ [data-webgpu-profiler-btn]:focus-visible {
107
+ outline: 2px solid rgba(255, 255, 255, 0.5);
108
+ outline-offset: -2px;
109
+ border-radius: 4px;
110
+ }
111
+ `;
112
+ document.head.appendChild(el);
113
+ }
114
+
115
+ export function MemoryHUD({
116
+ instrumentation: explicit,
117
+ refreshMs = DEFAULT_REFRESH_MS,
118
+ corner = "top-right",
119
+ className,
120
+ style,
121
+ }: MemoryHUDProps = {}) {
122
+ const active = useActiveInstrumentation();
123
+ const instrumentation = explicit !== undefined ? explicit : active;
124
+
125
+ const [report, setReport] = useState<MemoryReport | null>(null);
126
+ const [open, setOpen] = useState(true);
127
+ const [copied, setCopied] = useState(false);
128
+ const [headerHover, setHeaderHover] = useState(false);
129
+ const [copyHover, setCopyHover] = useState(false);
130
+ const timerRef = useRef<number | null>(null);
131
+
132
+ useEffect(() => {
133
+ ensureFocusStyle();
134
+ }, []);
135
+
136
+ useEffect(() => {
137
+ if (!instrumentation) return;
138
+ const tick = () => {
139
+ try {
140
+ setReport(profileMemory(instrumentation));
141
+ } catch {
142
+ // Device may not yet be ready; ignore until next tick.
143
+ }
144
+ };
145
+ tick();
146
+ timerRef.current = window.setInterval(tick, refreshMs);
147
+ return () => {
148
+ if (timerRef.current !== null) window.clearInterval(timerRef.current);
149
+ };
150
+ }, [instrumentation, refreshMs]);
151
+
152
+ if (!report) return null;
153
+
154
+ const [copyFailed, setCopyFailed] = useState(false);
155
+ const handleCopy = async () => {
156
+ try {
157
+ await navigator.clipboard.writeText(reportToText(report));
158
+ setCopied(true);
159
+ window.setTimeout(() => setCopied(false), 1200);
160
+ } catch (err) {
161
+ // Insecure context, sandboxed iframe, permission denied, etc.
162
+ // Surface the failure so the user knows to fall back to manual select.
163
+ console.warn("[webgpu-profiler] clipboard write failed:", err);
164
+ setCopyFailed(true);
165
+ window.setTimeout(() => setCopyFailed(false), 1500);
166
+ }
167
+ };
168
+
169
+ const containerStyle: CSSProperties = {
170
+ position: "fixed",
171
+ zIndex: 9999,
172
+ pointerEvents: "auto",
173
+ minWidth: open ? MIN_WIDTH : undefined,
174
+ maxHeight: "calc(100vh - 16px)",
175
+ display: "flex",
176
+ flexDirection: "column",
177
+ background: C.bg,
178
+ border: `1px solid ${C.border}`,
179
+ borderRadius: 6,
180
+ fontFamily: monoFont,
181
+ fontSize: 11,
182
+ color: C.textSecondary,
183
+ backdropFilter: "blur(8px)",
184
+ WebkitBackdropFilter: "blur(8px)",
185
+ ...CORNERS[corner],
186
+ ...style,
187
+ };
188
+
189
+ return (
190
+ <div className={className} style={containerStyle}>
191
+ <button
192
+ type="button"
193
+ data-webgpu-profiler-btn=""
194
+ onClick={() => setOpen((v) => !v)}
195
+ onMouseEnter={() => setHeaderHover(true)}
196
+ onMouseLeave={() => setHeaderHover(false)}
197
+ style={{
198
+ all: "unset",
199
+ cursor: "pointer",
200
+ display: "flex",
201
+ alignItems: "center",
202
+ justifyContent: "space-between",
203
+ gap: 12,
204
+ padding: "6px 10px",
205
+ color: C.textPrimary,
206
+ borderBottom: open ? `1px solid ${C.border}` : undefined,
207
+ background: headerHover ? C.hover : "transparent",
208
+ flexShrink: 0,
209
+ transition: "background 100ms ease",
210
+ }}
211
+ >
212
+ <span>VRAM</span>
213
+ <span style={{ fontVariantNumeric: "tabular-nums" }}>
214
+ {mb(report.totals.all)}
215
+ </span>
216
+ <span style={{ color: C.textMuted }}>{open ? "▾" : "▸"}</span>
217
+ </button>
218
+
219
+ {open && (
220
+ <div
221
+ style={{
222
+ padding: "8px 10px 0",
223
+ display: "flex",
224
+ flexDirection: "column",
225
+ gap: 6,
226
+ minHeight: 0,
227
+ flex: "1 1 auto",
228
+ }}
229
+ >
230
+ {report.sampledTextures.length > 0 && (
231
+ <Section
232
+ label="Sampled textures"
233
+ total={report.totals.sampledTextures}
234
+ >
235
+ {report.sampledTextures.map((t, i) => (
236
+ <TextureRow key={i} entry={t} />
237
+ ))}
238
+ </Section>
239
+ )}
240
+
241
+ {report.renderTargets.length > 0 && (
242
+ <Section
243
+ label="Render targets"
244
+ total={report.totals.renderTargets}
245
+ topBorder
246
+ >
247
+ {report.renderTargets.map((t, i) => (
248
+ <TextureRow key={i} entry={t} />
249
+ ))}
250
+ </Section>
251
+ )}
252
+
253
+ {report.buffers.length > 0 && (
254
+ <Section label="Buffers" total={report.totals.buffers} topBorder>
255
+ {report.buffers.map((b, i) => (
256
+ <BufferRow key={i} entry={b} />
257
+ ))}
258
+ </Section>
259
+ )}
260
+ </div>
261
+ )}
262
+
263
+ {open && (
264
+ <div
265
+ style={{
266
+ display: "flex",
267
+ justifyContent: "flex-end",
268
+ borderTop: `1px solid ${C.border}`,
269
+ padding: "6px 10px",
270
+ flexShrink: 0,
271
+ }}
272
+ >
273
+ <button
274
+ type="button"
275
+ data-webgpu-profiler-btn=""
276
+ onClick={handleCopy}
277
+ onMouseEnter={() => setCopyHover(true)}
278
+ onMouseLeave={() => setCopyHover(false)}
279
+ title="Copy report to clipboard"
280
+ style={{
281
+ all: "unset",
282
+ cursor: "pointer",
283
+ fontSize: 10,
284
+ color: copyHover ? C.textPrimary : C.textMuted,
285
+ transition: "color 100ms ease",
286
+ }}
287
+ >
288
+ {copyFailed ? "copy failed" : copied ? "copied" : "copy report"}
289
+ </button>
290
+ </div>
291
+ )}
292
+ </div>
293
+ );
294
+ }
295
+
296
+ // ── Rows ────────────────────────────────────────────────────────────────────
297
+ //
298
+ // Table rows with `width: 100%` and `table-layout: auto`. Names are
299
+ // pre-truncated to NAME_MAX_CHARS in JS so column widths stay reasonable;
300
+ // CSS overflow/ellipsis is the safety net.
301
+
302
+ function TextureRow({ entry }: { entry: TextureEntry }) {
303
+ return (
304
+ <tr style={{ lineHeight: 1.4 }}>
305
+ <td
306
+ style={{
307
+ paddingRight: 8,
308
+ color: C.textPrimary,
309
+ overflow: "hidden",
310
+ textOverflow: "ellipsis",
311
+ whiteSpace: "nowrap",
312
+ }}
313
+ title={entry.name}
314
+ >
315
+ {shortName(entry.name)}
316
+ </td>
317
+ <td style={{ paddingRight: 8, color: C.textMuted, whiteSpace: "nowrap" }}>
318
+ {entry.width}x{entry.height}
319
+ {entry.depth > 1 ? `x${entry.depth}` : ""}
320
+ </td>
321
+ <td style={{ paddingRight: 8, color: C.textMuted, whiteSpace: "nowrap" }}>
322
+ {entry.format}
323
+ </td>
324
+ <td
325
+ style={{
326
+ textAlign: "right",
327
+ fontVariantNumeric: "tabular-nums",
328
+ color: C.textSecondary,
329
+ whiteSpace: "nowrap",
330
+ }}
331
+ >
332
+ {mb(entry.bytes)}
333
+ </td>
334
+ </tr>
335
+ );
336
+ }
337
+
338
+ function BufferRow({ entry }: { entry: BufferEntry }) {
339
+ return (
340
+ <tr style={{ lineHeight: 1.4 }}>
341
+ <td
342
+ style={{
343
+ paddingRight: 8,
344
+ color: C.textPrimary,
345
+ overflow: "hidden",
346
+ textOverflow: "ellipsis",
347
+ whiteSpace: "nowrap",
348
+ }}
349
+ title={entry.name}
350
+ >
351
+ {shortName(entry.name)}
352
+ </td>
353
+ <td
354
+ style={{
355
+ paddingRight: 8,
356
+ color: C.textMuted,
357
+ fontSize: 10,
358
+ whiteSpace: "nowrap",
359
+ }}
360
+ title={entry.usage}
361
+ >
362
+ {shortName(entry.usage, 18)}
363
+ </td>
364
+ <td
365
+ style={{
366
+ textAlign: "right",
367
+ fontVariantNumeric: "tabular-nums",
368
+ color: C.textSecondary,
369
+ whiteSpace: "nowrap",
370
+ }}
371
+ >
372
+ {mb(entry.bytes)}
373
+ </td>
374
+ </tr>
375
+ );
376
+ }
377
+
378
+ function Section({
379
+ label,
380
+ total,
381
+ topBorder,
382
+ children,
383
+ }: {
384
+ label: string;
385
+ total: number;
386
+ topBorder?: boolean;
387
+ children: ReactNode;
388
+ }) {
389
+ return (
390
+ <div
391
+ style={{
392
+ display: "flex",
393
+ flexDirection: "column",
394
+ minHeight: 0,
395
+ flex: "0 1 auto",
396
+ marginTop: topBorder ? 6 : 0,
397
+ paddingTop: topBorder ? 6 : 0,
398
+ borderTop: topBorder ? `1px solid ${C.border}` : undefined,
399
+ }}
400
+ >
401
+ <div
402
+ style={{
403
+ display: "flex",
404
+ justifyContent: "space-between",
405
+ color: C.textMuted,
406
+ marginBottom: 4,
407
+ flexShrink: 0,
408
+ }}
409
+ >
410
+ <span>{label}</span>
411
+ <span style={{ fontVariantNumeric: "tabular-nums" }}>{mb(total)}</span>
412
+ </div>
413
+ <div
414
+ style={{
415
+ overflowY: "auto",
416
+ overflowX: "hidden",
417
+ maxHeight: SECTION_MAX_HEIGHT,
418
+ minHeight: 0,
419
+ // `thin` shrinks the scrollbar; `stable` reserves space for it
420
+ // even when it's not visible, so the rightmost column never gets
421
+ // overlapped by an overlay scrollbar (macOS, mobile).
422
+ scrollbarWidth: "thin",
423
+ scrollbarGutter: "stable",
424
+ }}
425
+ >
426
+ <table
427
+ style={{
428
+ width: "100%",
429
+ tableLayout: "auto",
430
+ borderCollapse: "collapse",
431
+ }}
432
+ >
433
+ <tbody>{children}</tbody>
434
+ </table>
435
+ </div>
436
+ </div>
437
+ );
438
+ }
@@ -0,0 +1 @@
1
+ export { MemoryHUD, type MemoryHUDProps } from "./MemoryHUD.js";