rayzee 5.6.0 → 5.7.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.
Files changed (37) hide show
  1. package/README.md +3 -0
  2. package/dist/assets/CDFWorker-BFQUr3By.js +2 -0
  3. package/dist/assets/CDFWorker-BFQUr3By.js.map +1 -0
  4. package/dist/rayzee.es.js +965 -903
  5. package/dist/rayzee.es.js.map +1 -1
  6. package/dist/rayzee.umd.js +49 -43
  7. package/dist/rayzee.umd.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/EngineEvents.js +3 -0
  10. package/src/Passes/AIUpscaler.js +22 -0
  11. package/src/Passes/OIDNDenoiser.js +93 -28
  12. package/src/PathTracerApp.js +17 -0
  13. package/src/Pipeline/RenderPipeline.js +3 -0
  14. package/src/Processor/EquirectHDRInfo.js +76 -16
  15. package/src/Processor/ShaderBuilder.js +1 -0
  16. package/src/Processor/Workers/CDFWorker.js +72 -11
  17. package/src/Stages/ASVGF.js +18 -0
  18. package/src/Stages/AdaptiveSampling.js +2 -0
  19. package/src/Stages/AutoExposure.js +2 -0
  20. package/src/Stages/BilateralFilter.js +2 -0
  21. package/src/Stages/Display.js +1 -0
  22. package/src/Stages/EdgeFilter.js +1 -0
  23. package/src/Stages/MotionVector.js +1 -0
  24. package/src/Stages/SSRC.js +6 -0
  25. package/src/Stages/Variance.js +3 -0
  26. package/src/TSL/Common.js +9 -0
  27. package/src/TSL/Environment.js +15 -6
  28. package/src/TSL/LightsIndirect.js +1 -1
  29. package/src/TSL/LightsSampling.js +5 -4
  30. package/src/TSL/PathTracer.js +2 -2
  31. package/src/TSL/PathTracerCore.js +6 -5
  32. package/src/managers/DenoisingManager.js +68 -14
  33. package/src/managers/EnvironmentManager.js +17 -0
  34. package/src/managers/TransformManager.js +9 -0
  35. package/src/managers/UniformManager.js +1 -0
  36. package/dist/assets/CDFWorker-2MoynL4F.js +0 -2
  37. package/dist/assets/CDFWorker-2MoynL4F.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "5.6.0",
3
+ "version": "5.7.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",
@@ -50,4 +50,7 @@ export const EngineEvents = {
50
50
  // Video rendering
51
51
  VIDEO_RENDER_PROGRESS: 'engine:videoRenderProgress',
52
52
  VIDEO_RENDER_COMPLETE: 'engine:videoRenderComplete',
53
+
54
+ // Lifecycle
55
+ DISPOSE: 'engine:dispose',
53
56
  };
@@ -104,6 +104,7 @@ export class AIUpscaler extends EventDispatcher {
104
104
  this._worker = null;
105
105
  this._currentModelUrl = null;
106
106
  this._tileId = 0;
107
+ this._pendingWorkerHandlers = new Set();
107
108
 
108
109
  // Alpha channel cache (bilinear-upscaled from source, applied per tile)
109
110
  this._upscaledAlpha = null;
@@ -315,6 +316,7 @@ export class AIUpscaler extends EventDispatcher {
315
316
 
316
317
  this._capturedSource = null;
317
318
  this._upscaledAlpha = null;
319
+ this._backupCanvas = null;
318
320
 
319
321
  // Only clean up if abort() hasn't already done it
320
322
  if ( this.state.isUpscaling ) {
@@ -334,6 +336,7 @@ export class AIUpscaler extends EventDispatcher {
334
336
  if ( ! this.state.isUpscaling ) return;
335
337
 
336
338
  this.state.abortController?.abort();
339
+ this._cleanupPendingWorkerHandlers();
337
340
 
338
341
  // Restore input visibility and canvas state
339
342
  this.input.style.opacity = '1';
@@ -610,6 +613,7 @@ export class AIUpscaler extends EventDispatcher {
610
613
 
611
614
  if ( e.data.id !== id ) return;
612
615
  this._worker.removeEventListener( 'message', handler );
616
+ this._pendingWorkerHandlers.delete( handler );
613
617
 
614
618
  if ( e.data.type === 'inferred' ) {
615
619
 
@@ -623,6 +627,7 @@ export class AIUpscaler extends EventDispatcher {
623
627
 
624
628
  };
625
629
 
630
+ this._pendingWorkerHandlers.add( handler );
626
631
  this._worker.addEventListener( 'message', handler );
627
632
  this._worker.postMessage(
628
633
  { type: 'infer', tileData, width, height, id },
@@ -858,9 +863,26 @@ export class AIUpscaler extends EventDispatcher {
858
863
 
859
864
  // ─── Disposal ─────────────────────────────────────────────────────────────
860
865
 
866
+ _cleanupPendingWorkerHandlers() {
867
+
868
+ if ( this._worker ) {
869
+
870
+ for ( const handler of this._pendingWorkerHandlers ) {
871
+
872
+ this._worker.removeEventListener( 'message', handler );
873
+
874
+ }
875
+
876
+ }
877
+
878
+ this._pendingWorkerHandlers.clear();
879
+
880
+ }
881
+
861
882
  async dispose() {
862
883
 
863
884
  this.abort();
885
+ this._cleanupPendingWorkerHandlers();
864
886
 
865
887
  if ( this._worker ) {
866
888
 
@@ -87,6 +87,7 @@ export class OIDNDenoiser extends EventDispatcher {
87
87
  // renderer.getArrayBufferAsync because the source is a raw GPUBuffer,
88
88
  // not a Three.js BufferAttribute.
89
89
  this._alphaReadbackBuffer = null;
90
+ this._alphaReadbackMapped = false;
90
91
 
91
92
  // Cached alpha channel from the input color buffer (OIDN discards alpha)
92
93
  this._cachedAlpha = null;
@@ -108,6 +109,9 @@ export class OIDNDenoiser extends EventDispatcher {
108
109
  abortController: null
109
110
  };
110
111
 
112
+ // Track in-flight tile staging buffers so they can be destroyed on abort
113
+ this._pendingStagingBuffers = new Set();
114
+
111
115
  this.currentTZAUrl = null;
112
116
  this.unet = null;
113
117
 
@@ -514,7 +518,22 @@ export class OIDNDenoiser extends EventDispatcher {
514
518
  this._gpuInputBuffers.albedo?.destroy();
515
519
  this._gpuInputBuffers.normal?.destroy();
516
520
  this._gpuInputPadBuffer?.destroy();
521
+
522
+ // Unmap before destroying if a mapAsync resolved but unmap hasn't been called yet.
523
+ // If mapAsync is still pending, destroy() will reject it — _cacheInputAlpha's
524
+ // catch handler covers that case.
525
+ if ( this._alphaReadbackMapped && this._alphaReadbackBuffer ) {
526
+
527
+ try {
528
+
529
+ this._alphaReadbackBuffer.unmap();
530
+
531
+ } catch { /* already unmapped or destroyed */ }
532
+
533
+ }
534
+
517
535
  this._alphaReadbackBuffer?.destroy();
536
+ this._alphaReadbackMapped = false;
518
537
  this._gpuInputBuffers = { color: null, albedo: null, normal: null };
519
538
  this._gpuInputPadBuffer = null;
520
539
  this._gpuInputPaddedRowBytes = 0;
@@ -550,7 +569,19 @@ export class OIDNDenoiser extends EventDispatcher {
550
569
  enc.copyBufferToBuffer( this._gpuInputBuffers.color, 0, staging, 0, byteSize );
551
570
  device.queue.submit( [ enc.finish() ] );
552
571
 
553
- await staging.mapAsync( GPUMapMode.READ );
572
+ this._alphaReadbackMapped = true;
573
+ try {
574
+
575
+ await staging.mapAsync( GPUMapMode.READ );
576
+
577
+ } catch {
578
+
579
+ // Buffer was destroyed while mapAsync was pending (resize or dispose)
580
+ this._alphaReadbackMapped = false;
581
+ return;
582
+
583
+ }
584
+
554
585
  const f32 = new Float32Array( staging.getMappedRange() );
555
586
 
556
587
  // Extract alpha channel as uint8 (pre-multiplied is not needed — alpha is 0 or 1)
@@ -563,6 +594,7 @@ export class OIDNDenoiser extends EventDispatcher {
563
594
  }
564
595
 
565
596
  staging.unmap();
597
+ this._alphaReadbackMapped = false;
566
598
 
567
599
  this._cachedAlpha = alpha;
568
600
  this._cachedAlphaWidth = width;
@@ -653,6 +685,8 @@ export class OIDNDenoiser extends EventDispatcher {
653
685
  usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
654
686
  } );
655
687
 
688
+ this._pendingStagingBuffers.add( staging );
689
+
656
690
  // Copy each tile row from its position in the full output buffer
657
691
  const enc = device.createCommandEncoder();
658
692
 
@@ -710,8 +744,15 @@ export class OIDNDenoiser extends EventDispatcher {
710
744
 
711
745
  staging.unmap();
712
746
  staging.destroy();
747
+ this._pendingStagingBuffers.delete( staging );
713
748
  this.ctx.putImageData( tileImageData, tile.x, tile.y );
714
749
 
750
+ } ).catch( () => {
751
+
752
+ // mapAsync rejected (abort or GPU lost) — destroy the buffer
753
+ staging.destroy();
754
+ this._pendingStagingBuffers.delete( staging );
755
+
715
756
  } );
716
757
 
717
758
  }
@@ -745,44 +786,50 @@ export class OIDNDenoiser extends EventDispatcher {
745
786
  usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
746
787
  } );
747
788
 
748
- // Queue a copy from the oidn output buffer (STORAGE|COPY_SRC) to staging
749
- const encoder = device.createCommandEncoder( { label: 'oidn-readback' } );
750
- encoder.copyBufferToBuffer( gpuBuffer, 0, stagingBuffer, 0, byteSize );
751
- device.queue.submit( [ encoder.finish() ] );
789
+ try {
790
+
791
+ // Queue a copy from the oidn output buffer (STORAGE|COPY_SRC) to staging
792
+ const encoder = device.createCommandEncoder( { label: 'oidn-readback' } );
793
+ encoder.copyBufferToBuffer( gpuBuffer, 0, stagingBuffer, 0, byteSize );
794
+ device.queue.submit( [ encoder.finish() ] );
795
+
796
+ await stagingBuffer.mapAsync( GPUMapMode.READ );
797
+ const float32 = new Float32Array( stagingBuffer.getMappedRange() );
798
+
799
+ const imageData = new ImageData( width, height );
800
+ const exposure = this.getExposure();
801
+ const saturation = this.getSaturation();
802
+ const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
803
+ const alpha = this._cachedAlpha;
752
804
 
753
- await stagingBuffer.mapAsync( GPUMapMode.READ );
754
- const float32 = new Float32Array( stagingBuffer.getMappedRange() );
805
+ for ( let i = 0, len = float32.length; i < len; i += 4 ) {
755
806
 
756
- const imageData = new ImageData( width, height );
757
- const exposure = this.getExposure();
758
- const saturation = this.getSaturation();
759
- const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
760
- const alpha = this._cachedAlpha;
807
+ // Exposure + saturation (pre-tonemap, matching Display)
808
+ let er = float32[ i ] * exposure, eg = float32[ i + 1 ] * exposure, eb = float32[ i + 2 ] * exposure;
809
+ if ( saturation !== 1.0 ) {
761
810
 
762
- for ( let i = 0, len = float32.length; i < len; i += 4 ) {
811
+ _tmOut[ 0 ] = er; _tmOut[ 1 ] = eg; _tmOut[ 2 ] = eb;
812
+ applySaturation( _tmOut, saturation );
813
+ er = _tmOut[ 0 ]; eg = _tmOut[ 1 ]; eb = _tmOut[ 2 ];
763
814
 
764
- // Exposure + saturation (pre-tonemap, matching Display)
765
- let er = float32[ i ] * exposure, eg = float32[ i + 1 ] * exposure, eb = float32[ i + 2 ] * exposure;
766
- if ( saturation !== 1.0 ) {
815
+ }
767
816
 
768
- _tmOut[ 0 ] = er; _tmOut[ 1 ] = eg; _tmOut[ 2 ] = eb;
769
- applySaturation( _tmOut, saturation );
770
- er = _tmOut[ 0 ]; eg = _tmOut[ 1 ]; eb = _tmOut[ 2 ];
817
+ tmFn( er, eg, eb, 1.0, _tmOut );
818
+ imageData.data[ i ] = _tmOut[ 0 ] ** SRGB_GAMMA * 255 | 0;
819
+ imageData.data[ i + 1 ] = _tmOut[ 1 ] ** SRGB_GAMMA * 255 | 0;
820
+ imageData.data[ i + 2 ] = _tmOut[ 2 ] ** SRGB_GAMMA * 255 | 0;
821
+ imageData.data[ i + 3 ] = alpha ? alpha[ i >> 2 ] : 255;
771
822
 
772
823
  }
773
824
 
774
- tmFn( er, eg, eb, 1.0, _tmOut );
775
- imageData.data[ i ] = _tmOut[ 0 ] ** SRGB_GAMMA * 255 | 0;
776
- imageData.data[ i + 1 ] = _tmOut[ 1 ] ** SRGB_GAMMA * 255 | 0;
777
- imageData.data[ i + 2 ] = _tmOut[ 2 ] ** SRGB_GAMMA * 255 | 0;
778
- imageData.data[ i + 3 ] = alpha ? alpha[ i >> 2 ] : 255;
825
+ stagingBuffer.unmap();
826
+ this.ctx.putImageData( imageData, 0, 0 );
779
827
 
780
- }
828
+ } finally {
781
829
 
782
- stagingBuffer.unmap();
783
- stagingBuffer.destroy();
830
+ stagingBuffer.destroy();
784
831
 
785
- this.ctx.putImageData( imageData, 0, 0 );
832
+ }
786
833
 
787
834
  }
788
835
 
@@ -793,6 +840,9 @@ export class OIDNDenoiser extends EventDispatcher {
793
840
  // Signal abort to current operation
794
841
  this.state.abortController?.abort();
795
842
 
843
+ // Destroy any in-flight tile staging buffers that mapAsync won't resolve
844
+ this._destroyPendingStagingBuffers();
845
+
796
846
  // Restore input visibility
797
847
  this.input.style.opacity = '1';
798
848
 
@@ -896,11 +946,26 @@ export class OIDNDenoiser extends EventDispatcher {
896
946
 
897
947
  }
898
948
 
949
+ _destroyPendingStagingBuffers() {
950
+
951
+ for ( const buf of this._pendingStagingBuffers ) {
952
+
953
+ buf.destroy();
954
+
955
+ }
956
+
957
+ this._pendingStagingBuffers.clear();
958
+
959
+ }
960
+
899
961
  dispose() {
900
962
 
901
963
  // Abort any ongoing operations
902
964
  this.abort();
903
965
 
966
+ // Destroy any remaining staging buffers
967
+ this._destroyPendingStagingBuffers();
968
+
904
969
  // Dispose resources
905
970
  this.unet?.dispose();
906
971
  this._destroyGPUInputBuffers();
@@ -36,6 +36,10 @@ import { OverlayManager } from './managers/OverlayManager.js';
36
36
  import { AnimationManager } from './managers/AnimationManager.js';
37
37
  import { TransformManager } from './managers/TransformManager.js';
38
38
 
39
+ // One app per canvas — auto-dispose a prior owner if the caller double-
40
+ // instantiates (StrictMode, HMR, etc.) so its rAF loop can't burn CPU.
41
+ const _appsByCanvas = new WeakMap();
42
+
39
43
 
40
44
  /**
41
45
  * WebGPU Path Tracer Application.
@@ -67,6 +71,18 @@ export class PathTracerApp extends EventDispatcher {
67
71
 
68
72
  super();
69
73
 
74
+ try {
75
+
76
+ _appsByCanvas.get( canvas )?.dispose();
77
+
78
+ } catch ( err ) {
79
+
80
+ console.warn( 'PathTracerApp: prior canvas owner dispose failed', err );
81
+
82
+ }
83
+
84
+ _appsByCanvas.set( canvas, this );
85
+
70
86
  this.canvas = canvas;
71
87
  this._autoResize = options.autoResize !== false;
72
88
  this._showStats = options.showStats !== false;
@@ -399,6 +415,7 @@ export class PathTracerApp extends EventDispatcher {
399
415
  if ( this._disposed ) return;
400
416
  this._disposed = true;
401
417
 
418
+ this.dispatchEvent( { type: EngineEvents.DISPOSE } );
402
419
  this.stopAnimation();
403
420
  clearTimeout( this._resizeDebounceTimer );
404
421
  this._resizeDebounceTimer = null;
@@ -120,6 +120,9 @@ export class RenderPipeline {
120
120
 
121
121
  }
122
122
 
123
+ // Prune stale stats entry for removed stage
124
+ this.stats.timings.delete( stage.name );
125
+
123
126
  return true;
124
127
 
125
128
  }
@@ -172,6 +172,7 @@ export class EquirectHDRInfo {
172
172
  this.marginalData = new Float32Array( [ 0, 1 ] );
173
173
  this.conditionalData = new Float32Array( [ 0, 0, 1, 1 ] );
174
174
  this.totalSum = 0;
175
+ this.compensationDelta = 0;
175
176
  this.width = 0;
176
177
  this.height = 0;
177
178
 
@@ -205,6 +206,7 @@ export class EquirectHDRInfo {
205
206
  this.marginalData = result.marginalData;
206
207
  this.conditionalData = result.conditionalData;
207
208
  this.totalSum = result.totalSum;
209
+ this.compensationDelta = result.compensationDelta;
208
210
  this.width = width;
209
211
  this.height = height;
210
212
 
@@ -263,6 +265,7 @@ export class EquirectHDRInfo {
263
265
  this.marginalData = result.marginalData;
264
266
  this.conditionalData = result.conditionalData;
265
267
  this.totalSum = result.totalSum;
268
+ this.compensationDelta = result.compensationDelta;
266
269
  this.width = result.width;
267
270
  this.height = result.height;
268
271
 
@@ -285,28 +288,87 @@ export class EquirectHDRInfo {
285
288
  */
286
289
  static computeCDF( floatData, width, height ) {
287
290
 
288
- const cdfConditional = new Float32Array( width * height );
291
+ const numPixels = width * height;
292
+
293
+ // Pass 1: compute per-pixel luminance weighted by sin(theta) and raw total sum.
294
+ // sin(theta) compensates for the equirectangular projection: pixels near the poles
295
+ // cover less solid angle, so weighting by sin(theta) makes the CDF proportional to
296
+ // luminance per solid angle rather than luminance per pixel.
297
+ const pixelWeights = new Float32Array( numPixels );
298
+ let rawTotalSum = 0.0;
299
+
300
+ for ( let y = 0; y < height; y ++ ) {
301
+
302
+ const sinTheta = Math.sin( Math.PI * ( y + 0.5 ) / height );
303
+
304
+ for ( let x = 0; x < width; x ++ ) {
305
+
306
+ const i = y * width + x;
307
+ const w = colorToLuminance(
308
+ floatData[ 4 * i ],
309
+ floatData[ 4 * i + 1 ],
310
+ floatData[ 4 * i + 2 ],
311
+ ) * sinTheta;
312
+ pixelWeights[ i ] = w;
313
+ rawTotalSum += w;
314
+
315
+ }
316
+
317
+ }
318
+
319
+ // MIS Compensation (Karlík et al. 2019, Eq. 14)
320
+ // With equal sample allocation (c_I = 0.5): delta = 2*(1 - 0.5)*meanWeight = meanWeight
321
+ // Subtracting mean sharpens the env map PDF, reducing oversampling
322
+ // of dim regions already well-covered by BSDF sampling.
323
+ const meanWeight = rawTotalSum / numPixels;
324
+ let compensatedTotalSum = 0.0;
325
+
326
+ for ( let i = 0; i < numPixels; i ++ ) {
327
+
328
+ pixelWeights[ i ] = Math.max( 0, pixelWeights[ i ] - meanWeight );
329
+ compensatedTotalSum += pixelWeights[ i ];
330
+
331
+ }
332
+
333
+ // Fall back to raw weights if compensation zeroed everything (uniform env map)
334
+ const useCompensation = compensatedTotalSum > 0;
335
+ const totalSumValue = useCompensation ? compensatedTotalSum : rawTotalSum;
336
+ const compensationDelta = useCompensation ? meanWeight : 0;
337
+
338
+ if ( ! useCompensation ) {
339
+
340
+ for ( let y = 0; y < height; y ++ ) {
341
+
342
+ const sinTheta = Math.sin( Math.PI * ( y + 0.5 ) / height );
343
+
344
+ for ( let x = 0; x < width; x ++ ) {
345
+
346
+ const i = y * width + x;
347
+ pixelWeights[ i ] = colorToLuminance(
348
+ floatData[ 4 * i ],
349
+ floatData[ 4 * i + 1 ],
350
+ floatData[ 4 * i + 2 ],
351
+ ) * sinTheta;
352
+
353
+ }
354
+
355
+ }
356
+
357
+ }
358
+
359
+ // Pass 2: build conditional and marginal CDFs from (compensated) weights
360
+ const cdfConditional = new Float32Array( numPixels );
289
361
  const cdfMarginal = new Float32Array( height );
290
362
 
291
- let totalSumValue = 0.0;
292
363
  let cumulativeWeightMarginal = 0.0;
293
364
 
294
- // Build conditional CDFs (per-row distribution)
295
365
  for ( let y = 0; y < height; y ++ ) {
296
366
 
297
367
  let cumulativeRowWeight = 0.0;
298
368
  for ( let x = 0; x < width; x ++ ) {
299
369
 
300
370
  const i = y * width + x;
301
- const r = floatData[ 4 * i ];
302
- const g = floatData[ 4 * i + 1 ];
303
- const b = floatData[ 4 * i + 2 ];
304
-
305
- // Weight by luminance
306
- const weight = colorToLuminance( r, g, b );
307
- cumulativeRowWeight += weight;
308
- totalSumValue += weight;
309
-
371
+ cumulativeRowWeight += pixelWeights[ i ];
310
372
  cdfConditional[ i ] = cumulativeRowWeight;
311
373
 
312
374
  }
@@ -323,8 +385,6 @@ export class EquirectHDRInfo {
323
385
  }
324
386
 
325
387
  cumulativeWeightMarginal += cumulativeRowWeight;
326
-
327
- // Build marginal CDF (row distribution)
328
388
  cdfMarginal[ y ] = cumulativeWeightMarginal;
329
389
 
330
390
  }
@@ -342,7 +402,7 @@ export class EquirectHDRInfo {
342
402
 
343
403
  // Create inverted CDF arrays (Float32 directly for storage buffers)
344
404
  const marginalData = new Float32Array( height );
345
- const conditionalData = new Float32Array( width * height );
405
+ const conditionalData = new Float32Array( numPixels );
346
406
 
347
407
  // Invert marginal CDF
348
408
  for ( let i = 0; i < height; i ++ ) {
@@ -369,7 +429,7 @@ export class EquirectHDRInfo {
369
429
 
370
430
  }
371
431
 
372
- return { marginalData, conditionalData, totalSum: totalSumValue };
432
+ return { marginalData, conditionalData, totalSum: totalSumValue, compensationDelta };
373
433
 
374
434
  }
375
435
 
@@ -365,6 +365,7 @@ export class ShaderBuilder {
365
365
  envMatrix: stage.environmentMatrix,
366
366
  envCDFBuffer: envCDFStorage,
367
367
  envTotalSum: stage.envTotalSum,
368
+ envCompensationDelta: stage.envCompensationDelta,
368
369
  envResolution: stage.envResolution,
369
370
  enableEnvironmentLight: stage.enableEnvironment,
370
371
  useEnvMapIS: stage.useEnvMapIS,
@@ -33,16 +33,19 @@ function binarySearchFindClosestIndexOf( array, targetValue, offset, count ) {
33
33
 
34
34
  function buildCDF( floatData, width, height ) {
35
35
 
36
- const cdfConditional = new Float32Array( width * height );
37
- const cdfMarginal = new Float32Array( height );
36
+ const numPixels = width * height;
38
37
 
39
- let totalSumValue = 0.0;
40
- let cumulativeWeightMarginal = 0.0;
38
+ // Pass 1: compute per-pixel luminance weighted by sin(theta) and raw total sum.
39
+ // sin(theta) compensates for the equirectangular projection: pixels near the poles
40
+ // cover less solid angle, so weighting by sin(theta) makes the CDF proportional to
41
+ // luminance per solid angle rather than luminance per pixel.
42
+ const pixelWeights = new Float32Array( numPixels );
43
+ let rawTotalSum = 0.0;
41
44
 
42
- // Build conditional CDFs (per-row distribution)
43
45
  for ( let y = 0; y < height; y ++ ) {
44
46
 
45
- let cumulativeRowWeight = 0.0;
47
+ const sinTheta = Math.sin( Math.PI * ( y + 0.5 ) / height );
48
+
46
49
  for ( let x = 0; x < width; x ++ ) {
47
50
 
48
51
  const i = y * width + x;
@@ -50,11 +53,68 @@ function buildCDF( floatData, width, height ) {
50
53
  const g = floatData[ 4 * i + 1 ];
51
54
  const b = floatData[ 4 * i + 2 ];
52
55
 
53
- // Luminance (Rec. 709)
54
- const weight = 0.2126 * r + 0.7152 * g + 0.0722 * b;
55
- cumulativeRowWeight += weight;
56
- totalSumValue += weight;
56
+ // Luminance (Rec. 709) weighted by solid angle factor
57
+ const w = ( 0.2126 * r + 0.7152 * g + 0.0722 * b ) * sinTheta;
58
+ pixelWeights[ i ] = w;
59
+ rawTotalSum += w;
60
+
61
+ }
62
+
63
+ }
64
+
65
+ // MIS Compensation (Karlík et al. 2019, Eq. 14)
66
+ // With equal sample allocation (c_I = 0.5): delta = 2*(1 - 0.5)*meanWeight = meanWeight
67
+ // Subtracting mean sharpens the env map PDF, reducing oversampling
68
+ // of dim regions already well-covered by BSDF sampling.
69
+ const meanWeight = rawTotalSum / numPixels;
70
+ let compensatedTotalSum = 0.0;
71
+
72
+ for ( let i = 0; i < numPixels; i ++ ) {
73
+
74
+ pixelWeights[ i ] = Math.max( 0, pixelWeights[ i ] - meanWeight );
75
+ compensatedTotalSum += pixelWeights[ i ];
76
+
77
+ }
78
+
79
+ // Fall back to raw weights if compensation zeroed everything (uniform env map)
80
+ const useCompensation = compensatedTotalSum > 0;
81
+ const totalSumValue = useCompensation ? compensatedTotalSum : rawTotalSum;
82
+ const compensationDelta = useCompensation ? meanWeight : 0;
83
+
84
+ if ( ! useCompensation ) {
85
+
86
+ // Restore raw sin-weighted luminance
87
+ for ( let y = 0; y < height; y ++ ) {
88
+
89
+ const sinTheta = Math.sin( Math.PI * ( y + 0.5 ) / height );
90
+
91
+ for ( let x = 0; x < width; x ++ ) {
57
92
 
93
+ const i = y * width + x;
94
+ const r = floatData[ 4 * i ];
95
+ const g = floatData[ 4 * i + 1 ];
96
+ const b = floatData[ 4 * i + 2 ];
97
+ pixelWeights[ i ] = ( 0.2126 * r + 0.7152 * g + 0.0722 * b ) * sinTheta;
98
+
99
+ }
100
+
101
+ }
102
+
103
+ }
104
+
105
+ // Pass 2: build conditional and marginal CDFs from (compensated) weights
106
+ const cdfConditional = new Float32Array( numPixels );
107
+ const cdfMarginal = new Float32Array( height );
108
+
109
+ let cumulativeWeightMarginal = 0.0;
110
+
111
+ for ( let y = 0; y < height; y ++ ) {
112
+
113
+ let cumulativeRowWeight = 0.0;
114
+ for ( let x = 0; x < width; x ++ ) {
115
+
116
+ const i = y * width + x;
117
+ cumulativeRowWeight += pixelWeights[ i ];
58
118
  cdfConditional[ i ] = cumulativeRowWeight;
59
119
 
60
120
  }
@@ -111,7 +171,7 @@ function buildCDF( floatData, width, height ) {
111
171
 
112
172
  }
113
173
 
114
- return { marginalData, conditionalData, totalSum: totalSumValue };
174
+ return { marginalData, conditionalData, totalSum: totalSumValue, compensationDelta };
115
175
 
116
176
  }
117
177
 
@@ -129,6 +189,7 @@ self.onmessage = function ( e ) {
129
189
  marginalData: result.marginalData,
130
190
  conditionalData: result.conditionalData,
131
191
  totalSum: result.totalSum,
192
+ compensationDelta: result.compensationDelta,
132
193
  width,
133
194
  height,
134
195
  },
@@ -832,6 +832,24 @@ export class ASVGF extends RenderStage {
832
832
  this._heatmapComputeNode?.dispose();
833
833
  this._heatmapStorageTex?.dispose();
834
834
  this.heatmapTarget?.dispose();
835
+
836
+ // Dispose input TextureNode objects
837
+ this._colorTexNode?.dispose();
838
+ this._normalDepthTexNode?.dispose();
839
+ this._motionTexNode?.dispose();
840
+ this._readTemporalTexNode?.dispose();
841
+ this._readPrevNDTexNode?.dispose();
842
+ this._gradientReadTexNode?.dispose();
843
+
844
+ // Dispose heatmap TextureNode objects
845
+ this._heatmapRawColorTexNode?.dispose();
846
+ this._heatmapColorTexNode?.dispose();
847
+ this._heatmapTemporalTexNode?.dispose();
848
+ this._heatmapNDTexNode?.dispose();
849
+ this._heatmapMotionTexNode?.dispose();
850
+ this._heatmapGradientTexNode?.dispose();
851
+
852
+ // dispose() also removes the DOM node
835
853
  this.heatmapHelper?.dispose();
836
854
 
837
855
  }
@@ -483,6 +483,7 @@ export class AdaptiveSampling extends RenderStage {
483
483
  this._heatmapStorageTex?.dispose();
484
484
  this._outputStorageTex?.dispose();
485
485
  this.heatmapTarget?.dispose();
486
+ this._varianceTexNode?.dispose();
486
487
  this.helper?.dispose();
487
488
 
488
489
  this._computeNode = null;
@@ -490,6 +491,7 @@ export class AdaptiveSampling extends RenderStage {
490
491
  this._heatmapStorageTex = null;
491
492
  this._outputStorageTex = null;
492
493
  this.heatmapTarget = null;
494
+ this._varianceTexNode = null;
493
495
  this.helper = null;
494
496
 
495
497
  }
@@ -698,6 +698,8 @@ export class AutoExposure extends RenderStage {
698
698
  this._reductionStorageTex?.dispose();
699
699
  this._reductionReadTarget?.dispose();
700
700
  this._readbackBuffer?.dispose();
701
+ this._inputTexNode?.dispose();
702
+ this._reductionReadTexNode?.dispose();
701
703
 
702
704
  }
703
705
 
@@ -321,6 +321,8 @@ export class BilateralFilter extends RenderStage {
321
321
  this._computeNodeB?.dispose();
322
322
  this._storageTexA?.dispose();
323
323
  this._storageTexB?.dispose();
324
+ this._readTexNode?.dispose();
325
+ this._normalDepthTexNode?.dispose();
324
326
 
325
327
  }
326
328
 
@@ -110,6 +110,7 @@ export class Display extends RenderStage {
110
110
 
111
111
  dispose() {
112
112
 
113
+ this._displayTexNode?.dispose();
113
114
  this.displayMaterial?.dispose();
114
115
  // QuadMesh extends Mesh — no dispose method; material already disposed.
115
116
  this.displayQuad = null;