rayzee 7.11.0 → 7.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,7 @@ import {
25
25
  } from '../TSL/SortGlobalKernels.js';
26
26
  import { ENGINE_DEFAULTS } from '../EngineDefaults.js';
27
27
  import {
28
- Fn, uint, atomicStore, atomicLoad, instanceIndex, If, Return,
28
+ Fn, uint, int, atomicStore, atomicLoad, atomicAdd, instanceIndex, If, Return,
29
29
  } from 'three/tsl';
30
30
 
31
31
  export class PathTracer extends PathTracerStage {
@@ -39,6 +39,12 @@ export class PathTracer extends PathTracerStage {
39
39
  this._queueManager = null;
40
40
  this._kernelManager = null;
41
41
  this._gBufferAttr = null; // per-pixel first-hit MRT (ND + albedo); see _buildWavefrontKernels
42
+ this._m2Attr = null; // per-pixel running mean of luminance² for the Tier-1 convergence early-stop
43
+ this._streakAttr = null; // Tier-2: per-pixel freeze-candidate streak (u32); frozen := streak >= K
44
+ // Tier-2 dilation: per-pixel frozen mask (1=skip), written race-free in buildActivePixels; a frozen pixel
45
+ // stays active if any 8-neighbour is still active (Cycles box-filter) to avoid hard frozen/active seams.
46
+ this._frozenMaskAttr = null;
47
+ this._dilateFrozenUniform = uniform( 1, 'int' ); // 1 = dilate (default); 0 = plain per-pixel freeze
42
48
  this._wavefrontReady = false;
43
49
 
44
50
  // Aux MRT (normalDepth + albedo) feeds only the denoiser/OIDN. When no denoiser is active the
@@ -46,6 +52,11 @@ export class PathTracer extends PathTracerStage {
46
52
  // uniform — NOT baked — so DenoisingManager can toggle it without a (UI-freezing) kernel rebuild.
47
53
  this._auxGBufferEnabled = false;
48
54
  this._auxGBufferUniform = uniform( 0, 'uint' );
55
+ // Clean-aux mode: temporally accumulate + renormalize the aux NORMAL (like albedo) so a clean-aux
56
+ // OIDN model (calb_cnrm/high, alb_nrm/balanced) gets the prefiltered-ish normal it expects instead
57
+ // of the point-sampled per-frame value that leaks noise. Off for fast/ASVGF (they want the bump normal).
58
+ this._cleanAuxNormalEnabled = false;
59
+ this._cleanAuxNormalUniform = uniform( 0, 'uint' );
49
60
 
50
61
  // CPU sizes per-bounce kernels from last frame's survivor curve; kernels bound on ENTERING_COUNT so over-sizing is safe. (indirect dispatch not viable — three.js doesn't sync compute-written indirect buffers across submissions)
51
62
  this._useDynamicDispatch = true;
@@ -74,6 +85,16 @@ export class PathTracer extends PathTracerStage {
74
85
  // 0.1% of primary ray count, floored at 100; -1 to disable. Updated per-scene in _buildWavefrontKernels.
75
86
  this._bounceEarlyExitThreshold = 100;
76
87
 
88
+ // Tier-1 convergence early-stop: fraction of pixels converged in the last settled-view readback, and a
89
+ // single-flight guard for its async counter read. Zeroed on reset/camera-move/resize; never refreshed
90
+ // mid-motion (the readback early-return + frozen frameCount keep the stop from firing while moving).
91
+ this._convergedFraction = 0;
92
+ this._convergedReadbackPending = false;
93
+
94
+ // Tier-2: last settled active-pixel count (maxRays − frozen), sizes next frame's bounce-0 grid.
95
+ // 0 until a settled readback lands (and on reset/camera-move/resize) → grid stays full-size until then.
96
+ this._lastActivePixelCount = 0;
97
+
77
98
  this._wfRenderWidth = uniform( 1920, 'int' );
78
99
  this._wfRenderHeight = uniform( 1080, 'int' );
79
100
  this._wfMaxRayCount = uniform( 0, 'uint' );
@@ -112,8 +133,17 @@ export class PathTracer extends PathTracerStage {
112
133
 
113
134
  } );
114
135
 
115
- // Per-pixel first-hit G-buffer (normal/depth + albedo)
116
- t.register( 'gbuffer', () => this._gBufferAttr ? [ this._gBufferAttr ] : null );
136
+ // Per-pixel first-hit G-buffer (normal/depth + albedo) + convergence m2 buffer + Tier-2 freeze streak
137
+ t.register( 'gbuffer', () => {
138
+
139
+ const a = [];
140
+ if ( this._gBufferAttr ) a.push( this._gBufferAttr );
141
+ if ( this._m2Attr ) a.push( this._m2Attr );
142
+ if ( this._streakAttr ) a.push( this._streakAttr );
143
+ if ( this._frozenMaskAttr ) a.push( this._frozenMaskAttr );
144
+ return a.length ? a : null;
145
+
146
+ } );
117
147
 
118
148
  // Accumulation pool: 3 write StorageTextures (2048²) + readable MRT RenderTarget
119
149
  t.register( 'accum', () => {
@@ -169,7 +199,7 @@ export class PathTracer extends PathTracerStage {
169
199
  // Kernels not built yet (first frame / mid-resize) — skip until ready.
170
200
  if ( ! this.isReady || ! this._wavefrontReady ) return;
171
201
 
172
- if ( this.isComplete || this.frameCount >= this.completionThreshold ) {
202
+ if ( this.isComplete || this.frameCount >= this.completionThreshold || this._isConvergedComplete() ) {
173
203
 
174
204
  if ( ! this.isComplete ) this.isComplete = true;
175
205
  return;
@@ -207,6 +237,10 @@ export class PathTracer extends PathTracerStage {
207
237
 
208
238
  this._curveSizingValid = false;
209
239
  this._readbackGeneration ++;
240
+ // Drop the stale converged fraction so the early-stop can't fire on the pose we're leaving.
241
+ this._convergedFraction = 0;
242
+ // Tier-2: drop the stale active-pixel count so bounce 0 full-sizes until the new pose re-measures.
243
+ this._lastActivePixelCount = 0;
210
244
 
211
245
  }
212
246
 
@@ -220,6 +254,7 @@ export class PathTracer extends PathTracerStage {
220
254
 
221
255
  this.shaderBuilder.prevColorTexNode.value = readTextures.color;
222
256
  this.shaderBuilder.prevAlbedoTexNode.value = readTextures.albedo;
257
+ this.shaderBuilder.prevNormalDepthTexNode.value = readTextures.normalDepth;
223
258
 
224
259
  }
225
260
 
@@ -249,16 +284,37 @@ export class PathTracer extends PathTracerStage {
249
284
 
250
285
  }
251
286
 
252
- km.dispatch( 'resetCounters' );
253
- km.dispatch( 'generate' );
254
- // Generate traces every pixel; seed ENTERING_COUNT from the full identity active list.
255
- km.dispatch( 'initActiveIndices' );
256
-
257
287
  const maxBounces = this.maxBounces.value;
258
288
  // Transmissive/SSS steps consume iterations without advancing camera-bounce depth, so the loop must run far enough for deep glass/subsurface walks (mirror PathTracerCore); the survivor curve + early-exit break it early on non-SSS scenes.
259
289
  const loopBound = maxBounces + this.transmissiveBounces.value + this.maxSubsurfaceSteps.value;
260
290
  const maxRays = this._wfMaxRayCount.value;
261
291
 
292
+ // Tier-2 bounce-0 grid size from the stale settled active-pixel count (maxRays until one lands).
293
+ // Monotonic freeze ⇒ a stale count is a safe over-estimate; reset/camera-move → _curveSizingValid=false → maxRays.
294
+ const adaptiveFreeze = this.usePixelFreeze.value > 0;
295
+ const activeBounce0 = ( adaptiveFreeze && this._curveSizingValid && this._lastActivePixelCount > 0 )
296
+ ? this._lastActivePixelCount : maxRays;
297
+
298
+ if ( adaptiveFreeze ) {
299
+
300
+ // reset counters → compact non-frozen pixel IDs → publish count → list-driven 1D generate.
301
+ const genSized = Math.min( maxRays, Math.ceil( activeBounce0 * 1.5 ) + 1024 );
302
+ km.setDispatchCount( 'buildActivePixels', [ Math.ceil( maxRays / 256 ), 1, 1 ] );
303
+ km.setDispatchCount( 'generateList', [ Math.ceil( genSized / 256 ), 1, 1 ] );
304
+ km.dispatch( 'resetFrameCounters' );
305
+ km.dispatch( 'buildActivePixels' );
306
+ km.dispatch( 'seedEnter' );
307
+ km.dispatch( 'generateList' );
308
+
309
+ } else {
310
+
311
+ km.dispatch( 'generate' );
312
+ // Generate traces every pixel; seed ACTIVE + ENTERING_COUNT from the full identity active list
313
+ // (initActiveIndices overwrites both counters, so no separate frame-start reset is needed).
314
+ km.dispatch( 'initActiveIndices' );
315
+
316
+ }
317
+
262
318
  // The survivor curve survives a maxBounces change (reset() preserves it), and is reusable
263
319
  // across one: for loop iterations below BOTH the old and new camera-bounce caps, no ray has
264
320
  // been killed by either cap, so the counts are cap-independent. Trust the curve up to that
@@ -307,6 +363,11 @@ export class PathTracer extends PathTracerStage {
307
363
 
308
364
  entering = prev > 0 ? prev : maxRays;
309
365
 
366
+ } else if ( bounce === 0 && adaptiveFreeze ) {
367
+
368
+ // Bounce 0 traces only non-frozen pixels — size from the stale active count (full until a readback).
369
+ entering = activeBounce0;
370
+
310
371
  }
311
372
 
312
373
  const sized = Math.min( maxRays, Math.ceil( entering * 1.5 ) + 1024 );
@@ -342,12 +403,18 @@ export class PathTracer extends PathTracerStage {
342
403
 
343
404
  }
344
405
 
345
- km.dispatch( 'shade' );
346
-
347
- km.dispatch( 'resetActiveCounter' );
406
+ km.dispatch( 'shade' ); // shade thread 0 folds resetActiveCounter (zeroes ACTIVE_RAY_COUNT before compact)
348
407
  km.dispatch( 'compact' );
349
- if ( useFunctionalCompaction ) km.dispatch( 'compactCopyback' );
350
- km.dispatch( 'snapshotBounceCount' );
408
+ if ( useFunctionalCompaction ) {
409
+
410
+ // compactCopyback thread 0 folds snapshotBounceCount (records the survivor curve + seeds ENTERING).
411
+ km.dispatch( 'compactCopyback' );
412
+
413
+ } else {
414
+
415
+ km.dispatch( 'snapshotBounceCount' );
416
+
417
+ }
351
418
  // No swap: pingPong stays 0 (kernels are build-time-bound to buffer A).
352
419
 
353
420
  // Early-exit on last frame's per-bounce snapshot (stale via async readback, fine for a heuristic).
@@ -385,6 +452,26 @@ export class PathTracer extends PathTracerStage {
385
452
 
386
453
  }
387
454
 
455
+ // Tier-1 convergence early-stop: retire the WHOLE frame once enough samples have accumulated AND ~all pixels
456
+ // hit the relative-error floor. Keeps the global 1/(frame+1) alpha untouched — only the stop condition
457
+ // changes. Naturally gated off while moving: frameCount is frozen in interaction mode (< minSamples) and
458
+ // _convergedFraction is zeroed on camera-move and never refreshed mid-motion (readback early-returns).
459
+ _isConvergedComplete() {
460
+
461
+ return this.useAdaptiveSampling.value > 0
462
+ && this.frameCount >= this.adaptiveMinSamples.value
463
+ && this._convergedFraction >= this.adaptiveStopFraction.value;
464
+
465
+ }
466
+
467
+ reset() {
468
+
469
+ super.reset();
470
+ this._convergedFraction = 0;
471
+ this._lastActivePixelCount = 0;
472
+
473
+ }
474
+
388
475
  // Parent resizes storageTextures/shaderBuilder; wavefront also needs its buffers/uniforms/kernels rebuilt.
389
476
  _handleResize() {
390
477
 
@@ -410,6 +497,19 @@ export class PathTracer extends PathTracerStage {
410
497
 
411
498
  }
412
499
 
500
+ // Clean-aux normal: when a clean-aux OIDN model is active (calb_cnrm/high, alb_nrm/balanced), FinalWrite
501
+ // temporally accumulates + renormalizes the aux normal so the model isn't fed per-frame point-sampled
502
+ // noise. DenoisingManager calls this on OIDN enable + quality change. Live uniform → value flip + reset.
503
+ setCleanAuxNormal( enabled ) {
504
+
505
+ enabled = !! enabled;
506
+ if ( this._cleanAuxNormalEnabled === enabled ) return;
507
+ this._cleanAuxNormalEnabled = enabled;
508
+ this._cleanAuxNormalUniform.value = enabled ? 1 : 0;
509
+ this.reset();
510
+
511
+ }
512
+
413
513
  // UI-driven resize (Resolution dropdown) — parent bypasses _handleResize(), so hook here too.
414
514
  setSize( width, height ) {
415
515
 
@@ -472,6 +572,40 @@ export class PathTracer extends PathTracerStage {
472
572
 
473
573
  } );
474
574
 
575
+ // Tier-1 convergence: on the SAME settled-view cadence, read the converged-pixel count and derive the
576
+ // fraction that drives the whole-frame early-stop. Separate single-flight flag (different buffer) + the
577
+ // same _readbackGeneration guard so a count measured before a camera-move/resize is dropped when stale.
578
+ if ( ! this._convergedReadbackPending ) {
579
+
580
+ const cAttr = this._queueManager?.getCountersAttribute();
581
+ if ( cAttr ) {
582
+
583
+ this._convergedReadbackPending = true;
584
+ const cgen = this._readbackGeneration;
585
+ const total = this._wfMaxRayCount.value;
586
+ this.renderer.getArrayBufferAsync( cAttr ).then( ( buf ) => {
587
+
588
+ if ( cgen === this._readbackGeneration && total > 0 ) {
589
+
590
+ const c = new Uint32Array( buf );
591
+ this._convergedFraction = c[ COUNTER.CONVERGED_COUNT ] / total;
592
+ // Tier-2: bounce-0 active-pixel count measured this settled frame → sizes next frame's grid.
593
+ this._lastActivePixelCount = c[ COUNTER.ACTIVE_PIXEL_COUNT ];
594
+
595
+ }
596
+
597
+ this._convergedReadbackPending = false;
598
+
599
+ } ).catch( () => {
600
+
601
+ this._convergedReadbackPending = false;
602
+
603
+ } );
604
+
605
+ }
606
+
607
+ }
608
+
475
609
  }
476
610
 
477
611
  // Sync wavefront's texture nodes with current env/material textures; only a changed ref triggers GPU rebind.
@@ -507,6 +641,8 @@ export class PathTracer extends PathTracerStage {
507
641
  this._curveSizingValid = false;
508
642
  this._readbackFrameCounter = 0;
509
643
  this._readbackGeneration ++;
644
+ this._convergedFraction = 0;
645
+ this._lastActivePixelCount = 0;
510
646
 
511
647
  // Recompile only when buffers reallocate (capacity grows); otherwise resize uniforms in place.
512
648
  const neededCap = PackedRayBuffer.requiredCapacity( newW * newH );
@@ -580,6 +716,25 @@ export class PathTracer extends PathTracerStage {
580
716
  const gBufferRW = storage( this._gBufferAttr, 'uvec4' );
581
717
  const gBufferRO = storage( this._gBufferAttr, 'uvec4' ).toReadOnly();
582
718
 
719
+ // Tier-1 convergence: one f32/pixel holding the running mean of luminance² (Welford second moment),
720
+ // read+written by FinalWrite. 1.25× margin (same as the per-ray buffers) so it survives in-place resize;
721
+ // allocated only on this capacity-growth/rebuild path, never in _resizeWavefrontInPlace.
722
+ freeStorageAttribute( this.renderer, this._m2Attr );
723
+ this._m2Attr = new StorageInstancedBufferAttribute( new Float32Array( PackedRayBuffer.requiredCapacity( maxRays ) ), 1 );
724
+ const m2RW = storage( this._m2Attr, 'float' );
725
+
726
+ // Tier-2: one u32/pixel freeze-candidate streak. FinalWrite writes (RW), buildActivePixels reads (RO).
727
+ freeStorageAttribute( this.renderer, this._streakAttr );
728
+ this._streakAttr = new StorageInstancedBufferAttribute( new Uint32Array( PackedRayBuffer.requiredCapacity( maxRays ) ), 1 );
729
+ const streakRW = storage( this._streakAttr, 'uint' );
730
+ const streakRO = storage( this._streakAttr, 'uint' ).toReadOnly();
731
+
732
+ // Tier-2: dilated frozen mask (1 = skip). buildActivePixels writes; active-list + FinalWrite read → race-free.
733
+ freeStorageAttribute( this.renderer, this._frozenMaskAttr );
734
+ this._frozenMaskAttr = new StorageInstancedBufferAttribute( new Uint32Array( PackedRayBuffer.requiredCapacity( maxRays ) ), 1 );
735
+ const frozenMaskRW = storage( this._frozenMaskAttr, 'uint' );
736
+ const frozenMaskRO = storage( this._frozenMaskAttr, 'uint' ).toReadOnly();
737
+
583
738
  if ( ! this._queueManager ) {
584
739
 
585
740
  this._queueManager = new QueueManager( this._packedBuffers.capacity, this.renderer );
@@ -605,28 +760,14 @@ export class PathTracer extends PathTracerStage {
605
760
 
606
761
  const prevColor = this.shaderBuilder.prevColorTexNode;
607
762
  const prevAlbedo = this.shaderBuilder.prevAlbedoTexNode;
763
+ const prevNormalDepth = this.shaderBuilder.prevNormalDepthTexNode;
608
764
  const writeTex = this.storageTextures.getWriteTextures();
609
765
 
610
766
  const counters = qm.getCounters();
611
- const resetFn = Fn( () => {
612
-
613
- atomicStore( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), uint( 0 ) );
614
-
615
- } );
616
- this._kernelManager.register( 'resetCounters',
617
- resetFn().compute( [ 1, 1, 1 ], [ 1, 1, 1 ] )
618
- );
619
-
620
- const resetActiveFn = Fn( () => {
621
-
622
- atomicStore( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), uint( 0 ) );
623
-
624
- } );
625
- this._kernelManager.register( 'resetActiveCounter',
626
- resetActiveFn().compute( [ 1, 1, 1 ], [ 1, 1, 1 ] )
627
- );
628
767
 
629
768
  // Copy ACTIVE_RAY_COUNT into bounceCounts[currentBounce] for the readback survivor curve.
769
+ // Standalone kernel is dispatched only on the non-dynamic path; the dynamic path folds this
770
+ // into compactCopyback's thread 0.
630
771
  const bounceCountsBuf = qm.getBounceCounts();
631
772
  const wfCurrentBounce = this._wfCurrentBounce;
632
773
  const snapshotFn = Fn( () => {
@@ -652,6 +793,10 @@ export class PathTracer extends PathTracerStage {
652
793
 
653
794
  atomicStore( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), this._wfMaxRayCount );
654
795
  atomicStore( counters.element( uint( COUNTER.ENTERING_COUNT ) ), this._wfMaxRayCount );
796
+ // Tier-1 convergence: zero the per-frame converged-pixel counter here — once/frame at frame start,
797
+ // before FinalWrite atomicAdds into it. (Was in the now-removed resetCounters kernel; must NOT go
798
+ // in shade's per-bounce reset, since FinalWrite accumulates only once, at frame end.)
799
+ atomicStore( counters.element( uint( COUNTER.CONVERGED_COUNT ) ), uint( 0 ) );
655
800
 
656
801
  } );
657
802
 
@@ -660,7 +805,7 @@ export class PathTracer extends PathTracerStage {
660
805
  initFn().compute( [ Math.ceil( maxRays / 256 ), 1, 1 ], [ 256, 1, 1 ] )
661
806
  );
662
807
 
663
- const genFn = buildGenerateKernel( {
808
+ const genParams = {
664
809
  rayBufferRW: pb.rayBuffer.rw,
665
810
  rngBufferRW: pb.rngBuffer.rw,
666
811
  gBufferRW,
@@ -680,7 +825,8 @@ export class PathTracer extends PathTracerStage {
680
825
  transmissiveBounces: this.transmissiveBounces,
681
826
  transparentBackground: this.transparentBackground,
682
827
  auxGBufferEnabled: this._auxGBufferUniform,
683
- } );
828
+ };
829
+ const genFn = buildGenerateKernel( genParams );
684
830
  this._kernelManager.register( 'generate',
685
831
  genFn().compute(
686
832
  [ Math.ceil( w / GENERATE_WG_SIZE ), Math.ceil( h / GENERATE_WG_SIZE ), 1 ],
@@ -688,6 +834,100 @@ export class PathTracer extends PathTracerStage {
688
834
  )
689
835
  );
690
836
 
837
+ // --- Tier-2 freeze seed path (gated by usePixelFreeze in render()) ---
838
+ // Replaces generate+initActiveIndices with: reset counters → scatter non-frozen pixel IDs into the active
839
+ // list → publish count → 1D list-driven generate. Split so ACTIVE_RAY_COUNT is zeroed BEFORE the scatter.
840
+ const freezeK = this.pixelFreezeStability;
841
+ const resetFrameFn = Fn( () => {
842
+
843
+ atomicStore( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), uint( 0 ) );
844
+ atomicStore( counters.element( uint( COUNTER.CONVERGED_COUNT ) ), uint( 0 ) );
845
+ atomicStore( counters.element( uint( COUNTER.FROZEN_COUNT ) ), uint( 0 ) );
846
+
847
+ } );
848
+ this._kernelManager.register( 'resetFrameCounters',
849
+ resetFrameFn().compute( [ 1, 1, 1 ], [ 1, 1, 1 ] )
850
+ );
851
+
852
+ const buildActiveFn = Fn( () => {
853
+
854
+ const tid = instanceIndex;
855
+ If( tid.lessThan( this._wfMaxRayCount ), () => {
856
+
857
+ // frozen=1 skips the pixel this frame. Frame 0 seeds all (streak may be stale post-reset). A pixel
858
+ // freezes once streak>=K; with dilation ON it stays active if any 8-neighbour is still active
859
+ // (streak<K), softening the boundary. Mask is read by FinalWrite too → race-free.
860
+ const frozen = uint( 0 ).toVar();
861
+
862
+ If( this.frame.greaterThan( uint( 0 ) ).and( streakRO.element( tid ).greaterThanEqual( uint( freezeK ) ) ), () => {
863
+
864
+ frozen.assign( uint( 1 ) );
865
+
866
+ If( this._dilateFrozenUniform.greaterThan( int( 0 ) ), () => {
867
+
868
+ const px = int( tid ).mod( this._wfRenderWidth );
869
+ const py = int( tid ).div( this._wfRenderWidth );
870
+
871
+ for ( const [ dx, dy ] of [[ - 1, - 1 ], [ 0, - 1 ], [ 1, - 1 ], [ - 1, 0 ], [ 1, 0 ], [ - 1, 1 ], [ 0, 1 ], [ 1, 1 ]] ) {
872
+
873
+ const nx = px.add( int( dx ) );
874
+ const ny = py.add( int( dy ) );
875
+ If( nx.greaterThanEqual( int( 0 ) ).and( nx.lessThan( this._wfRenderWidth ) )
876
+ .and( ny.greaterThanEqual( int( 0 ) ) ).and( ny.lessThan( this._wfRenderHeight ) ), () => {
877
+
878
+ If( streakRO.element( uint( ny.mul( this._wfRenderWidth ).add( nx ) ) ).lessThan( uint( freezeK ) ), () => {
879
+
880
+ frozen.assign( uint( 0 ) ); // a still-active neighbour keeps this pixel active
881
+
882
+ } );
883
+
884
+ } );
885
+
886
+ }
887
+
888
+ } );
889
+
890
+ } );
891
+
892
+ frozenMaskRW.element( tid ).assign( frozen );
893
+
894
+ If( frozen.equal( uint( 0 ) ), () => {
895
+
896
+ const slot = atomicAdd( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), uint( 1 ) );
897
+ activeWriteA.element( slot ).assign( tid );
898
+
899
+ } );
900
+
901
+ } );
902
+
903
+ } );
904
+ this._kernelManager.register( 'buildActivePixels',
905
+ buildActiveFn().compute( [ Math.ceil( maxRays / 256 ), 1, 1 ], [ 256, 1, 1 ] )
906
+ );
907
+
908
+ const seedEnterFn = Fn( () => {
909
+
910
+ const active = atomicLoad( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ) );
911
+ // ENTERING_COUNT drives the bounce loop; ACTIVE_PIXEL_COUNT is a stable snapshot for the sizing readback.
912
+ atomicStore( counters.element( uint( COUNTER.ENTERING_COUNT ) ), active );
913
+ atomicStore( counters.element( uint( COUNTER.ACTIVE_PIXEL_COUNT ) ), active );
914
+
915
+ } );
916
+ this._kernelManager.register( 'seedEnter',
917
+ seedEnterFn().compute( [ 1, 1, 1 ], [ 1, 1, 1 ] )
918
+ );
919
+
920
+ // 1D list-driven generate: one thread per active-list slot, bounded on ENTERING_COUNT.
921
+ const genListFn = buildGenerateKernel( {
922
+ ...genParams,
923
+ listDriven: true,
924
+ activeIndicesRO: qm.activeIndicesRO.a,
925
+ counters,
926
+ } );
927
+ this._kernelManager.register( 'generateList',
928
+ genListFn().compute( [ Math.ceil( maxRays / 256 ), 1, 1 ], [ 256, 1, 1 ] )
929
+ );
930
+
691
931
  const freshBvh = this.bvhStorageNode;
692
932
  const freshTri = this.triangleStorageNode;
693
933
  const freshMat = this.materialData.materialStorageNode;
@@ -880,7 +1120,19 @@ export class PathTracer extends PathTracerStage {
880
1120
  const copyFn = Fn( () => {
881
1121
 
882
1122
  const tid = instanceIndex;
883
- If( tid.greaterThanEqual( atomicLoad( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ) ) ), () => {
1123
+ const active = atomicLoad( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ) );
1124
+
1125
+ // Folds snapshotBounceCount: thread 0 records the survivor count for the readback curve and
1126
+ // seeds ENTERING_COUNT for the next bounce. Above the guard so it runs even when active is 0.
1127
+ If( tid.equal( uint( 0 ) ), () => {
1128
+
1129
+ const slot = uint( wfCurrentBounce ).clamp( uint( 0 ), uint( qm.MAX_BOUNCE_SNAPSHOTS - 1 ) );
1130
+ bounceCountsBuf.element( slot ).assign( active );
1131
+ atomicStore( counters.element( uint( COUNTER.ENTERING_COUNT ) ), active );
1132
+
1133
+ } );
1134
+
1135
+ If( tid.greaterThanEqual( active ), () => {
884
1136
 
885
1137
  Return();
886
1138
 
@@ -907,10 +1159,24 @@ export class PathTracer extends PathTracerStage {
907
1159
  transparentBackground: this.transparentBackground,
908
1160
  prevAccumTexture: prevColor,
909
1161
  prevAlbedoTexture: prevAlbedo,
1162
+ prevNormalDepthTexture: prevNormalDepth,
910
1163
  renderWidth: this._wfRenderWidth,
911
1164
  renderHeight: this._wfRenderHeight,
912
1165
  visMode: this.visMode,
913
1166
  auxGBufferEnabled: this._auxGBufferUniform,
1167
+ cleanAuxNormalEnabled: this._cleanAuxNormalUniform,
1168
+ counters,
1169
+ m2BufferRW: m2RW,
1170
+ useAdaptiveSampling: this.useAdaptiveSampling,
1171
+ noiseThreshold: this.noiseThreshold,
1172
+ darkNoiseFloor: this.darkNoiseFloor,
1173
+ adaptiveMinSamples: this.adaptiveMinSamples,
1174
+ // Tier-2 freeze (stamp + pass-through)
1175
+ usePixelFreeze: this.usePixelFreeze,
1176
+ pixelFreezeThreshold: this.pixelFreezeThreshold,
1177
+ pixelFreezeStability: this.pixelFreezeStability,
1178
+ streakBufferRW: streakRW,
1179
+ frozenMaskRO, // dilated frozen mask (read-only; matches the active-list decision)
914
1180
  } );
915
1181
  this._kernelManager.register( 'finalWrite',
916
1182
  // Per-pixel (w×h) — kernel averages the S sample-slots internally.
@@ -990,10 +1256,16 @@ export class PathTracer extends PathTracerStage {
990
1256
  this._queueManager?.dispose();
991
1257
  this._kernelManager?.dispose();
992
1258
  this._gBufferAttr?.dispose?.();
1259
+ this._m2Attr?.dispose?.();
1260
+ this._streakAttr?.dispose?.();
1261
+ this._frozenMaskAttr?.dispose?.();
993
1262
  this._packedBuffers = null;
994
1263
  this._queueManager = null;
995
1264
  this._kernelManager = null;
996
1265
  this._gBufferAttr = null;
1266
+ this._m2Attr = null;
1267
+ this._streakAttr = null;
1268
+ this._frozenMaskAttr = null;
997
1269
  this._wavefrontReady = false;
998
1270
 
999
1271
  }
@@ -175,7 +175,10 @@ const fastRayAABBDst = wgslFn( `
175
175
  // Side culling is performed inline inside traverseBVH/traverseBVHShadow using
176
176
  // the per-triangle side flag stored in normalCData.w (slot 5, .w channel).
177
177
 
178
- export const traverseBVH = Fn( ( [
178
+ // Factory: the boxTests/triTests debug counters are compiled OUT of the hot closest-hit
179
+ // path (extend/normalDepth use traverseBVH = trackStats false) — only the debug viz reads
180
+ // them, and keeping them live across the traversal loop costs registers/occupancy.
181
+ const makeTraverseBVH = ( trackStats ) => Fn( ( [
179
182
  ray,
180
183
  bvhBuffer,
181
184
  triangleBuffer,
@@ -236,7 +239,7 @@ export const traverseBVH = Fn( ( [
236
239
  // Inner: vec4(0) = [leftMin.xyz, leftChild], vec4(1) = [leftMax.xyz, rightChild],
237
240
  // vec4(2) = [rightMin.xyz, 0], vec4(3) = [rightMax.xyz, 0]
238
241
  const nodeData0 = getDatafromStorageBuffer( bvhBuffer, nodeIndex, int( 0 ), int( BVH_STRIDE ) );
239
- closestHit.boxTests.addAssign( 1 );
242
+ if ( trackStats ) closestHit.boxTests.addAssign( 1 );
240
243
 
241
244
  If( nodeData0.w.lessThan( 0.0 ), () => {
242
245
 
@@ -250,7 +253,7 @@ export const traverseBVH = Fn( ( [
250
253
  // Process triangles in leaf
251
254
  Loop( { start: int( 0 ), end: triCount }, ( { i } ) => {
252
255
 
253
- closestHit.triTests.addAssign( 1 );
256
+ if ( trackStats ) closestHit.triTests.addAssign( 1 );
254
257
  const triIndex = triStart.add( i ).toVar();
255
258
 
256
259
  // Fetch geometry first (3 fetches from storage buffer)
@@ -398,6 +401,10 @@ export const traverseBVH = Fn( ( [
398
401
 
399
402
  } );
400
403
 
404
+ // Hot path (extend/normalDepth): no debug counters. Debug viz uses traverseBVHDebug.
405
+ export const traverseBVH = /*@__PURE__*/ makeTraverseBVH( false );
406
+ export const traverseBVHDebug = /*@__PURE__*/ makeTraverseBVH( true );
407
+
401
408
  // ================================================================================
402
409
  // SHADOW RAY TRAVERSAL (OPTIMIZED - early exit on any hit)
403
410
  // ================================================================================
@@ -21,7 +21,7 @@ import {
21
21
  } from 'three/tsl';
22
22
 
23
23
  import { Ray, HitInfo, RayTracingMaterial, MaterialSamples } from './Struct.js';
24
- import { traverseBVH } from './BVHTraversal.js';
24
+ import { traverseBVHDebug as traverseBVH } from './BVHTraversal.js';
25
25
  import { sampleEnvironment } from './Environment.js';
26
26
  import { REC709_LUMINANCE_COEFFICIENTS, getMaterial } from './Common.js';
27
27
  import { sampleAllMaterialTextures } from './TextureSampling.js';