rayzee 7.11.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.11.1",
3
+ "version": "7.12.0",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -83,6 +83,19 @@ export const ENGINE_DEFAULTS = {
83
83
  bounces: 3,
84
84
  transmissiveBounces: 5,
85
85
  maxSubsurfaceSteps: 8, // interactive default: low cap (bounded random-walk SSS)
86
+
87
+ // Adaptive sampling (Blender-style): stop the frame once enough pixels drop below the noise threshold.
88
+ useAdaptiveSampling: true,
89
+ noiseThreshold: 0.02, // per-pixel noise below which a pixel is converged
90
+ darkNoiseFloor: 0.003, // extra absolute-noise floor so dark pixels can converge too
91
+ adaptiveMinSamples: 8, // min samples before adaptive sampling can trigger
92
+ adaptiveStopFraction: 0.95, // retire the frame once this fraction of pixels has converged
93
+ // Per-pixel freeze: skip tracing pixels that individually converged (noise threshold only — no dark floor,
94
+ // which would bake dim regions too dark). Naturally engages only on static/idle views. See docs/specs/tier2-adaptive-sampling-plan.md.
95
+ usePixelFreeze: false,
96
+ pixelFreezeThreshold: 0.02, // per-pixel noise below which a pixel becomes a freeze candidate
97
+ pixelFreezeStability: 8, // consecutive candidate frames before a pixel freezes
98
+
86
99
  samplingTechnique: 3,
87
100
  enableEmissiveTriangleSampling: false,
88
101
  emissiveBoost: 1.0,
@@ -556,10 +569,18 @@ export const DEFAULT_TEXTURE_MATRIX = [ 0, 0, 1, 1, 0, 0, 0, 1 ];
556
569
  // 'interactive' — low-sample, bounded bounces, no offline denoising, controls enabled.
557
570
  // 'production' — high-sample, deep bounces, OIDN enabled, controls disabled.
558
571
  export const PRODUCTION_RENDER_CONFIG = {
559
- maxSamples: 30, bounces: 20, transmissiveBounces: 8, maxSubsurfaceSteps: 64,
572
+ // maxSamples is a CEILING: adaptive sampling retires the frame once adaptiveStopFraction of pixels converge,
573
+ // so easy scenes finish well under it while hard GI scenes use the full budget.
574
+ maxSamples: 150, bounces: 20, transmissiveBounces: 8, maxSubsurfaceSteps: 64,
560
575
  renderMode: 1, enableAlphaShadows: true,
561
576
  enableOIDN: true, oidnQuality: 'balance',
562
577
  interactionModeEnabled: false,
578
+ // Looser thresholds + stop at 90%, leaning on OIDN to clean the residual tail.
579
+ useAdaptiveSampling: true,
580
+ noiseThreshold: 0.1,
581
+ darkNoiseFloor: 0.01,
582
+ adaptiveStopFraction: 0.9,
583
+ usePixelFreeze: true,
563
584
  };
564
585
 
565
586
  export const INTERACTIVE_RENDER_CONFIG = {
@@ -569,6 +590,8 @@ export const INTERACTIVE_RENDER_CONFIG = {
569
590
  maxSubsurfaceSteps: ENGINE_DEFAULTS.maxSubsurfaceSteps,
570
591
  enableOIDN: false, oidnQuality: 'fast',
571
592
  interactionModeEnabled: true,
593
+ useAdaptiveSampling: true, // idle refine stops early when converged; frozen during motion
594
+ usePixelFreeze: true, // speeds up idle refinement on heavy/high-res views; inert while moving (freeze resets)
572
595
  };
573
596
 
574
597
  // Memory management constants
@@ -323,6 +323,7 @@ export class PathTracerApp extends EventDispatcher {
323
323
  const tracker = this.stages.pathTracer?.vramTracker;
324
324
  const frame = this.stages.pathTracer?.frameCount ?? 0;
325
325
  if ( tracker && ( frame <= 1 || frame % 30 === 0 ) ) tracker.measure();
326
+
326
327
  updateStats( {
327
328
  timeElapsed: this.completion.timeElapsed,
328
329
  samples: getDisplaySamples( this.stages.pathTracer ),
@@ -1236,6 +1237,19 @@ export class PathTracerApp extends EventDispatcher {
1236
1237
  this.stages.pathTracer?.setUniform( 'renderMode', parseInt( config.renderMode ) );
1237
1238
  this.stages.pathTracer?.setUniform( 'enableAlphaShadows', config.enableAlphaShadows ?? false );
1238
1239
 
1240
+ // Tier-1 convergence early-stop (live uniforms, no kernel rebuild). Threshold/min-samples fall back to
1241
+ // engine defaults when a config doesn't override them.
1242
+ this.stages.pathTracer?.setUniform( 'useAdaptiveSampling', config.useAdaptiveSampling ?? false );
1243
+ this.stages.pathTracer?.setUniform( 'noiseThreshold', config.noiseThreshold ?? DEFAULT_STATE.noiseThreshold );
1244
+ this.stages.pathTracer?.setUniform( 'darkNoiseFloor', config.darkNoiseFloor ?? DEFAULT_STATE.darkNoiseFloor );
1245
+ this.stages.pathTracer?.setUniform( 'adaptiveStopFraction', config.adaptiveStopFraction ?? DEFAULT_STATE.adaptiveStopFraction );
1246
+ this.stages.pathTracer?.setUniform( 'adaptiveMinSamples', config.adaptiveMinSamples ?? DEFAULT_STATE.adaptiveMinSamples );
1247
+
1248
+ // Tier-2 per-pixel freeze (per-mode; falls back off when a config doesn't set it).
1249
+ this.stages.pathTracer?.setUniform( 'usePixelFreeze', config.usePixelFreeze ?? false );
1250
+ this.stages.pathTracer?.setUniform( 'pixelFreezeThreshold', config.pixelFreezeThreshold ?? DEFAULT_STATE.pixelFreezeThreshold );
1251
+ this.stages.pathTracer?.setUniform( 'pixelFreezeStability', config.pixelFreezeStability ?? DEFAULT_STATE.pixelFreezeStability );
1252
+
1239
1253
  this.stages.pathTracer?.updateCompletionThreshold?.();
1240
1254
 
1241
1255
  const denoiser = this.denoisingManager?.denoiser;
@@ -48,7 +48,10 @@ export class CompletionTracker {
48
48
 
49
49
  if ( this.isTimeLimitReached( renderLimitMode, renderTimeLimit ) ) return true;
50
50
 
51
- return pathTracer.frameCount >= pathTracer.completionThreshold;
51
+ // Tier-1 convergence early-stop must agree with PathTracer.render()'s own check, else the app-level
52
+ // reconcile would flip isComplete back off and keep dispatching after the frame converged.
53
+ return pathTracer.frameCount >= pathTracer.completionThreshold
54
+ || ( pathTracer._isConvergedComplete?.() ?? false );
52
55
 
53
56
  }
54
57
 
@@ -16,7 +16,6 @@ const WORKGROUP_SIZES = {
16
16
  connect: [ 256, 1, 1 ], // 1D shadow-ray-parallel
17
17
  accumulate: [ 256, 1, 1 ], // 1D shadow-ray-parallel
18
18
  compact: [ 256, 1, 1 ], // 1D ray-parallel
19
- resetCounters: [ 1, 1, 1 ], // Single thread
20
19
  finalWrite: [ 16, 16, 1 ], // 2D screen-space
21
20
  };
22
21
 
@@ -11,7 +11,14 @@ export const COUNTER = {
11
11
  ACTIVE_RAY_COUNT: 0,
12
12
  // rays entering current bounce; snapshotted before ACTIVE_RAY_COUNT reset so over-sized dispatch is safe.
13
13
  ENTERING_COUNT: 1,
14
- COUNT: 2,
14
+ // per-frame count of pixels whose Tier-1 relative-error dropped below threshold; zeroed at frame start by
15
+ // initActiveIndices, incremented in FinalWrite, read back async to drive the whole-frame convergence early-stop.
16
+ CONVERGED_COUNT: 2,
17
+ // Tier-2 per-pixel freeze: FROZEN_COUNT = pixels skipped this frame; ACTIVE_PIXEL_COUNT = bounce-0 active
18
+ // count (maxRays − frozen), read back to size next frame's grid.
19
+ FROZEN_COUNT: 3,
20
+ ACTIVE_PIXEL_COUNT: 4,
21
+ COUNT: 5,
15
22
  };
16
23
 
17
24
  /** Ray flag bits packed into rayBounceFlags (uint) */
@@ -40,6 +40,15 @@ const SETTING_ROUTES = {
40
40
  samplingTechnique: { uniform: 'samplingTechnique', reset: true },
41
41
  fireflyThreshold: { uniform: 'fireflyThreshold', reset: true },
42
42
  enableAlphaShadows: { uniform: 'enableAlphaShadows', reset: true },
43
+ // Adaptive sampling — whole-frame early-stop (useAdaptiveSampling) + per-pixel freeze (usePixelFreeze)
44
+ useAdaptiveSampling: { uniform: 'useAdaptiveSampling', reset: true },
45
+ noiseThreshold: { uniform: 'noiseThreshold', reset: true },
46
+ darkNoiseFloor: { uniform: 'darkNoiseFloor', reset: true },
47
+ adaptiveMinSamples: { uniform: 'adaptiveMinSamples', reset: true },
48
+ adaptiveStopFraction: { uniform: 'adaptiveStopFraction', reset: true },
49
+ usePixelFreeze: { uniform: 'usePixelFreeze', reset: true },
50
+ pixelFreezeThreshold: { uniform: 'pixelFreezeThreshold', reset: true },
51
+ pixelFreezeStability: { uniform: 'pixelFreezeStability', reset: true },
43
52
  enableEmissiveTriangleSampling: { uniform: 'enableEmissiveTriangleSampling', reset: true },
44
53
  emissiveBoost: { uniform: 'emissiveBoost', reset: true },
45
54
  visMode: { uniform: 'visMode', reset: true },
@@ -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
@@ -79,6 +85,16 @@ export class PathTracer extends PathTracerStage {
79
85
  // 0.1% of primary ray count, floored at 100; -1 to disable. Updated per-scene in _buildWavefrontKernels.
80
86
  this._bounceEarlyExitThreshold = 100;
81
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
+
82
98
  this._wfRenderWidth = uniform( 1920, 'int' );
83
99
  this._wfRenderHeight = uniform( 1080, 'int' );
84
100
  this._wfMaxRayCount = uniform( 0, 'uint' );
@@ -117,8 +133,17 @@ export class PathTracer extends PathTracerStage {
117
133
 
118
134
  } );
119
135
 
120
- // Per-pixel first-hit G-buffer (normal/depth + albedo)
121
- 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
+ } );
122
147
 
123
148
  // Accumulation pool: 3 write StorageTextures (2048²) + readable MRT RenderTarget
124
149
  t.register( 'accum', () => {
@@ -174,7 +199,7 @@ export class PathTracer extends PathTracerStage {
174
199
  // Kernels not built yet (first frame / mid-resize) — skip until ready.
175
200
  if ( ! this.isReady || ! this._wavefrontReady ) return;
176
201
 
177
- if ( this.isComplete || this.frameCount >= this.completionThreshold ) {
202
+ if ( this.isComplete || this.frameCount >= this.completionThreshold || this._isConvergedComplete() ) {
178
203
 
179
204
  if ( ! this.isComplete ) this.isComplete = true;
180
205
  return;
@@ -212,6 +237,10 @@ export class PathTracer extends PathTracerStage {
212
237
 
213
238
  this._curveSizingValid = false;
214
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;
215
244
 
216
245
  }
217
246
 
@@ -255,16 +284,37 @@ export class PathTracer extends PathTracerStage {
255
284
 
256
285
  }
257
286
 
258
- km.dispatch( 'resetCounters' );
259
- km.dispatch( 'generate' );
260
- // Generate traces every pixel; seed ENTERING_COUNT from the full identity active list.
261
- km.dispatch( 'initActiveIndices' );
262
-
263
287
  const maxBounces = this.maxBounces.value;
264
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.
265
289
  const loopBound = maxBounces + this.transmissiveBounces.value + this.maxSubsurfaceSteps.value;
266
290
  const maxRays = this._wfMaxRayCount.value;
267
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
+
268
318
  // The survivor curve survives a maxBounces change (reset() preserves it), and is reusable
269
319
  // across one: for loop iterations below BOTH the old and new camera-bounce caps, no ray has
270
320
  // been killed by either cap, so the counts are cap-independent. Trust the curve up to that
@@ -313,6 +363,11 @@ export class PathTracer extends PathTracerStage {
313
363
 
314
364
  entering = prev > 0 ? prev : maxRays;
315
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
+
316
371
  }
317
372
 
318
373
  const sized = Math.min( maxRays, Math.ceil( entering * 1.5 ) + 1024 );
@@ -348,12 +403,18 @@ export class PathTracer extends PathTracerStage {
348
403
 
349
404
  }
350
405
 
351
- km.dispatch( 'shade' );
352
-
353
- km.dispatch( 'resetActiveCounter' );
406
+ km.dispatch( 'shade' ); // shade thread 0 folds resetActiveCounter (zeroes ACTIVE_RAY_COUNT before compact)
354
407
  km.dispatch( 'compact' );
355
- if ( useFunctionalCompaction ) km.dispatch( 'compactCopyback' );
356
- 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
+ }
357
418
  // No swap: pingPong stays 0 (kernels are build-time-bound to buffer A).
358
419
 
359
420
  // Early-exit on last frame's per-bounce snapshot (stale via async readback, fine for a heuristic).
@@ -391,6 +452,26 @@ export class PathTracer extends PathTracerStage {
391
452
 
392
453
  }
393
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
+
394
475
  // Parent resizes storageTextures/shaderBuilder; wavefront also needs its buffers/uniforms/kernels rebuilt.
395
476
  _handleResize() {
396
477
 
@@ -491,6 +572,40 @@ export class PathTracer extends PathTracerStage {
491
572
 
492
573
  } );
493
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
+
494
609
  }
495
610
 
496
611
  // Sync wavefront's texture nodes with current env/material textures; only a changed ref triggers GPU rebind.
@@ -526,6 +641,8 @@ export class PathTracer extends PathTracerStage {
526
641
  this._curveSizingValid = false;
527
642
  this._readbackFrameCounter = 0;
528
643
  this._readbackGeneration ++;
644
+ this._convergedFraction = 0;
645
+ this._lastActivePixelCount = 0;
529
646
 
530
647
  // Recompile only when buffers reallocate (capacity grows); otherwise resize uniforms in place.
531
648
  const neededCap = PackedRayBuffer.requiredCapacity( newW * newH );
@@ -599,6 +716,25 @@ export class PathTracer extends PathTracerStage {
599
716
  const gBufferRW = storage( this._gBufferAttr, 'uvec4' );
600
717
  const gBufferRO = storage( this._gBufferAttr, 'uvec4' ).toReadOnly();
601
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
+
602
738
  if ( ! this._queueManager ) {
603
739
 
604
740
  this._queueManager = new QueueManager( this._packedBuffers.capacity, this.renderer );
@@ -628,25 +764,10 @@ export class PathTracer extends PathTracerStage {
628
764
  const writeTex = this.storageTextures.getWriteTextures();
629
765
 
630
766
  const counters = qm.getCounters();
631
- const resetFn = Fn( () => {
632
-
633
- atomicStore( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), uint( 0 ) );
634
-
635
- } );
636
- this._kernelManager.register( 'resetCounters',
637
- resetFn().compute( [ 1, 1, 1 ], [ 1, 1, 1 ] )
638
- );
639
-
640
- const resetActiveFn = Fn( () => {
641
-
642
- atomicStore( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), uint( 0 ) );
643
-
644
- } );
645
- this._kernelManager.register( 'resetActiveCounter',
646
- resetActiveFn().compute( [ 1, 1, 1 ], [ 1, 1, 1 ] )
647
- );
648
767
 
649
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.
650
771
  const bounceCountsBuf = qm.getBounceCounts();
651
772
  const wfCurrentBounce = this._wfCurrentBounce;
652
773
  const snapshotFn = Fn( () => {
@@ -672,6 +793,10 @@ export class PathTracer extends PathTracerStage {
672
793
 
673
794
  atomicStore( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), this._wfMaxRayCount );
674
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 ) );
675
800
 
676
801
  } );
677
802
 
@@ -680,7 +805,7 @@ export class PathTracer extends PathTracerStage {
680
805
  initFn().compute( [ Math.ceil( maxRays / 256 ), 1, 1 ], [ 256, 1, 1 ] )
681
806
  );
682
807
 
683
- const genFn = buildGenerateKernel( {
808
+ const genParams = {
684
809
  rayBufferRW: pb.rayBuffer.rw,
685
810
  rngBufferRW: pb.rngBuffer.rw,
686
811
  gBufferRW,
@@ -700,7 +825,8 @@ export class PathTracer extends PathTracerStage {
700
825
  transmissiveBounces: this.transmissiveBounces,
701
826
  transparentBackground: this.transparentBackground,
702
827
  auxGBufferEnabled: this._auxGBufferUniform,
703
- } );
828
+ };
829
+ const genFn = buildGenerateKernel( genParams );
704
830
  this._kernelManager.register( 'generate',
705
831
  genFn().compute(
706
832
  [ Math.ceil( w / GENERATE_WG_SIZE ), Math.ceil( h / GENERATE_WG_SIZE ), 1 ],
@@ -708,6 +834,100 @@ export class PathTracer extends PathTracerStage {
708
834
  )
709
835
  );
710
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
+
711
931
  const freshBvh = this.bvhStorageNode;
712
932
  const freshTri = this.triangleStorageNode;
713
933
  const freshMat = this.materialData.materialStorageNode;
@@ -900,7 +1120,19 @@ export class PathTracer extends PathTracerStage {
900
1120
  const copyFn = Fn( () => {
901
1121
 
902
1122
  const tid = instanceIndex;
903
- 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 ), () => {
904
1136
 
905
1137
  Return();
906
1138
 
@@ -933,6 +1165,18 @@ export class PathTracer extends PathTracerStage {
933
1165
  visMode: this.visMode,
934
1166
  auxGBufferEnabled: this._auxGBufferUniform,
935
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)
936
1180
  } );
937
1181
  this._kernelManager.register( 'finalWrite',
938
1182
  // Per-pixel (w×h) — kernel averages the S sample-slots internally.
@@ -1012,10 +1256,16 @@ export class PathTracer extends PathTracerStage {
1012
1256
  this._queueManager?.dispose();
1013
1257
  this._kernelManager?.dispose();
1014
1258
  this._gBufferAttr?.dispose?.();
1259
+ this._m2Attr?.dispose?.();
1260
+ this._streakAttr?.dispose?.();
1261
+ this._frozenMaskAttr?.dispose?.();
1015
1262
  this._packedBuffers = null;
1016
1263
  this._queueManager = null;
1017
1264
  this._kernelManager = null;
1018
1265
  this._gBufferAttr = null;
1266
+ this._m2Attr = null;
1267
+ this._streakAttr = null;
1268
+ this._frozenMaskAttr = null;
1019
1269
  this._wavefrontReady = false;
1020
1270
 
1021
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';