rayzee 7.22.2 → 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.
@@ -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
@@ -761,10 +854,10 @@ export class PathTracer extends PathTracerStage {
761
854
  // MAX_STORAGE_TEXTURE_SIZE and can't be resized, so recreate the pool at the (new) live reserved size and
762
855
  // rebuild the wavefront kernels in place. The stage OBJECT is unchanged, so manager/event refs stay valid;
763
856
  // only GPU textures + compute pipelines are rebuilt. Scene buffers (BVH/tri/material) are resolution-
764
- // independent and untouched. Caller must have rendering paused. No-op before the first build.
857
+ // independent and untouched. Caller must have rendering paused.
765
858
  reallocateReservedStorage() {
766
859
 
767
- if ( ! this._packedBuffers || ! ( this.materialData?.materialCount > 0 ) ) return;
860
+ if ( ! this.storageTextures.writeColor ) return;
768
861
 
769
862
  // Clamp into the (possibly just-lowered) reserve: create() sizes the write StorageTextures at
770
863
  // MAX_STORAGE_TEXTURE_SIZE but the readTarget at what's passed here, and the tracked render size still
@@ -777,6 +870,11 @@ export class PathTracer extends PathTracerStage {
777
870
  this.storageTextures.create( w, h );
778
871
  this.resolution.value.set( w, h );
779
872
 
873
+ // No kernels before the first scene with materials — nothing to rebuild, and a build here would compile
874
+ // against 0 materials. The pool above is recreated regardless: it is allocated at construction, so a
875
+ // reserve raised before the first model load must still reach it or every later copy overflows it.
876
+ if ( ! this._packedBuffers || ! ( this.materialData?.materialCount > 0 ) ) return;
877
+
780
878
  // Rebuild kernels: re-references the fresh write textures + recreates the per-pixel aux buffers
781
879
  // (m2/streak/frozenMask) at the new maxPixels. Prev-frame nodes are repointed per-frame in render().
782
880
  if ( this._kernelManager ) this._kernelManager.dispose();
@@ -838,20 +936,28 @@ export class PathTracer extends PathTracerStage {
838
936
  // max resolution (small — 12 B/pixel) and NEVER realloc on a resize.
839
937
  const maxPixels = MAX_STORAGE_TEXTURE_SIZE * MAX_STORAGE_TEXTURE_SIZE;
840
938
 
841
- // Tier-1 convergence: per-pixel running mean of luminance² (Welford second moment), read+written by FinalWrite.
842
- freeStorageAttribute( this.renderer, this._m2Attr );
843
- this._m2Attr = new StorageInstancedBufferAttribute( new Float32Array( maxPixels ), 1 );
844
- 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 );
951
+
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
+ }
845
957
 
846
- // Tier-2: per-pixel freeze-candidate streak. FinalWrite writes (RW), buildActivePixels reads (RO).
847
- freeStorageAttribute( this.renderer, this._streakAttr );
848
- this._streakAttr = new StorageInstancedBufferAttribute( new Uint32Array( maxPixels ), 1 );
958
+ const m2RW = storage( this._m2Attr, 'float' );
849
959
  const streakRW = storage( this._streakAttr, 'uint' );
850
960
  const streakRO = storage( this._streakAttr, 'uint' ).toReadOnly();
851
-
852
- // Tier-2: dilated frozen mask (1 = skip). buildActivePixels writes; active-list + FinalWrite read → race-free.
853
- freeStorageAttribute( this.renderer, this._frozenMaskAttr );
854
- this._frozenMaskAttr = new StorageInstancedBufferAttribute( new Uint32Array( maxPixels ), 1 );
855
961
  const frozenMaskRW = storage( this._frozenMaskAttr, 'uint' );
856
962
  const frozenMaskRO = storage( this._frozenMaskAttr, 'uint' ).toReadOnly();
857
963
 
@@ -924,6 +1030,8 @@ export class PathTracer extends PathTracerStage {
924
1030
  If( this._wfIsFirstChunk.greaterThan( uint( 0 ) ), () => {
925
1031
 
926
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 ) );
927
1035
 
928
1036
  } );
929
1037
 
@@ -983,6 +1091,8 @@ export class PathTracer extends PathTracerStage {
983
1091
  If( this._wfIsFirstChunk.greaterThan( uint( 0 ) ), () => {
984
1092
 
985
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 ) );
986
1096
 
987
1097
  } );
988
1098
 
@@ -1005,7 +1115,8 @@ export class PathTracer extends PathTracerStage {
1005
1115
  // (streak<K), softening the boundary. Mask is read by FinalWrite too → race-free.
1006
1116
  const frozen = uint( 0 ).toVar();
1007
1117
 
1008
- 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 ) ) ), () => {
1009
1120
 
1010
1121
  frozen.assign( uint( 1 ) );
1011
1122
 
@@ -1021,7 +1132,7 @@ export class PathTracer extends PathTracerStage {
1021
1132
  If( nx.greaterThanEqual( int( 0 ) ).and( nx.lessThan( this._wfRenderWidth ) )
1022
1133
  .and( ny.greaterThanEqual( int( 0 ) ) ).and( ny.lessThan( this._wfRenderHeight ) ), () => {
1023
1134
 
1024
- 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 ) ), () => {
1025
1136
 
1026
1137
  frozen.assign( uint( 0 ) ); // a still-active neighbour keeps this pixel active
1027
1138
 
@@ -1052,6 +1163,74 @@ export class PathTracer extends PathTracerStage {
1052
1163
  buildActiveFn().compute( [ Math.ceil( ( this._chunkRows * w ) / LIST_WG_SIZE ), 1, 1 ], [ LIST_WG_SIZE, 1, 1 ] )
1053
1164
  );
1054
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
+
1055
1234
  const seedEnterFn = Fn( () => {
1056
1235
 
1057
1236
  const active = atomicLoad( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ) );
@@ -1314,7 +1493,6 @@ export class PathTracer extends PathTracerStage {
1314
1493
  visMode: this.visMode,
1315
1494
  auxGBufferEnabled: this._auxGBufferUniform,
1316
1495
  cleanAuxNormalEnabled: this._cleanAuxNormalUniform,
1317
- counters,
1318
1496
  m2BufferRW: m2RW,
1319
1497
  useAdaptiveSampling: this.useAdaptiveSampling,
1320
1498
  noiseThreshold: this.noiseThreshold,
@@ -1324,6 +1502,7 @@ export class PathTracer extends PathTracerStage {
1324
1502
  pixelFreezeThreshold: this.pixelFreezeThreshold,
1325
1503
  streakBufferRW: streakRW,
1326
1504
  frozenMaskRO, // dilated frozen mask (read-only; matches the active-list decision)
1505
+ convergenceOverlay: this.convergenceOverlay,
1327
1506
  chunkRowBase: this._wfChunkRowBase,
1328
1507
  chunkRows: this._wfChunkRows,
1329
1508
  } );
@@ -1398,6 +1577,8 @@ export class PathTracer extends PathTracerStage {
1398
1577
  const h = this._wfRenderHeight.value;
1399
1578
 
1400
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 );
1401
1582
 
1402
1583
  }
1403
1584
 
@@ -1440,6 +1621,7 @@ export class PathTracer extends PathTracerStage {
1440
1621
  this._m2Attr = null;
1441
1622
  this._streakAttr = null;
1442
1623
  this._frozenMaskAttr = null;
1624
+ this._convDebugSource = null;
1443
1625
  this._wavefrontReady = false;
1444
1626
 
1445
1627
  }
@@ -9,7 +9,7 @@
9
9
 
10
10
  import {
11
11
  Fn, wgslFn, float, vec2, vec4, int, uint, uvec2,
12
- If, mix, select, texture, textureStore, length, atomicAdd,
12
+ If, mix, select, texture, textureStore, length,
13
13
  localId, workgroupId,
14
14
  } from 'three/tsl';
15
15
 
@@ -17,7 +17,6 @@ import {
17
17
  readRayRadiance, readGBuffer, gbDecodeNormalDepth, gbDecodeAlbedo,
18
18
  } from '../Processor/PackedRayBuffer.js';
19
19
  import { luminance } from './Common.js';
20
- import { COUNTER } from '../Processor/QueueManager.js';
21
20
 
22
21
  const WG_SIZE = 16;
23
22
 
@@ -49,12 +48,17 @@ export function buildFinalWriteKernel( params ) {
49
48
  // Clean-aux normal (1 = temporally accumulate + renormalize the aux normal). On only for clean-aux
50
49
  // OIDN models (calb_cnrm/high, alb_nrm/balanced); off for fast/ASVGF which want the bump normal.
51
50
  cleanAuxNormalEnabled,
52
- // Tier-1 convergence early-stop: per-pixel Welford luminance second moment + converged-pixel counter.
53
- counters, m2BufferRW, useAdaptiveSampling, noiseThreshold, adaptiveMinSamples,
51
+ // Tier-1 convergence early-stop: per-pixel Welford luminance second moment + converged bit (stamped
52
+ // into streak bit 31; counted with 3×3 erosion by countConvergedDilated at the next frame start).
53
+ m2BufferRW, useAdaptiveSampling, noiseThreshold, adaptiveMinSamples,
54
54
 
55
- // Tier-2 per-pixel freeze: streakBufferRW = per-pixel freeze-candidate streak (stamped here);
56
- // frozenMaskRO = dilated frozen mask from buildActivePixels (pass-through gates on it, not streak).
55
+ // Tier-2 per-pixel freeze: streakBufferRW = per-pixel freeze-candidate streak in bits 0-30 (stamped
56
+ // here); frozenMaskRO = dilated frozen mask from buildActivePixels (pass-through gates on it, not streak).
57
57
  usePixelFreeze, pixelFreezeThreshold, streakBufferRW, frozenMaskRO,
58
+
59
+ // Display-only convergence overlay (Compositor). Only keeps m2 updated so the overlay has a live
60
+ // error field with adaptive sampling off — the counter and the freeze streak stay under convOn.
61
+ convergenceOverlay,
58
62
  } = params;
59
63
 
60
64
  const auxOn = auxGBufferEnabled.greaterThan( uint( 0 ) );
@@ -62,6 +66,9 @@ export function buildFinalWriteKernel( params ) {
62
66
  // useAdaptiveSampling is registered via UniformManager.ub() → an int 0/1 uniform, so compare against int(0).
63
67
  const convOn = useAdaptiveSampling.greaterThan( int( 0 ) );
64
68
  const adaptiveOn = usePixelFreeze.greaterThan( int( 0 ) );
69
+ // The m2/relErr block feeds three consumers: the frame early-stop, the per-pixel freeze, and the debug
70
+ // overlay. Any one of them being on has to keep it live.
71
+ const statsOn = convOn.or( adaptiveOn ).or( convergenceOverlay.greaterThan( int( 0 ) ) );
65
72
 
66
73
  const computeFn = Fn( () => {
67
74
 
@@ -139,13 +146,10 @@ export function buildFinalWriteKernel( params ) {
139
146
 
140
147
  } );
141
148
 
142
- // Tier-1 convergence: per-pixel running second moment of LUMINANCE (Welford). luminance() is linear,
143
- // so luminance(running-mean color) == running-mean luminance == E[L]; m2 tracks E[L²] under the SAME
144
- // global 1/(frame+1) alpha (NO per-pixel alpha). sampleVar = E[L²]-E[L]²; varOfMean = sampleVar/(N);
145
- // absSE = SE(mean); a pixel converges once frame>=minSamples AND the √-normalized error < threshold.
146
- // The m2 write runs every frame (incl. frame 0, where alpha==1 self-inits it → no explicit clear).
147
- // Sits AFTER the accumulation mix (finalColor is the mean) and BEFORE the visMode-11 mutation.
148
- If( convOn, () => {
149
+ // Per-pixel running second moment of luminance under the SAME global 1/(frame+1) alpha as the
150
+ // colour, so sampleVar = E[L²]-E[L]². Must sit after the accumulation mix (finalColor is the mean)
151
+ // and before the visMode-11 mutation. Frame 0 self-inits m2 via alpha==1 — no explicit clear.
152
+ If( statsOn, () => {
149
153
 
150
154
  const sampleLum = luminance( sampleColor.xyz );
151
155
  const prevM2 = m2BufferRW.element( pixelId ).toVar();
@@ -155,44 +159,67 @@ export function buildFinalWriteKernel( params ) {
155
159
  const meanLum = luminance( finalColor ).toVar();
156
160
  const sampleVar = m2.sub( meanLum.mul( meanLum ) ).max( float( 0 ) );
157
161
  const varOfMean = sampleVar.div( float( frame ).add( 1.0 ) );
158
- // absSE = absolute standard error of the mean luminance; relErr = its ratio to the mean.
159
- // Combined criterion: bright pixels converge on relErr<threshold; dark/dim pixels (where relErr
160
- // stays high forever) converge on absSE<absFloor, since their absolute noise is imperceptible.
161
162
  const absSE = varOfMean.sqrt().toVar();
162
163
  const relErr = absSE.div( meanLum.add( float( 1e-4 ) ) ); // Tier-2 freeze predicate reads this
163
164
 
164
- // Cycles-style convergence: normalize the standard error by √luminance for dim pixels (lum<1),
165
- // by luminance for bright ones. The √ loosens the relative bar in shadows so dark pixels converge
166
- // on their small absolute noise instead of stalling forever on a pure relative bar.
165
+ // √luminance normalization below 1 loosens the bar in shadows, so dark pixels converge on their
166
+ // small absolute noise instead of stalling forever on a pure relative one.
167
167
  const convNorm = select( meanLum.lessThan( float( 1.0 ) ), meanLum.sqrt(), meanLum );
168
168
  const converged = absSE.div( convNorm.add( float( 1e-4 ) ) ).lessThan( noiseThreshold );
169
169
 
170
- If( frame.greaterThanEqual( uint( adaptiveMinSamples ) ).and( converged ), () => {
170
+ // Must cover adaptiveOn: nested under convOn alone, freeze neither updated its streak nor ran
171
+ // its frame-0 clear, so buildActivePixels kept applying the last run's mask across resets.
172
+ If( convOn.or( adaptiveOn ), () => {
171
173
 
172
- atomicAdd( counters.element( uint( COUNTER.CONVERGED_COUNT ) ), uint( 1 ) );
174
+ // Streak word: bit 31 = converged, bit 30 = hit geometry, bits 0-29 = freeze streak. Both flags
175
+ // are read by countConvergedDilated, which erodes them 3×3 before counting — a pointwise count is
176
+ // blind to undiscovered energy, which has zero variance and so passes any threshold.
177
+ const eligible = convOn.and( frame.greaterThanEqual( uint( adaptiveMinSamples ) ) ).and( converged );
178
+ const prevWord = streakBufferRW.element( pixelId ).toVar();
179
+ const oldStreak = prevWord.bitAnd( uint( 0x3FFFFFFF ) ).toVar();
180
+ const newStreak = oldStreak.toVar();
173
181
 
174
- } );
182
+ // Sticky within a run, else silhouette pixels flicker in and out of the subject denominator under
183
+ // AA jitter. Alpha is the only hit/miss signal valid here: the aux G-buffer is denoiser-gated and
184
+ // the ray flags do not survive to FinalWrite.
185
+ const hitGeometry = sampleColor.w.greaterThan( float( 0.5 ) );
186
+ const wasGeometry = prevWord.bitAnd( uint( 0x40000000 ) ).notEqual( uint( 0 ) );
187
+ const geometryBit = select(
188
+ frame.equal( uint( 0 ) ),
189
+ select( hitGeometry, uint( 0x40000000 ), uint( 0 ) ),
190
+ select( hitGeometry.or( wasGeometry ), uint( 0x40000000 ), uint( 0 ) )
191
+ ).toVar();
175
192
 
176
- // Maintain the freeze streak on the relErr-only predicate (NO absFloor — it bakes dim regions dark
177
- // when it permanently freezes a pixel). Monotonic: streak>=K freezes for the run, so global alpha stays exact.
178
- If( adaptiveOn, () => {
193
+ // relErr-only, no absolute floor — a floor bakes dim regions dark once it freezes them.
194
+ // Monotonic: streak>=K freezes for the run, so the global alpha stays exact.
195
+ If( adaptiveOn, () => {
179
196
 
180
- If( frame.equal( uint( 0 ) ), () => {
197
+ If( frame.equal( uint( 0 ) ), () => {
181
198
 
182
- // Frame 0 (post-reset/camera-move) clears the frozen state so each run re-freezes fresh.
183
- streakBufferRW.element( pixelId ).assign( uint( 0 ) );
199
+ newStreak.assign( uint( 0 ) );
184
200
 
185
- } );
201
+ } );
186
202
 
187
- If( wasFrozen.not().and( frame.greaterThan( uint( 0 ) ) ), () => {
203
+ If( wasFrozen.not().and( frame.greaterThan( uint( 0 ) ) ), () => {
188
204
 
189
- const freezeCandidate = frame.greaterThanEqual( uint( adaptiveMinSamples ) )
190
- .and( relErr.lessThan( pixelFreezeThreshold ) );
191
- const newStreak = select( freezeCandidate, streakBufferRW.element( pixelId ).add( uint( 1 ) ), uint( 0 ) ).toVar();
192
- streakBufferRW.element( pixelId ).assign( newStreak );
205
+ // meanLum>0: an all-zero pixel has zero variance and passes any bar, but freezing skips its
206
+ // rays so it could never then discover its energy (−39% mean luminance on Veach bidir). The
207
+ // whole-frame count deliberately has no such guard — true black must count as converged there.
208
+ const freezeCandidate = frame.greaterThanEqual( uint( adaptiveMinSamples ) )
209
+ .and( relErr.lessThan( pixelFreezeThreshold ) )
210
+ .and( meanLum.greaterThan( float( 0 ) ) );
211
+ newStreak.assign( select( freezeCandidate, oldStreak.add( uint( 1 ) ), uint( 0 ) ) );
212
+
213
+ } );
193
214
 
194
215
  } );
195
216
 
217
+ streakBufferRW.element( pixelId ).assign(
218
+ newStreak
219
+ .bitOr( select( eligible, uint( 0x80000000 ), uint( 0 ) ) )
220
+ .bitOr( geometryBit )
221
+ );
222
+
196
223
  } );
197
224
 
198
225
  } );
@@ -88,10 +88,10 @@ export function buildGenerateKernel( params ) {
88
88
  // pdf inits to 0 = prevBouncePdf (megakernel parity PathTracerCore.js:556). The bounce>0 env/emissive
89
89
  // MIS gate skips until an opaque scatter writes a real combinedPdf; free bounces preserve it.
90
90
  writeRayThroughputPdf( rayBufferRW, rayID, vec4( 1.0, 1.0, 1.0, 0.0 ).xyz, float( 0.0 ) );
91
- // Alpha inits to 1 in transparent-bg mode (megakernel parity: PathTracerCore.js:554). Shade zeroes
92
- // it only on env-escape-without-opaque; a ray that dies inside geometry (e.g. SSS walk termination)
93
- // keeps alpha 1 → solid. Non-transparent mode is inert (FinalWrite forces alpha 1).
94
- writeRayRadiance( rayBufferRW, rayID, vec4( vec3( 0.0 ), select( transparentBackground, float( 1.0 ), float( 0.0 ) ) ) );
91
+ // Alpha inits to 1 unconditionally (megakernel parity: PathTracerCore.js:554). Shade zeroes it only on
92
+ // env-escape-without-opaque, so the lane always carries coverage — which the convergence gate reads to
93
+ // tell subject from background. Output is unaffected: FinalWrite forces alpha 1 when transparent-bg is off.
94
+ writeRayRadiance( rayBufferRW, rayID, vec4( vec3( 0.0 ), float( 1.0 ) ) );
95
95
 
96
96
  If( auxOn, () => {
97
97
 
@@ -469,7 +469,10 @@ export function buildShadeKernel( params ) {
469
469
  // geometry (megakernel parity: PathTracerCore.js:784). A secondary bounce off an opaque
470
470
  // surface that escapes to env keeps alpha 1 (HAS_HIT_OPAQUE set), so glass-in-front-of-an-
471
471
  // object stays opaque while glass-in-front-of-sky exports see-through.
472
- If( transparentBackground.and( flags.bitAnd( uint( RAY_FLAG.HAS_HIT_OPAQUE ) ).equal( uint( 0 ) ) ), () => {
472
+ // Ungated (was transparentBackground-only) so the alpha lane always carries coverage: it is the
473
+ // only hit/miss signal valid at FinalWrite. The image is unchanged — FinalWrite forces alpha 1
474
+ // when transparent-bg is off.
475
+ If( flags.bitAnd( uint( RAY_FLAG.HAS_HIT_OPAQUE ) ).equal( uint( 0 ) ), () => {
473
476
 
474
477
  currentRadiance.w.assign( 0.0 );
475
478
 
package/src/index.js CHANGED
@@ -36,6 +36,7 @@ export {
36
36
  MEMORY_CONSTANTS,
37
37
  PRODUCTION_RENDER_CONFIG,
38
38
  INTERACTIVE_RENDER_CONFIG,
39
+ MAX_RESERVABLE_RENDER_SIZE,
39
40
  } from './EngineDefaults.js';
40
41
 
41
42
  // Settings & managers (for advanced consumers)
@@ -186,6 +186,10 @@ export class UniformManager {
186
186
  u( 'pixelFreezeThreshold', DEFAULT_STATE.pixelFreezeThreshold, 'float' );
187
187
  u( 'pixelFreezeStability', DEFAULT_STATE.pixelFreezeStability, 'int' );
188
188
 
189
+ // Convergence debug overlay: keeps FinalWrite's m2 estimate alive when adaptive sampling is off,
190
+ // so the Compositor overlay reads a live error field instead of a stale buffer.
191
+ ub( 'convergenceOverlay', DEFAULT_STATE.convergenceOverlay );
192
+
189
193
  // Accumulation
190
194
  ub( 'enableAccumulation', true );
191
195
  u( 'accumulationAlpha', 0.0, 'float' );