@gnsx/three 0.185.10 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gnsx/three",
3
- "version": "0.185.10",
3
+ "version": "0.185.11",
4
4
  "description": "JavaScript 3D library",
5
5
  "type": "module",
6
6
  "main": "./build/three.cjs",
@@ -407,6 +407,9 @@ class ProfilerServiceClass {
407
407
 
408
408
  if ( label !== null && label !== undefined && label !== '' ) {
409
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.
410
413
  const passSpan = {
411
414
  label: `GPU pass: ${label}`,
412
415
  renderer,
@@ -420,7 +423,13 @@ class ProfilerServiceClass {
420
423
  };
421
424
  passSpan._queries[ type ].add( uid );
422
425
  state.pendingSpans.push( passSpan );
423
- this._scheduleGpuFlush( state );
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
+ }
424
433
 
425
434
  }
426
435
 
@@ -481,38 +490,22 @@ class ProfilerServiceClass {
481
490
 
482
491
  if ( state.gpuTimestampOrigin === null ) {
483
492
 
484
- let earliestRange = null;
485
- let earliestUid = null;
486
- for ( const span of spans ) {
487
-
488
- for ( const type of [ TimestampQuery.RENDER, TimestampQuery.COMPUTE ] ) {
489
-
490
- for ( const uid of span._queries[ type ] ) {
491
-
492
- const range = state.renderer.backend.getTimestampRange( uid );
493
- if ( range !== null && ( earliestRange === null || range.start < earliestRange.start ) ) {
494
-
495
- earliestRange = range;
496
- earliestUid = uid;
497
-
498
- }
499
-
500
- }
501
-
502
- }
503
-
504
- }
505
-
506
- if ( earliestRange !== null ) {
493
+ const origin = this._findEarliestGpuTimestampOrigin( state, spans );
494
+ if ( origin !== null ) {
507
495
 
508
- state.gpuTimestampOrigin = earliestRange.start;
509
- state.gpuTraceOrigin = state.queryCpuTimes.get( earliestUid ) ?? spans[ 0 ].t0;
496
+ state.gpuTimestampOrigin = origin.gpuTimestampOrigin;
497
+ state.gpuTraceOrigin = origin.gpuTraceOrigin;
510
498
 
511
499
  }
512
500
 
513
501
  }
514
502
 
515
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
+
516
509
  for ( const span of spans ) {
517
510
 
518
511
  if ( span._generation !== this._gpuGeneration ) continue;
@@ -550,20 +543,247 @@ class ProfilerServiceClass {
550
543
  if ( resolvedQueries > 0 ) {
551
544
 
552
545
  let startTime = span.t0;
553
- if ( rangedQueries === resolvedQueries && gpuStart !== null && gpuEnd !== null && state.gpuTimestampOrigin !== null ) {
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 ) {
554
550
 
555
551
  duration = Number( gpuEnd - gpuStart ) / 1e6;
556
552
  startTime = state.gpuTraceOrigin + Number( gpuStart - state.gpuTimestampOrigin ) / 1e6;
557
553
 
558
554
  }
559
555
 
560
- this._commitGpuDurationSample( span.label, startTime, duration );
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
+ } );
561
573
 
562
574
  }
563
575
 
564
576
  }
565
577
 
566
- for ( const uid of resolvedUids ) state.queryCpuTimes.delete( uid );
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;
567
787
 
568
788
  }
569
789
 
@@ -791,8 +1011,9 @@ class ProfilerServiceClass {
791
1011
  * @param {string} label
792
1012
  * @param {number} startTime
793
1013
  * @param {number} durationMs
1014
+ * @param {{ ts: number, dur: number }} [traceOverride]
794
1015
  */
795
- _commitGpuDurationSample( label, startTime, durationMs ) {
1016
+ _commitGpuDurationSample( label, startTime, durationMs, traceOverride = null ) {
796
1017
 
797
1018
  let buffer = this.gpuBuffers.get( label );
798
1019
  if ( buffer === undefined ) {
@@ -815,8 +1036,8 @@ class ProfilerServiceClass {
815
1036
  this.traceEvents.push( {
816
1037
  name: label,
817
1038
  ph: 'X',
818
- ts: Math.round( this._getTraceTimestamp( startTime ) ),
819
- dur: Math.max( 1, Math.round( durationMs * 1000 ) ),
1039
+ ts: traceOverride?.ts ?? Math.round( this._getTraceTimestamp( startTime ) ),
1040
+ dur: traceOverride?.dur ?? Math.max( 1, Math.round( durationMs * 1000 ) ),
820
1041
  pid: 1,
821
1042
  tid: TRACE_TID_GPU,
822
1043
  cat: 'gnsx-gpu',
@@ -1618,8 +1618,23 @@ class Renderer {
1618
1618
  const renderContext = this._renderContexts.get( renderTarget, this._mrt, this._callDepth );
1619
1619
 
1620
1620
  // WITH_GENESYS
1621
- renderContext.gpuProfilerLabel = scene.isQuadMesh === true && scene.name !== '' ? scene.name : null;
1621
+ // Prefer an explicit scene/pass name; otherwise fall back to the render
1622
+ // target texture name so depth/shadow/RTT work is not an invisible hole.
1623
+ let gpuProfilerLabel = scene.name !== '' ? scene.name : null;
1624
+ if ( gpuProfilerLabel === null && renderTarget !== null ) {
1625
+
1626
+ const textureName = renderTarget.texture?.name;
1627
+ if ( textureName !== undefined && textureName !== null && textureName !== '' ) {
1628
+
1629
+ gpuProfilerLabel = textureName;
1630
+
1631
+ }
1632
+
1633
+ }
1634
+
1635
+ renderContext.gpuProfilerLabel = gpuProfilerLabel;
1622
1636
  // !WITH_GENESYS
1637
+ // renderContext.gpuProfilerLabel = scene.name !== '' ? scene.name : null;
1623
1638
 
1624
1639
  this._currentRenderContext = renderContext;
1625
1640
  this._currentRenderObjectFunction = this._renderObjectFunction || this.renderObject;
@@ -2620,7 +2635,7 @@ class Renderer {
2620
2635
 
2621
2636
  // WITH_GENESYS
2622
2637
  // Drop the strong reference to the active `RenderContext` so module-level
2623
- // `WeakMap<RenderContext, �?>` caches can release entries once
2638
+ // `WeakMap<RenderContext, ...>` caches can release entries once
2624
2639
  // `_renderContexts.dispose()` has cleared its dictionary.
2625
2640
  this._currentRenderContext = null;
2626
2641
  // !WITH_GENESYS
@@ -25,7 +25,7 @@ import GPUShaderModuleDescriptor from './descriptors/GPUShaderModuleDescriptor.j
25
25
  import GPURenderPassColorAttachment from './descriptors/GPURenderPassColorAttachment.js';
26
26
  import GPURenderPassDepthStencilAttachment from './descriptors/GPURenderPassDepthStencilAttachment.js';
27
27
  import GPURenderPassDescriptor from './descriptors/GPURenderPassDescriptor.js';
28
- import GPURenderPassTimestampWrites from './descriptors/GPURenderPassTimestampWrites.js';
28
+ // import GPURenderPassTimestampWrites from './descriptors/GPURenderPassTimestampWrites.js';
29
29
  import GPUTexelCopyTextureInfo from './descriptors/GPUTexelCopyTextureInfo.js';
30
30
  import GPUTextureViewDescriptor from './descriptors/GPUTextureViewDescriptor.js';
31
31
  import GPUExtent3D from './descriptors/GPUExtent3D.js';
@@ -36,7 +36,7 @@ const _commandEncoderDescriptor = new GPUCommandEncoderDescriptor();
36
36
  const _computePassDescriptor = new GPUComputePassDescriptor();
37
37
  const _querySetDescriptor = new GPUQuerySetDescriptor();
38
38
  const _shaderModuleDescriptor = new GPUShaderModuleDescriptor();
39
- const _renderPassTimestampWrites = new GPURenderPassTimestampWrites();
39
+ // const _renderPassTimestampWrites = new GPURenderPassTimestampWrites();
40
40
  const _texelCopyTextureInfoSrc = new GPUTexelCopyTextureInfo();
41
41
  const _texelCopyTextureInfoDst = new GPUTexelCopyTextureInfo();
42
42
  const _viewDescriptor = new GPUTextureViewDescriptor();
@@ -2279,15 +2279,22 @@ class WebGPUBackend extends Backend {
2279
2279
  if ( baseOffset === null || timestampQueryPool.querySet === null ) return;
2280
2280
  // !WITH_GENESYS
2281
2281
 
2282
- _renderPassTimestampWrites.querySet = timestampQueryPool.querySet;
2283
- _renderPassTimestampWrites.beginningOfPassWriteIndex = baseOffset;
2284
- _renderPassTimestampWrites.endOfPassWriteIndex = baseOffset + 1;
2285
-
2286
- descriptor.timestampWrites = _renderPassTimestampWrites;
2287
-
2288
2282
  // WITH_GENESYS
2283
+ // Per-pass object: a shared descriptor is mutated by nested beginRender /
2284
+ // copyFramebuffer restarts and can make unrelated passes write the same
2285
+ // query indices (shadow maps collapsing to ~1µs is the common symptom).
2286
+ descriptor.timestampWrites = {
2287
+ querySet: timestampQueryPool.querySet,
2288
+ beginningOfPassWriteIndex: baseOffset,
2289
+ endOfPassWriteIndex: baseOffset + 1,
2290
+ };
2291
+
2289
2292
  this.notifyTimestampQuery( type, uid, label );
2290
2293
  // !WITH_GENESYS
2294
+ // _renderPassTimestampWrites.querySet = timestampQueryPool.querySet;
2295
+ // _renderPassTimestampWrites.beginningOfPassWriteIndex = baseOffset;
2296
+ // _renderPassTimestampWrites.endOfPassWriteIndex = baseOffset + 1;
2297
+ // descriptor.timestampWrites = _renderPassTimestampWrites;
2291
2298
 
2292
2299
  }
2293
2300
 
@@ -2848,6 +2855,17 @@ class WebGPUBackend extends Backend {
2848
2855
  if ( renderContext.depth ) descriptor.depthStencilAttachment.depthLoadOp = GPULoadOp.Load;
2849
2856
  if ( renderContext.stencil ) descriptor.depthStencilAttachment.stencilLoadOp = GPULoadOp.Load;
2850
2857
 
2858
+ // WITH_GENESYS
2859
+ // Restarted passes need fresh timestamp slots; reusing the previous
2860
+ // timestampWrites overwrites the first segment's query results.
2861
+ this.initTimestampQuery(
2862
+ TimestampQuery.RENDER,
2863
+ this.getTimestampUID( renderContext ),
2864
+ descriptor,
2865
+ renderContext.gpuProfilerLabel
2866
+ );
2867
+ // !WITH_GENESYS
2868
+
2851
2869
  renderContextData.currentPass = encoder.beginRenderPass( descriptor );
2852
2870
  renderContextData.currentSets = { attributes: {}, bindingGroups: [], pipeline: null, index: null };
2853
2871
 
@@ -78,7 +78,20 @@ class WebGPUTimestampQueryPool extends TimestampQueryPool {
78
78
  const baseOffset = this.currentQueryIndex;
79
79
  this.currentQueryIndex += 2;
80
80
 
81
- this.queryOffsets.set( uid, baseOffset );
81
+ // WITH_GENESYS
82
+ // One logical uid can own multiple GPU passes (nested interrupt /
83
+ // copyFramebuffer restart). Keep every offset; resolve sums them.
84
+ let offsets = this.queryOffsets.get( uid );
85
+ if ( offsets === undefined ) {
86
+
87
+ offsets = [];
88
+ this.queryOffsets.set( uid, offsets );
89
+
90
+ }
91
+
92
+ offsets.push( baseOffset );
93
+ // !WITH_GENESYS
94
+ // this.queryOffsets.set( uid, baseOffset );
82
95
 
83
96
  return baseOffset;
84
97
 
@@ -200,7 +213,7 @@ class WebGPUTimestampQueryPool extends TimestampQueryPool {
200
213
 
201
214
  const frames = [];
202
215
 
203
- for ( const [ uid, baseOffset ] of currentOffsets ) {
216
+ for ( const [ uid, offsetOrOffsets ] of currentOffsets ) {
204
217
 
205
218
  const match = uid.match( /^(.*):f(\d+)$/ );
206
219
  const frame = parseInt( match[ 2 ] );
@@ -213,14 +226,34 @@ class WebGPUTimestampQueryPool extends TimestampQueryPool {
213
226
 
214
227
  if ( framesDuration[ frame ] === undefined ) framesDuration[ frame ] = 0;
215
228
 
216
- const startTime = times[ baseOffset ];
217
- const endTime = times[ baseOffset + 1 ];
218
- const duration = Number( endTime - startTime ) / 1e6;
229
+ // WITH_GENESYS
230
+ const offsets = Array.isArray( offsetOrOffsets ) ? offsetOrOffsets : [ offsetOrOffsets ];
231
+ let duration = 0;
232
+ let rangeStart = null;
233
+ let rangeEnd = null;
234
+
235
+ for ( const baseOffset of offsets ) {
236
+
237
+ const startTime = times[ baseOffset ];
238
+ const endTime = times[ baseOffset + 1 ];
239
+ duration += Number( endTime - startTime ) / 1e6;
240
+
241
+ if ( rangeStart === null || startTime < rangeStart ) rangeStart = startTime;
242
+ if ( rangeEnd === null || endTime > rangeEnd ) rangeEnd = endTime;
243
+
244
+ }
219
245
 
220
246
  this.timestamps.set( uid, duration );
221
- // WITH_GENESYS
222
- this.timestampRanges.set( uid, { start: startTime, end: endTime } );
247
+ if ( rangeStart !== null && rangeEnd !== null ) {
248
+
249
+ this.timestampRanges.set( uid, { start: rangeStart, end: rangeEnd } );
250
+
251
+ }
223
252
  // !WITH_GENESYS
253
+ // const startTime = times[ baseOffset ];
254
+ // const endTime = times[ baseOffset + 1 ];
255
+ // const duration = Number( endTime - startTime ) / 1e6;
256
+ // this.timestamps.set( uid, duration );
224
257
 
225
258
  framesDuration[ frame ] += duration;
226
259