rayzee 7.22.6 → 7.22.8

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.6",
3
+ "version": "7.22.8",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -604,7 +604,8 @@ export const PRODUCTION_RENDER_CONFIG = {
604
604
  // so easy scenes finish well under it while hard GI scenes use the full budget.
605
605
  maxSamples: 150, bounces: 20, transmissiveBounces: 8, maxSubsurfaceSteps: 64,
606
606
  renderMode: 1, enableAlphaShadows: true,
607
- enableOIDN: true, oidnQuality: 'balance',
607
+ // 'high' is the only tier that reaches OIDN's _large weights (calb_cnrm); ~2x denoise cost.
608
+ enableOIDN: true, oidnQuality: 'high',
608
609
  interactionModeEnabled: false,
609
610
  // 0.94 against the eroded count ≈ the old raw-count 0.98; erosion holds the fraction a few points lower.
610
611
  useAdaptiveSampling: true,
@@ -52,20 +52,65 @@ import { getAssetConfig } from '../AssetConfig.js';
52
52
  const _tmOut = new Float32Array( 3 );
53
53
 
54
54
  const MODEL_CONFIG = {
55
- // clean-aux models — first-hit albedo/normal are deterministic per pixel
55
+ // No cleanAux flag in oidn-web, so the blob is it — and must match what setCleanAuxNormal
56
+ // feeds: `fast` point-samples the normal (noisy aux), balance/high accumulate it. calb_cnrm
57
+ // is also the only model with a `_large` blob.
56
58
  QUALITY_MODELS: {
57
59
  fast: 'rt_hdr_alb_nrm_small',
58
- balance: 'rt_hdr_alb_nrm',
60
+ balance: 'rt_hdr_calb_cnrm',
59
61
  high: 'rt_hdr_calb_cnrm_large'
60
62
  },
61
63
  DEFAULT_OPTIONS: {
62
64
  enableOIDN: true,
63
65
  oidnQuality: 'fast',
64
66
  debugGbufferMaps: true,
65
- tileSize: 256
67
+ // Tiles run at tileSize + 2*overlap (96px, 112 for _large), so small tiles pay that padding
68
+ // every tile: 256 puts 3.06x the pixels through the UNet at 512². Measured 3.64x faster.
69
+ tileSize: 512
66
70
  }
67
71
  };
68
72
 
73
+ // Stands in for OIDN's inputScale, which oidn-web only applies on its CPU path — the GPUBuffer
74
+ // path leaves the uniform at 1.0. Equivalent because oidn-web does PUForward(col*inputScale) in
75
+ // and PUInverse(..)/inputScale out, so pre-multiplying here and dividing it back out of the
76
+ // output exposure round-trips. Alpha is preserved for _cacheInputAlpha.
77
+ const COLOR_SCALE_WGSL = /* wgsl */`
78
+ @group(0) @binding(0) var<storage, read_write> col: array<vec4<f32>>;
79
+ @group(0) @binding(1) var<uniform> scale: f32;
80
+
81
+ @compute @workgroup_size(64)
82
+ fn main( @builtin(global_invocation_id) gid: vec3<u32>,
83
+ @builtin(num_workgroups) nwg: vec3<u32> ) {
84
+ let i = gid.y * nwg.x * 64u + gid.x;
85
+ if ( i >= arrayLength( &col ) ) { return; }
86
+ let c = col[ i ];
87
+ col[ i ] = vec4<f32>( c.xyz * scale, c.w );
88
+ }
89
+ `;
90
+
91
+ const SCALE_WG_SIZE = 64;
92
+ // 1D would exceed maxComputeWorkgroupsPerDimension (65535) past ~2048², so the dispatch is 2D.
93
+ const SCALE_MAX_WG_X = 32768;
94
+
95
+ // OIDN's autoexposure, mirroring oidn-web's avgLogLum. Whole-frame, so it cannot seam per tile.
96
+ const OIDN_AUTOEXPOSURE_KEY = 0.18;
97
+
98
+ function oidnInputScale( f32, pixelCount ) {
99
+
100
+ let logSum = 0;
101
+ for ( let i = 0; i < pixelCount; i ++ ) {
102
+
103
+ const o = i * 4;
104
+ const lum = 0.212671 * f32[ o ] + 0.71516 * f32[ o + 1 ] + 0.072169 * f32[ o + 2 ];
105
+ logSum += Math.log2( Math.max( lum, 0 ) + 0.0001 );
106
+
107
+ }
108
+
109
+ const scale = OIDN_AUTOEXPOSURE_KEY / Math.pow( 2, logSum / pixelCount );
110
+ return Number.isFinite( scale ) && scale > 0 ? scale : 1.0;
111
+
112
+ }
113
+
69
114
  export class OIDNDenoiser extends EventDispatcher {
70
115
 
71
116
  constructor( output, renderer, scene, camera, options = {} ) {
@@ -110,6 +155,11 @@ export class OIDNDenoiser extends EventDispatcher {
110
155
  // order guarantees the overwrites are serialized).
111
156
  this._gpuInputPadBuffer = null;
112
157
  this._gpuInputPaddedRowBytes = 0;
158
+ // 1.0 until the first readback.
159
+ this._colorScalePipeline = null;
160
+ this._colorScaleBindGroup = null;
161
+ this._colorScaleUniform = null;
162
+ this._oidnInputScale = 1.0;
113
163
  // Pooled MAP_READ staging buffer for _cacheInputAlpha. Only allocated
114
164
  // when transparent-background readback is used, destroyed on resolution
115
165
  // change or dispose. Same spirit as r184's ReadbackBuffer — we can't use
@@ -130,6 +180,8 @@ export class OIDNDenoiser extends EventDispatcher {
130
180
  this.quality = this.config.oidnQuality;
131
181
  this.debugGbufferMaps = this.config.debugGbufferMaps;
132
182
  this.tileSize = this.config.tileSize;
183
+ // tileSize actually baked into the live UNet, so a runtime change forces a rebuild.
184
+ this._activeTileSize = 0;
133
185
 
134
186
  // State management
135
187
  this.state = {
@@ -212,8 +264,8 @@ export class OIDNDenoiser extends EventDispatcher {
212
264
  this.state.isLoading = true;
213
265
  const tzaUrl = this._generateTzaUrl();
214
266
 
215
- // Skip setup if URL hasn't changed
216
- if ( this.currentTZAUrl === tzaUrl && this.unet ) {
267
+ // maxTileSize is a constructor arg, so comparing the URL alone left tileSize changes inert.
268
+ if ( this.currentTZAUrl === tzaUrl && this._activeTileSize === this.tileSize && this.unet ) {
217
269
 
218
270
  this.state.isLoading = false;
219
271
  return;
@@ -251,6 +303,7 @@ export class OIDNDenoiser extends EventDispatcher {
251
303
  } );
252
304
 
253
305
  this.currentTZAUrl = tzaUrl;
306
+ this._activeTileSize = this.tileSize;
254
307
  this.dispatchEvent( { type: 'loaded' } );
255
308
  log.debug( 'UNet weights loaded:', tzaUrl );
256
309
 
@@ -477,21 +530,18 @@ export class OIDNDenoiser extends EventDispatcher {
477
530
 
478
531
  copyTex( textures.color, this._gpuInputBuffers.color );
479
532
  copyTex( textures.albedo, this._gpuInputBuffers.albedo );
533
+ // The normal stays [0,1]-encoded. This contradicts OIDN's API ("must be in the [-1,1]
534
+ // range") but is right for this port: OIDN's own getNormal (cpu_input_process.isph) does
535
+ // `value*0.5+0.5` before the network, and oidn-web omits that remap. Decoding to [-1,1]
536
+ // measured 0.878 -> 2.439 denoise ratio at 64 spp. Do not "fix" it.
480
537
  copyTex( textures.normal, this._gpuInputBuffers.normal );
481
538
 
482
539
  device.queue.submit( [ encoder.finish() ] );
483
540
 
484
- // Cache alpha channel from input color buffer when transparent background is enabled.
485
- // OIDN only processes RGB — the alpha channel is lost, so we read it before denoising.
486
- if ( this.getTransparentBackground() ) {
487
-
488
- await this._cacheInputAlpha( device, width, height );
489
-
490
- } else {
491
-
492
- this._cachedAlpha = null;
493
-
494
- }
541
+ // One readback serves the autoexposure scale and the alpha OIDN discards. Must precede the scale.
542
+ await this._readbackColor( device, width, height );
543
+ if ( globalThis.__OIDN_NO_INPUT_SCALE ) this._oidnInputScale = 1.0;
544
+ this._applyColorScale( device, width * height );
495
545
 
496
546
  // Draw the current noisy frame as the base — denoised tiles paint on top progressively
497
547
  this.ctx.drawImage( this.input, 0, 0, width, height );
@@ -571,6 +621,10 @@ export class OIDNDenoiser extends EventDispatcher {
571
621
 
572
622
  this._alphaReadbackBuffer?.destroy();
573
623
  this._alphaReadbackMapped = false;
624
+ // Bind group holds the destroyed buffer; the pipeline is device-scoped and survives.
625
+ this._colorScaleBindGroup = null;
626
+ this._colorScaleUniform?.destroy();
627
+ this._colorScaleUniform = null;
574
628
  this._gpuInputBuffers = { color: null, albedo: null, normal: null };
575
629
  this._gpuInputPadBuffer = null;
576
630
  this._gpuInputPaddedRowBytes = 0;
@@ -580,10 +634,10 @@ export class OIDNDenoiser extends EventDispatcher {
580
634
  }
581
635
 
582
636
  /**
583
- * Reads the alpha channel from the input color GPU buffer and caches it as a Uint8Array.
584
- * Called before OIDN denoising when transparent background is enabled.
637
+ * Reads back the colour buffer for the autoexposure scale, plus alpha when transparent
638
+ * background is on (OIDN discards alpha).
585
639
  */
586
- async _cacheInputAlpha( device, width, height ) {
640
+ async _readbackColor( device, width, height ) {
587
641
 
588
642
  const byteSize = width * height * 16; // rgba32float, tightly packed
589
643
 
@@ -620,13 +674,20 @@ export class OIDNDenoiser extends EventDispatcher {
620
674
  }
621
675
 
622
676
  const f32 = new Float32Array( staging.getMappedRange() );
623
-
624
- // Extract alpha channel as uint8 (pre-multiplied is not needed — alpha is 0 or 1)
625
677
  const pixelCount = width * height;
626
- const alpha = new Uint8Array( pixelCount );
627
- for ( let i = 0; i < pixelCount; i ++ ) {
628
678
 
629
- alpha[ i ] = Math.min( Math.max( f32[ i * 4 + 3 ] * 255, 0 ), 255 ) | 0;
679
+ this._oidnInputScale = oidnInputScale( f32, pixelCount );
680
+
681
+ let alpha = null;
682
+ if ( this.getTransparentBackground() ) {
683
+
684
+ // Alpha as uint8; pre-multiplication is not needed — alpha is 0 or 1
685
+ alpha = new Uint8Array( pixelCount );
686
+ for ( let i = 0; i < pixelCount; i ++ ) {
687
+
688
+ alpha[ i ] = Math.min( Math.max( f32[ i * 4 + 3 ] * 255, 0 ), 255 ) | 0;
689
+
690
+ }
630
691
 
631
692
  }
632
693
 
@@ -638,6 +699,81 @@ export class OIDNDenoiser extends EventDispatcher {
638
699
 
639
700
  }
640
701
 
702
+ /** Display exposure with the input scale divided back out — output arrives pre-multiplied. */
703
+ _outputExposure() {
704
+
705
+ return this.getExposure() / this._oidnInputScale;
706
+
707
+ }
708
+
709
+ /** Pre-multiplies the colour buffer by the autoexposure scale. */
710
+ _applyColorScale( device, pixelCount ) {
711
+
712
+ const scale = this._oidnInputScale;
713
+ if ( ! this._ensureColorScalePipeline( device ) ) return;
714
+
715
+ device.queue.writeBuffer( this._colorScaleUniform, 0, new Float32Array( [ scale ] ) );
716
+
717
+ const wgTotal = Math.ceil( pixelCount / SCALE_WG_SIZE );
718
+ const wgX = Math.min( wgTotal, SCALE_MAX_WG_X );
719
+ const wgY = Math.ceil( wgTotal / wgX );
720
+
721
+ const encoder = device.createCommandEncoder( { label: 'oidn-color-scale' } );
722
+ const pass = encoder.beginComputePass( { label: 'oidn-color-scale' } );
723
+ pass.setPipeline( this._colorScalePipeline );
724
+ pass.setBindGroup( 0, this._colorScaleBindGroup );
725
+ pass.dispatchWorkgroups( wgX, wgY, 1 );
726
+ pass.end();
727
+ device.queue.submit( [ encoder.finish() ] );
728
+
729
+ }
730
+
731
+ /** @returns {GPUComputePipeline|null} */
732
+ _ensureColorScalePipeline( device ) {
733
+
734
+ const buffer = this._gpuInputBuffers.color;
735
+ if ( ! device || ! buffer ) return null;
736
+
737
+ if ( ! this._colorScalePipeline ) {
738
+
739
+ this._colorScalePipeline = device.createComputePipeline( {
740
+ label: 'oidn-color-scale',
741
+ layout: 'auto',
742
+ compute: {
743
+ module: device.createShaderModule( { label: 'oidn-color-scale', code: COLOR_SCALE_WGSL } ),
744
+ entryPoint: 'main'
745
+ }
746
+ } );
747
+
748
+ }
749
+
750
+ if ( ! this._colorScaleUniform ) {
751
+
752
+ this._colorScaleUniform = device.createBuffer( {
753
+ label: 'oidn-color-scale-uniform',
754
+ size: 16, // min uniform binding size
755
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
756
+ } );
757
+
758
+ }
759
+
760
+ if ( ! this._colorScaleBindGroup ) {
761
+
762
+ this._colorScaleBindGroup = device.createBindGroup( {
763
+ label: 'oidn-color-scale',
764
+ layout: this._colorScalePipeline.getBindGroupLayout( 0 ),
765
+ entries: [
766
+ { binding: 0, resource: { buffer } },
767
+ { binding: 1, resource: { buffer: this._colorScaleUniform } }
768
+ ]
769
+ } );
770
+
771
+ }
772
+
773
+ return this._colorScalePipeline;
774
+
775
+ }
776
+
641
777
  /**
642
778
  * Promise wrapper around tileExecute for the GPU path.
643
779
  * Outputs a GPUBuffer — copied to a staging buffer then converted to ImageData for the 2D canvas.
@@ -754,7 +890,7 @@ export class OIDNDenoiser extends EventDispatcher {
754
890
 
755
891
  const f32 = new Float32Array( staging.getMappedRange() );
756
892
  const tileImageData = new ImageData( clampedW, clampedH );
757
- const exposure = this.getExposure();
893
+ const exposure = this._outputExposure();
758
894
  const saturation = this.getSaturation();
759
895
  const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
760
896
  const alpha = this._cachedAlpha;
@@ -856,7 +992,7 @@ export class OIDNDenoiser extends EventDispatcher {
856
992
  const float32 = new Float32Array( stagingBuffer.getMappedRange() );
857
993
 
858
994
  const imageData = new ImageData( width, height );
859
- const exposure = this.getExposure();
995
+ const exposure = this._outputExposure();
860
996
  const saturation = this.getSaturation();
861
997
  const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
862
998
  const alpha = this._cachedAlpha;
@@ -962,6 +1098,7 @@ export class OIDNDenoiser extends EventDispatcher {
962
1098
  // TFJS tears down the cached backend's buffers/textures.
963
1099
  removeOidnTfjsBackend();
964
1100
  this._destroyGPUInputBuffers();
1101
+ this._colorScalePipeline = null;
965
1102
 
966
1103
  // Clean up DOM
967
1104
  if ( this.output?.parentNode ) {
@@ -442,16 +442,7 @@ export class PathTracerApp extends EventDispatcher {
442
442
 
443
443
  }
444
444
 
445
- // Abort post-processing and restore denoiser canvas resolution
446
- this.denoisingManager?.abort( this.canvas );
447
-
448
- if ( this.denoisingManager?.restoreBaseResolution() ) {
449
-
450
- const w = this.denoisingManager._lastRenderWidth;
451
- const h = this.denoisingManager._lastRenderHeight;
452
- this.dispatchEvent( { type: 'resolution_changed', width: w, height: h } );
453
-
454
- }
445
+ this._abortPostProcess();
455
446
 
456
447
  this.completion.reset();
457
448
  this.wake();
@@ -1524,6 +1515,37 @@ export class PathTracerApp extends EventDispatcher {
1524
1515
 
1525
1516
  }
1526
1517
 
1518
+ // Aborts any in-flight denoise/upscale and puts the denoiser canvas back at base resolution (the
1519
+ // upscaler leaves it enlarged), so the live canvas is what's on screen again.
1520
+ _abortPostProcess() {
1521
+
1522
+ this.denoisingManager?.abort( this.canvas );
1523
+
1524
+ if ( this.denoisingManager?.restoreBaseResolution() ) {
1525
+
1526
+ const w = this.denoisingManager._lastRenderWidth;
1527
+ const h = this.denoisingManager._lastRenderHeight;
1528
+ this.dispatchEvent( { type: 'resolution_changed', width: w, height: h } );
1529
+
1530
+ }
1531
+
1532
+ }
1533
+
1534
+ /**
1535
+ * Re-runs the post-process chain (OIDN → upscaler) against the accumulated image. The chain fires once,
1536
+ * on the frame the render completes, so a denoiser switched on afterwards would otherwise never run.
1537
+ */
1538
+ requestPostProcessRefresh() {
1539
+
1540
+ if ( ! this.stages.pathTracer?.isReady || this._deviceLost ) return;
1541
+
1542
+ this._abortPostProcess();
1543
+
1544
+ this.completion.renderCompleteDispatched = false;
1545
+ this.wake();
1546
+
1547
+ }
1548
+
1527
1549
  // ═══════════════════════════════════════════════════════════════
1528
1550
  // Deterministic / headless control
1529
1551
  // ═══════════════════════════════════════════════════════════════
@@ -2408,6 +2430,7 @@ export class PathTracerApp extends EventDispatcher {
2408
2430
 
2409
2431
  this.denoisingManager.setOverlayManager( this.overlayManager );
2410
2432
  this.denoisingManager.setResetCallback( () => this.reset() );
2433
+ this.denoisingManager.setPostProcessRefreshCallback( () => this.requestPostProcessRefresh() );
2411
2434
  this.denoisingManager.setSettings( this.settings );
2412
2435
 
2413
2436
  // Expose environment manager (lives on pathTracer stage)
@@ -68,6 +68,10 @@ export class PathTracer extends PathTracerStage {
68
68
  // of the point-sampled per-frame value that leaks noise. Off for fast/ASVGF (they want the bump normal).
69
69
  this._cleanAuxNormalEnabled = false;
70
70
  this._cleanAuxNormalUniform = uniform( 0, 'uint' );
71
+ // Aux MRT accumulates on an epoch of its own: enabling the denoiser at colour frame N must not leave
72
+ // the stale aux pinned by the colour's 1/(N+1) alpha.
73
+ this._auxSamples = 0;
74
+ this._auxSeedPending = false;
71
75
 
72
76
  // CPU sizes per-bounce kernels from last frame's survivor curve; kernels bound on ENTERING_COUNT so over-sizing is safe. (indirect dispatch not viable — three.js doesn't sync compute-written indirect buffers across submissions)
73
77
  this._useDynamicDispatch = true;
@@ -238,8 +242,17 @@ export class PathTracer extends PathTracerStage {
238
242
 
239
243
  if ( this.isComplete || this.frameCount >= this.completionThreshold || this._isConvergedComplete() ) {
240
244
 
241
- if ( ! this.isComplete ) this.isComplete = true;
242
- return;
245
+ // Denoiser switched on after the render finished — spend one more frame to fill the aux MRT.
246
+ if ( this._auxSeedPending ) {
247
+
248
+ this.isComplete = false;
249
+
250
+ } else {
251
+
252
+ if ( ! this.isComplete ) this.isComplete = true;
253
+ return;
254
+
255
+ }
243
256
 
244
257
  }
245
258
 
@@ -283,6 +296,7 @@ export class PathTracer extends PathTracerStage {
283
296
  }
284
297
 
285
298
  this._updateAccumulationUniforms( frameValue, renderMode );
299
+ const auxMixes = this._updateAuxAccumulationUniforms( frameValue );
286
300
  this.frame.value = frameValue;
287
301
  this.seedFrame.value = this._seedTick ++;
288
302
 
@@ -354,7 +368,8 @@ export class PathTracer extends PathTracerStage {
354
368
 
355
369
  // Tier-2 bounce-0 grid size from the stale settled active-pixel count (single-chunk only; else full).
356
370
  // Monotonic freeze ⇒ a stale count is a safe over-estimate; reset/camera-move → _curveSizingValid=false.
357
- const adaptiveFreeze = this.usePixelFreeze.value > 0;
371
+ // An aux seed frame must trace every pixel: Generate/Shade skip the G-buffer for frozen ones.
372
+ const adaptiveFreeze = this.usePixelFreeze.value > 0 && ! this._auxSeedPending;
358
373
  const activeBounce0 = ( singleChunk && adaptiveFreeze && this._curveSizingValid && this._lastActivePixelCount > 0 )
359
374
  ? this._lastActivePixelCount : maxRays;
360
375
 
@@ -488,6 +503,9 @@ export class PathTracer extends PathTracerStage {
488
503
  // Don't count interaction-mode (1-SPP feedback) frames toward completion (megakernel parity Stages/PathTracer.js:1240) — else a continuous orbit "completes" on noise.
489
504
  if ( ! this.cameraOptimizer?.isInInteractionMode() ) this.frameCount ++;
490
505
 
506
+ if ( this._auxGBufferEnabled ) this._auxSamples = auxMixes ? this._auxSamples + 1 : 1;
507
+ this._auxSeedPending = false;
508
+
491
509
  if ( originalMaxBounces !== null ) this.maxBounces.value = originalMaxBounces;
492
510
 
493
511
  this.performanceMonitor?.end();
@@ -518,6 +536,8 @@ export class PathTracer extends PathTracerStage {
518
536
  this._convergedFraction = 0;
519
537
  this._convergedGeometryFraction = 0;
520
538
  this._lastActivePixelCount = 0;
539
+ this._auxSamples = 0;
540
+ this._auxSeedPending = false;
521
541
 
522
542
  }
523
543
 
@@ -585,28 +605,62 @@ export class PathTracer extends PathTracerStage {
585
605
  }
586
606
 
587
607
  // Aux MRT (normalDepth/albedo) is needed only by the denoiser/OIDN; DenoisingManager calls this to
588
- // turn the wavefront's aux writes on/off. It's a live uniform, so toggling is just a value flip +
589
- // accumulation reset — no kernel rebuild, no UI freeze.
608
+ // turn the wavefront's aux writes on/off. It's a live uniform, so toggling is just a value flip —
609
+ // no kernel rebuild, no UI freeze.
590
610
  setAuxGBufferEnabled( enabled ) {
591
611
 
592
612
  enabled = !! enabled;
593
613
  if ( this._auxGBufferEnabled === enabled ) return;
594
614
  this._auxGBufferEnabled = enabled;
595
615
  this._auxGBufferUniform.value = enabled ? 1 : 0;
596
- this.reset();
616
+ this.restartAuxAccumulation();
597
617
 
598
618
  }
599
619
 
600
620
  // Clean-aux normal: when a clean-aux OIDN model is active (calb_cnrm/high, alb_nrm/balanced), FinalWrite
601
621
  // temporally accumulates + renormalizes the aux normal so the model isn't fed per-frame point-sampled
602
- // noise. DenoisingManager calls this on OIDN enable + quality change. Live uniform → value flip + reset.
622
+ // noise. DenoisingManager calls this on OIDN enable + quality change. The two modes write the normal in
623
+ // incompatible ways, so the aux buffer has to refill.
603
624
  setCleanAuxNormal( enabled ) {
604
625
 
605
626
  enabled = !! enabled;
606
627
  if ( this._cleanAuxNormalEnabled === enabled ) return;
607
628
  this._cleanAuxNormalEnabled = enabled;
608
629
  this._cleanAuxNormalUniform.value = enabled ? 1 : 0;
609
- this.reset();
630
+ this.restartAuxAccumulation();
631
+
632
+ }
633
+
634
+ /** Drops the aux MRT's history, leaving the colour's accumulation intact. */
635
+ restartAuxAccumulation() {
636
+
637
+ this._auxSamples = 0;
638
+ this._auxSeedPending = this._auxGBufferEnabled;
639
+
640
+ }
641
+
642
+ /**
643
+ * @returns {boolean} whether FinalWrite will MIX the aux this frame rather than seed it.
644
+ *
645
+ * colorAccumulates mirrors FinalWrite's outer gate: when that gate is false the aux is written verbatim
646
+ * regardless, which restarts the epoch. A mismatch costs aux quality only — the shader also reads
647
+ * hasPreviousAux, so it can never mix against textures this epoch has not written.
648
+ */
649
+ _updateAuxAccumulationUniforms( frameValue ) {
650
+
651
+ const colorAccumulates = this.accumulationEnabled
652
+ && ! this.cameraIsMoving.value
653
+ && frameValue > 0
654
+ && this.hasPreviousAccumulated.value > 0
655
+ && this.visMode.value !== 11;
656
+
657
+ const mixes = this._auxGBufferEnabled && colorAccumulates
658
+ && ! this._auxSeedPending && this._auxSamples > 0;
659
+
660
+ this.hasPreviousAux.value = mixes ? 1 : 0;
661
+ this.auxAccumulationAlpha.value = mixes ? 1 / ( this._auxSamples + 1 ) : 1.0;
662
+
663
+ return mixes;
610
664
 
611
665
  }
612
666
 
@@ -1483,6 +1537,8 @@ export class PathTracer extends PathTracerStage {
1483
1537
  enableAccumulation: this.enableAccumulation,
1484
1538
  hasPreviousAccumulated: this.hasPreviousAccumulated,
1485
1539
  accumulationAlpha: this.accumulationAlpha,
1540
+ hasPreviousAux: this.hasPreviousAux,
1541
+ auxAccumulationAlpha: this.auxAccumulationAlpha,
1486
1542
  cameraIsMoving: this.cameraIsMoving,
1487
1543
  transparentBackground: this.transparentBackground,
1488
1544
  prevAccumTexture: prevColor,
@@ -37,6 +37,8 @@ export function buildFinalWriteKernel( params ) {
37
37
  writeColorTex, writeNDTex, writeAlbedoTex,
38
38
  resolution, frame,
39
39
  enableAccumulation, hasPreviousAccumulated, accumulationAlpha, cameraIsMoving,
40
+ // Aux MRT accumulates on its own epoch; hasPreviousAux=0 → write it verbatim instead of mixing.
41
+ hasPreviousAux, auxAccumulationAlpha,
40
42
  transparentBackground,
41
43
  prevAccumTexture, prevAlbedoTexture, prevNormalDepthTexture,
42
44
  renderWidth,
@@ -113,10 +115,10 @@ export function buildFinalWriteKernel( params ) {
113
115
 
114
116
  // Frozen pixels pass prev colour through unchanged (stale sample); active pixels accumulate.
115
117
  finalColor.assign( select( wasFrozen, prevAccumSample.xyz, mix( prevAccumSample.xyz, sampleColor.xyz, accumulationAlpha ) ) );
116
- If( auxOn, () => {
118
+ If( auxOn.and( hasPreviousAux ), () => {
117
119
 
118
120
  // Albedo averages cleanly (it's a colour).
119
- finalAlbedo.assign( mix( texture( prevAlbedoTexture, prevUV, 0 ).xyz, finalAlbedo, accumulationAlpha ) );
121
+ finalAlbedo.assign( mix( texture( prevAlbedoTexture, prevUV, 0 ).xyz, finalAlbedo, auxAccumulationAlpha ) );
120
122
 
121
123
  // NORMAL: by default keep this frame's POINT-SAMPLED normal — it varies with the bump,
122
124
  // which fast/ASVGF want to preserve edge detail. But a CLEAN-AUX OIDN model (calb_cnrm/high,
@@ -129,7 +131,7 @@ export function buildFinalWriteKernel( params ) {
129
131
 
130
132
  const prevN = texture( prevNormalDepthTexture, prevUV, 0 ).xyz.mul( 2.0 ).sub( 1.0 );
131
133
  const curN = finalNormalDepth.xyz.mul( 2.0 ).sub( 1.0 ).toVar();
132
- const mixedN = mix( prevN, curN, accumulationAlpha ).toVar();
134
+ const mixedN = mix( prevN, curN, auxAccumulationAlpha ).toVar();
133
135
  const len = length( mixedN );
134
136
  const avgN = select( len.greaterThan( 1e-4 ), mixedN.div( len ), curN );
135
137
  finalNormalDepth.assign( vec4( avgN.mul( 0.5 ).add( 0.5 ), finalNormalDepth.w ) );
@@ -50,6 +50,9 @@ export class DenoisingManager extends EventDispatcher {
50
50
  this.denoiser = null;
51
51
  this.upscaler = null;
52
52
 
53
+ this._onReset = null;
54
+ this._onPostProcessRefresh = null;
55
+
53
56
  // Resolution tracking — used for canvas restoration on reset
54
57
  this._lastRenderWidth = 0;
55
58
  this._lastRenderHeight = 0;
@@ -547,6 +550,13 @@ export class DenoisingManager extends EventDispatcher {
547
550
 
548
551
  }
549
552
 
553
+ /** @param {Function} fn - () => void, re-runs the completion chain on the accumulated image */
554
+ setPostProcessRefreshCallback( fn ) {
555
+
556
+ this._onPostProcessRefresh = fn;
557
+
558
+ }
559
+
550
560
  /** @param {import('../RenderSettings.js').RenderSettings} settings */
551
561
  setSettings( settings ) {
552
562
 
@@ -620,6 +630,7 @@ export class DenoisingManager extends EventDispatcher {
620
630
  if ( this.denoiser ) this.denoiser.enabled = enabled;
621
631
  // OIDN reads the PathTracer aux MRT; re-sync so the wavefront produces it while OIDN is on.
622
632
  this._syncGBufferStages();
633
+ this._onPostProcessRefresh?.();
623
634
 
624
635
  }
625
636
 
@@ -629,6 +640,7 @@ export class DenoisingManager extends EventDispatcher {
629
640
  this.denoiser?.updateQuality( quality );
630
641
  // Clean-aux normal follows the model: balanced/high (clean-aux models) → accumulate; fast → keep bump.
631
642
  this._stages.pathTracer?.setCleanAuxNormal?.( !! this.denoiser?.enabled && quality !== 'fast' );
643
+ this._onPostProcessRefresh?.();
632
644
 
633
645
  }
634
646
 
@@ -196,6 +196,10 @@ export class UniformManager {
196
196
  ub( 'cameraIsMoving', false );
197
197
  ub( 'hasPreviousAccumulated', false );
198
198
 
199
+ // Aux MRT epoch, separate from the colour's so the denoiser can be enabled mid-render.
200
+ u( 'auxAccumulationAlpha', 1.0, 'float' );
201
+ ub( 'hasPreviousAux', false );
202
+
199
203
  // Environment
200
204
  u( 'environmentIntensity', DEFAULT_STATE.environmentIntensity, 'float' );
201
205
  u( 'backgroundIntensity', DEFAULT_STATE.backgroundIntensity, 'float' );