@gnsx/three 0.185.10 → 0.185.12

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/build/three.cjs CHANGED
@@ -60389,6 +60389,9 @@ class ProfilerServiceClass {
60389
60389
 
60390
60390
  if ( label !== null && label !== undefined && label !== '' ) {
60391
60391
 
60392
+ // Queue labelled passes with the active beginGpu span(s). Flushing
60393
+ // immediately would commit them before their parent envelope exists and
60394
+ // can anchor gpuTraceOrigin mid-frame, which breaks Speedscope nesting.
60392
60395
  const passSpan = {
60393
60396
  label: `GPU pass: ${label}`,
60394
60397
  renderer,
@@ -60402,7 +60405,13 @@ class ProfilerServiceClass {
60402
60405
  };
60403
60406
  passSpan._queries[ type ].add( uid );
60404
60407
  state.pendingSpans.push( passSpan );
60405
- this._scheduleGpuFlush( state );
60408
+ // Only auto-flush when no beginGpu parent is open. Nested parents flush on endGpu
60409
+ // so pass slices share one origin and nest inside the parent envelope.
60410
+ if ( state.activeSpans.length === 0 ) {
60411
+
60412
+ this._scheduleGpuFlush( state );
60413
+
60414
+ }
60406
60415
 
60407
60416
  }
60408
60417
 
@@ -60463,38 +60472,22 @@ class ProfilerServiceClass {
60463
60472
 
60464
60473
  if ( state.gpuTimestampOrigin === null ) {
60465
60474
 
60466
- let earliestRange = null;
60467
- let earliestUid = null;
60468
- for ( const span of spans ) {
60469
-
60470
- for ( const type of [ TimestampQuery.RENDER, TimestampQuery.COMPUTE ] ) {
60471
-
60472
- for ( const uid of span._queries[ type ] ) {
60473
-
60474
- const range = state.renderer.backend.getTimestampRange( uid );
60475
- if ( range !== null && ( earliestRange === null || range.start < earliestRange.start ) ) {
60476
-
60477
- earliestRange = range;
60478
- earliestUid = uid;
60479
-
60480
- }
60481
-
60482
- }
60483
-
60484
- }
60485
-
60486
- }
60487
-
60488
- if ( earliestRange !== null ) {
60475
+ const origin = this._findEarliestGpuTimestampOrigin( state, spans );
60476
+ if ( origin !== null ) {
60489
60477
 
60490
- state.gpuTimestampOrigin = earliestRange.start;
60491
- state.gpuTraceOrigin = state.queryCpuTimes.get( earliestUid ) ?? spans[ 0 ].t0;
60478
+ state.gpuTimestampOrigin = origin.gpuTimestampOrigin;
60479
+ state.gpuTraceOrigin = origin.gpuTraceOrigin;
60492
60480
 
60493
60481
  }
60494
60482
 
60495
60483
  }
60496
60484
 
60497
60485
  const resolvedUids = new Set();
60486
+ /** @type {Array<{ label: string, startTime: number, durationMs: number, isPass: boolean }>} */
60487
+ const samples = [];
60488
+ /** @type {Set<string>} */
60489
+ const labelledQueryUids = new Set();
60490
+
60498
60491
  for ( const span of spans ) {
60499
60492
 
60500
60493
  if ( span._generation !== this._gpuGeneration ) continue;
@@ -60532,20 +60525,247 @@ class ProfilerServiceClass {
60532
60525
  if ( resolvedQueries > 0 ) {
60533
60526
 
60534
60527
  let startTime = span.t0;
60535
- if ( rangedQueries === resolvedQueries && gpuStart !== null && gpuEnd !== null && state.gpuTimestampOrigin !== null ) {
60528
+ // Prefer the device envelope whenever any query has a range. Requiring
60529
+ // *all* queries to be ranged forced a CPU-t0 + summed-duration fallback
60530
+ // that often ended before later GPU pass slices, breaking flame nesting.
60531
+ if ( rangedQueries > 0 && gpuStart !== null && gpuEnd !== null && state.gpuTimestampOrigin !== null ) {
60536
60532
 
60537
60533
  duration = Number( gpuEnd - gpuStart ) / 1e6;
60538
60534
  startTime = state.gpuTraceOrigin + Number( gpuStart - state.gpuTimestampOrigin ) / 1e6;
60539
60535
 
60540
60536
  }
60541
60537
 
60542
- this._commitGpuDurationSample( span.label, startTime, duration );
60538
+ const isPass = span.label.startsWith( 'GPU pass:' );
60539
+ if ( isPass ) {
60540
+
60541
+ for ( const type of [ TimestampQuery.RENDER, TimestampQuery.COMPUTE ] ) {
60542
+
60543
+ for ( const uid of span._queries[ type ] ) labelledQueryUids.add( uid );
60544
+
60545
+ }
60546
+
60547
+ }
60548
+
60549
+ samples.push( {
60550
+ label: span.label,
60551
+ startTime,
60552
+ durationMs: duration,
60553
+ isPass,
60554
+ } );
60555
+
60556
+ }
60557
+
60558
+ }
60559
+
60560
+ // Parent beginGpu spans collect every timestamp query, including renders that
60561
+ // had no gpuProfilerLabel. Surface those as pass slices so they are not empty
60562
+ // holes under the parent envelope.
60563
+ for ( const span of spans ) {
60564
+
60565
+ if ( span._generation !== this._gpuGeneration ) continue;
60566
+ if ( span.label.startsWith( 'GPU pass:' ) ) continue;
60567
+ if ( state.gpuTimestampOrigin === null ) continue;
60568
+
60569
+ for ( const type of [ TimestampQuery.RENDER, TimestampQuery.COMPUTE ] ) {
60570
+
60571
+ for ( const uid of span._queries[ type ] ) {
60572
+
60573
+ if ( labelledQueryUids.has( uid ) ) continue;
60574
+ if ( state.renderer.backend.hasTimestampQuery( uid ) === false ) continue;
60575
+
60576
+ const range = state.renderer.backend.getTimestampRange( uid );
60577
+ if ( range === null ) continue;
60578
+
60579
+ const durationMs = Number( range.end - range.start ) / 1e6;
60580
+ if ( durationMs <= 0 ) continue;
60581
+
60582
+ samples.push( {
60583
+ label: 'GPU pass: (unlabeled)',
60584
+ startTime: state.gpuTraceOrigin + Number( range.start - state.gpuTimestampOrigin ) / 1e6,
60585
+ durationMs,
60586
+ isPass: true,
60587
+ } );
60588
+ labelledQueryUids.add( uid );
60589
+
60590
+ }
60591
+
60592
+ }
60593
+
60594
+ }
60595
+
60596
+ const passSamples = samples.filter( sample => sample.isPass );
60597
+ const otherSamples = samples.filter( sample => sample.isPass === false );
60598
+ this._snapHalfOpenGpuPassTraceIntervals( passSamples );
60599
+ this._expandGpuParentTraceToContainPasses( otherSamples, passSamples );
60600
+
60601
+ for ( const sample of otherSamples ) {
60602
+
60603
+ this._commitGpuDurationSample( sample.label, sample.startTime, sample.durationMs, {
60604
+ ts: sample.traceTs,
60605
+ dur: sample.traceDur,
60606
+ } );
60607
+
60608
+ }
60609
+
60610
+ for ( const sample of passSamples ) {
60611
+
60612
+ this._commitGpuDurationSample( sample.label, sample.startTime, sample.durationMs, {
60613
+ ts: sample.traceTs,
60614
+ dur: sample.traceDur,
60615
+ } );
60616
+
60617
+ }
60618
+
60619
+ // Keep CPU times for queries still referenced by active parent spans.
60620
+ for ( const uid of resolvedUids ) {
60621
+
60622
+ if ( this._isGpuQueryReferencedByActiveSpans( state, uid ) ) continue;
60623
+ state.queryCpuTimes.delete( uid );
60624
+
60625
+ }
60626
+
60627
+ }
60628
+
60629
+ /**
60630
+ * Convert labelled GPU pass samples to non-overlapping half-open µs intervals for
60631
+ * Chrome/Speedscope traces. Stats keep the raw measured durations.
60632
+ *
60633
+ * @param {Array<{ startTime: number, durationMs: number, traceTs?: number, traceDur?: number }>} samples
60634
+ */
60635
+ _snapHalfOpenGpuPassTraceIntervals( samples ) {
60636
+
60637
+ if ( samples.length === 0 ) return;
60638
+
60639
+ for ( const sample of samples ) {
60640
+
60641
+ sample.traceTs = Math.round( this._getTraceTimestamp( sample.startTime ) );
60642
+ sample.traceDur = Math.max( 1, Math.round( sample.durationMs * 1000 ) );
60643
+
60644
+ }
60645
+
60646
+ // Longer first at the same timestamp so the substantial pass keeps its start.
60647
+ samples.sort( ( a, b ) => a.traceTs - b.traceTs || b.traceDur - a.traceDur );
60648
+
60649
+ let cursor = Number.NEGATIVE_INFINITY;
60650
+ for ( const sample of samples ) {
60651
+
60652
+ if ( sample.traceTs < cursor ) {
60653
+
60654
+ const end = Math.max( sample.traceTs + sample.traceDur, cursor + 1 );
60655
+ sample.traceTs = cursor;
60656
+ sample.traceDur = Math.max( 1, end - sample.traceTs );
60543
60657
 
60544
60658
  }
60545
60659
 
60660
+ cursor = sample.traceTs + sample.traceDur;
60661
+
60546
60662
  }
60547
60663
 
60548
- for ( const uid of resolvedUids ) state.queryCpuTimes.delete( uid );
60664
+ }
60665
+
60666
+ /**
60667
+ * Speedscope nests by time containment on a thread. If a snapped GPU pass
60668
+ * sticks out past its beginGpu parent (rounding / half-open adjust), the
60669
+ * parent fails containment and is pushed onto a lower lane under renderFrame.
60670
+ * Expand overlapping parents so they strictly cover their pass children.
60671
+ *
60672
+ * @param {Array<{ startTime: number, durationMs: number, traceTs?: number, traceDur?: number }>} parents
60673
+ * @param {Array<{ traceTs: number, traceDur: number }>} passes
60674
+ */
60675
+ _expandGpuParentTraceToContainPasses( parents, passes ) {
60676
+
60677
+ if ( parents.length === 0 || passes.length === 0 ) return;
60678
+
60679
+ for ( const parent of parents ) {
60680
+
60681
+ parent.traceTs = Math.round( this._getTraceTimestamp( parent.startTime ) );
60682
+ parent.traceDur = Math.max( 1, Math.round( parent.durationMs * 1000 ) );
60683
+
60684
+ const parentEnd = parent.traceTs + parent.traceDur;
60685
+ let minTs = parent.traceTs;
60686
+ let maxEnd = parentEnd;
60687
+ let touched = false;
60688
+
60689
+ for ( const pass of passes ) {
60690
+
60691
+ const passEnd = pass.traceTs + pass.traceDur;
60692
+ if ( pass.traceTs >= parentEnd || passEnd <= parent.traceTs ) continue;
60693
+
60694
+ touched = true;
60695
+ if ( pass.traceTs < minTs ) minTs = pass.traceTs;
60696
+ if ( passEnd > maxEnd ) maxEnd = passEnd;
60697
+
60698
+ }
60699
+
60700
+ if ( touched === false ) continue;
60701
+
60702
+ // Start 1µs before the first child so equal-start pairs still nest
60703
+ // (Speedscope sorts same-ts by longer-first; a child that begins
60704
+ // strictly after the parent is unambiguous).
60705
+ parent.traceTs = minTs > 0 ? minTs - 1 : minTs;
60706
+ parent.traceDur = Math.max( 1, maxEnd - parent.traceTs );
60707
+
60708
+ }
60709
+
60710
+ }
60711
+
60712
+ /**
60713
+ * @param {import('./ProfilerService.js').CommonGpuRendererState} state
60714
+ * @param {import('./ProfilerService.js').GpuSpanHandle[]} spans
60715
+ * @return {?{ gpuTimestampOrigin: bigint, gpuTraceOrigin: number }}
60716
+ */
60717
+ _findEarliestGpuTimestampOrigin( state, spans ) {
60718
+
60719
+ let earliestRange = null;
60720
+ let earliestUid = null;
60721
+ const candidates = spans.concat( state.activeSpans );
60722
+
60723
+ for ( const span of candidates ) {
60724
+
60725
+ for ( const type of [ TimestampQuery.RENDER, TimestampQuery.COMPUTE ] ) {
60726
+
60727
+ const queries = span._queries?.[ type ];
60728
+ if ( queries === undefined ) continue;
60729
+
60730
+ for ( const uid of queries ) {
60731
+
60732
+ const range = state.renderer.backend.getTimestampRange( uid );
60733
+ if ( range !== null && ( earliestRange === null || range.start < earliestRange.start ) ) {
60734
+
60735
+ earliestRange = range;
60736
+ earliestUid = uid;
60737
+
60738
+ }
60739
+
60740
+ }
60741
+
60742
+ }
60743
+
60744
+ }
60745
+
60746
+ if ( earliestRange === null ) return null;
60747
+
60748
+ return {
60749
+ gpuTimestampOrigin: earliestRange.start,
60750
+ gpuTraceOrigin: state.queryCpuTimes.get( earliestUid ) ?? spans[ 0 ].t0,
60751
+ };
60752
+
60753
+ }
60754
+
60755
+ /**
60756
+ * @param {import('./ProfilerService.js').CommonGpuRendererState} state
60757
+ * @param {string} uid
60758
+ * @return {boolean}
60759
+ */
60760
+ _isGpuQueryReferencedByActiveSpans( state, uid ) {
60761
+
60762
+ for ( const span of state.activeSpans ) {
60763
+
60764
+ if ( span._queries.render.has( uid ) || span._queries.compute.has( uid ) ) return true;
60765
+
60766
+ }
60767
+
60768
+ return false;
60549
60769
 
60550
60770
  }
60551
60771
 
@@ -60773,8 +60993,9 @@ class ProfilerServiceClass {
60773
60993
  * @param {string} label
60774
60994
  * @param {number} startTime
60775
60995
  * @param {number} durationMs
60996
+ * @param {{ ts: number, dur: number }} [traceOverride]
60776
60997
  */
60777
- _commitGpuDurationSample( label, startTime, durationMs ) {
60998
+ _commitGpuDurationSample( label, startTime, durationMs, traceOverride = null ) {
60778
60999
 
60779
61000
  let buffer = this.gpuBuffers.get( label );
60780
61001
  if ( buffer === undefined ) {
@@ -60797,8 +61018,8 @@ class ProfilerServiceClass {
60797
61018
  this.traceEvents.push( {
60798
61019
  name: label,
60799
61020
  ph: 'X',
60800
- ts: Math.round( this._getTraceTimestamp( startTime ) ),
60801
- dur: Math.max( 1, Math.round( durationMs * 1000 ) ),
61021
+ ts: traceOverride?.ts ?? Math.round( this._getTraceTimestamp( startTime ) ),
61022
+ dur: traceOverride?.dur ?? Math.max( 1, Math.round( durationMs * 1000 ) ),
60802
61023
  pid: 1,
60803
61024
  tid: TRACE_TID_GPU,
60804
61025
  cat: 'gnsx-gpu',
@@ -60409,6 +60409,9 @@ class ProfilerServiceClass {
60409
60409
 
60410
60410
  if ( label !== null && label !== undefined && label !== '' ) {
60411
60411
 
60412
+ // Queue labelled passes with the active beginGpu span(s). Flushing
60413
+ // immediately would commit them before their parent envelope exists and
60414
+ // can anchor gpuTraceOrigin mid-frame, which breaks Speedscope nesting.
60412
60415
  const passSpan = {
60413
60416
  label: `GPU pass: ${label}`,
60414
60417
  renderer,
@@ -60422,7 +60425,13 @@ class ProfilerServiceClass {
60422
60425
  };
60423
60426
  passSpan._queries[ type ].add( uid );
60424
60427
  state.pendingSpans.push( passSpan );
60425
- this._scheduleGpuFlush( state );
60428
+ // Only auto-flush when no beginGpu parent is open. Nested parents flush on endGpu
60429
+ // so pass slices share one origin and nest inside the parent envelope.
60430
+ if ( state.activeSpans.length === 0 ) {
60431
+
60432
+ this._scheduleGpuFlush( state );
60433
+
60434
+ }
60426
60435
 
60427
60436
  }
60428
60437
 
@@ -60483,38 +60492,22 @@ class ProfilerServiceClass {
60483
60492
 
60484
60493
  if ( state.gpuTimestampOrigin === null ) {
60485
60494
 
60486
- let earliestRange = null;
60487
- let earliestUid = null;
60488
- for ( const span of spans ) {
60489
-
60490
- for ( const type of [ TimestampQuery.RENDER, TimestampQuery.COMPUTE ] ) {
60491
-
60492
- for ( const uid of span._queries[ type ] ) {
60493
-
60494
- const range = state.renderer.backend.getTimestampRange( uid );
60495
- if ( range !== null && ( earliestRange === null || range.start < earliestRange.start ) ) {
60496
-
60497
- earliestRange = range;
60498
- earliestUid = uid;
60499
-
60500
- }
60501
-
60502
- }
60503
-
60504
- }
60505
-
60506
- }
60507
-
60508
- if ( earliestRange !== null ) {
60495
+ const origin = this._findEarliestGpuTimestampOrigin( state, spans );
60496
+ if ( origin !== null ) {
60509
60497
 
60510
- state.gpuTimestampOrigin = earliestRange.start;
60511
- state.gpuTraceOrigin = state.queryCpuTimes.get( earliestUid ) ?? spans[ 0 ].t0;
60498
+ state.gpuTimestampOrigin = origin.gpuTimestampOrigin;
60499
+ state.gpuTraceOrigin = origin.gpuTraceOrigin;
60512
60500
 
60513
60501
  }
60514
60502
 
60515
60503
  }
60516
60504
 
60517
60505
  const resolvedUids = new Set();
60506
+ /** @type {Array<{ label: string, startTime: number, durationMs: number, isPass: boolean }>} */
60507
+ const samples = [];
60508
+ /** @type {Set<string>} */
60509
+ const labelledQueryUids = new Set();
60510
+
60518
60511
  for ( const span of spans ) {
60519
60512
 
60520
60513
  if ( span._generation !== this._gpuGeneration ) continue;
@@ -60552,20 +60545,247 @@ class ProfilerServiceClass {
60552
60545
  if ( resolvedQueries > 0 ) {
60553
60546
 
60554
60547
  let startTime = span.t0;
60555
- if ( rangedQueries === resolvedQueries && gpuStart !== null && gpuEnd !== null && state.gpuTimestampOrigin !== null ) {
60548
+ // Prefer the device envelope whenever any query has a range. Requiring
60549
+ // *all* queries to be ranged forced a CPU-t0 + summed-duration fallback
60550
+ // that often ended before later GPU pass slices, breaking flame nesting.
60551
+ if ( rangedQueries > 0 && gpuStart !== null && gpuEnd !== null && state.gpuTimestampOrigin !== null ) {
60556
60552
 
60557
60553
  duration = Number( gpuEnd - gpuStart ) / 1e6;
60558
60554
  startTime = state.gpuTraceOrigin + Number( gpuStart - state.gpuTimestampOrigin ) / 1e6;
60559
60555
 
60560
60556
  }
60561
60557
 
60562
- this._commitGpuDurationSample( span.label, startTime, duration );
60558
+ const isPass = span.label.startsWith( 'GPU pass:' );
60559
+ if ( isPass ) {
60560
+
60561
+ for ( const type of [ TimestampQuery.RENDER, TimestampQuery.COMPUTE ] ) {
60562
+
60563
+ for ( const uid of span._queries[ type ] ) labelledQueryUids.add( uid );
60564
+
60565
+ }
60566
+
60567
+ }
60568
+
60569
+ samples.push( {
60570
+ label: span.label,
60571
+ startTime,
60572
+ durationMs: duration,
60573
+ isPass,
60574
+ } );
60575
+
60576
+ }
60577
+
60578
+ }
60579
+
60580
+ // Parent beginGpu spans collect every timestamp query, including renders that
60581
+ // had no gpuProfilerLabel. Surface those as pass slices so they are not empty
60582
+ // holes under the parent envelope.
60583
+ for ( const span of spans ) {
60584
+
60585
+ if ( span._generation !== this._gpuGeneration ) continue;
60586
+ if ( span.label.startsWith( 'GPU pass:' ) ) continue;
60587
+ if ( state.gpuTimestampOrigin === null ) continue;
60588
+
60589
+ for ( const type of [ TimestampQuery.RENDER, TimestampQuery.COMPUTE ] ) {
60590
+
60591
+ for ( const uid of span._queries[ type ] ) {
60592
+
60593
+ if ( labelledQueryUids.has( uid ) ) continue;
60594
+ if ( state.renderer.backend.hasTimestampQuery( uid ) === false ) continue;
60595
+
60596
+ const range = state.renderer.backend.getTimestampRange( uid );
60597
+ if ( range === null ) continue;
60598
+
60599
+ const durationMs = Number( range.end - range.start ) / 1e6;
60600
+ if ( durationMs <= 0 ) continue;
60601
+
60602
+ samples.push( {
60603
+ label: 'GPU pass: (unlabeled)',
60604
+ startTime: state.gpuTraceOrigin + Number( range.start - state.gpuTimestampOrigin ) / 1e6,
60605
+ durationMs,
60606
+ isPass: true,
60607
+ } );
60608
+ labelledQueryUids.add( uid );
60609
+
60610
+ }
60611
+
60612
+ }
60613
+
60614
+ }
60615
+
60616
+ const passSamples = samples.filter( sample => sample.isPass );
60617
+ const otherSamples = samples.filter( sample => sample.isPass === false );
60618
+ this._snapHalfOpenGpuPassTraceIntervals( passSamples );
60619
+ this._expandGpuParentTraceToContainPasses( otherSamples, passSamples );
60620
+
60621
+ for ( const sample of otherSamples ) {
60622
+
60623
+ this._commitGpuDurationSample( sample.label, sample.startTime, sample.durationMs, {
60624
+ ts: sample.traceTs,
60625
+ dur: sample.traceDur,
60626
+ } );
60627
+
60628
+ }
60629
+
60630
+ for ( const sample of passSamples ) {
60631
+
60632
+ this._commitGpuDurationSample( sample.label, sample.startTime, sample.durationMs, {
60633
+ ts: sample.traceTs,
60634
+ dur: sample.traceDur,
60635
+ } );
60636
+
60637
+ }
60638
+
60639
+ // Keep CPU times for queries still referenced by active parent spans.
60640
+ for ( const uid of resolvedUids ) {
60641
+
60642
+ if ( this._isGpuQueryReferencedByActiveSpans( state, uid ) ) continue;
60643
+ state.queryCpuTimes.delete( uid );
60644
+
60645
+ }
60646
+
60647
+ }
60648
+
60649
+ /**
60650
+ * Convert labelled GPU pass samples to non-overlapping half-open µs intervals for
60651
+ * Chrome/Speedscope traces. Stats keep the raw measured durations.
60652
+ *
60653
+ * @param {Array<{ startTime: number, durationMs: number, traceTs?: number, traceDur?: number }>} samples
60654
+ */
60655
+ _snapHalfOpenGpuPassTraceIntervals( samples ) {
60656
+
60657
+ if ( samples.length === 0 ) return;
60658
+
60659
+ for ( const sample of samples ) {
60660
+
60661
+ sample.traceTs = Math.round( this._getTraceTimestamp( sample.startTime ) );
60662
+ sample.traceDur = Math.max( 1, Math.round( sample.durationMs * 1000 ) );
60663
+
60664
+ }
60665
+
60666
+ // Longer first at the same timestamp so the substantial pass keeps its start.
60667
+ samples.sort( ( a, b ) => a.traceTs - b.traceTs || b.traceDur - a.traceDur );
60668
+
60669
+ let cursor = Number.NEGATIVE_INFINITY;
60670
+ for ( const sample of samples ) {
60671
+
60672
+ if ( sample.traceTs < cursor ) {
60673
+
60674
+ const end = Math.max( sample.traceTs + sample.traceDur, cursor + 1 );
60675
+ sample.traceTs = cursor;
60676
+ sample.traceDur = Math.max( 1, end - sample.traceTs );
60563
60677
 
60564
60678
  }
60565
60679
 
60680
+ cursor = sample.traceTs + sample.traceDur;
60681
+
60566
60682
  }
60567
60683
 
60568
- for ( const uid of resolvedUids ) state.queryCpuTimes.delete( uid );
60684
+ }
60685
+
60686
+ /**
60687
+ * Speedscope nests by time containment on a thread. If a snapped GPU pass
60688
+ * sticks out past its beginGpu parent (rounding / half-open adjust), the
60689
+ * parent fails containment and is pushed onto a lower lane under renderFrame.
60690
+ * Expand overlapping parents so they strictly cover their pass children.
60691
+ *
60692
+ * @param {Array<{ startTime: number, durationMs: number, traceTs?: number, traceDur?: number }>} parents
60693
+ * @param {Array<{ traceTs: number, traceDur: number }>} passes
60694
+ */
60695
+ _expandGpuParentTraceToContainPasses( parents, passes ) {
60696
+
60697
+ if ( parents.length === 0 || passes.length === 0 ) return;
60698
+
60699
+ for ( const parent of parents ) {
60700
+
60701
+ parent.traceTs = Math.round( this._getTraceTimestamp( parent.startTime ) );
60702
+ parent.traceDur = Math.max( 1, Math.round( parent.durationMs * 1000 ) );
60703
+
60704
+ const parentEnd = parent.traceTs + parent.traceDur;
60705
+ let minTs = parent.traceTs;
60706
+ let maxEnd = parentEnd;
60707
+ let touched = false;
60708
+
60709
+ for ( const pass of passes ) {
60710
+
60711
+ const passEnd = pass.traceTs + pass.traceDur;
60712
+ if ( pass.traceTs >= parentEnd || passEnd <= parent.traceTs ) continue;
60713
+
60714
+ touched = true;
60715
+ if ( pass.traceTs < minTs ) minTs = pass.traceTs;
60716
+ if ( passEnd > maxEnd ) maxEnd = passEnd;
60717
+
60718
+ }
60719
+
60720
+ if ( touched === false ) continue;
60721
+
60722
+ // Start 1µs before the first child so equal-start pairs still nest
60723
+ // (Speedscope sorts same-ts by longer-first; a child that begins
60724
+ // strictly after the parent is unambiguous).
60725
+ parent.traceTs = minTs > 0 ? minTs - 1 : minTs;
60726
+ parent.traceDur = Math.max( 1, maxEnd - parent.traceTs );
60727
+
60728
+ }
60729
+
60730
+ }
60731
+
60732
+ /**
60733
+ * @param {import('./ProfilerService.js').CommonGpuRendererState} state
60734
+ * @param {import('./ProfilerService.js').GpuSpanHandle[]} spans
60735
+ * @return {?{ gpuTimestampOrigin: bigint, gpuTraceOrigin: number }}
60736
+ */
60737
+ _findEarliestGpuTimestampOrigin( state, spans ) {
60738
+
60739
+ let earliestRange = null;
60740
+ let earliestUid = null;
60741
+ const candidates = spans.concat( state.activeSpans );
60742
+
60743
+ for ( const span of candidates ) {
60744
+
60745
+ for ( const type of [ TimestampQuery.RENDER, TimestampQuery.COMPUTE ] ) {
60746
+
60747
+ const queries = span._queries?.[ type ];
60748
+ if ( queries === undefined ) continue;
60749
+
60750
+ for ( const uid of queries ) {
60751
+
60752
+ const range = state.renderer.backend.getTimestampRange( uid );
60753
+ if ( range !== null && ( earliestRange === null || range.start < earliestRange.start ) ) {
60754
+
60755
+ earliestRange = range;
60756
+ earliestUid = uid;
60757
+
60758
+ }
60759
+
60760
+ }
60761
+
60762
+ }
60763
+
60764
+ }
60765
+
60766
+ if ( earliestRange === null ) return null;
60767
+
60768
+ return {
60769
+ gpuTimestampOrigin: earliestRange.start,
60770
+ gpuTraceOrigin: state.queryCpuTimes.get( earliestUid ) ?? spans[ 0 ].t0,
60771
+ };
60772
+
60773
+ }
60774
+
60775
+ /**
60776
+ * @param {import('./ProfilerService.js').CommonGpuRendererState} state
60777
+ * @param {string} uid
60778
+ * @return {boolean}
60779
+ */
60780
+ _isGpuQueryReferencedByActiveSpans( state, uid ) {
60781
+
60782
+ for ( const span of state.activeSpans ) {
60783
+
60784
+ if ( span._queries.render.has( uid ) || span._queries.compute.has( uid ) ) return true;
60785
+
60786
+ }
60787
+
60788
+ return false;
60569
60789
 
60570
60790
  }
60571
60791
 
@@ -60793,8 +61013,9 @@ class ProfilerServiceClass {
60793
61013
  * @param {string} label
60794
61014
  * @param {number} startTime
60795
61015
  * @param {number} durationMs
61016
+ * @param {{ ts: number, dur: number }} [traceOverride]
60796
61017
  */
60797
- _commitGpuDurationSample( label, startTime, durationMs ) {
61018
+ _commitGpuDurationSample( label, startTime, durationMs, traceOverride = null ) {
60798
61019
 
60799
61020
  let buffer = this.gpuBuffers.get( label );
60800
61021
  if ( buffer === undefined ) {
@@ -60817,8 +61038,8 @@ class ProfilerServiceClass {
60817
61038
  this.traceEvents.push( {
60818
61039
  name: label,
60819
61040
  ph: 'X',
60820
- ts: Math.round( this._getTraceTimestamp( startTime ) ),
60821
- dur: Math.max( 1, Math.round( durationMs * 1000 ) ),
61041
+ ts: traceOverride?.ts ?? Math.round( this._getTraceTimestamp( startTime ) ),
61042
+ dur: traceOverride?.dur ?? Math.max( 1, Math.round( durationMs * 1000 ) ),
60822
61043
  pid: 1,
60823
61044
  tid: TRACE_TID_GPU,
60824
61045
  cat: 'gnsx-gpu',