rayzee 5.6.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "5.6.1",
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
  };
@@ -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;
@@ -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
  },
package/src/TSL/Common.js CHANGED
@@ -99,6 +99,15 @@ export const powerHeuristic = wgslFn( `
99
99
  }
100
100
  ` );
101
101
 
102
+ // Balance heuristic — optimal for MIS-compensated env map sampling (Karlík et al. 2019)
103
+ export const balanceHeuristic = wgslFn( `
104
+ fn balanceHeuristic( pdf1: f32, pdf2: f32 ) -> f32 {
105
+
106
+ return pdf1 / max( pdf1 + pdf2, ${MIN_PDF} );
107
+
108
+ }
109
+ ` );
110
+
102
111
  // Bayer matrix 4x4 dithering — exact port of GLSL
103
112
  export const applyDithering = wgslFn( `
104
113
  fn applyDithering( color: vec3f, uv: vec2f, ditheringAmount: f32, resolution: vec2f ) -> vec3f {
@@ -1,4 +1,4 @@
1
- import { Fn, wgslFn, vec2, vec4, float, int, If, texture, sampler, dot, floor, fract, min, mix, clamp } from 'three/tsl';
1
+ import { Fn, wgslFn, vec2, vec4, float, int, If, texture, sampler, dot, sin, floor, fract, min, max, mix, clamp } from 'three/tsl';
2
2
 
3
3
  import { REC709_LUMINANCE_COEFFICIENTS } from './Common.js';
4
4
 
@@ -47,9 +47,9 @@ export const equirectDirectionPdf = /*@__PURE__*/ wgslFn( `
47
47
  `, [ equirectDirectionToUv ] );
48
48
 
49
49
  // Evaluate PDF for a given direction (for MIS)
50
- // Exact implementation from three-gpu-pathtracer
51
50
  // Returns vec4(color.rgb, pdf) since TSL cannot use inout params
52
- export const sampleEquirect = Fn( ( [ environment, direction, environmentMatrix, envTotalSum, envResolution ] ) => {
51
+ // Uses MIS-compensated PDF (Karlík et al. 2019): max(0, lum - delta) / compensatedTotalSum
52
+ export const sampleEquirect = Fn( ( [ environment, direction, environmentMatrix, envTotalSum, envCompensationDelta, envResolution ] ) => {
53
53
 
54
54
  const result = vec4( 0.0 ).toVar();
55
55
 
@@ -63,8 +63,13 @@ export const sampleEquirect = Fn( ( [ environment, direction, environmentMatrix,
63
63
  const uv = equirectDirectionToUv( { direction, environmentMatrix } ).toVar();
64
64
  const color = texture( environment, uv, 0 ).rgb.toVar();
65
65
 
66
+ // sin(theta) matches the CDF's solid-angle weighting (lum * sinTheta)
67
+ const sinTheta = sin( uv.y.mul( Math.PI ) ).toVar();
66
68
  const lum = dot( color, REC709_LUMINANCE_COEFFICIENTS ).toVar();
67
- const pdf = lum.div( envTotalSum ).toVar();
69
+ const weightedLum = lum.mul( sinTheta ).toVar();
70
+ // MIS Compensation: subtract delta to match the sharpened CDF
71
+ const compensatedWeight = max( float( 0.0 ), weightedLum.sub( envCompensationDelta ) ).toVar();
72
+ const pdf = compensatedWeight.div( envTotalSum ).toVar();
68
73
 
69
74
  const dirPdf = equirectDirectionPdf( { direction, environmentMatrix } ).toVar();
70
75
  const finalPdf = float( envResolution.x ).mul( float( envResolution.y ) ).mul( pdf ).mul( dirPdf ).toVar();
@@ -86,6 +91,7 @@ export const sampleEquirectProbability = Fn( ( [
86
91
  environmentMatrix,
87
92
  environmentIntensity,
88
93
  envTotalSum,
94
+ envCompensationDelta,
89
95
  envResolution,
90
96
  r,
91
97
  colorOutput
@@ -132,9 +138,12 @@ export const sampleEquirectProbability = Fn( ( [
132
138
  // Write color to output parameter (avoids redundant CDF texture lookups)
133
139
  colorOutput.assign( color );
134
140
 
135
- // Calculate PDF
141
+ // Calculate PDF — sin(theta) weighting + MIS Compensation (Karlík et al. 2019)
142
+ const sinTheta = sin( uv.y.mul( Math.PI ) ).toVar();
136
143
  const lum = dot( color.div( environmentIntensity ), REC709_LUMINANCE_COEFFICIENTS ).toVar();
137
- const pdf = lum.div( envTotalSum ).toVar();
144
+ const weightedLum = lum.mul( sinTheta ).toVar();
145
+ const compensatedWeight = max( float( 0.0 ), weightedLum.sub( envCompensationDelta ) ).toVar();
146
+ const pdf = compensatedWeight.div( envTotalSum ).toVar();
138
147
 
139
148
  const dirPdf = equirectDirectionPdf( { direction, environmentMatrix } ).toVar();
140
149
  const finalPdf = float( envResolution.x ).mul( float( envResolution.y ) ).mul( pdf ).mul( dirPdf ).toVar();
@@ -273,7 +273,7 @@ export const calculateIndirectLighting = Fn( ( [
273
273
  samplingInfo,
274
274
  // Environment resources
275
275
  envTexture, environmentIntensity, envMatrix,
276
- envTotalSum, envResolution,
276
+ envTotalSum, envCompensationDelta, envResolution,
277
277
  enableEnvironmentLight, useEnvMapIS,
278
278
  ] ) => {
279
279
 
@@ -84,6 +84,7 @@ import {
84
84
  EPSILON,
85
85
  MIN_PDF,
86
86
  powerHeuristic,
87
+ balanceHeuristic,
87
88
  } from './Common.js';
88
89
  import {
89
90
  sampleEquirectProbability,
@@ -935,7 +936,7 @@ export const calculateDirectLightingUnified = Fn( ( [
935
936
  // Environment resources
936
937
  envTexture, environmentIntensity, envMatrix,
937
938
  envCDFBuffer,
938
- envTotalSum, envResolution,
939
+ envTotalSum, envCompensationDelta, envResolution,
939
940
  enableEnvironmentLight,
940
941
  ] ) => {
941
942
 
@@ -1204,7 +1205,7 @@ export const calculateDirectLightingUnified = Fn( ( [
1204
1205
  // Sample direction + PDF + color from importance-sampled environment
1205
1206
  const envSampleResult = sampleEquirectProbability(
1206
1207
  envTexture, envCDFBuffer,
1207
- envMatrix, environmentIntensity, envTotalSum, envResolution, envRandom, envColor
1208
+ envMatrix, environmentIntensity, envTotalSum, envCompensationDelta, envResolution, envRandom, envColor
1208
1209
  ).toVar();
1209
1210
 
1210
1211
  const envDirection = envSampleResult.xyz.toVar();
@@ -1229,11 +1230,11 @@ export const calculateDirectLightingUnified = Fn( ( [
1229
1230
  const brdfValue = evaluateMaterialResponse( viewDir, envDirection, hitNormal, material );
1230
1231
  const bPdf = calculateMaterialPDF( viewDir, envDirection, hitNormal, material ).toVar();
1231
1232
 
1232
- // Standard two-strategy MIS: NEE (envPdf) vs implicit miss (materialPdf).
1233
+ // Balance heuristic for env MIS optimal for MIS-compensated PDFs (Karlík et al. 2019).
1233
1234
  // The implicit path uses material combinedPdf as prevBouncePdf at the miss check.
1234
1235
  const misW = select(
1235
1236
  bPdf.greaterThan( 0.0 ),
1236
- powerHeuristic( { pdf1: envPdf, pdf2: bPdf } ),
1237
+ balanceHeuristic( { pdf1: envPdf, pdf2: bPdf } ),
1237
1238
  float( 1.0 )
1238
1239
  ).toVar();
1239
1240
 
@@ -138,7 +138,7 @@ export const pathTracerMain = ( params ) => {
138
138
  spotLightsBuffer, numSpotLights,
139
139
  envTexture, environmentIntensity, envMatrix,
140
140
  envCDFBuffer,
141
- envTotalSum, envResolution,
141
+ envTotalSum, envCompensationDelta, envResolution,
142
142
  enableEnvironmentLight, useEnvMapIS,
143
143
  maxBounceCount, transmissiveBounces,
144
144
  showBackground, transparentBackground, backgroundIntensity,
@@ -285,7 +285,7 @@ export const pathTracerMain = ( params ) => {
285
285
  spotLightsBuffer, numSpotLights,
286
286
  envTexture, environmentIntensity, envMatrix,
287
287
  envCDFBuffer,
288
- envTotalSum, envResolution,
288
+ envTotalSum, envCompensationDelta, envResolution,
289
289
  enableEnvironmentLight, useEnvMapIS,
290
290
  maxBounceCount, transmissiveBounces,
291
291
  backgroundIntensity, showBackground, transparentBackground,
@@ -56,6 +56,7 @@ import {
56
56
  applySoftSuppressionRGB,
57
57
  getMaterial,
58
58
  powerHeuristic,
59
+ balanceHeuristic,
59
60
  } from './Common.js';
60
61
  import {
61
62
  DirectionSample,
@@ -582,7 +583,7 @@ export const Trace = Fn( ( [
582
583
  // Environment
583
584
  envTexture, environmentIntensity, envMatrix,
584
585
  envCDFBuffer,
585
- envTotalSum, envResolution,
586
+ envTotalSum, envCompensationDelta, envResolution,
586
587
  enableEnvironmentLight, useEnvMapIS,
587
588
  // Rendering parameters
588
589
  maxBounceCount, transmissiveBounces,
@@ -703,12 +704,12 @@ export const Trace = Fn( ( [
703
704
  If( prevBouncePdf.greaterThan( 0.0 ).and( enableEnvironmentLight ).and( useEnvMapIS ), () => {
704
705
 
705
706
  const envEval = sampleEquirect(
706
- envTexture, rayDirection, envMatrix, envTotalSum, envResolution,
707
+ envTexture, rayDirection, envMatrix, envTotalSum, envCompensationDelta, envResolution,
707
708
  );
708
709
  const envPdf = envEval.w.toVar();
709
710
  If( envPdf.greaterThan( 0.0 ), () => {
710
711
 
711
- envMisWeight.assign( powerHeuristic( { pdf1: prevBouncePdf, pdf2: envPdf } ) );
712
+ envMisWeight.assign( balanceHeuristic( { pdf1: prevBouncePdf, pdf2: envPdf } ) );
712
713
 
713
714
  } );
714
715
 
@@ -1001,7 +1002,7 @@ export const Trace = Fn( ( [
1001
1002
  materialBuffer,
1002
1003
  envTexture, environmentIntensity, envMatrix,
1003
1004
  envCDFBuffer,
1004
- envTotalSum, envResolution,
1005
+ envTotalSum, envCompensationDelta, envResolution,
1005
1006
  enableEnvironmentLight,
1006
1007
  );
1007
1008
 
@@ -1120,7 +1121,7 @@ export const Trace = Fn( ( [
1120
1121
  rngState,
1121
1122
  samplingInfo,
1122
1123
  envTexture, environmentIntensity, envMatrix,
1123
- envTotalSum, envResolution,
1124
+ envTotalSum, envCompensationDelta, envResolution,
1124
1125
  enableEnvironmentLight, useEnvMapIS,
1125
1126
  ) );
1126
1127
  throughput.mulAssign( indirectResult.throughput );
@@ -280,6 +280,7 @@ export class EnvironmentManager {
280
280
 
281
281
  this._updateCDFStorageBuffers();
282
282
  this.uniforms.set( 'envTotalSum', 0.0 );
283
+ this.uniforms.set( 'envCompensationDelta', 0.0 );
283
284
  this.uniforms.set( 'useEnvMapIS', 0 );
284
285
  return;
285
286
 
@@ -294,6 +295,7 @@ export class EnvironmentManager {
294
295
 
295
296
  this._updateCDFStorageBuffers();
296
297
  this.uniforms.set( 'envTotalSum', 0.0 );
298
+ this.uniforms.set( 'envCompensationDelta', 0.0 );
297
299
  this.uniforms.set( 'useEnvMapIS', 0 );
298
300
  return;
299
301
 
@@ -313,6 +315,7 @@ export class EnvironmentManager {
313
315
 
314
316
  this._updateCDFStorageBuffers();
315
317
  this.uniforms.set( 'envTotalSum', this.equirectHdrInfo.totalSum );
318
+ this.uniforms.set( 'envCompensationDelta', this.equirectHdrInfo.compensationDelta );
316
319
  this.uniforms.set( 'useEnvMapIS', 1 );
317
320
 
318
321
  const { width, height } = this.equirectHdrInfo;
@@ -329,6 +332,7 @@ export class EnvironmentManager {
329
332
  console.error( 'Error building environment CDF:', error );
330
333
  this.uniforms.set( 'useEnvMapIS', 0 );
331
334
  this.uniforms.set( 'envTotalSum', 0.0 );
335
+ this.uniforms.set( 'envCompensationDelta', 0.0 );
332
336
 
333
337
  }
334
338
 
@@ -375,6 +379,7 @@ export class EnvironmentManager {
375
379
 
376
380
  this._updateCDFStorageBuffers();
377
381
  this.uniforms.set( 'envTotalSum', 0.0 );
382
+ this.uniforms.set( 'envCompensationDelta', 0.0 );
378
383
  this.uniforms.set( 'useEnvMapIS', 0 );
379
384
 
380
385
  }
@@ -537,11 +542,23 @@ export class EnvironmentManager {
537
542
 
538
543
  this.proceduralSkyRenderer = null;
539
544
  this.simpleSkyRenderer = null;
545
+
546
+ this.envCDFStorageAttr?.dispose?.();
540
547
  this.envCDFStorageAttr = null;
541
548
  this.envCDFStorageNode = null;
549
+
550
+ // Dispose the HDRI environment texture unless it's the shared placeholder
551
+ // (the placeholder is handled separately just below).
552
+ if ( this.environmentTexture && this.environmentTexture !== this._envPlaceholder ) {
553
+
554
+ this.environmentTexture.dispose?.();
555
+
556
+ }
557
+
542
558
  this._envPlaceholder?.dispose();
543
559
  this._envPlaceholder = null;
544
560
  this.environmentTexture = null;
561
+ this._previousHDRI = null;
545
562
 
546
563
  }
547
564
 
@@ -437,6 +437,15 @@ export class TransformManager {
437
437
  this._normalCache = null;
438
438
  this._baselineComputed = false;
439
439
 
440
+ // Drop back-references to the owning app and shared resources so the
441
+ // PathTracerApp graph can be GC'd. Without this, _app pinned the entire
442
+ // engine (verified via heap snapshot retainer chain).
443
+ this._app = null;
444
+ this._orbitControls = null;
445
+ this._camera = null;
446
+ this._controls = null;
447
+ this._gizmoScene = null;
448
+
440
449
  }
441
450
 
442
451
  }
@@ -182,6 +182,7 @@ export class UniformManager {
182
182
  u( 'environmentMatrix', new Matrix4(), 'mat4' );
183
183
  ub( 'useEnvMapIS', DEFAULT_STATE.useImportanceSampledEnvironment );
184
184
  u( 'envTotalSum', 0.0, 'float' );
185
+ u( 'envCompensationDelta', 0.0, 'float' );
185
186
  u( 'envResolution', new Vector2( 1, 1 ), 'vec2' );
186
187
 
187
188
  // Sun parameters
@@ -1,2 +0,0 @@
1
- (function(){function e(e,t,n,r){let i=n,a=n+r-1;for(;i<a;){let n=i+a>>1;e[n]<t?i=n+1:a=n}return i-n}function t(t,n,r){let i=new Float32Array(n*r),a=new Float32Array(r),o=0,s=0;for(let e=0;e<r;e++){let r=0;for(let a=0;a<n;a++){let s=e*n+a,c=t[4*s],l=t[4*s+1],u=t[4*s+2],d=.2126*c+.7152*l+.0722*u;r+=d,o+=d,i[s]=r}if(r!==0)for(let t=e*n,a=e*n+n;t<a;t++)i[t]/=r;s+=r,a[e]=s}if(s!==0)for(let e=0,t=a.length;e<t;e++)a[e]/=s;let c=new Float32Array(r);for(let t=0;t<r;t++)c[t]=(e(a,(t+1)/r,0,r)+.5)/r;let l=new Float32Array(n*r);for(let t=0;t<r;t++)for(let r=0;r<n;r++){let a=t*n+r;l[a]=(e(i,(r+1)/n,t*n,n)+.5)/n}return{marginalData:c,conditionalData:l,totalSum:o}}self.onmessage=function(e){let{floatData:n,width:r,height:i}=e.data;try{let e=t(n,r,i);self.postMessage({marginalData:e.marginalData,conditionalData:e.conditionalData,totalSum:e.totalSum,width:r,height:i},[e.marginalData.buffer,e.conditionalData.buffer])}catch(e){self.postMessage({error:e.message})}}})();
2
- //# sourceMappingURL=CDFWorker-2MoynL4F.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"CDFWorker-2MoynL4F.js","names":[],"sources":["../../src/Processor/Workers/CDFWorker.js"],"sourcesContent":["/**\n * Web Worker for computing environment map CDF (Cumulative Distribution Function)\n * for importance sampling. Pure math — no Three.js dependencies.\n *\n * Input: { floatData: Float32Array, width, height }\n * Output: { marginalData: Float32Array, conditionalData: Float32Array, totalSum, width, height }\n */\n\nfunction binarySearchFindClosestIndexOf( array, targetValue, offset, count ) {\n\n\tlet lower = offset;\n\tlet upper = offset + count - 1;\n\n\twhile ( lower < upper ) {\n\n\t\tconst mid = ( lower + upper ) >> 1;\n\n\t\tif ( array[ mid ] < targetValue ) {\n\n\t\t\tlower = mid + 1;\n\n\t\t} else {\n\n\t\t\tupper = mid;\n\n\t\t}\n\n\t}\n\n\treturn lower - offset;\n\n}\n\nfunction buildCDF( floatData, width, height ) {\n\n\tconst cdfConditional = new Float32Array( width * height );\n\tconst cdfMarginal = new Float32Array( height );\n\n\tlet totalSumValue = 0.0;\n\tlet cumulativeWeightMarginal = 0.0;\n\n\t// Build conditional CDFs (per-row distribution)\n\tfor ( let y = 0; y < height; y ++ ) {\n\n\t\tlet cumulativeRowWeight = 0.0;\n\t\tfor ( let x = 0; x < width; x ++ ) {\n\n\t\t\tconst i = y * width + x;\n\t\t\tconst r = floatData[ 4 * i ];\n\t\t\tconst g = floatData[ 4 * i + 1 ];\n\t\t\tconst b = floatData[ 4 * i + 2 ];\n\n\t\t\t// Luminance (Rec. 709)\n\t\t\tconst weight = 0.2126 * r + 0.7152 * g + 0.0722 * b;\n\t\t\tcumulativeRowWeight += weight;\n\t\t\ttotalSumValue += weight;\n\n\t\t\tcdfConditional[ i ] = cumulativeRowWeight;\n\n\t\t}\n\n\t\t// Normalize row CDF to [0, 1]\n\t\tif ( cumulativeRowWeight !== 0 ) {\n\n\t\t\tfor ( let i = y * width, l = y * width + width; i < l; i ++ ) {\n\n\t\t\t\tcdfConditional[ i ] /= cumulativeRowWeight;\n\n\t\t\t}\n\n\t\t}\n\n\t\tcumulativeWeightMarginal += cumulativeRowWeight;\n\t\tcdfMarginal[ y ] = cumulativeWeightMarginal;\n\n\t}\n\n\t// Normalize marginal CDF to [0, 1]\n\tif ( cumulativeWeightMarginal !== 0 ) {\n\n\t\tfor ( let i = 0, l = cdfMarginal.length; i < l; i ++ ) {\n\n\t\t\tcdfMarginal[ i ] /= cumulativeWeightMarginal;\n\n\t\t}\n\n\t}\n\n\t// Invert marginal CDF\n\tconst marginalData = new Float32Array( height );\n\tfor ( let i = 0; i < height; i ++ ) {\n\n\t\tconst dist = ( i + 1 ) / height;\n\t\tconst row = binarySearchFindClosestIndexOf( cdfMarginal, dist, 0, height );\n\t\tmarginalData[ i ] = ( row + 0.5 ) / height;\n\n\t}\n\n\t// Invert conditional CDFs\n\tconst conditionalData = new Float32Array( width * height );\n\tfor ( let y = 0; y < height; y ++ ) {\n\n\t\tfor ( let x = 0; x < width; x ++ ) {\n\n\t\t\tconst i = y * width + x;\n\t\t\tconst dist = ( x + 1 ) / width;\n\t\t\tconst col = binarySearchFindClosestIndexOf( cdfConditional, dist, y * width, width );\n\t\t\tconditionalData[ i ] = ( col + 0.5 ) / width;\n\n\t\t}\n\n\t}\n\n\treturn { marginalData, conditionalData, totalSum: totalSumValue };\n\n}\n\nself.onmessage = function ( e ) {\n\n\tconst { floatData, width, height } = e.data;\n\n\ttry {\n\n\t\tconst result = buildCDF( floatData, width, height );\n\n\t\t// Transfer arrays back zero-copy\n\t\tself.postMessage(\n\t\t\t{\n\t\t\t\tmarginalData: result.marginalData,\n\t\t\t\tconditionalData: result.conditionalData,\n\t\t\t\ttotalSum: result.totalSum,\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t},\n\t\t\t[ result.marginalData.buffer, result.conditionalData.buffer ]\n\t\t);\n\n\t} catch ( error ) {\n\n\t\tself.postMessage( { error: error.message } );\n\n\t}\n\n};\n"],"mappings":"YAQA,SAAS,EAAgC,EAAO,EAAa,EAAQ,EAAQ,CAE5E,IAAI,EAAQ,EACR,EAAQ,EAAS,EAAQ,EAE7B,KAAQ,EAAQ,GAAQ,CAEvB,IAAM,EAAQ,EAAQ,GAAW,EAE5B,EAAO,GAAQ,EAEnB,EAAQ,EAAM,EAId,EAAQ,EAMV,OAAO,EAAQ,EAIhB,SAAS,EAAU,EAAW,EAAO,EAAS,CAE7C,IAAM,EAAiB,IAAI,aAAc,EAAQ,EAAQ,CACnD,EAAc,IAAI,aAAc,EAAQ,CAE1C,EAAgB,EAChB,EAA2B,EAG/B,IAAM,IAAI,EAAI,EAAG,EAAI,EAAQ,IAAO,CAEnC,IAAI,EAAsB,EAC1B,IAAM,IAAI,EAAI,EAAG,EAAI,EAAO,IAAO,CAElC,IAAM,EAAI,EAAI,EAAQ,EAChB,EAAI,EAAW,EAAI,GACnB,EAAI,EAAW,EAAI,EAAI,GACvB,EAAI,EAAW,EAAI,EAAI,GAGvB,EAAS,MAAS,EAAI,MAAS,EAAI,MAAS,EAClD,GAAuB,EACvB,GAAiB,EAEjB,EAAgB,GAAM,EAKvB,GAAK,IAAwB,EAE5B,IAAM,IAAI,EAAI,EAAI,EAAO,EAAI,EAAI,EAAQ,EAAO,EAAI,EAAG,IAEtD,EAAgB,IAAO,EAMzB,GAA4B,EAC5B,EAAa,GAAM,EAKpB,GAAK,IAA6B,EAEjC,IAAM,IAAI,EAAI,EAAG,EAAI,EAAY,OAAQ,EAAI,EAAG,IAE/C,EAAa,IAAO,EAOtB,IAAM,EAAe,IAAI,aAAc,EAAQ,CAC/C,IAAM,IAAI,EAAI,EAAG,EAAI,EAAQ,IAI5B,EAAc,IADF,EAAgC,GAD7B,EAAI,GAAM,EACsC,EAAG,EAAQ,CAC9C,IAAQ,EAKrC,IAAM,EAAkB,IAAI,aAAc,EAAQ,EAAQ,CAC1D,IAAM,IAAI,EAAI,EAAG,EAAI,EAAQ,IAE5B,IAAM,IAAI,EAAI,EAAG,EAAI,EAAO,IAAO,CAElC,IAAM,EAAI,EAAI,EAAQ,EAGtB,EAAiB,IADL,EAAgC,GAD7B,EAAI,GAAM,EACyC,EAAI,EAAO,EAAO,CACrD,IAAQ,EAMzC,MAAO,CAAE,eAAc,kBAAiB,SAAU,EAAe,CAIlE,KAAK,UAAY,SAAW,EAAI,CAE/B,GAAM,CAAE,YAAW,QAAO,UAAW,EAAE,KAEvC,GAAI,CAEH,IAAM,EAAS,EAAU,EAAW,EAAO,EAAQ,CAGnD,KAAK,YACJ,CACC,aAAc,EAAO,aACrB,gBAAiB,EAAO,gBACxB,SAAU,EAAO,SACjB,QACA,SACA,CACD,CAAE,EAAO,aAAa,OAAQ,EAAO,gBAAgB,OAAQ,CAC7D,OAEQ,EAAQ,CAEjB,KAAK,YAAa,CAAE,MAAO,EAAM,QAAS,CAAE"}