@gnsx/three 0.185.9 → 0.185.11

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.
@@ -1,4 +1,7 @@
1
1
  // WITH_GENESYS
2
+ import { TimestampQuery } from '../constants.js';
3
+ import { warnOnce } from '../utils.js';
4
+
2
5
  /**
3
6
  * ProfilerService — per-label CPU timing with ring-buffer aggregation and DevTools integration.
4
7
  *
@@ -19,12 +22,16 @@ const RING_SIZE = 120;
19
22
  const FRAME_BUDGET_MS = 1000 / 60;
20
23
  const TRACE_TID_MAIN = 1;
21
24
  const TRACE_TID_ASYNC = 2;
25
+ const TRACE_TID_GPU = 3;
22
26
  const NOOP = () => {};
23
27
 
24
28
  const DUMMY_SPAN = Object.freeze( { label: '', t0: 0, _seq: 0 } );
29
+ const DUMMY_GPU_SPAN = Object.freeze( { label: '', renderer: null, t0: 0, _seq: 0, _generation: 0 } );
25
30
  const NOOP_BEGIN_SPAN = () => DUMMY_SPAN;
26
31
  const NOOP_END_SPAN = () => {};
27
32
 
33
+ const NOOP_BEGIN_GPU_SPAN = () => DUMMY_GPU_SPAN;
34
+
28
35
  class ProfilerServiceClass {
29
36
 
30
37
  constructor() {
@@ -59,6 +66,18 @@ class ProfilerServiceClass {
59
66
  /** @type {Map<string, number>} Total (uncapped) invocation count since last reset, for calls-per-frame. */
60
67
  this.invocations = new Map();
61
68
 
69
+ /** @type {Map<string, Float64Array>} GPU time ring buffers (one per label). */
70
+ this.gpuBuffers = new Map();
71
+ /** @type {Map<string, number>} */
72
+ this.gpuCursors = new Map();
73
+ /** @type {Map<string, number>} */
74
+ this.gpuCounts = new Map();
75
+ /** @type {Map<string, number>} */
76
+ this.gpuInvocations = new Map();
77
+ /** @type {Map<Object, import('./ProfilerService.js').GpuRendererState>} */
78
+ this.gpuRendererStates = new Map();
79
+ this._gpuGeneration = 1;
80
+
62
81
  if ( this._enabled ) {
63
82
 
64
83
  this.traceStartTime = performance.now();
@@ -66,6 +85,8 @@ class ProfilerServiceClass {
66
85
  this.end = this._endImpl.bind( this );
67
86
  this.beginSpan = this._beginSpanImpl.bind( this );
68
87
  this.endSpan = this._endSpanImpl.bind( this );
88
+ this.beginGpu = this._beginGpuImpl.bind( this );
89
+ this.endGpu = this._endGpuImpl.bind( this );
69
90
  this._exposeGlobal();
70
91
  console.log( `[ProfilerService] auto-started from env (profile: ${this._profile})` );
71
92
 
@@ -75,6 +96,8 @@ class ProfilerServiceClass {
75
96
  this.end = NOOP;
76
97
  this.beginSpan = NOOP_BEGIN_SPAN;
77
98
  this.endSpan = NOOP_END_SPAN;
99
+ this.beginGpu = NOOP_BEGIN_GPU_SPAN;
100
+ this.endGpu = NOOP_END_SPAN;
78
101
 
79
102
  }
80
103
 
@@ -108,6 +131,8 @@ class ProfilerServiceClass {
108
131
  this.end = this._endImpl.bind( this );
109
132
  this.beginSpan = this._beginSpanImpl.bind( this );
110
133
  this.endSpan = this._endSpanImpl.bind( this );
134
+ this.beginGpu = this._beginGpuImpl.bind( this );
135
+ this.endGpu = this._endGpuImpl.bind( this );
111
136
  this._exposeGlobal();
112
137
  console.log( `[ProfilerService] enabled (profile: ${this._profile}) — call __gnsx_profiler.report() or downloadTrace() from the console` );
113
138
 
@@ -121,6 +146,9 @@ class ProfilerServiceClass {
121
146
  this.end = NOOP;
122
147
  this.beginSpan = NOOP_BEGIN_SPAN;
123
148
  this.endSpan = NOOP_END_SPAN;
149
+ this.beginGpu = NOOP_BEGIN_GPU_SPAN;
150
+ this.endGpu = NOOP_END_SPAN;
151
+ this._detachGpuRenderers();
124
152
  console.log( '[ProfilerService] disabled' );
125
153
 
126
154
  }
@@ -141,6 +169,687 @@ class ProfilerServiceClass {
141
169
 
142
170
  }
143
171
 
172
+ /**
173
+ * Enables GPU timestamp collection for a renderer.
174
+ *
175
+ * @param {Object} renderer
176
+ * @return {Promise<boolean>} Whether GPU timestamps are available.
177
+ */
178
+ async attachGpuRenderer( renderer ) {
179
+
180
+ if ( this._enabled === false || renderer === null || renderer === undefined ) return false;
181
+
182
+ const existingState = this.gpuRendererStates.get( renderer );
183
+ if ( existingState !== undefined ) return existingState.available;
184
+
185
+ if ( renderer.isWebGLRenderer === true ) {
186
+
187
+ const gl = renderer.getContext();
188
+ const extension = gl.getExtension( 'EXT_disjoint_timer_query_webgl2' );
189
+ const state = {
190
+ kind: 'legacy-webgl',
191
+ available: extension !== null,
192
+ renderer,
193
+ gl,
194
+ extension,
195
+ activeSpan: null,
196
+ pendingSpans: [],
197
+ flushPromise: null,
198
+ flushScheduled: false,
199
+ };
200
+ this.gpuRendererStates.set( renderer, state );
201
+
202
+ if ( extension === null ) {
203
+
204
+ warnOnce( 'ProfilerService: EXT_disjoint_timer_query_webgl2 is unavailable; GPU profiling is disabled for this WebGLRenderer.' );
205
+
206
+ }
207
+
208
+ return state.available;
209
+
210
+ }
211
+
212
+ if ( renderer.isRenderer === true && renderer.backend !== undefined ) {
213
+
214
+ await renderer.init();
215
+ const previousTrackTimestamp = renderer.backend.trackTimestamp;
216
+ renderer.backend.trackTimestamp = true;
217
+
218
+ const available = renderer.backend.hasTimestamp === true && renderer.hasFeature( 'timestamp-query' ) === true;
219
+ const listener = ( type, uid, label ) => this._captureGpuTimestampQuery( renderer, type, uid, label );
220
+ const state = {
221
+ kind: 'common',
222
+ available,
223
+ renderer,
224
+ listener,
225
+ previousTrackTimestamp,
226
+ activeSpans: [],
227
+ pendingSpans: [],
228
+ queryCpuTimes: new Map(),
229
+ gpuTimestampOrigin: null,
230
+ gpuTraceOrigin: null,
231
+ flushPromise: null,
232
+ flushScheduled: false,
233
+ };
234
+ this.gpuRendererStates.set( renderer, state );
235
+
236
+ if ( available ) {
237
+
238
+ renderer.backend.addTimestampQueryListener( listener );
239
+
240
+ } else {
241
+
242
+ warnOnce( 'ProfilerService: Timestamp queries are unavailable; GPU profiling is disabled for this renderer.' );
243
+
244
+ }
245
+
246
+ return available;
247
+
248
+ }
249
+
250
+ warnOnce( 'ProfilerService: Unsupported renderer; expected Renderer or WebGLRenderer.' );
251
+ return false;
252
+
253
+ }
254
+
255
+ /**
256
+ * @param {string} label
257
+ * @param {Object} renderer
258
+ * @return {import('./ProfilerService.js').GpuSpanHandle}
259
+ */
260
+ _beginGpuImpl( label, renderer ) {
261
+
262
+ const state = this.gpuRendererStates.get( renderer );
263
+ if ( state === undefined || state.available === false ) return DUMMY_GPU_SPAN;
264
+
265
+ const handle = {
266
+ label,
267
+ renderer,
268
+ t0: performance.now(),
269
+ _seq: ++ this._markId,
270
+ _generation: this._gpuGeneration,
271
+ };
272
+
273
+ if ( state.kind === 'common' ) {
274
+
275
+ handle._queries = {
276
+ [ TimestampQuery.RENDER ]: new Set(),
277
+ [ TimestampQuery.COMPUTE ]: new Set(),
278
+ };
279
+ state.activeSpans.push( handle );
280
+ return handle;
281
+
282
+ }
283
+
284
+ if ( state.activeSpan !== null ) {
285
+
286
+ warnOnce( 'ProfilerService: Nested GPU spans are unsupported by legacy WebGLRenderer.' );
287
+ return DUMMY_GPU_SPAN;
288
+
289
+ }
290
+
291
+ const query = state.gl.createQuery();
292
+ if ( query === null ) return DUMMY_GPU_SPAN;
293
+
294
+ try {
295
+
296
+ state.gl.beginQuery( state.extension.TIME_ELAPSED_EXT, query );
297
+ handle._query = query;
298
+ state.activeSpan = handle;
299
+ return handle;
300
+
301
+ } catch ( error ) {
302
+
303
+ state.gl.deleteQuery( query );
304
+ warnOnce( `ProfilerService: Unable to begin a WebGL GPU span (${error.message}).` );
305
+ return DUMMY_GPU_SPAN;
306
+
307
+ }
308
+
309
+ }
310
+
311
+ /**
312
+ * @param {import('./ProfilerService.js').GpuSpanHandle} handle
313
+ */
314
+ _endGpuImpl( handle ) {
315
+
316
+ if ( handle._seq === 0 || handle._generation !== this._gpuGeneration ) return;
317
+
318
+ const state = this.gpuRendererStates.get( handle.renderer );
319
+ if ( state === undefined || state.available === false ) return;
320
+
321
+ handle._endTime = performance.now();
322
+
323
+ if ( state.kind === 'common' ) {
324
+
325
+ const index = state.activeSpans.indexOf( handle );
326
+ if ( index === - 1 ) return;
327
+
328
+ state.activeSpans.splice( index, 1 );
329
+ if ( handle._queries.render.size > 0 || handle._queries.compute.size > 0 ) {
330
+
331
+ state.pendingSpans.push( handle );
332
+ this._scheduleGpuFlush( state );
333
+
334
+ }
335
+
336
+ return;
337
+
338
+ }
339
+
340
+ if ( state.activeSpan !== handle ) return;
341
+
342
+ try {
343
+
344
+ state.gl.endQuery( state.extension.TIME_ELAPSED_EXT );
345
+ state.activeSpan = null;
346
+ state.pendingSpans.push( handle );
347
+ this._scheduleGpuFlush( state );
348
+
349
+ } catch ( error ) {
350
+
351
+ state.activeSpan = null;
352
+ state.gl.deleteQuery( handle._query );
353
+ warnOnce( `ProfilerService: Unable to end a WebGL GPU span (${error.message}).` );
354
+
355
+ }
356
+
357
+ }
358
+
359
+ /**
360
+ * Resolves pending GPU timestamp queries for a renderer.
361
+ *
362
+ * @param {Object} renderer
363
+ * @return {Promise<void>}
364
+ */
365
+ async flushGpu( renderer ) {
366
+
367
+ const state = this.gpuRendererStates.get( renderer );
368
+ if ( state === undefined || state.available === false ) return;
369
+ if ( state.flushPromise !== null ) return state.flushPromise;
370
+
371
+ state.flushPromise = state.kind === 'common'
372
+ ? this._flushCommonGpuState( state )
373
+ : this._flushLegacyWebGLState( state );
374
+
375
+ try {
376
+
377
+ await state.flushPromise;
378
+
379
+ } finally {
380
+
381
+ state.flushPromise = null;
382
+ if ( state.pendingSpans.length > 0 ) this._scheduleGpuFlush( state );
383
+
384
+ }
385
+
386
+ }
387
+
388
+ /**
389
+ * @param {Object} renderer
390
+ * @param {'render'|'compute'} type
391
+ * @param {string} uid
392
+ * @param {?string} [label=null]
393
+ */
394
+ _captureGpuTimestampQuery( renderer, type, uid, label = null ) {
395
+
396
+ const state = this.gpuRendererStates.get( renderer );
397
+ if ( state === undefined || state.kind !== 'common' ) return;
398
+
399
+ const cpuTime = performance.now();
400
+ state.queryCpuTimes.set( uid, cpuTime );
401
+
402
+ for ( const span of state.activeSpans ) {
403
+
404
+ span._queries[ type ].add( uid );
405
+
406
+ }
407
+
408
+ if ( label !== null && label !== undefined && label !== '' ) {
409
+
410
+ // Queue labelled passes with the active beginGpu span(s). Flushing
411
+ // immediately would commit them before their parent envelope exists and
412
+ // can anchor gpuTraceOrigin mid-frame, which breaks Speedscope nesting.
413
+ const passSpan = {
414
+ label: `GPU pass: ${label}`,
415
+ renderer,
416
+ t0: cpuTime,
417
+ _seq: ++ this._markId,
418
+ _generation: this._gpuGeneration,
419
+ _queries: {
420
+ [ TimestampQuery.RENDER ]: new Set(),
421
+ [ TimestampQuery.COMPUTE ]: new Set(),
422
+ },
423
+ };
424
+ passSpan._queries[ type ].add( uid );
425
+ state.pendingSpans.push( passSpan );
426
+ // Only auto-flush when no beginGpu parent is open. Nested parents flush on endGpu
427
+ // so pass slices share one origin and nest inside the parent envelope.
428
+ if ( state.activeSpans.length === 0 ) {
429
+
430
+ this._scheduleGpuFlush( state );
431
+
432
+ }
433
+
434
+ }
435
+
436
+ }
437
+
438
+ /**
439
+ * @param {import('./ProfilerService.js').GpuRendererState} state
440
+ */
441
+ _scheduleGpuFlush( state ) {
442
+
443
+ if ( state.flushScheduled ) return;
444
+ state.flushScheduled = true;
445
+
446
+ const callback = () => {
447
+
448
+ state.flushScheduled = false;
449
+ if ( this.gpuRendererStates.get( state.renderer ) === state ) {
450
+
451
+ this.flushGpu( state.renderer ).catch( error => {
452
+
453
+ warnOnce( `ProfilerService: Unable to resolve GPU timestamps (${error.message}).` );
454
+
455
+ } );
456
+
457
+ }
458
+
459
+ };
460
+
461
+ if ( typeof requestAnimationFrame === 'function' ) {
462
+
463
+ requestAnimationFrame( callback );
464
+
465
+ } else {
466
+
467
+ setTimeout( callback, 0 );
468
+
469
+ }
470
+
471
+ }
472
+
473
+ /**
474
+ * @param {import('./ProfilerService.js').CommonGpuRendererState} state
475
+ */
476
+ async _flushCommonGpuState( state ) {
477
+
478
+ const spans = state.pendingSpans.splice( 0 );
479
+ if ( spans.length === 0 ) return;
480
+
481
+ const hasRenderQueries = spans.some( span => span._queries.render.size > 0 );
482
+ const hasComputeQueries = spans.some( span => span._queries.compute.size > 0 );
483
+ const resolutions = [];
484
+
485
+ if ( hasRenderQueries ) resolutions.push( state.renderer.resolveTimestampsAsync( TimestampQuery.RENDER ) );
486
+ if ( hasComputeQueries ) resolutions.push( state.renderer.resolveTimestampsAsync( TimestampQuery.COMPUTE ) );
487
+ await Promise.all( resolutions );
488
+
489
+ if ( this._enabled === false ) return;
490
+
491
+ if ( state.gpuTimestampOrigin === null ) {
492
+
493
+ const origin = this._findEarliestGpuTimestampOrigin( state, spans );
494
+ if ( origin !== null ) {
495
+
496
+ state.gpuTimestampOrigin = origin.gpuTimestampOrigin;
497
+ state.gpuTraceOrigin = origin.gpuTraceOrigin;
498
+
499
+ }
500
+
501
+ }
502
+
503
+ const resolvedUids = new Set();
504
+ /** @type {Array<{ label: string, startTime: number, durationMs: number, isPass: boolean }>} */
505
+ const samples = [];
506
+ /** @type {Set<string>} */
507
+ const labelledQueryUids = new Set();
508
+
509
+ for ( const span of spans ) {
510
+
511
+ if ( span._generation !== this._gpuGeneration ) continue;
512
+
513
+ let duration = 0;
514
+ let resolvedQueries = 0;
515
+ let rangedQueries = 0;
516
+ let gpuStart = null;
517
+ let gpuEnd = null;
518
+ for ( const type of [ TimestampQuery.RENDER, TimestampQuery.COMPUTE ] ) {
519
+
520
+ for ( const uid of span._queries[ type ] ) {
521
+
522
+ if ( state.renderer.backend.hasTimestampQuery( uid ) ) {
523
+
524
+ duration += state.renderer.backend.getTimestamp( uid );
525
+ resolvedQueries ++;
526
+ resolvedUids.add( uid );
527
+
528
+ const range = state.renderer.backend.getTimestampRange( uid );
529
+ if ( range !== null ) {
530
+
531
+ rangedQueries ++;
532
+ if ( gpuStart === null || range.start < gpuStart ) gpuStart = range.start;
533
+ if ( gpuEnd === null || range.end > gpuEnd ) gpuEnd = range.end;
534
+
535
+ }
536
+
537
+ }
538
+
539
+ }
540
+
541
+ }
542
+
543
+ if ( resolvedQueries > 0 ) {
544
+
545
+ let startTime = span.t0;
546
+ // Prefer the device envelope whenever any query has a range. Requiring
547
+ // *all* queries to be ranged forced a CPU-t0 + summed-duration fallback
548
+ // that often ended before later GPU pass slices, breaking flame nesting.
549
+ if ( rangedQueries > 0 && gpuStart !== null && gpuEnd !== null && state.gpuTimestampOrigin !== null ) {
550
+
551
+ duration = Number( gpuEnd - gpuStart ) / 1e6;
552
+ startTime = state.gpuTraceOrigin + Number( gpuStart - state.gpuTimestampOrigin ) / 1e6;
553
+
554
+ }
555
+
556
+ const isPass = span.label.startsWith( 'GPU pass:' );
557
+ if ( isPass ) {
558
+
559
+ for ( const type of [ TimestampQuery.RENDER, TimestampQuery.COMPUTE ] ) {
560
+
561
+ for ( const uid of span._queries[ type ] ) labelledQueryUids.add( uid );
562
+
563
+ }
564
+
565
+ }
566
+
567
+ samples.push( {
568
+ label: span.label,
569
+ startTime,
570
+ durationMs: duration,
571
+ isPass,
572
+ } );
573
+
574
+ }
575
+
576
+ }
577
+
578
+ // Parent beginGpu spans collect every timestamp query, including renders that
579
+ // had no gpuProfilerLabel. Surface those as pass slices so they are not empty
580
+ // holes under the parent envelope.
581
+ for ( const span of spans ) {
582
+
583
+ if ( span._generation !== this._gpuGeneration ) continue;
584
+ if ( span.label.startsWith( 'GPU pass:' ) ) continue;
585
+ if ( state.gpuTimestampOrigin === null ) continue;
586
+
587
+ for ( const type of [ TimestampQuery.RENDER, TimestampQuery.COMPUTE ] ) {
588
+
589
+ for ( const uid of span._queries[ type ] ) {
590
+
591
+ if ( labelledQueryUids.has( uid ) ) continue;
592
+ if ( state.renderer.backend.hasTimestampQuery( uid ) === false ) continue;
593
+
594
+ const range = state.renderer.backend.getTimestampRange( uid );
595
+ if ( range === null ) continue;
596
+
597
+ const durationMs = Number( range.end - range.start ) / 1e6;
598
+ if ( durationMs <= 0 ) continue;
599
+
600
+ samples.push( {
601
+ label: 'GPU pass: (unlabeled)',
602
+ startTime: state.gpuTraceOrigin + Number( range.start - state.gpuTimestampOrigin ) / 1e6,
603
+ durationMs,
604
+ isPass: true,
605
+ } );
606
+ labelledQueryUids.add( uid );
607
+
608
+ }
609
+
610
+ }
611
+
612
+ }
613
+
614
+ const passSamples = samples.filter( sample => sample.isPass );
615
+ const otherSamples = samples.filter( sample => sample.isPass === false );
616
+ this._snapHalfOpenGpuPassTraceIntervals( passSamples );
617
+ this._expandGpuParentTraceToContainPasses( otherSamples, passSamples );
618
+
619
+ for ( const sample of otherSamples ) {
620
+
621
+ this._commitGpuDurationSample( sample.label, sample.startTime, sample.durationMs, {
622
+ ts: sample.traceTs,
623
+ dur: sample.traceDur,
624
+ } );
625
+
626
+ }
627
+
628
+ for ( const sample of passSamples ) {
629
+
630
+ this._commitGpuDurationSample( sample.label, sample.startTime, sample.durationMs, {
631
+ ts: sample.traceTs,
632
+ dur: sample.traceDur,
633
+ } );
634
+
635
+ }
636
+
637
+ // Keep CPU times for queries still referenced by active parent spans.
638
+ for ( const uid of resolvedUids ) {
639
+
640
+ if ( this._isGpuQueryReferencedByActiveSpans( state, uid ) ) continue;
641
+ state.queryCpuTimes.delete( uid );
642
+
643
+ }
644
+
645
+ }
646
+
647
+ /**
648
+ * Convert labelled GPU pass samples to non-overlapping half-open µs intervals for
649
+ * Chrome/Speedscope traces. Stats keep the raw measured durations.
650
+ *
651
+ * @param {Array<{ startTime: number, durationMs: number, traceTs?: number, traceDur?: number }>} samples
652
+ */
653
+ _snapHalfOpenGpuPassTraceIntervals( samples ) {
654
+
655
+ if ( samples.length === 0 ) return;
656
+
657
+ for ( const sample of samples ) {
658
+
659
+ sample.traceTs = Math.round( this._getTraceTimestamp( sample.startTime ) );
660
+ sample.traceDur = Math.max( 1, Math.round( sample.durationMs * 1000 ) );
661
+
662
+ }
663
+
664
+ // Longer first at the same timestamp so the substantial pass keeps its start.
665
+ samples.sort( ( a, b ) => a.traceTs - b.traceTs || b.traceDur - a.traceDur );
666
+
667
+ let cursor = Number.NEGATIVE_INFINITY;
668
+ for ( const sample of samples ) {
669
+
670
+ if ( sample.traceTs < cursor ) {
671
+
672
+ const end = Math.max( sample.traceTs + sample.traceDur, cursor + 1 );
673
+ sample.traceTs = cursor;
674
+ sample.traceDur = Math.max( 1, end - sample.traceTs );
675
+
676
+ }
677
+
678
+ cursor = sample.traceTs + sample.traceDur;
679
+
680
+ }
681
+
682
+ }
683
+
684
+ /**
685
+ * Speedscope nests by time containment on a thread. If a snapped GPU pass
686
+ * sticks out past its beginGpu parent (rounding / half-open adjust), the
687
+ * parent fails containment and is pushed onto a lower lane under renderFrame.
688
+ * Expand overlapping parents so they strictly cover their pass children.
689
+ *
690
+ * @param {Array<{ startTime: number, durationMs: number, traceTs?: number, traceDur?: number }>} parents
691
+ * @param {Array<{ traceTs: number, traceDur: number }>} passes
692
+ */
693
+ _expandGpuParentTraceToContainPasses( parents, passes ) {
694
+
695
+ if ( parents.length === 0 || passes.length === 0 ) return;
696
+
697
+ for ( const parent of parents ) {
698
+
699
+ parent.traceTs = Math.round( this._getTraceTimestamp( parent.startTime ) );
700
+ parent.traceDur = Math.max( 1, Math.round( parent.durationMs * 1000 ) );
701
+
702
+ const parentEnd = parent.traceTs + parent.traceDur;
703
+ let minTs = parent.traceTs;
704
+ let maxEnd = parentEnd;
705
+ let touched = false;
706
+
707
+ for ( const pass of passes ) {
708
+
709
+ const passEnd = pass.traceTs + pass.traceDur;
710
+ if ( pass.traceTs >= parentEnd || passEnd <= parent.traceTs ) continue;
711
+
712
+ touched = true;
713
+ if ( pass.traceTs < minTs ) minTs = pass.traceTs;
714
+ if ( passEnd > maxEnd ) maxEnd = passEnd;
715
+
716
+ }
717
+
718
+ if ( touched === false ) continue;
719
+
720
+ // Start 1µs before the first child so equal-start pairs still nest
721
+ // (Speedscope sorts same-ts by longer-first; a child that begins
722
+ // strictly after the parent is unambiguous).
723
+ parent.traceTs = minTs > 0 ? minTs - 1 : minTs;
724
+ parent.traceDur = Math.max( 1, maxEnd - parent.traceTs );
725
+
726
+ }
727
+
728
+ }
729
+
730
+ /**
731
+ * @param {import('./ProfilerService.js').CommonGpuRendererState} state
732
+ * @param {import('./ProfilerService.js').GpuSpanHandle[]} spans
733
+ * @return {?{ gpuTimestampOrigin: bigint, gpuTraceOrigin: number }}
734
+ */
735
+ _findEarliestGpuTimestampOrigin( state, spans ) {
736
+
737
+ let earliestRange = null;
738
+ let earliestUid = null;
739
+ const candidates = spans.concat( state.activeSpans );
740
+
741
+ for ( const span of candidates ) {
742
+
743
+ for ( const type of [ TimestampQuery.RENDER, TimestampQuery.COMPUTE ] ) {
744
+
745
+ const queries = span._queries?.[ type ];
746
+ if ( queries === undefined ) continue;
747
+
748
+ for ( const uid of queries ) {
749
+
750
+ const range = state.renderer.backend.getTimestampRange( uid );
751
+ if ( range !== null && ( earliestRange === null || range.start < earliestRange.start ) ) {
752
+
753
+ earliestRange = range;
754
+ earliestUid = uid;
755
+
756
+ }
757
+
758
+ }
759
+
760
+ }
761
+
762
+ }
763
+
764
+ if ( earliestRange === null ) return null;
765
+
766
+ return {
767
+ gpuTimestampOrigin: earliestRange.start,
768
+ gpuTraceOrigin: state.queryCpuTimes.get( earliestUid ) ?? spans[ 0 ].t0,
769
+ };
770
+
771
+ }
772
+
773
+ /**
774
+ * @param {import('./ProfilerService.js').CommonGpuRendererState} state
775
+ * @param {string} uid
776
+ * @return {boolean}
777
+ */
778
+ _isGpuQueryReferencedByActiveSpans( state, uid ) {
779
+
780
+ for ( const span of state.activeSpans ) {
781
+
782
+ if ( span._queries.render.has( uid ) || span._queries.compute.has( uid ) ) return true;
783
+
784
+ }
785
+
786
+ return false;
787
+
788
+ }
789
+
790
+ /**
791
+ * @param {import('./ProfilerService.js').LegacyWebGLGpuRendererState} state
792
+ */
793
+ async _flushLegacyWebGLState( state ) {
794
+
795
+ const spans = state.pendingSpans.splice( 0 );
796
+
797
+ for ( const span of spans ) {
798
+
799
+ const duration = await this._resolveLegacyWebGLQuery( state, span._query );
800
+ if ( duration !== null && span._generation === this._gpuGeneration && this._enabled ) {
801
+
802
+ this._commitGpuDurationSample( span.label, span.t0, duration );
803
+
804
+ }
805
+
806
+ }
807
+
808
+ }
809
+
810
+ /**
811
+ * @param {import('./ProfilerService.js').LegacyWebGLGpuRendererState} state
812
+ * @param {WebGLQuery} query
813
+ * @return {Promise<?number>}
814
+ */
815
+ _resolveLegacyWebGLQuery( state, query ) {
816
+
817
+ return new Promise( resolve => {
818
+
819
+ const poll = () => {
820
+
821
+ if ( state.gl.isContextLost() ) {
822
+
823
+ state.gl.deleteQuery( query );
824
+ resolve( null );
825
+ return;
826
+
827
+ }
828
+
829
+ const disjoint = state.gl.getParameter( state.extension.GPU_DISJOINT_EXT );
830
+ const available = state.gl.getQueryParameter( query, state.gl.QUERY_RESULT_AVAILABLE );
831
+
832
+ if ( available === false ) {
833
+
834
+ setTimeout( poll, 1 );
835
+ return;
836
+
837
+ }
838
+
839
+ const result = disjoint
840
+ ? null
841
+ : Number( state.gl.getQueryParameter( query, state.gl.QUERY_RESULT ) ) / 1e6;
842
+ state.gl.deleteQuery( query );
843
+ resolve( result );
844
+
845
+ };
846
+
847
+ poll();
848
+
849
+ } );
850
+
851
+ }
852
+
144
853
  /**
145
854
  * @param {string} label
146
855
  */
@@ -298,6 +1007,46 @@ class ProfilerServiceClass {
298
1007
 
299
1008
  }
300
1009
 
1010
+ /**
1011
+ * @param {string} label
1012
+ * @param {number} startTime
1013
+ * @param {number} durationMs
1014
+ * @param {{ ts: number, dur: number }} [traceOverride]
1015
+ */
1016
+ _commitGpuDurationSample( label, startTime, durationMs, traceOverride = null ) {
1017
+
1018
+ let buffer = this.gpuBuffers.get( label );
1019
+ if ( buffer === undefined ) {
1020
+
1021
+ buffer = new Float64Array( RING_SIZE );
1022
+ this.gpuBuffers.set( label, buffer );
1023
+ this.gpuCursors.set( label, 0 );
1024
+ this.gpuCounts.set( label, 0 );
1025
+
1026
+ }
1027
+
1028
+ const cursor = this.gpuCursors.get( label );
1029
+ buffer[ cursor ] = durationMs;
1030
+ this.gpuCursors.set( label, ( cursor + 1 ) % RING_SIZE );
1031
+ this.gpuCounts.set( label, Math.min( this.gpuCounts.get( label ) + 1, RING_SIZE ) );
1032
+ this.gpuInvocations.set( label, ( this.gpuInvocations.get( label ) ?? 0 ) + 1 );
1033
+
1034
+ if ( this._profile === 'full' ) {
1035
+
1036
+ this.traceEvents.push( {
1037
+ name: label,
1038
+ ph: 'X',
1039
+ ts: traceOverride?.ts ?? Math.round( this._getTraceTimestamp( startTime ) ),
1040
+ dur: traceOverride?.dur ?? Math.max( 1, Math.round( durationMs * 1000 ) ),
1041
+ pid: 1,
1042
+ tid: TRACE_TID_GPU,
1043
+ cat: 'gnsx-gpu',
1044
+ } );
1045
+
1046
+ }
1047
+
1048
+ }
1049
+
301
1050
  /**
302
1051
  * @param {string} label
303
1052
  * @return {number[]}
@@ -313,6 +1062,21 @@ class ProfilerServiceClass {
313
1062
 
314
1063
  }
315
1064
 
1065
+ /**
1066
+ * @param {string} label
1067
+ * @return {number[]}
1068
+ */
1069
+ getValidGpuSamples( label ) {
1070
+
1071
+ const buffer = this.gpuBuffers.get( label );
1072
+ const count = this.gpuCounts.get( label ) ?? 0;
1073
+ if ( buffer === undefined || count === 0 ) return [];
1074
+ return count < RING_SIZE
1075
+ ? Array.from( buffer.subarray( 0, count ) )
1076
+ : Array.from( buffer );
1077
+
1078
+ }
1079
+
316
1080
  /**
317
1081
  * @param {string} label
318
1082
  * @return {import('./ProfilerService.js').ProfilerStats|null}
@@ -360,6 +1124,31 @@ class ProfilerServiceClass {
360
1124
 
361
1125
  }
362
1126
 
1127
+ /**
1128
+ * @param {string} label
1129
+ * @return {import('./ProfilerService.js').GpuProfilerStats|null}
1130
+ */
1131
+ getGpuStats( label ) {
1132
+
1133
+ const samples = this.getValidGpuSamples( label );
1134
+ if ( samples.length === 0 ) return null;
1135
+
1136
+ const sorted = [ ...samples ].sort( ( a, b ) => a - b );
1137
+ const avg = sorted.reduce( ( a, b ) => a + b, 0 ) / sorted.length;
1138
+
1139
+ return {
1140
+ label,
1141
+ samples: samples.length,
1142
+ avg,
1143
+ min: sorted[ 0 ],
1144
+ max: sorted[ sorted.length - 1 ],
1145
+ p95: sorted[ Math.floor( sorted.length * 0.95 ) ],
1146
+ frameBudget: ( avg / FRAME_BUDGET_MS ) * 100,
1147
+ totalInvocations: this.gpuInvocations.get( label ) ?? 0,
1148
+ };
1149
+
1150
+ }
1151
+
363
1152
  /**
364
1153
  * @return {import('./ProfilerService.js').ProfilerStats[]}
365
1154
  */
@@ -371,10 +1160,22 @@ class ProfilerServiceClass {
371
1160
 
372
1161
  }
373
1162
 
1163
+ /**
1164
+ * @return {import('./ProfilerService.js').GpuProfilerStats[]}
1165
+ */
1166
+ getAllGpuStats() {
1167
+
1168
+ return [ ...this.gpuBuffers.keys() ]
1169
+ .map( label => this.getGpuStats( label ) )
1170
+ .filter( stats => stats !== null );
1171
+
1172
+ }
1173
+
374
1174
  report() {
375
1175
 
376
1176
  const stats = this.getAllStats();
377
- if ( stats.length === 0 ) {
1177
+ const gpuStats = this.getAllGpuStats();
1178
+ if ( stats.length === 0 && gpuStats.length === 0 ) {
378
1179
 
379
1180
  console.log( '[ProfilerService] No data. Enable profiling first and wait a few frames.' );
380
1181
  return;
@@ -394,6 +1195,24 @@ class ProfilerServiceClass {
394
1195
  } ) )
395
1196
  );
396
1197
 
1198
+ if ( gpuStats.length > 0 ) {
1199
+
1200
+ gpuStats.sort( ( a, b ) => b.avg - a.avg );
1201
+ console.log( '[ProfilerService] GPU timings' );
1202
+ console.table(
1203
+ gpuStats.map( stats => ( {
1204
+ label: stats.label,
1205
+ 'avg ms': stats.avg.toFixed( 3 ),
1206
+ 'min ms': stats.min.toFixed( 3 ),
1207
+ 'max ms': stats.max.toFixed( 3 ),
1208
+ 'p95 ms': stats.p95.toFixed( 3 ),
1209
+ 'budget %': stats.frameBudget.toFixed( 1 ) + '%',
1210
+ samples: stats.samples,
1211
+ } ) )
1212
+ );
1213
+
1214
+ }
1215
+
397
1216
  }
398
1217
 
399
1218
  /**
@@ -410,6 +1229,7 @@ class ProfilerServiceClass {
410
1229
 
411
1230
  } );
412
1231
  const hasAsync = slices.some( e => e.tid === TRACE_TID_ASYNC );
1232
+ const hasGpu = slices.some( e => e.tid === TRACE_TID_GPU );
413
1233
  /** @type {import('./ProfilerService.js').ChromeTraceMetadataEvent[]} */
414
1234
  const prefix = [
415
1235
  {
@@ -445,6 +1265,20 @@ class ProfilerServiceClass {
445
1265
 
446
1266
  }
447
1267
 
1268
+ if ( hasGpu ) {
1269
+
1270
+ prefix.push( {
1271
+ cat: '__metadata',
1272
+ name: 'thread_name',
1273
+ ph: 'M',
1274
+ pid: 1,
1275
+ tid: TRACE_TID_GPU,
1276
+ ts: 0,
1277
+ args: { name: 'GPU (device timestamps)' },
1278
+ } );
1279
+
1280
+ }
1281
+
448
1282
  return {
449
1283
  displayTimeUnit: 'ms',
450
1284
  traceEvents: [ ...prefix, ...slices ],
@@ -509,8 +1343,24 @@ class ProfilerServiceClass {
509
1343
 
510
1344
  }
511
1345
 
1346
+ /**
1347
+ * @return {import('./ProfilerService.js').GpuProfilerStats[]}
1348
+ */
1349
+ exportGpuJSON() {
1350
+
1351
+ return this.getAllGpuStats();
1352
+
1353
+ }
1354
+
512
1355
  _clearState() {
513
1356
 
1357
+ this._gpuGeneration ++;
1358
+ for ( const state of this.gpuRendererStates.values() ) {
1359
+
1360
+ this._clearGpuRendererState( state );
1361
+
1362
+ }
1363
+
514
1364
  this.buffers.clear();
515
1365
  this.cursors.clear();
516
1366
  this.counts.clear();
@@ -523,6 +1373,79 @@ class ProfilerServiceClass {
523
1373
  this.selfCounts.clear();
524
1374
  this.callStack.length = 0;
525
1375
  this.invocations.clear();
1376
+ this.gpuBuffers.clear();
1377
+ this.gpuCursors.clear();
1378
+ this.gpuCounts.clear();
1379
+ this.gpuInvocations.clear();
1380
+
1381
+ }
1382
+
1383
+ /**
1384
+ * @param {import('./ProfilerService.js').GpuRendererState} state
1385
+ */
1386
+ _clearGpuRendererState( state ) {
1387
+
1388
+ if ( state.kind === 'common' ) {
1389
+
1390
+ state.activeSpans.length = 0;
1391
+ state.pendingSpans.length = 0;
1392
+ state.queryCpuTimes.clear();
1393
+ state.gpuTimestampOrigin = null;
1394
+ state.gpuTraceOrigin = null;
1395
+ return;
1396
+
1397
+ }
1398
+
1399
+ if ( state.activeSpan !== null ) {
1400
+
1401
+ try {
1402
+
1403
+ state.gl.endQuery( state.extension.TIME_ELAPSED_EXT );
1404
+
1405
+ } catch {
1406
+
1407
+ // The context may have been lost while the query was active.
1408
+
1409
+ }
1410
+
1411
+ state.gl.deleteQuery( state.activeSpan._query );
1412
+ state.activeSpan = null;
1413
+
1414
+ }
1415
+
1416
+ if ( state.flushPromise === null ) {
1417
+
1418
+ for ( const span of state.pendingSpans ) {
1419
+
1420
+ state.gl.deleteQuery( span._query );
1421
+
1422
+ }
1423
+
1424
+ }
1425
+
1426
+ state.pendingSpans.length = 0;
1427
+
1428
+ }
1429
+
1430
+ _detachGpuRenderers() {
1431
+
1432
+ for ( const state of this.gpuRendererStates.values() ) {
1433
+
1434
+ if ( state.kind === 'common' && state.available ) {
1435
+
1436
+ state.renderer.backend.removeTimestampQueryListener( state.listener );
1437
+
1438
+ }
1439
+
1440
+ if ( state.kind === 'common' ) {
1441
+
1442
+ state.renderer.backend.trackTimestamp = state.previousTrackTimestamp;
1443
+
1444
+ }
1445
+
1446
+ }
1447
+
1448
+ this.gpuRendererStates.clear();
526
1449
 
527
1450
  }
528
1451
 
@@ -552,6 +1475,18 @@ class ProfilerServiceClass {
552
1475
  * @property {number|undefined} selfFrameBudget Exclusive time as percentage of a 60 fps frame budget.
553
1476
  */
554
1477
 
1478
+ /**
1479
+ * @typedef {Object} GpuProfilerStats
1480
+ * @property {string} label
1481
+ * @property {number} samples
1482
+ * @property {number} avg
1483
+ * @property {number} min
1484
+ * @property {number} max
1485
+ * @property {number} p95
1486
+ * @property {number} frameBudget Percentage of a 60 fps frame budget (16.67 ms)
1487
+ * @property {number} totalInvocations
1488
+ */
1489
+
555
1490
  /**
556
1491
  * @typedef {Object} SpanHandle
557
1492
  * @property {string} label
@@ -560,6 +1495,51 @@ class ProfilerServiceClass {
560
1495
  * @property {string} _startMark
561
1496
  */
562
1497
 
1498
+ /**
1499
+ * @typedef {Object} GpuSpanHandle
1500
+ * @property {string} label
1501
+ * @property {?Object} renderer
1502
+ * @property {number} t0
1503
+ * @property {number} _seq
1504
+ * @property {number} _generation
1505
+ * @property {{render: Set<string>, compute: Set<string>}} [_queries]
1506
+ * @property {WebGLQuery} [_query]
1507
+ * @property {number} [_endTime]
1508
+ */
1509
+
1510
+ /**
1511
+ * @typedef {Object} CommonGpuRendererState
1512
+ * @property {'common'} kind
1513
+ * @property {boolean} available
1514
+ * @property {Object} renderer
1515
+ * @property {function('render'|'compute', string, ?string): void} listener
1516
+ * @property {boolean} previousTrackTimestamp
1517
+ * @property {GpuSpanHandle[]} activeSpans
1518
+ * @property {GpuSpanHandle[]} pendingSpans
1519
+ * @property {Map<string, number>} queryCpuTimes
1520
+ * @property {?bigint} gpuTimestampOrigin
1521
+ * @property {?number} gpuTraceOrigin
1522
+ * @property {?Promise<void>} flushPromise
1523
+ * @property {boolean} flushScheduled
1524
+ */
1525
+
1526
+ /**
1527
+ * @typedef {Object} LegacyWebGLGpuRendererState
1528
+ * @property {'legacy-webgl'} kind
1529
+ * @property {boolean} available
1530
+ * @property {Object} renderer
1531
+ * @property {WebGL2RenderingContext} gl
1532
+ * @property {?EXT_disjoint_timer_query_webgl2} extension
1533
+ * @property {?GpuSpanHandle} activeSpan
1534
+ * @property {GpuSpanHandle[]} pendingSpans
1535
+ * @property {?Promise<void>} flushPromise
1536
+ * @property {boolean} flushScheduled
1537
+ */
1538
+
1539
+ /**
1540
+ * @typedef {CommonGpuRendererState|LegacyWebGLGpuRendererState} GpuRendererState
1541
+ */
1542
+
563
1543
  /**
564
1544
  * @typedef {Object} EndSpanOptions
565
1545
  * @property {boolean} [asyncTimeline] When true, trace slice uses `tid=2` (virtual async row).
@@ -573,7 +1553,7 @@ class ProfilerServiceClass {
573
1553
  * @property {number} dur
574
1554
  * @property {1} pid
575
1555
  * @property {number} tid
576
- * @property {'gnsx'} cat
1556
+ * @property {'gnsx'|'gnsx-gpu'} cat
577
1557
  */
578
1558
 
579
1559
  /**