rayzee 7.22.3 → 7.22.4

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.22.3",
3
+ "version": "7.22.4",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -93,24 +93,31 @@ export const ENGINE_DEFAULTS = {
93
93
 
94
94
  // Adaptive sampling (Blender-style): stop the frame once enough pixels drop below the noise threshold.
95
95
  useAdaptiveSampling: true,
96
- noiseThreshold: 0.02, // √-luminance-normalized per-pixel noise below which a pixel is converged
96
+ // √-luminance-normalized per-pixel noise below which a pixel counts as converged. Base tracks the
97
+ // interactive tier — that is what a freshly booted engine renders before configureForMode runs.
98
+ noiseThreshold: 0.1,
97
99
  adaptiveMinSamples: 8, // min samples before adaptive sampling can trigger
98
- adaptiveStopFraction: 0.95, // retire the frame once this fraction of pixels has converged
100
+ // Fraction of pixels that must pass the 3×3-eroded convergence count before the frame retires; the
101
+ // geometry-only fraction has to clear the same bar (PathTracer._isConvergedComplete).
102
+ // ENGINE-INTERNAL: a calibration constant rather than a quality dial — it only means anything against
103
+ // those two counts. configureForMode supplies the per-tier value.
104
+ adaptiveStopFraction: 0.90,
99
105
  // Per-pixel freeze: skip tracing pixels that individually converged (noise threshold only — no dark floor,
100
106
  // which would bake dim regions too dark). Naturally engages only on static/idle views.
107
+ // Set together with useAdaptiveSampling by the single UI switch — two tiers of one feature.
101
108
  usePixelFreeze: true,
102
- pixelFreezeThreshold: 0.02, // per-pixel noise below which a pixel becomes a freeze candidate
103
- pixelFreezeStability: 8, // consecutive candidate frames before a pixel freezes
109
+ // ENGINE-INTERNAL, and not the sibling of noiseThreshold it looks like: freeze bars on plain relErr where
110
+ // the frame test uses the √-normalized error, so the same number is 2-5× stricter here. Deriving it from
111
+ // noiseThreshold was measured and rejected — a pixel frozen before it satisfies the frame test can never
112
+ // satisfy it afterwards, so a looser bar delays the early stop rather than hastening it.
113
+ pixelFreezeThreshold: 0.02,
114
+ pixelFreezeStability: 8, // ENGINE-INTERNAL: consecutive candidate frames before a pixel freezes
115
+ convergenceOverlay: false, // display-only Compositor overlay; never alters the render
104
116
 
105
117
  samplingTechnique: 2,
106
118
  enableEmissiveTriangleSampling: false,
107
119
  emissiveBoost: 1.0,
108
120
 
109
- temporalVarianceWeight: 0.6,
110
- enableEarlyTermination: true,
111
- earlyTerminationThreshold: 0.002,
112
- performanceModeAdaptive: 'medium',
113
-
114
121
  fireflyThreshold: 3.0,
115
122
  // Wavefront material-coherence sort: global counting-sort of entering rays by material before
116
123
  // Shade (material-pure workgroups), under dynamic dispatch. Measured −8% at 1024²/8b. Gated on
@@ -599,11 +606,10 @@ export const PRODUCTION_RENDER_CONFIG = {
599
606
  renderMode: 1, enableAlphaShadows: true,
600
607
  enableOIDN: true, oidnQuality: 'balance',
601
608
  interactionModeEnabled: false,
602
- // √-norm convergence reaches a true ~all-converged gate, so stop at 0.98 — Cycles-faithful, leaving
603
- // the unbiased residual for OIDN.
609
+ // 0.94 against the eroded count ≈ the old raw-count 0.98; erosion holds the fraction a few points lower.
604
610
  useAdaptiveSampling: true,
605
- noiseThreshold: 0.1,
606
- adaptiveStopFraction: 0.98,
611
+ noiseThreshold: 0.02,
612
+ adaptiveStopFraction: 0.94,
607
613
  usePixelFreeze: true,
608
614
  };
609
615
 
@@ -615,6 +621,7 @@ export const INTERACTIVE_RENDER_CONFIG = {
615
621
  enableOIDN: false, oidnQuality: 'fast',
616
622
  interactionModeEnabled: true,
617
623
  useAdaptiveSampling: true, // idle refine stops early when converged; frozen during motion
624
+ noiseThreshold: 0.1, // loose: preview wants a fast settle, not a clean one
618
625
  usePixelFreeze: true, // speeds up idle refinement on heavy/high-res views; inert while moving (freeze resets)
619
626
  };
620
627
 
@@ -1984,6 +1984,18 @@ export class PathTracerApp extends EventDispatcher {
1984
1984
 
1985
1985
  }
1986
1986
 
1987
+ /**
1988
+ * Adaptive-sampling telemetry: `{ converged, activePixels, totalPixels, frame }`.
1989
+ * Counts come from an async readback taken on settled views only, so they lag a few frames
1990
+ * and read zero while the camera moves.
1991
+ * @returns {?Object}
1992
+ */
1993
+ getConvergenceStats() {
1994
+
1995
+ return this.stages.pathTracer?.getConvergenceStats?.() ?? null;
1996
+
1997
+ }
1998
+
1987
1999
  /** The path tracer's VRAM tracker, or null before stages are built. */
1988
2000
  get vram() {
1989
2001
 
@@ -2567,6 +2579,7 @@ export class PathTracerApp extends EventDispatcher {
2567
2579
 
2568
2580
  this.stages.compositor = new Compositor( this.renderer, {
2569
2581
  saturation: this.settings.get( 'saturation' ) ?? DEFAULT_STATE.saturation,
2582
+ pathTracer: this.stages.pathTracer,
2570
2583
  } );
2571
2584
 
2572
2585
  }
@@ -22,7 +22,12 @@ export const COUNTER = {
22
22
  RESERVED_3: 3,
23
23
  // Tier-2 per-pixel freeze: bounce-0 active count (maxRays − frozen), read back to size next frame's grid.
24
24
  ACTIVE_PIXEL_COUNT: 4,
25
- COUNT: 5,
25
+ // Geometry-only convergence gate. CONVERGED_COUNT counts every pixel, so a mostly-sky frame inherits the
26
+ // sky's free convergence and can retire while the subject is still noisy. These count the same eroded
27
+ // bits restricted to pixels that hit geometry, so the stop can require both fractions.
28
+ GEOMETRY_COUNT: 5,
29
+ CONVERGED_GEOMETRY_COUNT: 6,
30
+ COUNT: 7,
26
31
  };
27
32
 
28
33
  /** Ray flag bits packed into rayBounceFlags (uint) */
@@ -50,6 +50,7 @@ const SETTING_ROUTES = {
50
50
  usePixelFreeze: { uniform: 'usePixelFreeze', reset: true },
51
51
  pixelFreezeThreshold: { uniform: 'pixelFreezeThreshold', reset: true },
52
52
  pixelFreezeStability: { uniform: 'pixelFreezeStability', reset: true },
53
+ convergenceOverlay: { handler: 'handleConvergenceOverlay', reset: false },
53
54
  enableEmissiveTriangleSampling: { uniform: 'enableEmissiveTriangleSampling', reset: true },
54
55
  emissiveBoost: { uniform: 'emissiveBoost', reset: true },
55
56
  visMode: { uniform: 'visMode', reset: true },
@@ -142,7 +143,7 @@ export class RenderSettings extends EventDispatcher {
142
143
  * Builds handler functions for multi-stage settings that can't
143
144
  * be routed with a simple uniform forward.
144
145
  */
145
- _buildHandlers( { stages, renderer, reconcileCompletion, denoisingManager, cameraManager } ) {
146
+ _buildHandlers( { stages, renderer, resetCallback, reconcileCompletion, denoisingManager, cameraManager } ) {
146
147
 
147
148
  // UniformManager copies into the existing node, so one scratch vector serves every write.
148
149
  const panoScratch = new Vector2();
@@ -209,6 +210,17 @@ export class RenderSettings extends EventDispatcher {
209
210
 
210
211
  },
211
212
 
213
+ handleConvergenceOverlay: ( value ) => {
214
+
215
+ stages.pathTracer?.setUniform( 'convergenceOverlay', value );
216
+ stages.compositor?.setConvergenceOverlay( value );
217
+
218
+ // With adaptive sampling off nothing kept m2 current, so the estimate has no history to
219
+ // read — restart accumulation so it self-inits at frame 0 instead of painting stale error.
220
+ if ( value && ! this.get( 'useAdaptiveSampling' ) ) resetCallback?.();
221
+
222
+ },
223
+
212
224
  handleRenderLimitMode: ( value ) => {
213
225
 
214
226
  stages.pathTracer?.setRenderLimitMode?.( value );
@@ -1,6 +1,6 @@
1
- import { vec4, vec3, uv, uniform, select, dot, mix } from 'three/tsl';
1
+ import { vec4, vec3, uv, uniform, select, dot, mix, float, int, uint, storage, Fn, If } from 'three/tsl';
2
2
  import { MeshBasicNodeMaterial, QuadMesh, TextureNode } from 'three/webgpu';
3
- import { NoBlending } from 'three';
3
+ import { NoBlending, NoToneMapping } from 'three';
4
4
  import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
5
5
  import { REC709_LUMINANCE_COEFFICIENTS } from '../TSL/Common.js';
6
6
 
@@ -31,6 +31,17 @@ export class Compositor extends RenderStage {
31
31
 
32
32
  this._transparentBackground = uniform( 0, 'int' );
33
33
 
34
+ // Convergence debug overlay — reads the path tracer's per-pixel convergence buffers read-only.
35
+ // Material is compiled on first enable, so the normal display path carries none of its bindings.
36
+ this._pathTracer = options.pathTracer ?? null;
37
+ this._convergenceOverlay = false;
38
+ this._debugMaterial = null;
39
+ this._debugQuad = null;
40
+ this._debugTexNode = null;
41
+ this._debugM2Attr = null;
42
+ this._debugMaskAttr = null;
43
+ this._debugExposureComp = uniform( 1.0 );
44
+
34
45
  // TextureNode reused across frames — only `.value` mutates, shader doesn't recompile.
35
46
  this._sourceTexNode = new TextureNode();
36
47
 
@@ -63,10 +74,117 @@ export class Compositor extends RenderStage {
63
74
 
64
75
  }
65
76
 
77
+ /**
78
+ * Builds (or rebuilds) the convergence-overlay material.
79
+ *
80
+ * Re-derives FinalWrite's own convergence predicate from the accumulated colour (the running mean)
81
+ * and the m2 buffer (its running second moment), so what is painted IS the decision the renderer
82
+ * makes — nothing is recomputed differently or written back.
83
+ *
84
+ * @returns {boolean} True when a material is ready to render.
85
+ */
86
+ _ensureDebugMaterial() {
87
+
88
+ const src = this._pathTracer?.getConvergenceDebugSource?.();
89
+ if ( ! src ) return false;
90
+
91
+ // The buffers are only reallocated when the render reserve grows; rebind when that happens.
92
+ if ( this._debugMaterial && this._debugM2Attr === src.m2 && this._debugMaskAttr === src.frozenMask ) return true;
93
+
94
+ this._debugMaterial?.dispose();
95
+ this._debugTexNode?.dispose();
96
+
97
+ this._debugM2Attr = src.m2;
98
+ this._debugMaskAttr = src.frozenMask;
99
+ this._debugTexNode = new TextureNode();
100
+
101
+ // Must stay one Fn body: as a raw node graph TSL emits a .toVar() inside the FIRST branch that demands
102
+ // it, and sibling branches then read an unassigned var<private>.
103
+ const overlayColor = Fn( () => {
104
+
105
+ const texel = this._debugTexNode.sample( uv() ).toVar();
106
+ const lum = dot( texel.xyz, REC709_LUMINANCE_COEFFICIENTS ).toVar();
107
+
108
+ // The overlay draws at canvas size, the buffers are indexed at render size — go through the pixel index.
109
+ const w = src.renderWidth;
110
+ const h = src.renderHeight;
111
+ const ix = int( uv().x.mul( float( w ) ) ).min( w.sub( int( 1 ) ) ).max( int( 0 ) );
112
+ const iy = int( uv().y.mul( float( h ) ) ).min( h.sub( int( 1 ) ) ).max( int( 0 ) );
113
+ const pixelId = uint( iy.mul( w ).add( ix ) ).toVar();
114
+
115
+ const m2 = storage( src.m2, 'float' ).toReadOnly().element( pixelId ).toVar();
116
+ const frozenFlag = storage( src.frozenMask, 'uint' ).toReadOnly().element( pixelId ).toVar();
117
+
118
+ const sampleVar = m2.sub( lum.mul( lum ) ).max( float( 0 ) );
119
+ const absSE = sampleVar.div( float( src.frame ).add( 1.0 ) ).sqrt().toVar();
120
+
121
+ // Freeze bars on the raw relative error; the whole-frame stop bars on the √-normalized one.
122
+ // Show whichever is actually steering the render.
123
+ const freezeOn = src.usePixelFreeze.greaterThan( int( 0 ) );
124
+ const convNorm = select( lum.lessThan( float( 1.0 ) ), lum.sqrt(), lum ).toVar();
125
+ const err = select( freezeOn, absSE.div( lum.add( float( 1e-4 ) ) ), absSE.div( convNorm.add( float( 1e-4 ) ) ) ).toVar();
126
+ const bar = select( freezeOn, src.pixelFreezeThreshold, src.noiseThreshold ).toVar();
127
+ const ratio = err.div( bar.max( float( 1e-6 ) ) ).toVar();
128
+
129
+ // Dim tone-mapped grayscale so scene structure stays readable under the overlay.
130
+ const base = vec3( lum.div( lum.add( float( 1.0 ) ) ).sqrt().mul( 0.55 ) ).toVar();
131
+ const out = base.toVar();
132
+
133
+ // Below minSamples the variance is untrustworthy — at frame 0 it is identically zero, which would
134
+ // paint a converged image over pure noise.
135
+ const eligible = float( src.frame ).greaterThanEqual( float( src.adaptiveMinSamples ) );
136
+
137
+ If( ratio.greaterThanEqual( float( 1.0 ) ).or( eligible.not() ), () => {
138
+
139
+ const heat = mix( vec3( 1.0, 0.8, 0.1 ), vec3( 1.0, 0.1, 0.05 ), ratio.sub( 1.0 ).div( 3.0 ).clamp( 0.0, 1.0 ) );
140
+ out.assign( mix( base, heat, 0.85 ) );
141
+
142
+ } );
143
+
144
+ // Frozen wins: these are the pixels the renderer actually skipped, dilation halo included.
145
+ If( frozenFlag.equal( uint( 1 ) ).and( freezeOn ), () => {
146
+
147
+ out.assign( mix( base, vec3( 0.1, 0.45, 1.0 ), 0.55 ) );
148
+
149
+ } );
150
+
151
+ return out;
152
+
153
+ } );
154
+
155
+ // Undo the output pass's exposure (tone-mapped configs only) and linear→sRGB so the palette reads the
156
+ // same at any exposure.
157
+ this._debugMaterial = new MeshBasicNodeMaterial();
158
+ this._debugMaterial.colorNode = vec4( overlayColor().pow( 2.2 ).mul( this._debugExposureComp ), 1.0 );
159
+ this._debugMaterial.blending = NoBlending;
160
+ this._debugQuad = new QuadMesh( this._debugMaterial );
161
+
162
+ return true;
163
+
164
+ }
165
+
66
166
  render( context ) {
67
167
 
68
168
  if ( ! this.enabled ) return;
69
169
 
170
+ if ( this._convergenceOverlay && this._ensureDebugMaterial() ) {
171
+
172
+ // The error estimate is paired with the raw accumulated colour, not a denoised derivative.
173
+ const ptColor = context.getTexture( 'pathtracer:color' );
174
+ if ( ptColor ) {
175
+
176
+ this._debugTexNode.value = ptColor;
177
+ const exposed = this.renderer.toneMapping !== NoToneMapping;
178
+ this._debugExposureComp.value = exposed ? 1 / Math.max( this.renderer.toneMappingExposure, 1e-4 ) : 1;
179
+
180
+ this.renderer.setRenderTarget( null );
181
+ this._debugQuad.render( this.renderer );
182
+ return;
183
+
184
+ }
185
+
186
+ }
187
+
70
188
  const sourceTexture = this._resolveSourceTexture( context );
71
189
  if ( ! sourceTexture ) return;
72
190
 
@@ -77,6 +195,12 @@ export class Compositor extends RenderStage {
77
195
 
78
196
  }
79
197
 
198
+ setConvergenceOverlay( enabled ) {
199
+
200
+ this._convergenceOverlay = !! enabled;
201
+
202
+ }
203
+
80
204
  setSaturation( value ) {
81
205
 
82
206
  this.saturation.value = value;
@@ -93,8 +217,14 @@ export class Compositor extends RenderStage {
93
217
 
94
218
  this._sourceTexNode?.dispose();
95
219
  this.compositorMaterial?.dispose();
220
+ this._debugTexNode?.dispose();
221
+ this._debugMaterial?.dispose();
96
222
  // QuadMesh extends Mesh — no dispose method; material already released above.
97
223
  this.compositorQuad = null;
224
+ this._debugQuad = null;
225
+ this._debugMaterial = null;
226
+ this._debugTexNode = null;
227
+ this._pathTracer = null;
98
228
 
99
229
  }
100
230
 
@@ -54,6 +54,7 @@ export class PathTracer extends PathTracerStage {
54
54
  // Tier-2 dilation: per-pixel frozen mask (1=skip), written race-free in buildActivePixels; a frozen pixel
55
55
  // stays active if any 8-neighbour is still active (Cycles box-filter) to avoid hard frozen/active seams.
56
56
  this._frozenMaskAttr = null;
57
+ this._convDebugSource = null; // memoized handle set for the Compositor's convergence overlay
57
58
  this._dilateFrozenUniform = uniform( 1, 'int' ); // 1 = dilate (default); 0 = plain per-pixel freeze
58
59
  this._wavefrontReady = false;
59
60
 
@@ -99,6 +100,9 @@ export class PathTracer extends PathTracerStage {
99
100
  // single-flight guard for its async counter read. Zeroed on reset/camera-move/resize; never refreshed
100
101
  // mid-motion (the readback early-return + frozen frameCount keep the stop from firing while moving).
101
102
  this._convergedFraction = 0;
103
+ // Subject-only convergence, from the same eroded bits. Starts at 0 so the stop cannot fire unmeasured.
104
+ this._convergedGeometryFraction = 0;
105
+ this._geometryPixelCount = 0;
102
106
  this._convergedReadbackPending = false;
103
107
 
104
108
  // Tier-2: last settled active-pixel count (maxRays − frozen), sizes next frame's bounce-0 grid.
@@ -272,6 +276,7 @@ export class PathTracer extends PathTracerStage {
272
276
  this._readbackGeneration ++;
273
277
  // Drop the stale converged fraction so the early-stop can't fire on the pose we're leaving.
274
278
  this._convergedFraction = 0;
279
+ this._convergedGeometryFraction = 0;
275
280
  // Tier-2: drop the stale active-pixel count so bounce 0 full-sizes until the new pose re-measures.
276
281
  this._lastActivePixelCount = 0;
277
282
 
@@ -372,6 +377,14 @@ export class PathTracer extends PathTracerStage {
372
377
 
373
378
  }
374
379
 
380
+ // Full-frame kernel, so first chunk only, after the zeroing above. Frame 0 skips it — those bits
381
+ // still belong to the pre-reset view. Only on frames whose counters are actually read back.
382
+ if ( chunkIndex === 0 && frameValue > 0 && this.useAdaptiveSampling.value > 0 && this._willReadCountersThisFrame() ) {
383
+
384
+ km.dispatch( 'countConvergedDilated' );
385
+
386
+ }
387
+
375
388
  for ( let bounce = 0; bounce <= loopBound; bounce ++ ) {
376
389
 
377
390
  this._wfCurrentBounce.value = bounce;
@@ -487,9 +500,15 @@ export class PathTracer extends PathTracerStage {
487
500
  // _convergedFraction is zeroed on camera-move and never refreshed mid-motion (readback early-returns).
488
501
  _isConvergedComplete() {
489
502
 
503
+ // BOTH fractions must clear the bar. The whole-frame one counts every pixel, so it is diluted by
504
+ // however much easy background is in shot — the bar effectively becomes (bar−bgShare)/(1−bgShare) on
505
+ // the subject, which makes it framing-dependent. The geometry-only fraction gates that. Neither
506
+ // replaces the other: background can be genuinely noisy (DOF, sharp env under AA jitter), and then
507
+ // the whole-frame fraction is the binding one.
490
508
  return this.useAdaptiveSampling.value > 0
491
509
  && this.frameCount >= this.adaptiveMinSamples.value
492
- && this._convergedFraction >= this.adaptiveStopFraction.value;
510
+ && this._convergedFraction >= this.adaptiveStopFraction.value
511
+ && this._convergedGeometryFraction >= this.adaptiveStopFraction.value;
493
512
 
494
513
  }
495
514
 
@@ -497,10 +516,62 @@ export class PathTracer extends PathTracerStage {
497
516
 
498
517
  super.reset();
499
518
  this._convergedFraction = 0;
519
+ this._convergedGeometryFraction = 0;
500
520
  this._lastActivePixelCount = 0;
501
521
 
502
522
  }
503
523
 
524
+ /**
525
+ * Per-pixel convergence state for the Compositor's debug overlay: the buffers FinalWrite maintains
526
+ * plus the uniforms needed to re-derive its predicate. Read-only — the overlay never writes back.
527
+ * Null until the wavefront kernels exist.
528
+ */
529
+ getConvergenceDebugSource() {
530
+
531
+ if ( ! this._m2Attr || ! this._frozenMaskAttr ) return null;
532
+ // Polled every frame while the overlay is on — rebuild only when the buffers actually change.
533
+ if ( this._convDebugSource?.m2 === this._m2Attr && this._convDebugSource.frozenMask === this._frozenMaskAttr ) {
534
+
535
+ return this._convDebugSource;
536
+
537
+ }
538
+
539
+ this._convDebugSource = {
540
+ m2: this._m2Attr,
541
+ frozenMask: this._frozenMaskAttr,
542
+ frame: this.frame,
543
+ renderWidth: this._wfRenderWidth,
544
+ renderHeight: this._wfRenderHeight,
545
+ noiseThreshold: this.noiseThreshold,
546
+ pixelFreezeThreshold: this.pixelFreezeThreshold,
547
+ usePixelFreeze: this.usePixelFreeze,
548
+ adaptiveMinSamples: this.adaptiveMinSamples,
549
+ };
550
+
551
+ return this._convDebugSource;
552
+
553
+ }
554
+
555
+ /**
556
+ * Whole-frame adaptive-sampling telemetry, from the counters readback (settled views only).
557
+ * `converged` is the fraction that met the noise threshold; `activePixels` is the count the
558
+ * frozen-compaction path actually traced last settled frame.
559
+ */
560
+ getConvergenceStats() {
561
+
562
+ const total = this._wfRenderWidth.value * this._wfRenderHeight.value;
563
+
564
+ return {
565
+ converged: this._convergedFraction,
566
+ convergedGeometry: this._convergedGeometryFraction,
567
+ geometryPixels: this._geometryPixelCount,
568
+ activePixels: this._lastActivePixelCount,
569
+ totalPixels: total,
570
+ frame: this.frameCount,
571
+ };
572
+
573
+ }
574
+
504
575
  // Parent resizes storageTextures/shaderBuilder; wavefront also needs its buffers/uniforms/kernels rebuilt.
505
576
  _handleResize() {
506
577
 
@@ -551,6 +622,22 @@ export class PathTracer extends PathTracerStage {
551
622
 
552
623
  }
553
624
 
625
+ /**
626
+ * Whether THIS frame's counters will actually be read back, evaluated before the dispatches that fill
627
+ * them. Mirrors _maybeReadbackCounters' guards exactly; both run inside the same synchronous render()
628
+ * call, so no async continuation can flip a flag between them.
629
+ *
630
+ * countConvergedDilated is a full-frame pass whose only consumer is that readback, and the readback is
631
+ * on a 4-frame cadence — counting every frame threw ~75% of the work away.
632
+ */
633
+ _willReadCountersThisFrame() {
634
+
635
+ if ( this.cameraChanged || this.cameraOptimizer?.isInInteractionMode() ) return false;
636
+ if ( this._readbackPending || this._convergedReadbackPending ) return false;
637
+ return this._readbackFrameCounter + 1 >= this._readbackEveryNFrames;
638
+
639
+ }
640
+
554
641
  // Async readback of the per-bounce snapshot every N frames; never awaited, so the early-exit uses past-frame data.
555
642
  _maybeReadbackCounters() {
556
643
 
@@ -620,6 +707,11 @@ export class PathTracer extends PathTracerStage {
620
707
 
621
708
  const c = new Uint32Array( buf );
622
709
  this._convergedFraction = c[ COUNTER.CONVERGED_COUNT ] / total;
710
+ // No geometry at all (pure environment) leaves nothing to gate on — 1 lets the
711
+ // whole-frame fraction decide alone.
712
+ const geo = c[ COUNTER.GEOMETRY_COUNT ];
713
+ this._geometryPixelCount = geo;
714
+ this._convergedGeometryFraction = geo > 0 ? c[ COUNTER.CONVERGED_GEOMETRY_COUNT ] / geo : 1;
623
715
  // Tier-2: bounce-0 active-pixel count measured this settled frame → sizes next frame's grid.
624
716
  this._lastActivePixelCount = c[ COUNTER.ACTIVE_PIXEL_COUNT ];
625
717
 
@@ -673,6 +765,7 @@ export class PathTracer extends PathTracerStage {
673
765
  this._readbackFrameCounter = 0;
674
766
  this._readbackGeneration ++;
675
767
  this._convergedFraction = 0;
768
+ this._convergedGeometryFraction = 0;
676
769
  this._lastActivePixelCount = 0;
677
770
 
678
771
  // Chunked path pool: the wavefront buffers are sized to the fixed device budget B (not the
@@ -843,20 +936,28 @@ export class PathTracer extends PathTracerStage {
843
936
  // max resolution (small — 12 B/pixel) and NEVER realloc on a resize.
844
937
  const maxPixels = MAX_STORAGE_TEXTURE_SIZE * MAX_STORAGE_TEXTURE_SIZE;
845
938
 
846
- // Tier-1 convergence: per-pixel running mean of luminance² (Welford second moment), read+written by FinalWrite.
847
- freeStorageAttribute( this.renderer, this._m2Attr );
848
- this._m2Attr = new StorageInstancedBufferAttribute( new Float32Array( maxPixels ), 1 );
849
- const m2RW = storage( this._m2Attr, 'float' );
939
+ // Only reallocated on growth, never on a plain rebuild: the Compositor's overlay binds m2/frozenMask
940
+ // read-only and a fresh attribute would strand its bind group. Surviving contents are safe — every
941
+ // consumer re-seeds at frame 0.
942
+ if ( ! this._m2Attr || this._m2Attr.count < maxPixels ) {
943
+
944
+ // Tier-1 convergence: per-pixel running mean of luminance² (Welford second moment), read+written by FinalWrite.
945
+ freeStorageAttribute( this.renderer, this._m2Attr );
946
+ this._m2Attr = new StorageInstancedBufferAttribute( new Float32Array( maxPixels ), 1 );
947
+
948
+ // Tier-2: per-pixel freeze-candidate streak. FinalWrite writes (RW), buildActivePixels reads (RO).
949
+ freeStorageAttribute( this.renderer, this._streakAttr );
950
+ this._streakAttr = new StorageInstancedBufferAttribute( new Uint32Array( maxPixels ), 1 );
850
951
 
851
- // Tier-2: per-pixel freeze-candidate streak. FinalWrite writes (RW), buildActivePixels reads (RO).
852
- freeStorageAttribute( this.renderer, this._streakAttr );
853
- this._streakAttr = new StorageInstancedBufferAttribute( new Uint32Array( maxPixels ), 1 );
952
+ // Tier-2: dilated frozen mask (1 = skip). buildActivePixels writes; active-list + FinalWrite read → race-free.
953
+ freeStorageAttribute( this.renderer, this._frozenMaskAttr );
954
+ this._frozenMaskAttr = new StorageInstancedBufferAttribute( new Uint32Array( maxPixels ), 1 );
955
+
956
+ }
957
+
958
+ const m2RW = storage( this._m2Attr, 'float' );
854
959
  const streakRW = storage( this._streakAttr, 'uint' );
855
960
  const streakRO = storage( this._streakAttr, 'uint' ).toReadOnly();
856
-
857
- // Tier-2: dilated frozen mask (1 = skip). buildActivePixels writes; active-list + FinalWrite read → race-free.
858
- freeStorageAttribute( this.renderer, this._frozenMaskAttr );
859
- this._frozenMaskAttr = new StorageInstancedBufferAttribute( new Uint32Array( maxPixels ), 1 );
860
961
  const frozenMaskRW = storage( this._frozenMaskAttr, 'uint' );
861
962
  const frozenMaskRO = storage( this._frozenMaskAttr, 'uint' ).toReadOnly();
862
963
 
@@ -929,6 +1030,8 @@ export class PathTracer extends PathTracerStage {
929
1030
  If( this._wfIsFirstChunk.greaterThan( uint( 0 ) ), () => {
930
1031
 
931
1032
  atomicStore( counters.element( uint( COUNTER.CONVERGED_COUNT ) ), uint( 0 ) );
1033
+ atomicStore( counters.element( uint( COUNTER.GEOMETRY_COUNT ) ), uint( 0 ) );
1034
+ atomicStore( counters.element( uint( COUNTER.CONVERGED_GEOMETRY_COUNT ) ), uint( 0 ) );
932
1035
 
933
1036
  } );
934
1037
 
@@ -988,6 +1091,8 @@ export class PathTracer extends PathTracerStage {
988
1091
  If( this._wfIsFirstChunk.greaterThan( uint( 0 ) ), () => {
989
1092
 
990
1093
  atomicStore( counters.element( uint( COUNTER.CONVERGED_COUNT ) ), uint( 0 ) );
1094
+ atomicStore( counters.element( uint( COUNTER.GEOMETRY_COUNT ) ), uint( 0 ) );
1095
+ atomicStore( counters.element( uint( COUNTER.CONVERGED_GEOMETRY_COUNT ) ), uint( 0 ) );
991
1096
 
992
1097
  } );
993
1098
 
@@ -1010,7 +1115,8 @@ export class PathTracer extends PathTracerStage {
1010
1115
  // (streak<K), softening the boundary. Mask is read by FinalWrite too → race-free.
1011
1116
  const frozen = uint( 0 ).toVar();
1012
1117
 
1013
- If( this.frame.greaterThan( uint( 0 ) ).and( streakRO.element( p ).greaterThanEqual( uint( freezeK ) ) ), () => {
1118
+ // Bits 31/30 of the streak word are the converged + geometry flags — mask them off wherever the streak is read.
1119
+ If( this.frame.greaterThan( uint( 0 ) ).and( streakRO.element( p ).bitAnd( uint( 0x3FFFFFFF ) ).greaterThanEqual( uint( freezeK ) ) ), () => {
1014
1120
 
1015
1121
  frozen.assign( uint( 1 ) );
1016
1122
 
@@ -1026,7 +1132,7 @@ export class PathTracer extends PathTracerStage {
1026
1132
  If( nx.greaterThanEqual( int( 0 ) ).and( nx.lessThan( this._wfRenderWidth ) )
1027
1133
  .and( ny.greaterThanEqual( int( 0 ) ) ).and( ny.lessThan( this._wfRenderHeight ) ), () => {
1028
1134
 
1029
- If( streakRO.element( uint( ny.mul( this._wfRenderWidth ).add( nx ) ) ).lessThan( uint( freezeK ) ), () => {
1135
+ If( streakRO.element( uint( ny.mul( this._wfRenderWidth ).add( nx ) ) ).bitAnd( uint( 0x3FFFFFFF ) ).lessThan( uint( freezeK ) ), () => {
1030
1136
 
1031
1137
  frozen.assign( uint( 0 ) ); // a still-active neighbour keeps this pixel active
1032
1138
 
@@ -1057,6 +1163,74 @@ export class PathTracer extends PathTracerStage {
1057
1163
  buildActiveFn().compute( [ Math.ceil( ( this._chunkRows * w ) / LIST_WG_SIZE ), 1, 1 ], [ LIST_WG_SIZE, 1, 1 ] )
1058
1164
  );
1059
1165
 
1166
+ // Whole-frame converged count, eroded 3×3 (Cycles film_adaptive_sampling_filter_x/y parity): a pixel
1167
+ // counts only if it AND its 8 in-bounds neighbours carry last frame's converged bit. A pointwise count
1168
+ // is blind to undiscovered energy — all-zero pixels have zero variance and pass any threshold, so on
1169
+ // indirect-dominant scenes it peaks on a black image and retires the frame at ~10 spp. Erosion lets
1170
+ // each discovered speckle poison its neighbourhood while energy is still being found.
1171
+ // Reads the PREVIOUS frame's bits, which costs nothing: the stop already lags by the readback cadence.
1172
+ const countConvergedFn = Fn( () => {
1173
+
1174
+ const tid = instanceIndex;
1175
+ const W = this._wfRenderWidth;
1176
+ const H = this._wfRenderHeight;
1177
+ If( tid.lessThan( uint( W.mul( H ) ) ), () => {
1178
+
1179
+ const px = int( tid ).mod( W );
1180
+ const py = int( tid ).div( W );
1181
+ const CONV_BIT = uint( 0x80000000 );
1182
+ const GEOM_BIT = uint( 0x40000000 );
1183
+
1184
+ const word = streakRO.element( tid ).toVar();
1185
+ const isGeometry = word.bitAnd( GEOM_BIT ).notEqual( uint( 0 ) );
1186
+ const allConverged = word.bitAnd( CONV_BIT ).notEqual( uint( 0 ) ).toVar();
1187
+
1188
+ If( allConverged, () => {
1189
+
1190
+ for ( const [ dx, dy ] of [[ - 1, - 1 ], [ 0, - 1 ], [ 1, - 1 ], [ - 1, 0 ], [ 1, 0 ], [ - 1, 1 ], [ 0, 1 ], [ 1, 1 ]] ) {
1191
+
1192
+ const nx = px.add( int( dx ) );
1193
+ const ny = py.add( int( dy ) );
1194
+ If( nx.greaterThanEqual( int( 0 ) ).and( nx.lessThan( W ) )
1195
+ .and( ny.greaterThanEqual( int( 0 ) ) ).and( ny.lessThan( H ) ), () => {
1196
+
1197
+ If( streakRO.element( uint( ny.mul( W ).add( nx ) ) ).bitAnd( CONV_BIT ).equal( uint( 0 ) ), () => {
1198
+
1199
+ allConverged.assign( false );
1200
+
1201
+ } );
1202
+
1203
+ } );
1204
+
1205
+ }
1206
+
1207
+ } );
1208
+
1209
+ If( allConverged, () => {
1210
+
1211
+ atomicAdd( counters.element( uint( COUNTER.CONVERGED_COUNT ) ), uint( 1 ) );
1212
+
1213
+ } );
1214
+
1215
+ // Same eroded verdict, restricted to subject pixels — the framing-independent view.
1216
+ If( isGeometry, () => {
1217
+
1218
+ atomicAdd( counters.element( uint( COUNTER.GEOMETRY_COUNT ) ), uint( 1 ) );
1219
+ If( allConverged, () => {
1220
+
1221
+ atomicAdd( counters.element( uint( COUNTER.CONVERGED_GEOMETRY_COUNT ) ), uint( 1 ) );
1222
+
1223
+ } );
1224
+
1225
+ } );
1226
+
1227
+ } );
1228
+
1229
+ } );
1230
+ this._kernelManager.register( 'countConvergedDilated',
1231
+ countConvergedFn().compute( [ Math.ceil( ( w * h ) / LIST_WG_SIZE ), 1, 1 ], [ LIST_WG_SIZE, 1, 1 ] )
1232
+ );
1233
+
1060
1234
  const seedEnterFn = Fn( () => {
1061
1235
 
1062
1236
  const active = atomicLoad( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ) );
@@ -1319,7 +1493,6 @@ export class PathTracer extends PathTracerStage {
1319
1493
  visMode: this.visMode,
1320
1494
  auxGBufferEnabled: this._auxGBufferUniform,
1321
1495
  cleanAuxNormalEnabled: this._cleanAuxNormalUniform,
1322
- counters,
1323
1496
  m2BufferRW: m2RW,
1324
1497
  useAdaptiveSampling: this.useAdaptiveSampling,
1325
1498
  noiseThreshold: this.noiseThreshold,
@@ -1329,6 +1502,7 @@ export class PathTracer extends PathTracerStage {
1329
1502
  pixelFreezeThreshold: this.pixelFreezeThreshold,
1330
1503
  streakBufferRW: streakRW,
1331
1504
  frozenMaskRO, // dilated frozen mask (read-only; matches the active-list decision)
1505
+ convergenceOverlay: this.convergenceOverlay,
1332
1506
  chunkRowBase: this._wfChunkRowBase,
1333
1507
  chunkRows: this._wfChunkRows,
1334
1508
  } );
@@ -1403,6 +1577,8 @@ export class PathTracer extends PathTracerStage {
1403
1577
  const h = this._wfRenderHeight.value;
1404
1578
 
1405
1579
  this._kernelManager.setDispatchForGrid( 'debug', w, h );
1580
+ // Full-frame (not per-chunk); sized from live values so an in-place resize can't strand its grid.
1581
+ this._kernelManager.setDispatchForCount( 'countConvergedDilated', w * h );
1406
1582
 
1407
1583
  }
1408
1584
 
@@ -1445,6 +1621,7 @@ export class PathTracer extends PathTracerStage {
1445
1621
  this._m2Attr = null;
1446
1622
  this._streakAttr = null;
1447
1623
  this._frozenMaskAttr = null;
1624
+ this._convDebugSource = null;
1448
1625
  this._wavefrontReady = false;
1449
1626
 
1450
1627
  }