@plasius/gpu-demo-viewer 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,786 @@
1
+ import {
2
+ createRayTracingRenderPlan,
3
+ createWavefrontPathTracingComputeRenderer,
4
+ supportsWavefrontPathTracingCompute,
5
+ } from "@plasius/gpu-renderer";
6
+
7
+ const FEATURE_FLAG = "gpu.wavefrontPathTracingDemo";
8
+ const GPU_SYNC_SAMPLE_INTERVAL = 12;
9
+
10
+ const resolutionModes = Object.freeze({
11
+ capture: Object.freeze({ width: 960, height: 540, samplesPerPixel: 8 }),
12
+ balanced: Object.freeze({ width: 1280, height: 720, samplesPerPixel: 8 }),
13
+ detail: Object.freeze({ width: 1920, height: 1080, samplesPerPixel: 8 }),
14
+ uhd: Object.freeze({ width: 3840, height: 2160, samplesPerPixel: 4 }),
15
+ });
16
+
17
+ const environmentLighting = Object.freeze({
18
+ environmentColor: Object.freeze([0.58, 0.66, 0.76, 1]),
19
+ ambientColor: Object.freeze([0.04, 0.046, 0.052, 1]),
20
+ environmentPortalMode: "disabled",
21
+ environmentPortals: Object.freeze([]),
22
+ environmentLighting: Object.freeze({
23
+ horizonColor: Object.freeze([0.7, 0.78, 0.86, 1]),
24
+ zenithColor: Object.freeze([0.18, 0.24, 0.34, 1]),
25
+ sunDirection: Object.freeze([0.18, 0.9, -0.4]),
26
+ sunColor: Object.freeze([2.8, 2.55, 2.2, 1]),
27
+ intensity: 1.35,
28
+ mode: 1,
29
+ exposure: 1,
30
+ }),
31
+ });
32
+
33
+ const realtimeCamera = Object.freeze({
34
+ target: Object.freeze([-0.08, -0.12, -1.12]),
35
+ up: Object.freeze([0, 1, 0]),
36
+ fovYDegrees: 47,
37
+ basePosition: Object.freeze([0, 0.5, 2.85]),
38
+ });
39
+
40
+ const performancePhaseDefinitions = Object.freeze([
41
+ Object.freeze({ key: "dispatchMs", label: "dispatch", color: "#ebbc62" }),
42
+ Object.freeze({ key: "gpuWaitMs", label: "GPU sync", color: "#58c6d1" }),
43
+ Object.freeze({ key: "probeMs", label: "probe", color: "#b889ff" }),
44
+ Object.freeze({ key: "overlayMs", label: "overlay", color: "#8fd16a" }),
45
+ Object.freeze({ key: "paceMs", label: "RAF pace", color: "#7d8790" }),
46
+ ]);
47
+
48
+ function clamp(value, min, max) {
49
+ return Math.max(min, Math.min(max, value));
50
+ }
51
+
52
+ function normalize(vector) {
53
+ const length = Math.hypot(vector[0], vector[1], vector[2]) || 1;
54
+ return [vector[0] / length, vector[1] / length, vector[2] / length];
55
+ }
56
+
57
+ function createSmoothMesh({
58
+ id,
59
+ center,
60
+ radius,
61
+ color,
62
+ materialKind = "diffuse",
63
+ roughness = 0.36,
64
+ metallic = 0,
65
+ opacity = color[3] ?? 1,
66
+ rings = 14,
67
+ segments = 28,
68
+ }) {
69
+ const positions = [];
70
+ const normals = [];
71
+ const uvs = [];
72
+ const indices = [];
73
+
74
+ for (let ring = 0; ring <= rings; ring += 1) {
75
+ const v = ring / rings;
76
+ const theta = v * Math.PI;
77
+ const sinTheta = Math.sin(theta);
78
+ const cosTheta = Math.cos(theta);
79
+
80
+ for (let segment = 0; segment <= segments; segment += 1) {
81
+ const u = segment / segments;
82
+ const phi = u * Math.PI * 2;
83
+ const normal = [
84
+ Math.cos(phi) * sinTheta,
85
+ cosTheta,
86
+ Math.sin(phi) * sinTheta,
87
+ ];
88
+ positions.push(
89
+ center[0] + normal[0] * radius,
90
+ center[1] + normal[1] * radius,
91
+ center[2] + normal[2] * radius
92
+ );
93
+ normals.push(normal[0], normal[1], normal[2]);
94
+ uvs.push(u, v);
95
+ }
96
+ }
97
+
98
+ const stride = segments + 1;
99
+ for (let ring = 0; ring < rings; ring += 1) {
100
+ for (let segment = 0; segment < segments; segment += 1) {
101
+ const a = ring * stride + segment;
102
+ const b = a + 1;
103
+ const c = a + stride;
104
+ const d = c + 1;
105
+ indices.push(a, c, b, b, c, d);
106
+ }
107
+ }
108
+
109
+ return {
110
+ id,
111
+ positions,
112
+ indices,
113
+ normals,
114
+ uvs,
115
+ color,
116
+ materialKind,
117
+ roughness,
118
+ metallic,
119
+ opacity,
120
+ };
121
+ }
122
+
123
+ function createWavefrontDemoModelMeshes() {
124
+ return [
125
+ createSmoothMesh({
126
+ id: 200,
127
+ center: [-0.95, -0.08, -1.1],
128
+ radius: 0.62,
129
+ color: [0.72, 0.7, 0.64, 1],
130
+ materialKind: "metal",
131
+ metallic: 0.72,
132
+ roughness: 0.18,
133
+ }),
134
+ createSmoothMesh({
135
+ id: 201,
136
+ center: [0.72, -0.2, -1.32],
137
+ radius: 0.5,
138
+ color: [0.54, 0.68, 0.78, 0.9],
139
+ materialKind: "dielectric",
140
+ roughness: 0.05,
141
+ opacity: 0.9,
142
+ }),
143
+ createSmoothMesh({
144
+ id: 202,
145
+ center: [0.04, -0.32, -0.16],
146
+ radius: 0.34,
147
+ color: [0.38, 0.42, 0.46, 1],
148
+ materialKind: "diffuse",
149
+ roughness: 0.58,
150
+ }),
151
+ ];
152
+ }
153
+
154
+ function createSettings() {
155
+ const params = new URLSearchParams(window.location.search);
156
+ const capture = params.has("capture");
157
+ const resolutionKey =
158
+ params.get("resolution") && resolutionModes[params.get("resolution")]
159
+ ? params.get("resolution")
160
+ : capture
161
+ ? "capture"
162
+ : "balanced";
163
+ const requestedDepth = params.has("maxDepth") ? Number(params.get("maxDepth")) : null;
164
+ const resolution = resolutionModes[resolutionKey];
165
+ const requestedSamples = params.has("samplesPerPixel")
166
+ ? Number(params.get("samplesPerPixel"))
167
+ : params.has("samples")
168
+ ? Number(params.get("samples"))
169
+ : null;
170
+
171
+ return {
172
+ ...resolution,
173
+ resolutionKey,
174
+ capture,
175
+ maxDepth: Number.isInteger(requestedDepth)
176
+ ? clamp(requestedDepth, 3, 8)
177
+ : capture
178
+ ? 6
179
+ : 5,
180
+ samplesPerPixel: Number.isInteger(requestedSamples)
181
+ ? clamp(requestedSamples, 1, 16)
182
+ : resolution.samplesPerPixel,
183
+ denoise: params.get("denoise") !== "off",
184
+ experimentalEnabled:
185
+ params.get("experimental") === "wavefront" ||
186
+ params.get("quality") === "experimental" ||
187
+ params.get("feature") === FEATURE_FLAG,
188
+ };
189
+ }
190
+
191
+ function screenRayCount(settings) {
192
+ return settings.width * settings.height;
193
+ }
194
+
195
+ function primaryRayCount(settings) {
196
+ return screenRayCount(settings) * settings.samplesPerPixel;
197
+ }
198
+
199
+ function createRealtimeCamera(elapsedSeconds = 0) {
200
+ const orbit = elapsedSeconds * 0.42;
201
+ const breathing = elapsedSeconds * 0.31;
202
+ const lateral = Math.sin(orbit) * 0.42;
203
+ const depth = Math.cos(orbit) * 0.12;
204
+ const height = Math.sin(breathing) * 0.05;
205
+ return {
206
+ position: [
207
+ realtimeCamera.basePosition[0] + lateral,
208
+ realtimeCamera.basePosition[1] + height,
209
+ realtimeCamera.basePosition[2] + depth,
210
+ ],
211
+ target: [
212
+ realtimeCamera.target[0] + Math.sin(orbit * 0.7) * 0.06,
213
+ realtimeCamera.target[1],
214
+ realtimeCamera.target[2],
215
+ ],
216
+ up: [...realtimeCamera.up],
217
+ fovYDegrees: realtimeCamera.fovYDegrees,
218
+ };
219
+ }
220
+
221
+ function renderMetricList(root, metrics) {
222
+ root.innerHTML = metrics
223
+ .map(([label, value]) => `<div><dt>${label}</dt><dd>${value}</dd></div>`)
224
+ .join("");
225
+ }
226
+
227
+ function summarizeMemory(memory) {
228
+ return `${(memory.totalHotBufferBytes / 1024 / 1024).toFixed(2)} MiB hot buffers`;
229
+ }
230
+
231
+ function renderBounceRows(root, maxDepth) {
232
+ const plan = createRayTracingRenderPlan({
233
+ snapshotId: "gpu-demo-viewer-wavefront-mesh-bvh",
234
+ wavefront: {
235
+ maxDepth,
236
+ queueCapacity: 128 * 128,
237
+ },
238
+ });
239
+ const passOrder = plan.wavefront.bounceSchedule[0]?.passOrder ?? [];
240
+
241
+ root.innerHTML = Array.from({ length: maxDepth }, (_, bounce) => `
242
+ <div class="bounce-row">
243
+ <div class="bounce-row__top">
244
+ <strong>Bounce ${bounce}</strong>
245
+ <span>GPU queue pass</span>
246
+ </div>
247
+ <div class="bounce-meter"><span style="width: ${Math.max(14, 100 - bounce * 9)}%"></span></div>
248
+ <div class="bounce-row__meta">
249
+ ${passOrder.map((stage) => `<span>${stage}</span>`).join("")}
250
+ </div>
251
+ </div>
252
+ `).join("");
253
+ }
254
+
255
+ function formatMs(value) {
256
+ return Number.isFinite(value) ? `${value.toFixed(1)} ms` : "pending";
257
+ }
258
+
259
+ function formatFps(value) {
260
+ return Number.isFinite(value) ? value.toFixed(1) : "pending";
261
+ }
262
+
263
+ function createPerformanceState() {
264
+ const phaseSamples = Object.fromEntries(
265
+ performancePhaseDefinitions.map((phase) => [phase.key, []])
266
+ );
267
+ return {
268
+ frameCount: 0,
269
+ frameSamples: [],
270
+ submitSamples: [],
271
+ phaseSamples,
272
+ lastBreakdown: null,
273
+ lastFrameMs: Number.NaN,
274
+ lastSubmitMs: Number.NaN,
275
+ bestFrameMs: Number.POSITIVE_INFINITY,
276
+ worstFrameMs: 0,
277
+ startedAt: performance.now(),
278
+ };
279
+ }
280
+
281
+ function recordPerformanceSample(performanceState, sample) {
282
+ const frameMs = performancePhaseDefinitions.reduce(
283
+ (total, phase) => {
284
+ const value = sample[phase.key];
285
+ return total + (Number.isFinite(value) ? Math.max(0, value) : 0);
286
+ },
287
+ 0
288
+ );
289
+ performanceState.frameCount += 1;
290
+ performanceState.lastFrameMs = frameMs;
291
+ performanceState.lastSubmitMs = sample.dispatchMs;
292
+ performanceState.bestFrameMs = Math.min(performanceState.bestFrameMs, frameMs);
293
+ performanceState.worstFrameMs = Math.max(performanceState.worstFrameMs, frameMs);
294
+ performanceState.lastBreakdown = Object.freeze({
295
+ ...sample,
296
+ frameMs,
297
+ });
298
+ performanceState.frameSamples.push(frameMs);
299
+ performanceState.submitSamples.push(sample.dispatchMs);
300
+ for (const phase of performancePhaseDefinitions) {
301
+ if (Number.isFinite(sample[phase.key])) {
302
+ performanceState.phaseSamples[phase.key].push(Math.max(0, sample[phase.key]));
303
+ }
304
+ }
305
+
306
+ if (performanceState.frameSamples.length > 90) {
307
+ performanceState.frameSamples.shift();
308
+ }
309
+ if (performanceState.submitSamples.length > 90) {
310
+ performanceState.submitSamples.shift();
311
+ }
312
+ for (const phase of performancePhaseDefinitions) {
313
+ if (performanceState.phaseSamples[phase.key].length > 90) {
314
+ performanceState.phaseSamples[phase.key].shift();
315
+ }
316
+ }
317
+ }
318
+
319
+ function average(values) {
320
+ if (values.length === 0) {
321
+ return Number.NaN;
322
+ }
323
+ return values.reduce((total, value) => total + value, 0) / values.length;
324
+ }
325
+
326
+ function valuesWithPending(values, pendingValue) {
327
+ if (!Number.isFinite(pendingValue)) {
328
+ return values;
329
+ }
330
+ return [...values, pendingValue];
331
+ }
332
+
333
+ function summarizePerformance(performanceState, stats, running, pendingSample = null) {
334
+ const pendingFrameMs = pendingSample
335
+ ? performancePhaseDefinitions.reduce(
336
+ (total, phase) => {
337
+ const value = pendingSample[phase.key];
338
+ return total + (Number.isFinite(value) ? Math.max(0, value) : 0);
339
+ },
340
+ 0
341
+ )
342
+ : Number.NaN;
343
+ const averageFrameMs = average(valuesWithPending(performanceState.frameSamples, pendingFrameMs));
344
+ const averageSubmitMs = average(
345
+ valuesWithPending(performanceState.submitSamples, pendingSample?.dispatchMs)
346
+ );
347
+ const fps = Number.isFinite(averageFrameMs) && averageFrameMs > 0 ? 1000 / averageFrameMs : Number.NaN;
348
+ const raysPerSecond = Number.isFinite(fps) && stats
349
+ ? stats.primaryRays * fps
350
+ : Number.NaN;
351
+ const phaseAverages = performancePhaseDefinitions.map((phase) => {
352
+ const averageMs = average(
353
+ valuesWithPending(performanceState.phaseSamples[phase.key], pendingSample?.[phase.key])
354
+ );
355
+ const lastMs = pendingSample?.[phase.key] ?? performanceState.lastBreakdown?.[phase.key] ?? Number.NaN;
356
+
357
+ return {
358
+ ...phase,
359
+ averageMs: Number.isFinite(averageMs) ? averageMs : 0,
360
+ lastMs,
361
+ };
362
+ });
363
+ const totalPhaseMs = phaseAverages.reduce((total, phase) => total + phase.averageMs, 0);
364
+ const breakdown = phaseAverages.map((phase) => ({
365
+ ...phase,
366
+ percent: totalPhaseMs > 0 ? (phase.averageMs / totalPhaseMs) * 100 : 0,
367
+ }));
368
+
369
+ return {
370
+ running,
371
+ frameCount: performanceState.frameCount,
372
+ lastFrameMs: performanceState.lastFrameMs,
373
+ averageFrameMs,
374
+ averageSubmitMs,
375
+ bestFrameMs: Number.isFinite(performanceState.bestFrameMs)
376
+ ? performanceState.bestFrameMs
377
+ : Number.NaN,
378
+ worstFrameMs: performanceState.worstFrameMs || Number.NaN,
379
+ fps,
380
+ raysPerSecond,
381
+ breakdown,
382
+ elapsedSeconds: (performance.now() - performanceState.startedAt) / 1000,
383
+ };
384
+ }
385
+
386
+ function updatePerformanceBreakdown(performanceSummary) {
387
+ const stack = document.getElementById("timingStack");
388
+ const legend = document.getElementById("timingLegend");
389
+
390
+ stack.innerHTML = performanceSummary.breakdown
391
+ .map((phase) => `
392
+ <span
393
+ class="timing-stack__segment"
394
+ title="${phase.label}: ${phase.percent.toFixed(1)}%, ${formatMs(phase.averageMs)} avg"
395
+ style="width: ${phase.percent.toFixed(3)}%; background: ${phase.color};"
396
+ ></span>
397
+ `)
398
+ .join("");
399
+ legend.innerHTML = performanceSummary.breakdown
400
+ .map((phase) => `
401
+ <li>
402
+ <span class="timing-swatch" style="background: ${phase.color};"></span>
403
+ <span>${phase.label}</span>
404
+ <strong>${phase.percent.toFixed(1)}%</strong>
405
+ <code>${formatMs(phase.averageMs)}</code>
406
+ </li>
407
+ `)
408
+ .join("");
409
+ stack.setAttribute(
410
+ "aria-label",
411
+ `Frame timing breakdown: ${performanceSummary.breakdown
412
+ .map((phase) => `${phase.label} ${phase.percent.toFixed(1)} percent`)
413
+ .join(", ")}`
414
+ );
415
+ }
416
+
417
+ function updatePerformanceOverlay({ stats, performanceSummary }) {
418
+ updatePerformanceBreakdown(performanceSummary);
419
+ renderMetricList(document.getElementById("performanceStats"), [
420
+ ["loop", performanceSummary.running ? "running" : "paused"],
421
+ ["fps avg", formatFps(performanceSummary.fps)],
422
+ ["loop frame avg", formatMs(performanceSummary.averageFrameMs)],
423
+ ["loop frame last", formatMs(performanceSummary.lastFrameMs)],
424
+ ["submit avg", formatMs(performanceSummary.averageSubmitMs)],
425
+ ["best frame", formatMs(performanceSummary.bestFrameMs)],
426
+ ["worst frame", formatMs(performanceSummary.worstFrameMs)],
427
+ ["frames", performanceSummary.frameCount.toLocaleString("en-GB")],
428
+ [
429
+ "primary rays/s",
430
+ Number.isFinite(performanceSummary.raysPerSecond)
431
+ ? performanceSummary.raysPerSecond.toLocaleString("en-GB", { maximumFractionDigits: 0 })
432
+ : "pending",
433
+ ],
434
+ [
435
+ "tile work",
436
+ stats ? `${stats.tiles.toLocaleString("en-GB")} x ${stats.samplesPerPixel} spp` : "pending",
437
+ ],
438
+ ]);
439
+ }
440
+
441
+ function updateDebugOverlay({ stats, settings, probe, performanceSummary }) {
442
+ updatePerformanceOverlay({ stats, performanceSummary });
443
+ renderMetricList(document.getElementById("terminationStats"), [
444
+ ["geometry", "mesh BVH"],
445
+ ["scene", "model + skybox"],
446
+ ["animation", "camera orbit"],
447
+ ["display quality", stats.displayQuality ? "true" : "false"],
448
+ ["mesh triangles", stats.triangleCount.toLocaleString("en-GB")],
449
+ ["acceleration", stats.accelerationBuildMode],
450
+ ]);
451
+ renderMetricList(document.getElementById("contractStats"), [
452
+ ["resolution", `${stats.width}x${stats.height}`],
453
+ ["screen pixels", stats.screenRays.toLocaleString("en-GB")],
454
+ ["samples / pixel", stats.samplesPerPixel.toLocaleString("en-GB")],
455
+ ["primary samples", stats.primaryRays.toLocaleString("en-GB")],
456
+ ["triangles", stats.triangleCount.toLocaleString("en-GB")],
457
+ ["BVH nodes", stats.bvhNodeCount.toLocaleString("en-GB")],
458
+ ["max depth", `${stats.maxDepth} bounces`],
459
+ ["tiles", `${stats.tiles} @ ${stats.tileSize}px`],
460
+ [
461
+ "environment portals",
462
+ stats.environmentPortalCount > 0 ? String(stats.environmentPortalCount) : "off",
463
+ ],
464
+ ["denoise", settings.denoise ? "on" : "off"],
465
+ ["probe", probe ? probe.luminance.toFixed(4) : "pending"],
466
+ ["memory", summarizeMemory(stats.memory)],
467
+ ]);
468
+ renderBounceRows(document.getElementById("bounceRows"), stats.maxDepth);
469
+ }
470
+
471
+ function updateControls(settings) {
472
+ document.getElementById("maxDepthSelect").value = String(settings.maxDepth);
473
+ document.getElementById("resolutionSelect").value = settings.resolutionKey;
474
+ document.getElementById("samplesSelect").value = String(settings.samplesPerPixel);
475
+ document.getElementById("denoiseSelect").value = settings.denoise ? "on" : "off";
476
+ document.getElementById(
477
+ "captureLink"
478
+ ).href = `./?experimental=wavefront&capture=1&maxDepth=${settings.maxDepth}&samplesPerPixel=${settings.samplesPerPixel}&denoise=${settings.denoise ? "on" : "off"}`;
479
+ }
480
+
481
+ function createUrlFromControls() {
482
+ const maxDepth = document.getElementById("maxDepthSelect").value;
483
+ const resolution = document.getElementById("resolutionSelect").value;
484
+ const samplesPerPixel = document.getElementById("samplesSelect").value;
485
+ const denoise = document.getElementById("denoiseSelect").value;
486
+ return `./?experimental=wavefront&resolution=${resolution}&maxDepth=${maxDepth}&samplesPerPixel=${samplesPerPixel}&denoise=${denoise}`;
487
+ }
488
+
489
+ function withTimeout(promise, ms, fallback) {
490
+ let timer = null;
491
+ return Promise.race([
492
+ promise,
493
+ new Promise((resolve) => {
494
+ timer = window.setTimeout(() => resolve(fallback), ms);
495
+ }),
496
+ ]).finally(() => {
497
+ if (timer !== null) {
498
+ window.clearTimeout(timer);
499
+ }
500
+ });
501
+ }
502
+
503
+ async function renderWavefrontFrame(canvas, settings) {
504
+ const renderer = await createWavefrontPathTracingComputeRenderer({
505
+ canvas,
506
+ width: settings.width,
507
+ height: settings.height,
508
+ maxDepth: settings.maxDepth,
509
+ samplesPerPixel: settings.samplesPerPixel,
510
+ tileSize: 128,
511
+ displayQuality: true,
512
+ denoise: settings.denoise,
513
+ meshes: createWavefrontDemoModelMeshes(),
514
+ camera: createRealtimeCamera(0),
515
+ ...environmentLighting,
516
+ });
517
+ if (typeof renderer.updateCamera !== "function") {
518
+ renderer.destroy?.();
519
+ throw new Error("Realtime wavefront animation requires @plasius/gpu-renderer 0.2.3 or newer.");
520
+ }
521
+ return renderer;
522
+ }
523
+
524
+ async function readProbe(renderer, settings) {
525
+ return withTimeout(
526
+ renderer.readOutputProbe({
527
+ x: Math.floor(settings.width / 2),
528
+ y: Math.floor(settings.height / 2),
529
+ })
530
+ .catch(() => null),
531
+ 1500,
532
+ null
533
+ );
534
+ }
535
+
536
+ function installSnapshotHook({ renderer, stats, settings, probe, performanceSummary }) {
537
+ window.render_game_to_text = () =>
538
+ JSON.stringify({
539
+ surface: "gpu-demo-viewer-wavefront",
540
+ geometryMode: "mesh-bvh-display-quality",
541
+ displayQuality: true,
542
+ requiresMeshBvhForDisplayQuality: true,
543
+ screenRays: screenRayCount(settings),
544
+ primaryRays: primaryRayCount(settings),
545
+ samplesPerPixel: settings.samplesPerPixel,
546
+ renderer: renderer.getSnapshot(),
547
+ stats,
548
+ probe,
549
+ performance: performanceSummary,
550
+ animation: Object.freeze({
551
+ mode: "camera-orbit",
552
+ elapsedSeconds: performanceSummary.elapsedSeconds,
553
+ camera: settings.camera,
554
+ }),
555
+ });
556
+ window.__plasiusWavefrontPerformance = performanceSummary;
557
+ }
558
+
559
+ function boot() {
560
+ const canvas = document.getElementById("renderCanvas");
561
+ const status = document.getElementById("renderStatus");
562
+ const mode = document.getElementById("renderMode");
563
+ const toggleLoopButton = document.getElementById("toggleLoopButton");
564
+ const renderButton = document.getElementById("renderButton");
565
+ const state = {
566
+ renderer: null,
567
+ settings: null,
568
+ running: true,
569
+ loopToken: 0,
570
+ stats: null,
571
+ probe: null,
572
+ performance: createPerformanceState(),
573
+ renderingFrame: false,
574
+ lastFrameCompletedAt: Number.NaN,
575
+ animationStartedAt: performance.now(),
576
+ };
577
+
578
+ function getPerformanceSummary() {
579
+ return summarizePerformance(state.performance, state.stats, state.running);
580
+ }
581
+
582
+ function updateStatus(performanceSummary = getPerformanceSummary()) {
583
+ if (!state.stats || !state.settings) {
584
+ return;
585
+ }
586
+ status.textContent =
587
+ `${performanceSummary.running ? "Looping" : "Paused"} at ${formatFps(performanceSummary.fps)} FPS, ` +
588
+ `${screenRayCount(state.settings).toLocaleString("en-GB")} screen pixels, ` +
589
+ `${primaryRayCount(state.settings).toLocaleString("en-GB")} primary samples, ` +
590
+ `${state.stats.triangleCount.toLocaleString("en-GB")} mesh triangles.`;
591
+ }
592
+
593
+ function updateAllOverlays() {
594
+ if (!state.stats || !state.settings) {
595
+ return;
596
+ }
597
+ const performanceSummary = getPerformanceSummary();
598
+ updateDebugOverlay({
599
+ stats: state.stats,
600
+ settings: state.settings,
601
+ probe: state.probe,
602
+ performanceSummary,
603
+ });
604
+ installSnapshotHook({
605
+ renderer: state.renderer,
606
+ stats: state.stats,
607
+ settings: state.settings,
608
+ probe: state.probe,
609
+ performanceSummary,
610
+ });
611
+ updateStatus(performanceSummary);
612
+ }
613
+
614
+ async function renderOneFrame({ includeProbe = false, expectedToken = state.loopToken } = {}) {
615
+ const renderer = state.renderer;
616
+ const settings = state.settings;
617
+ const isCurrentFrame = () =>
618
+ state.renderer === renderer && state.settings === settings && state.loopToken === expectedToken;
619
+ if (!renderer || !settings || state.renderingFrame || !isCurrentFrame()) {
620
+ return;
621
+ }
622
+ state.renderingFrame = true;
623
+ const startedAt = performance.now();
624
+ const paceMs = Number.isFinite(state.lastFrameCompletedAt)
625
+ ? Math.max(0, startedAt - state.lastFrameCompletedAt)
626
+ : 0;
627
+ try {
628
+ const submitStartedAt = performance.now();
629
+ const elapsedSeconds = (submitStartedAt - state.animationStartedAt) / 1000;
630
+ const camera = createRealtimeCamera(elapsedSeconds);
631
+ renderer.updateCamera(camera);
632
+ settings.camera = camera;
633
+ const stats = renderer.renderOnce();
634
+ const dispatchMs = performance.now() - submitStartedAt;
635
+ const shouldSyncGpu =
636
+ includeProbe ||
637
+ state.performance.frameCount === 0 ||
638
+ state.performance.frameCount % GPU_SYNC_SAMPLE_INTERVAL === 0;
639
+ let gpuWaitMs = Number.NaN;
640
+ if (shouldSyncGpu) {
641
+ const gpuWaitStartedAt = performance.now();
642
+ await withTimeout(
643
+ renderer.device.queue.onSubmittedWorkDone?.() ?? Promise.resolve(),
644
+ 5000,
645
+ null
646
+ );
647
+ gpuWaitMs = performance.now() - gpuWaitStartedAt;
648
+ }
649
+ if (!isCurrentFrame()) {
650
+ return;
651
+ }
652
+ let probeMs = 0;
653
+ let probe = state.probe;
654
+ if (includeProbe || state.performance.frameCount === 1) {
655
+ const probeStartedAt = performance.now();
656
+ probe = await readProbe(renderer, settings);
657
+ probeMs = performance.now() - probeStartedAt;
658
+ if (!isCurrentFrame()) {
659
+ return;
660
+ }
661
+ }
662
+ state.stats = stats;
663
+ state.probe = probe;
664
+ const pendingSample = {
665
+ dispatchMs,
666
+ gpuWaitMs,
667
+ probeMs,
668
+ overlayMs: 0,
669
+ paceMs,
670
+ };
671
+ const overlayStartedAt = performance.now();
672
+ const provisionalSummary = summarizePerformance(
673
+ state.performance,
674
+ stats,
675
+ state.running,
676
+ pendingSample
677
+ );
678
+ updateDebugOverlay({
679
+ stats,
680
+ settings,
681
+ probe,
682
+ performanceSummary: provisionalSummary,
683
+ });
684
+ installSnapshotHook({
685
+ renderer,
686
+ stats,
687
+ settings,
688
+ probe,
689
+ performanceSummary: provisionalSummary,
690
+ });
691
+ updateStatus(provisionalSummary);
692
+ pendingSample.overlayMs = performance.now() - overlayStartedAt;
693
+ recordPerformanceSample(state.performance, pendingSample);
694
+ state.lastFrameCompletedAt = performance.now();
695
+ updateAllOverlays();
696
+ } finally {
697
+ state.renderingFrame = false;
698
+ }
699
+ }
700
+
701
+ async function runLoop(token) {
702
+ while (state.running && state.loopToken === token) {
703
+ await renderOneFrame({ expectedToken: token });
704
+ await new Promise((resolve) => window.requestAnimationFrame(resolve));
705
+ }
706
+ }
707
+
708
+ async function applySettings({ keepRunning = state.running } = {}) {
709
+ state.loopToken += 1;
710
+ const setupToken = state.loopToken;
711
+ const settings = createSettings();
712
+ state.settings = settings;
713
+ state.running = keepRunning;
714
+ state.performance = createPerformanceState();
715
+ state.stats = null;
716
+ state.probe = null;
717
+ state.lastFrameCompletedAt = Number.NaN;
718
+ state.animationStartedAt = performance.now();
719
+ updateControls(settings);
720
+ toggleLoopButton.textContent = state.running ? "Pause loop" : "Resume loop";
721
+ mode.textContent = settings.experimentalEnabled
722
+ ? `Feature flag: ${FEATURE_FLAG} enabled`
723
+ : `Feature flag: ${FEATURE_FLAG} disabled`;
724
+
725
+ if (!settings.experimentalEnabled) {
726
+ status.textContent = "Open with ?experimental=wavefront to run the mesh BVH path.";
727
+ return;
728
+ }
729
+
730
+ if (!supportsWavefrontPathTracingCompute()) {
731
+ status.textContent = window.isSecureContext
732
+ ? "WebGPU is unavailable in this browser."
733
+ : "WebGPU requires localhost or HTTPS.";
734
+ return;
735
+ }
736
+
737
+ const previousRenderer = state.renderer;
738
+ state.renderer = null;
739
+ previousRenderer?.destroy?.();
740
+ status.textContent = "Building GPU mesh BVH and preparing the live wavefront loop.";
741
+ const renderer = await renderWavefrontFrame(canvas, settings);
742
+ if (state.loopToken !== setupToken) {
743
+ renderer.destroy?.();
744
+ return;
745
+ }
746
+ state.renderer = renderer;
747
+ await renderOneFrame({ includeProbe: true, expectedToken: setupToken });
748
+ if (state.running && state.loopToken === setupToken) {
749
+ runLoop(setupToken).catch((error) => {
750
+ status.textContent = error instanceof Error ? error.message : String(error);
751
+ });
752
+ }
753
+ }
754
+
755
+ renderButton.addEventListener("click", () => {
756
+ state.running = false;
757
+ toggleLoopButton.textContent = "Resume loop";
758
+ renderOneFrame({ includeProbe: true }).catch((error) => {
759
+ status.textContent = error instanceof Error ? error.message : String(error);
760
+ });
761
+ });
762
+ toggleLoopButton.addEventListener("click", () => {
763
+ state.running = !state.running;
764
+ toggleLoopButton.textContent = state.running ? "Pause loop" : "Resume loop";
765
+ updateAllOverlays();
766
+ if (state.running) {
767
+ runLoop(state.loopToken).catch((error) => {
768
+ status.textContent = error instanceof Error ? error.message : String(error);
769
+ });
770
+ }
771
+ });
772
+ for (const id of ["maxDepthSelect", "resolutionSelect", "samplesSelect", "denoiseSelect"]) {
773
+ document.getElementById(id).addEventListener("change", () => {
774
+ window.history.replaceState(null, "", createUrlFromControls());
775
+ applySettings({ keepRunning: state.running }).catch((error) => {
776
+ status.textContent = error instanceof Error ? error.message : String(error);
777
+ });
778
+ });
779
+ }
780
+ window.addEventListener("pagehide", () => state.renderer?.destroy?.(), { once: true });
781
+ applySettings({ keepRunning: true }).catch((error) => {
782
+ status.textContent = error instanceof Error ? error.message : String(error);
783
+ });
784
+ }
785
+
786
+ boot();