@plasius/gpu-renderer 0.1.6 → 0.1.8

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/src/index.js CHANGED
@@ -1,5 +1,686 @@
1
1
  const DEFAULT_CLEAR_COLOR = Object.freeze([0.07, 0.11, 0.18, 1.0]);
2
2
  const DEFAULT_CANVAS_SELECTOR = "canvas[data-plasius-gpu-renderer]";
3
+ export const rendererDebugOwner = "renderer";
4
+ export const rendererWorkerQueueClass = "render";
5
+ export const defaultRendererWorkerProfile = "realtime";
6
+ export const rendererRepresentationBands = Object.freeze([
7
+ "near",
8
+ "mid",
9
+ "far",
10
+ "horizon",
11
+ ]);
12
+ export const rendererAccelerationStructureUpdateClasses = Object.freeze([
13
+ "static",
14
+ "rigid-dynamic",
15
+ "deforming",
16
+ "proxy",
17
+ ]);
18
+ export const rendererRayTracingStageOrder = Object.freeze([
19
+ "primaryVisibility",
20
+ "shadowAssist",
21
+ "opaqueFoundation",
22
+ "rtDirectLighting",
23
+ "rtReflections",
24
+ "rtGlobalIllumination",
25
+ "denoiseTemporal",
26
+ "transparents",
27
+ "composition",
28
+ "present",
29
+ ]);
30
+
31
+ const rendererRayTracingStageDefinitions = Object.freeze(
32
+ rendererRayTracingStageOrder.map((key, index) =>
33
+ Object.freeze({
34
+ key,
35
+ order: index + 1,
36
+ required: true,
37
+ description:
38
+ {
39
+ primaryVisibility: "Primary visibility and depth preparation.",
40
+ shadowAssist: "Shadow assist passes and regional shadow preparation.",
41
+ opaqueFoundation: "Main opaque foundation for shading and tracing inputs.",
42
+ rtDirectLighting: "Ray-traced direct lighting and premium shadows.",
43
+ rtReflections: "Ray-traced reflections for important surfaces.",
44
+ rtGlobalIllumination: "Selective ray-traced indirect lighting and GI.",
45
+ denoiseTemporal: "Required denoise and temporal accumulation stage.",
46
+ transparents: "Transparents, particles, and volumetrics composition.",
47
+ composition: "Final world composition and color resolve.",
48
+ present: "Presentation to the active surface.",
49
+ }[key],
50
+ })
51
+ )
52
+ );
53
+
54
+ const rendererRepresentationBandPolicies = Object.freeze({
55
+ near: Object.freeze({
56
+ band: "near",
57
+ rasterMode: "full-live",
58
+ rtParticipation: "premium",
59
+ shadowSource: "ray-traced-primary",
60
+ temporalReuse: "balanced",
61
+ updateCadenceDivisor: 1,
62
+ }),
63
+ mid: Object.freeze({
64
+ band: "mid",
65
+ rasterMode: "simplified-live",
66
+ rtParticipation: "selective",
67
+ shadowSource: "regional-raster-and-proxy",
68
+ temporalReuse: "aggressive",
69
+ updateCadenceDivisor: 2,
70
+ }),
71
+ far: Object.freeze({
72
+ band: "far",
73
+ rasterMode: "proxy-or-cached",
74
+ rtParticipation: "proxy",
75
+ shadowSource: "merged-proxy-casters",
76
+ temporalReuse: "high",
77
+ updateCadenceDivisor: 8,
78
+ }),
79
+ horizon: Object.freeze({
80
+ band: "horizon",
81
+ rasterMode: "horizon-shell",
82
+ rtParticipation: "disabled",
83
+ shadowSource: "baked-impression",
84
+ temporalReuse: "cached",
85
+ updateCadenceDivisor: 60,
86
+ }),
87
+ });
88
+
89
+ const rendererAccelerationStructurePolicies = Object.freeze(
90
+ rendererAccelerationStructureUpdateClasses.map((updateClass) =>
91
+ Object.freeze({
92
+ updateClass,
93
+ description:
94
+ {
95
+ static: "Stable static world geometry with infrequent rebuilds.",
96
+ "rigid-dynamic":
97
+ "Rigid transforms that can be refit or relinked without full deformation updates.",
98
+ deforming:
99
+ "Skinned or vertex-deforming content treated as a managed RT cost center.",
100
+ proxy:
101
+ "Low-cost RT proxy or distant representation updates.",
102
+ }[updateClass],
103
+ })
104
+ )
105
+ );
106
+
107
+ function buildRendererWorkerBudgetLevels(jobType, queueClass, levels) {
108
+ return Object.freeze(
109
+ levels.map((level) =>
110
+ Object.freeze({
111
+ id: level.id,
112
+ estimatedCostMs: level.estimatedCostMs,
113
+ config: Object.freeze({
114
+ maxDispatchesPerFrame: level.config.maxDispatchesPerFrame,
115
+ maxJobsPerDispatch: level.config.maxJobsPerDispatch,
116
+ cadenceDivisor: level.config.cadenceDivisor,
117
+ workgroupScale: level.config.workgroupScale,
118
+ maxQueueDepth: level.config.maxQueueDepth,
119
+ metadata: Object.freeze({
120
+ owner: rendererDebugOwner,
121
+ queueClass,
122
+ jobType,
123
+ quality: level.id,
124
+ }),
125
+ }),
126
+ })
127
+ )
128
+ );
129
+ }
130
+
131
+ const rendererWorkerProfileSpecs = {
132
+ realtime: {
133
+ description:
134
+ "Frame-stage DAG for flat rendering with visibility, main encode, post-processing, and submit.",
135
+ suggestedAllocationIds: [
136
+ "renderer.surface.current",
137
+ "renderer.visibility.worklist",
138
+ "renderer.post-process.history",
139
+ ],
140
+ jobs: {
141
+ acquire: {
142
+ priority: 5,
143
+ dependencies: [],
144
+ domain: "resolution",
145
+ importance: "critical",
146
+ levels: [
147
+ {
148
+ id: "fixed",
149
+ estimatedCostMs: 0.2,
150
+ config: {
151
+ maxDispatchesPerFrame: 1,
152
+ maxJobsPerDispatch: 1,
153
+ cadenceDivisor: 1,
154
+ workgroupScale: 1,
155
+ maxQueueDepth: 1,
156
+ },
157
+ },
158
+ ],
159
+ suggestedAllocationIds: ["renderer.surface.current"],
160
+ },
161
+ visibility: {
162
+ priority: 4,
163
+ dependencies: [],
164
+ domain: "geometry",
165
+ importance: "high",
166
+ levels: [
167
+ {
168
+ id: "low",
169
+ estimatedCostMs: 0.4,
170
+ config: {
171
+ maxDispatchesPerFrame: 1,
172
+ maxJobsPerDispatch: 128,
173
+ cadenceDivisor: 2,
174
+ workgroupScale: 0.5,
175
+ maxQueueDepth: 256,
176
+ },
177
+ },
178
+ {
179
+ id: "medium",
180
+ estimatedCostMs: 0.8,
181
+ config: {
182
+ maxDispatchesPerFrame: 1,
183
+ maxJobsPerDispatch: 256,
184
+ cadenceDivisor: 1,
185
+ workgroupScale: 0.75,
186
+ maxQueueDepth: 384,
187
+ },
188
+ },
189
+ {
190
+ id: "high",
191
+ estimatedCostMs: 1.2,
192
+ config: {
193
+ maxDispatchesPerFrame: 2,
194
+ maxJobsPerDispatch: 512,
195
+ cadenceDivisor: 1,
196
+ workgroupScale: 1,
197
+ maxQueueDepth: 512,
198
+ },
199
+ },
200
+ ],
201
+ suggestedAllocationIds: ["renderer.visibility.worklist"],
202
+ },
203
+ mainEncode: {
204
+ priority: 4,
205
+ dependencies: ["acquire", "visibility"],
206
+ domain: "geometry",
207
+ importance: "critical",
208
+ levels: [
209
+ {
210
+ id: "low",
211
+ estimatedCostMs: 1.2,
212
+ config: {
213
+ maxDispatchesPerFrame: 1,
214
+ maxJobsPerDispatch: 128,
215
+ cadenceDivisor: 1,
216
+ workgroupScale: 0.6,
217
+ maxQueueDepth: 192,
218
+ },
219
+ },
220
+ {
221
+ id: "medium",
222
+ estimatedCostMs: 2.1,
223
+ config: {
224
+ maxDispatchesPerFrame: 1,
225
+ maxJobsPerDispatch: 256,
226
+ cadenceDivisor: 1,
227
+ workgroupScale: 0.8,
228
+ maxQueueDepth: 256,
229
+ },
230
+ },
231
+ {
232
+ id: "high",
233
+ estimatedCostMs: 3,
234
+ config: {
235
+ maxDispatchesPerFrame: 1,
236
+ maxJobsPerDispatch: 384,
237
+ cadenceDivisor: 1,
238
+ workgroupScale: 1,
239
+ maxQueueDepth: 384,
240
+ },
241
+ },
242
+ ],
243
+ suggestedAllocationIds: ["renderer.surface.current"],
244
+ },
245
+ postProcess: {
246
+ priority: 3,
247
+ dependencies: ["mainEncode"],
248
+ domain: "post-processing",
249
+ importance: "high",
250
+ levels: [
251
+ {
252
+ id: "low",
253
+ estimatedCostMs: 0.5,
254
+ config: {
255
+ maxDispatchesPerFrame: 1,
256
+ maxJobsPerDispatch: 64,
257
+ cadenceDivisor: 2,
258
+ workgroupScale: 0.5,
259
+ maxQueueDepth: 96,
260
+ },
261
+ },
262
+ {
263
+ id: "medium",
264
+ estimatedCostMs: 0.9,
265
+ config: {
266
+ maxDispatchesPerFrame: 1,
267
+ maxJobsPerDispatch: 128,
268
+ cadenceDivisor: 1,
269
+ workgroupScale: 0.75,
270
+ maxQueueDepth: 128,
271
+ },
272
+ },
273
+ {
274
+ id: "high",
275
+ estimatedCostMs: 1.4,
276
+ config: {
277
+ maxDispatchesPerFrame: 2,
278
+ maxJobsPerDispatch: 192,
279
+ cadenceDivisor: 1,
280
+ workgroupScale: 1,
281
+ maxQueueDepth: 192,
282
+ },
283
+ },
284
+ ],
285
+ suggestedAllocationIds: ["renderer.post-process.history"],
286
+ },
287
+ submit: {
288
+ priority: 2,
289
+ dependencies: ["postProcess"],
290
+ domain: "resolution",
291
+ importance: "critical",
292
+ levels: [
293
+ {
294
+ id: "fixed",
295
+ estimatedCostMs: 0.2,
296
+ config: {
297
+ maxDispatchesPerFrame: 1,
298
+ maxJobsPerDispatch: 1,
299
+ cadenceDivisor: 1,
300
+ workgroupScale: 1,
301
+ maxQueueDepth: 1,
302
+ },
303
+ },
304
+ ],
305
+ suggestedAllocationIds: ["renderer.surface.current"],
306
+ },
307
+ },
308
+ },
309
+ xr: {
310
+ description:
311
+ "Frame-stage DAG for XR rendering with late-latch coordination before main encode and submit.",
312
+ suggestedAllocationIds: [
313
+ "renderer.xr.surface.current",
314
+ "renderer.xr.visibility.worklist",
315
+ ],
316
+ jobs: {
317
+ acquire: {
318
+ priority: 5,
319
+ dependencies: [],
320
+ domain: "xr",
321
+ importance: "critical",
322
+ levels: [
323
+ {
324
+ id: "fixed",
325
+ estimatedCostMs: 0.2,
326
+ config: {
327
+ maxDispatchesPerFrame: 1,
328
+ maxJobsPerDispatch: 1,
329
+ cadenceDivisor: 1,
330
+ workgroupScale: 1,
331
+ maxQueueDepth: 1,
332
+ },
333
+ },
334
+ ],
335
+ suggestedAllocationIds: ["renderer.xr.surface.current"],
336
+ },
337
+ visibility: {
338
+ priority: 4,
339
+ dependencies: [],
340
+ domain: "geometry",
341
+ importance: "high",
342
+ levels: [
343
+ {
344
+ id: "low",
345
+ estimatedCostMs: 0.5,
346
+ config: {
347
+ maxDispatchesPerFrame: 1,
348
+ maxJobsPerDispatch: 96,
349
+ cadenceDivisor: 2,
350
+ workgroupScale: 0.5,
351
+ maxQueueDepth: 192,
352
+ },
353
+ },
354
+ {
355
+ id: "medium",
356
+ estimatedCostMs: 0.9,
357
+ config: {
358
+ maxDispatchesPerFrame: 1,
359
+ maxJobsPerDispatch: 192,
360
+ cadenceDivisor: 1,
361
+ workgroupScale: 0.75,
362
+ maxQueueDepth: 256,
363
+ },
364
+ },
365
+ {
366
+ id: "high",
367
+ estimatedCostMs: 1.3,
368
+ config: {
369
+ maxDispatchesPerFrame: 2,
370
+ maxJobsPerDispatch: 320,
371
+ cadenceDivisor: 1,
372
+ workgroupScale: 1,
373
+ maxQueueDepth: 320,
374
+ },
375
+ },
376
+ ],
377
+ suggestedAllocationIds: ["renderer.xr.visibility.worklist"],
378
+ },
379
+ lateLatch: {
380
+ priority: 5,
381
+ dependencies: ["acquire"],
382
+ domain: "xr",
383
+ importance: "critical",
384
+ levels: [
385
+ {
386
+ id: "fixed",
387
+ estimatedCostMs: 0.15,
388
+ config: {
389
+ maxDispatchesPerFrame: 1,
390
+ maxJobsPerDispatch: 1,
391
+ cadenceDivisor: 1,
392
+ workgroupScale: 1,
393
+ maxQueueDepth: 1,
394
+ },
395
+ },
396
+ ],
397
+ suggestedAllocationIds: ["renderer.xr.surface.current"],
398
+ },
399
+ mainEncode: {
400
+ priority: 4,
401
+ dependencies: ["visibility", "lateLatch"],
402
+ domain: "xr",
403
+ importance: "critical",
404
+ levels: [
405
+ {
406
+ id: "low",
407
+ estimatedCostMs: 1.1,
408
+ config: {
409
+ maxDispatchesPerFrame: 1,
410
+ maxJobsPerDispatch: 96,
411
+ cadenceDivisor: 1,
412
+ workgroupScale: 0.6,
413
+ maxQueueDepth: 128,
414
+ },
415
+ },
416
+ {
417
+ id: "medium",
418
+ estimatedCostMs: 1.8,
419
+ config: {
420
+ maxDispatchesPerFrame: 1,
421
+ maxJobsPerDispatch: 192,
422
+ cadenceDivisor: 1,
423
+ workgroupScale: 0.8,
424
+ maxQueueDepth: 192,
425
+ },
426
+ },
427
+ {
428
+ id: "high",
429
+ estimatedCostMs: 2.6,
430
+ config: {
431
+ maxDispatchesPerFrame: 1,
432
+ maxJobsPerDispatch: 256,
433
+ cadenceDivisor: 1,
434
+ workgroupScale: 1,
435
+ maxQueueDepth: 256,
436
+ },
437
+ },
438
+ ],
439
+ suggestedAllocationIds: ["renderer.xr.surface.current"],
440
+ },
441
+ submit: {
442
+ priority: 2,
443
+ dependencies: ["mainEncode"],
444
+ domain: "xr",
445
+ importance: "critical",
446
+ levels: [
447
+ {
448
+ id: "fixed",
449
+ estimatedCostMs: 0.2,
450
+ config: {
451
+ maxDispatchesPerFrame: 1,
452
+ maxJobsPerDispatch: 1,
453
+ cadenceDivisor: 1,
454
+ workgroupScale: 1,
455
+ maxQueueDepth: 1,
456
+ },
457
+ },
458
+ ],
459
+ suggestedAllocationIds: ["renderer.xr.surface.current"],
460
+ },
461
+ },
462
+ },
463
+ };
464
+
465
+ function buildRendererInputBoundary(profile) {
466
+ return Object.freeze({
467
+ type: "stable-visual-snapshot",
468
+ owner: rendererDebugOwner,
469
+ profile,
470
+ authority: "visual",
471
+ source: "scene-preparation",
472
+ stable: true,
473
+ });
474
+ }
475
+
476
+ function buildRendererRenderStages(profile) {
477
+ return Object.freeze(
478
+ rendererRayTracingStageDefinitions.map((stage) =>
479
+ Object.freeze({
480
+ ...stage,
481
+ profile,
482
+ workerJobKeys:
483
+ profile === "xr" && stage.key === "primaryVisibility"
484
+ ? Object.freeze(["lateLatch", "visibility"])
485
+ : stage.key === "present"
486
+ ? Object.freeze(["submit"])
487
+ : stage.key === "denoiseTemporal" ||
488
+ stage.key === "transparents" ||
489
+ stage.key === "composition"
490
+ ? Object.freeze(["postProcess"])
491
+ : stage.key === "primaryVisibility"
492
+ ? Object.freeze(["visibility"])
493
+ : stage.key === "shadowAssist" ||
494
+ stage.key === "opaqueFoundation" ||
495
+ stage.key === "rtDirectLighting" ||
496
+ stage.key === "rtReflections" ||
497
+ stage.key === "rtGlobalIllumination"
498
+ ? Object.freeze(["mainEncode"])
499
+ : Object.freeze(["mainEncode"]),
500
+ })
501
+ )
502
+ );
503
+ }
504
+
505
+ function buildRendererRepresentationBands(profile) {
506
+ return Object.freeze(
507
+ rendererRepresentationBands.map((band) =>
508
+ Object.freeze({
509
+ ...rendererRepresentationBandPolicies[band],
510
+ profile,
511
+ })
512
+ )
513
+ );
514
+ }
515
+
516
+ function buildRendererAccelerationStructureUpdates(profile) {
517
+ return Object.freeze(
518
+ rendererAccelerationStructurePolicies.map((policy) =>
519
+ Object.freeze({
520
+ ...policy,
521
+ profile,
522
+ })
523
+ )
524
+ );
525
+ }
526
+
527
+ function assertRendererIdentifier(name, value) {
528
+ if (typeof value !== "string" || value.trim().length === 0) {
529
+ throw new Error(`${name} must be a non-empty string.`);
530
+ }
531
+ return value.trim();
532
+ }
533
+
534
+ function buildRendererWorkerProfile(name, spec) {
535
+ return Object.freeze({
536
+ name,
537
+ description: spec.description,
538
+ jobs: Object.freeze(Object.keys(spec.jobs)),
539
+ });
540
+ }
541
+
542
+ function buildRendererWorkerManifestJob(profileName, jobName, spec) {
543
+ const label = `renderer.${profileName}.${jobName}`;
544
+ return Object.freeze({
545
+ key: jobName,
546
+ label,
547
+ worker: Object.freeze({
548
+ jobType: label,
549
+ queueClass: rendererWorkerQueueClass,
550
+ priority: spec.priority,
551
+ dependencies: Object.freeze(
552
+ spec.dependencies.map((dependency) => `renderer.${profileName}.${dependency}`)
553
+ ),
554
+ schedulerMode: "dag",
555
+ }),
556
+ performance: Object.freeze({
557
+ id: label,
558
+ jobType: label,
559
+ queueClass: rendererWorkerQueueClass,
560
+ domain: spec.domain,
561
+ authority: "visual",
562
+ importance: spec.importance,
563
+ levels: buildRendererWorkerBudgetLevels(
564
+ label,
565
+ rendererWorkerQueueClass,
566
+ spec.levels
567
+ ),
568
+ }),
569
+ debug: Object.freeze({
570
+ owner: rendererDebugOwner,
571
+ queueClass: rendererWorkerQueueClass,
572
+ jobType: label,
573
+ tags: Object.freeze(["renderer", profileName, jobName, spec.domain]),
574
+ suggestedAllocationIds: Object.freeze([...spec.suggestedAllocationIds]),
575
+ }),
576
+ });
577
+ }
578
+
579
+ function buildRendererWorkerManifest(name, spec) {
580
+ return Object.freeze({
581
+ schemaVersion: 1,
582
+ owner: rendererDebugOwner,
583
+ profile: name,
584
+ description: spec.description,
585
+ queueClass: rendererWorkerQueueClass,
586
+ schedulerMode: "dag",
587
+ inputBoundary: buildRendererInputBoundary(name),
588
+ renderStages: buildRendererRenderStages(name),
589
+ representationBands: buildRendererRepresentationBands(name),
590
+ accelerationStructureUpdates: buildRendererAccelerationStructureUpdates(name),
591
+ suggestedAllocationIds: Object.freeze([...spec.suggestedAllocationIds]),
592
+ jobs: Object.freeze(
593
+ Object.entries(spec.jobs).map(([jobName, jobSpec]) =>
594
+ buildRendererWorkerManifestJob(name, jobName, jobSpec)
595
+ )
596
+ ),
597
+ });
598
+ }
599
+
600
+ export const rendererWorkerProfiles = Object.freeze(
601
+ Object.fromEntries(
602
+ Object.entries(rendererWorkerProfileSpecs).map(([name, spec]) => [
603
+ name,
604
+ buildRendererWorkerProfile(name, spec),
605
+ ])
606
+ )
607
+ );
608
+
609
+ export const rendererWorkerProfileNames = Object.freeze(
610
+ Object.keys(rendererWorkerProfiles)
611
+ );
612
+
613
+ export const rendererWorkerManifests = Object.freeze(
614
+ Object.fromEntries(
615
+ Object.entries(rendererWorkerProfileSpecs).map(([name, spec]) => [
616
+ name,
617
+ buildRendererWorkerManifest(name, spec),
618
+ ])
619
+ )
620
+ );
621
+
622
+ export function getRendererWorkerProfile(name = defaultRendererWorkerProfile) {
623
+ const profile = rendererWorkerProfiles[name];
624
+ if (!profile) {
625
+ const available = rendererWorkerProfileNames.join(", ");
626
+ throw new Error(`Unknown renderer worker profile "${name}". Available: ${available}.`);
627
+ }
628
+ return profile;
629
+ }
630
+
631
+ export function getRendererWorkerManifest(name = defaultRendererWorkerProfile) {
632
+ const manifest = rendererWorkerManifests[name];
633
+ if (!manifest) {
634
+ const available = rendererWorkerProfileNames.join(", ");
635
+ throw new Error(`Unknown renderer worker profile "${name}". Available: ${available}.`);
636
+ }
637
+ return manifest;
638
+ }
639
+
640
+ export function createRayTracingRenderPlan(options = {}) {
641
+ const profile = options.profile ?? defaultRendererWorkerProfile;
642
+ const snapshotId = assertRendererIdentifier(
643
+ "snapshotId",
644
+ options.snapshotId
645
+ );
646
+ const workerManifest = getRendererWorkerManifest(profile);
647
+ const representations = Array.isArray(options.representations)
648
+ ? Object.freeze(
649
+ options.representations.map((representation, index) => {
650
+ if (!representation || typeof representation !== "object") {
651
+ throw new Error(`representations[${index}] must be an object.`);
652
+ }
653
+ const band = assertRendererIdentifier(
654
+ `representations[${index}].band`,
655
+ representation.band
656
+ );
657
+ if (!rendererRepresentationBands.includes(band)) {
658
+ throw new Error(
659
+ `representations[${index}].band must be one of: ${rendererRepresentationBands.join(", ")}.`
660
+ );
661
+ }
662
+ return Object.freeze({
663
+ ...representation,
664
+ band,
665
+ });
666
+ })
667
+ )
668
+ : workerManifest.representationBands;
669
+
670
+ return Object.freeze({
671
+ schemaVersion: 1,
672
+ owner: rendererDebugOwner,
673
+ profile,
674
+ inputBoundary: Object.freeze({
675
+ ...workerManifest.inputBoundary,
676
+ snapshotId,
677
+ }),
678
+ renderStages: workerManifest.renderStages,
679
+ representationBands: representations,
680
+ accelerationStructureUpdates: workerManifest.accelerationStructureUpdates,
681
+ workerManifest,
682
+ });
683
+ }
3
684
 
4
685
  function clamp01(value) {
5
686
  return Math.min(1, Math.max(0, value));
@@ -41,6 +722,16 @@ function normalizeColor(value) {
41
722
  return [...DEFAULT_CLEAR_COLOR];
42
723
  }
43
724
 
725
+ function readPositiveNumber(name, value) {
726
+ if (value === undefined) {
727
+ return undefined;
728
+ }
729
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
730
+ throw new Error(`${name} must be a finite number greater than zero.`);
731
+ }
732
+ return value;
733
+ }
734
+
44
735
  function now() {
45
736
  if (typeof performance !== "undefined" && typeof performance.now === "function") {
46
737
  return performance.now();
@@ -48,6 +739,121 @@ function now() {
48
739
  return Date.now();
49
740
  }
50
741
 
742
+ function normalizeFrameId(value) {
743
+ if (typeof value !== "string" || value.trim().length === 0) {
744
+ throw new Error("frameIdFactory must return a non-empty string.");
745
+ }
746
+ return value.trim();
747
+ }
748
+
749
+ function resolveTargetFrameTimeMs(options, event) {
750
+ const {
751
+ targetFrameTimeMs: fixedTargetFrameTimeMs,
752
+ targetFrameRate,
753
+ getTargetFrameTimeMs,
754
+ } = options;
755
+
756
+ if (typeof getTargetFrameTimeMs === "function") {
757
+ const resolved = getTargetFrameTimeMs(event);
758
+ return readPositiveNumber("getTargetFrameTimeMs()", resolved);
759
+ }
760
+
761
+ if (fixedTargetFrameTimeMs !== undefined) {
762
+ return fixedTargetFrameTimeMs;
763
+ }
764
+
765
+ if (targetFrameRate !== undefined) {
766
+ return 1000 / targetFrameRate;
767
+ }
768
+
769
+ return undefined;
770
+ }
771
+
772
+ export function createRendererDebugHooks(options = {}) {
773
+ const {
774
+ debugSession,
775
+ targetFrameTimeMs,
776
+ targetFrameRate,
777
+ getTargetFrameTimeMs,
778
+ onFrameStart,
779
+ onFrameComplete,
780
+ } = options;
781
+
782
+ if (!debugSession || typeof debugSession.recordFrame !== "function") {
783
+ throw new Error(
784
+ "debugSession must expose recordFrame(sample). Use @plasius/gpu-debug createGpuDebugSession()."
785
+ );
786
+ }
787
+
788
+ const fixedTargetFrameTimeMs = readPositiveNumber(
789
+ "targetFrameTimeMs",
790
+ targetFrameTimeMs
791
+ );
792
+ const fixedTargetFrameRate = readPositiveNumber(
793
+ "targetFrameRate",
794
+ targetFrameRate
795
+ );
796
+
797
+ if (
798
+ fixedTargetFrameTimeMs !== undefined &&
799
+ fixedTargetFrameRate !== undefined
800
+ ) {
801
+ throw new Error(
802
+ "Provide either targetFrameTimeMs or targetFrameRate, not both."
803
+ );
804
+ }
805
+
806
+ if (
807
+ getTargetFrameTimeMs !== undefined &&
808
+ typeof getTargetFrameTimeMs !== "function"
809
+ ) {
810
+ throw new Error("getTargetFrameTimeMs must be a function when provided.");
811
+ }
812
+
813
+ const resolvedOptions = {
814
+ targetFrameTimeMs: fixedTargetFrameTimeMs,
815
+ targetFrameRate: fixedTargetFrameRate,
816
+ getTargetFrameTimeMs,
817
+ };
818
+
819
+ return {
820
+ onFrameStart(event) {
821
+ if (typeof onFrameStart === "function") {
822
+ onFrameStart({
823
+ ...event,
824
+ owner: rendererDebugOwner,
825
+ });
826
+ }
827
+ },
828
+ onFrameComplete(event) {
829
+ const resolvedTargetFrameTimeMs = resolveTargetFrameTimeMs(
830
+ resolvedOptions,
831
+ event
832
+ );
833
+
834
+ if (
835
+ typeof event.frameTimeMs === "number" &&
836
+ Number.isFinite(event.frameTimeMs) &&
837
+ event.frameTimeMs > 0
838
+ ) {
839
+ debugSession.recordFrame({
840
+ frameId: event.frameId,
841
+ frameTimeMs: event.frameTimeMs,
842
+ targetFrameTimeMs: resolvedTargetFrameTimeMs,
843
+ });
844
+ }
845
+
846
+ if (typeof onFrameComplete === "function") {
847
+ onFrameComplete({
848
+ ...event,
849
+ owner: rendererDebugOwner,
850
+ targetFrameTimeMs: resolvedTargetFrameTimeMs,
851
+ });
852
+ }
853
+ },
854
+ };
855
+ }
856
+
51
857
  function readNavigator(navigatorOverride) {
52
858
  const currentNavigator = navigatorOverride ?? globalThis.navigator;
53
859
  if (!currentNavigator || typeof currentNavigator !== "object") {
@@ -139,8 +945,11 @@ export async function createGpuRenderer(options = {}) {
139
945
  clearColor = DEFAULT_CLEAR_COLOR,
140
946
  requestAnimationFrame = globalThis.requestAnimationFrame?.bind(globalThis),
141
947
  cancelAnimationFrame = globalThis.cancelAnimationFrame?.bind(globalThis),
948
+ frameIdFactory,
949
+ onFrameStart,
142
950
  onBeforeEncode,
143
951
  onAfterSubmit,
952
+ onFrameComplete,
144
953
  } = options;
145
954
 
146
955
  const gpu = readGpu(navigatorOverride);
@@ -178,6 +987,33 @@ export async function createGpuRenderer(options = {}) {
178
987
  throw new Error("Renderer was destroyed.");
179
988
  }
180
989
 
990
+ const frameNumber = frame + 1;
991
+ const frameId = normalizeFrameId(
992
+ typeof frameIdFactory === "function"
993
+ ? frameIdFactory({
994
+ frame: frameNumber,
995
+ timestamp,
996
+ canvas: targetCanvas,
997
+ xrActive,
998
+ })
999
+ : `renderer.frame.${frameNumber}`
1000
+ );
1001
+ const frameTimeMs =
1002
+ lastTimestamp > 0 ? Math.max(0, timestamp - lastTimestamp) : undefined;
1003
+
1004
+ if (typeof onFrameStart === "function") {
1005
+ onFrameStart({
1006
+ frame: frameNumber,
1007
+ frameId,
1008
+ frameTimeMs,
1009
+ timestamp,
1010
+ device,
1011
+ context,
1012
+ canvas: targetCanvas,
1013
+ xrActive,
1014
+ });
1015
+ }
1016
+
181
1017
  const texture = context.getCurrentTexture?.();
182
1018
  if (!texture || typeof texture.createView !== "function") {
183
1019
  throw new Error("WebGPU context returned an invalid current texture.");
@@ -193,12 +1029,16 @@ export async function createGpuRenderer(options = {}) {
193
1029
  if (typeof onBeforeEncode === "function") {
194
1030
  onBeforeEncode({
195
1031
  frame,
1032
+ frameNumber,
1033
+ frameId,
1034
+ frameTimeMs,
196
1035
  timestamp,
197
1036
  device,
198
1037
  context,
199
1038
  encoder,
200
1039
  pass,
201
1040
  canvas: targetCanvas,
1041
+ xrActive,
202
1042
  });
203
1043
  }
204
1044
 
@@ -209,21 +1049,40 @@ export async function createGpuRenderer(options = {}) {
209
1049
  const commandBuffer = encoder.finish();
210
1050
  device.queue.submit([commandBuffer]);
211
1051
 
212
- frame += 1;
1052
+ frame = frameNumber;
213
1053
  lastTimestamp = timestamp;
214
1054
 
215
1055
  if (typeof onAfterSubmit === "function") {
216
1056
  onAfterSubmit({
217
- frame,
1057
+ frame: frameNumber,
1058
+ frameNumber,
1059
+ frameId,
1060
+ frameTimeMs,
1061
+ timestamp,
1062
+ device,
1063
+ context,
1064
+ canvas: targetCanvas,
1065
+ xrActive,
1066
+ });
1067
+ }
1068
+
1069
+ if (typeof onFrameComplete === "function") {
1070
+ onFrameComplete({
1071
+ frame: frameNumber,
1072
+ frameId,
1073
+ frameTimeMs,
218
1074
  timestamp,
219
1075
  device,
220
1076
  context,
221
1077
  canvas: targetCanvas,
1078
+ xrActive,
222
1079
  });
223
1080
  }
224
1081
 
225
1082
  return {
226
- frame,
1083
+ frame: frameNumber,
1084
+ frameId,
1085
+ frameTimeMs,
227
1086
  timestamp,
228
1087
  };
229
1088
  };