rayzee 5.6.1 → 5.7.1

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.1",
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
  };
@@ -1,4 +1,4 @@
1
- import { WebGPURenderer, RectAreaLightNode } from 'three/webgpu';
1
+ import { WebGPURenderer, RectAreaLightNode, LinearSRGBColorSpace, SRGBColorSpace } from 'three/webgpu';
2
2
  import { texture as _tslTexture, cubeTexture as _tslCubeTexture } from 'three/tsl';
3
3
  import {
4
4
  ACESFilmicToneMapping, Scene, EventDispatcher, TimestampQuery
@@ -16,7 +16,7 @@ import { AdaptiveSampling } from './Stages/AdaptiveSampling.js';
16
16
  import { EdgeFilter } from './Stages/EdgeFilter.js';
17
17
  import { AutoExposure } from './Stages/AutoExposure.js';
18
18
  import { SSRC } from './Stages/SSRC.js';
19
- import { Display } from './Stages/Display.js';
19
+ import { Compositor } from './Stages/Compositor.js';
20
20
  import { RenderPipeline } from './Pipeline/RenderPipeline.js';
21
21
  import { CompletionTracker } from './Pipeline/CompletionTracker.js';
22
22
  import { ENGINE_DEFAULTS as DEFAULT_STATE, FINAL_RENDER_CONFIG, PREVIEW_RENDER_CONFIG } from './EngineDefaults.js';
@@ -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;
@@ -270,7 +286,7 @@ export class PathTracerApp extends EventDispatcher {
270
286
  if ( this._needsDisplayRefresh ) {
271
287
 
272
288
  this._needsDisplayRefresh = false;
273
- this.stages.display.render( this.pipeline.context );
289
+ this.stages.compositor.render( this.pipeline.context );
274
290
  this._renderHelperOverlay();
275
291
 
276
292
  }
@@ -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;
@@ -758,7 +775,7 @@ export class PathTracerApp extends EventDispatcher {
758
775
  // Apply all settings to stages in one shot
759
776
  timer.start( 'Apply settings' );
760
777
  this.settings.applyAll();
761
- this.stages.display.setTransparentBackground( this.settings.get( 'transparentBackground' ) );
778
+ this.stages.compositor.setTransparentBackground( this.settings.get( 'transparentBackground' ) );
762
779
  timer.end( 'Apply settings' );
763
780
 
764
781
  timer.print();
@@ -989,10 +1006,10 @@ export class PathTracerApp extends EventDispatcher {
989
1006
 
990
1007
  if ( usePostProcess ) return dm.denoiserCanvas;
991
1008
 
992
- // Re-render display stage so the WebGPU canvas has valid content
993
- if ( this.stages.display && this.pipeline?.context ) {
1009
+ // Re-render compositor stage so the WebGPU canvas has valid content
1010
+ if ( this.stages.compositor && this.pipeline?.context ) {
994
1011
 
995
- this.stages.display.render( this.pipeline.context );
1012
+ this.stages.compositor.render( this.pipeline.context );
996
1013
 
997
1014
  }
998
1015
 
@@ -1191,6 +1208,8 @@ export class PathTracerApp extends EventDispatcher {
1191
1208
 
1192
1209
  RectAreaLightNode.setLTC( RectAreaLightTexturesLib.init() );
1193
1210
 
1211
+ this.renderer.workingColorSpace = SRGBColorSpace;
1212
+ this.renderer.outputColorSpace = SRGBColorSpace;
1194
1213
  this.renderer.toneMapping = ACESFilmicToneMapping;
1195
1214
  this.renderer.toneMappingExposure = 1.0;
1196
1215
  this.renderer.setPixelRatio( 1.0 );
@@ -1244,7 +1263,7 @@ export class PathTracerApp extends EventDispatcher {
1244
1263
  this.pipeline.addStage( this.stages.adaptiveSampling );
1245
1264
  this.pipeline.addStage( this.stages.edgeFilter );
1246
1265
  this.pipeline.addStage( this.stages.autoExposure );
1247
- this.pipeline.addStage( this.stages.display );
1266
+ this.pipeline.addStage( this.stages.compositor );
1248
1267
 
1249
1268
  const initRenderW = this.canvas.clientWidth || 1;
1250
1269
  const initRenderH = this.canvas.clientHeight || 1;
@@ -1350,10 +1369,13 @@ export class PathTracerApp extends EventDispatcher {
1350
1369
  // Bind settings to pipeline stages
1351
1370
  this.settings.bind( {
1352
1371
  stages: this.stages,
1372
+ renderer: this.renderer,
1353
1373
  resetCallback: () => this.reset(),
1354
1374
  reconcileCompletion: () => this._reconcileCompletion(),
1355
1375
  } );
1356
1376
 
1377
+ this.renderer.toneMappingExposure = this.settings.get( 'exposure' ) ?? 1.0;
1378
+
1357
1379
  // Resize handling
1358
1380
  this.onResize();
1359
1381
  this.resizeHandler = () => this.onResize();
@@ -1457,8 +1479,7 @@ export class PathTracerApp extends EventDispatcher {
1457
1479
  this.stages.edgeFilter = new EdgeFilter( this.renderer, { enabled: false } );
1458
1480
  this.stages.autoExposure = new AutoExposure( this.renderer, { enabled: DEFAULT_STATE.autoExposure ?? false } );
1459
1481
 
1460
- this.stages.display = new Display( this.renderer, {
1461
- exposure: ( DEFAULT_STATE.autoExposure ) ? 1.0 : ( this.settings.get( 'exposure' ) ?? 1.0 ),
1482
+ this.stages.compositor = new Compositor( this.renderer, {
1462
1483
  saturation: this.settings.get( 'saturation' ) ?? DEFAULT_STATE.saturation,
1463
1484
  } );
1464
1485
 
@@ -1480,7 +1501,7 @@ export class PathTracerApp extends EventDispatcher {
1480
1501
  edgeFilter: this.stages.edgeFilter,
1481
1502
  ssrc: this.stages.ssrc,
1482
1503
  autoExposure: this.stages.autoExposure,
1483
- display: this.stages.display,
1504
+ compositor: this.stages.compositor,
1484
1505
  },
1485
1506
  pipeline: this.pipeline,
1486
1507
  getExposure: () => this.settings.get( 'exposure' ) ?? 1.0,
@@ -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
  },
@@ -104,16 +104,16 @@ export class RenderSettings extends EventDispatcher {
104
104
  * Wires internal references. Called by PathTracerApp after init().
105
105
  *
106
106
  * @param {Object} params
107
- * @param {Object} params.stages - Pipeline stages { pathTracer, display, autoExposure, ... }
107
+ * @param {Object} params.stages - Pipeline stages { pathTracer, compositor, autoExposure, ... }
108
108
  * @param {Function} params.resetCallback - Called to reset accumulation
109
109
  * @param {Function} [params.reconcileCompletion] - Called when completion limits change
110
110
  */
111
- bind( { stages, resetCallback, reconcileCompletion } ) {
111
+ bind( { stages, renderer, resetCallback, reconcileCompletion } ) {
112
112
 
113
113
  this._pathTracer = stages.pathTracer;
114
114
  this._resetCallback = resetCallback;
115
115
  this._delegates = {};
116
- this._handlers = this._buildHandlers( stages, reconcileCompletion );
116
+ this._handlers = this._buildHandlers( stages, renderer, reconcileCompletion );
117
117
 
118
118
  }
119
119
 
@@ -121,22 +121,24 @@ export class RenderSettings extends EventDispatcher {
121
121
  * Builds handler functions for multi-stage settings that can't
122
122
  * be routed with a simple uniform forward.
123
123
  */
124
- _buildHandlers( stages, reconcileCompletion ) {
124
+ _buildHandlers( stages, renderer, reconcileCompletion ) {
125
125
 
126
126
  return {
127
127
 
128
128
  handleTransparentBackground: ( value ) => {
129
129
 
130
130
  stages.pathTracer?.setUniform( 'transparentBackground', value );
131
- stages.display?.setTransparentBackground( value );
131
+ stages.compositor?.setTransparentBackground( value );
132
132
 
133
133
  },
134
134
 
135
135
  handleExposure: ( value ) => {
136
136
 
137
- if ( ! stages.autoExposure?.enabled ) {
137
+ // Three.js applies toneMappingExposure inside the tone-mapping branch,
138
+ // so this has no effect when renderer.toneMapping === NoToneMapping.
139
+ if ( ! stages.autoExposure?.enabled && renderer ) {
138
140
 
139
- stages.display?.setExposure( value );
141
+ renderer.toneMappingExposure = value;
140
142
 
141
143
  }
142
144
 
@@ -144,7 +146,7 @@ export class RenderSettings extends EventDispatcher {
144
146
 
145
147
  handleSaturation: ( value ) => {
146
148
 
147
- stages.display?.setSaturation( value );
149
+ stages.compositor?.setSaturation( value );
148
150
 
149
151
  },
150
152
 
@@ -0,0 +1,101 @@
1
+ import { vec4, vec3, uv, uniform, select, dot, mix } from 'three/tsl';
2
+ import { MeshBasicNodeMaterial, QuadMesh, TextureNode } from 'three/webgpu';
3
+ import { NoBlending } from 'three';
4
+ import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
5
+ import { REC709_LUMINANCE_COEFFICIENTS } from '../TSL/Common.js';
6
+
7
+ /**
8
+ * Compositor — Terminal pipeline stage.
9
+ *
10
+ * Selects the latest upstream texture via a priority fallback chain, applies
11
+ * a saturation grade, sets alpha, and hands the linear HDR result to the
12
+ * renderer's output pass (tone mapping + sRGB gamma happen there).
13
+ *
14
+ * Exposure is not applied here — `renderer.toneMappingExposure` owns it,
15
+ * and Three.js applies it inside the tone-mapping branch of the output pass
16
+ * (so it has no effect when `renderer.toneMapping === NoToneMapping`).
17
+ */
18
+ export class Compositor extends RenderStage {
19
+
20
+ constructor( renderer, options = {} ) {
21
+
22
+ super( 'Compositor', {
23
+ ...options,
24
+ executionMode: StageExecutionMode.ALWAYS
25
+ } );
26
+
27
+ this.renderer = renderer;
28
+
29
+ // 1.0 = neutral; >1 boosts to compensate for ACES/AgX desaturation.
30
+ this.saturation = uniform( options.saturation ?? 1.0 );
31
+
32
+ this._transparentBackground = uniform( 0, 'int' );
33
+
34
+ // TextureNode reused across frames — only `.value` mutates, shader doesn't recompile.
35
+ this._sourceTexNode = new TextureNode();
36
+
37
+ const texSample = this._sourceTexNode.sample( uv() );
38
+
39
+ const luma = dot( texSample.xyz, REC709_LUMINANCE_COEFFICIENTS );
40
+ const gradedColor = mix( vec3( luma ), texSample.xyz, this.saturation );
41
+
42
+ const outputAlpha = select( this._transparentBackground, texSample.w, 1.0 );
43
+
44
+ this.compositorMaterial = new MeshBasicNodeMaterial();
45
+ this.compositorMaterial.colorNode = vec4( gradedColor, outputAlpha );
46
+ this.compositorMaterial.blending = NoBlending;
47
+
48
+ this.compositorQuad = new QuadMesh( this.compositorMaterial );
49
+
50
+ }
51
+
52
+ /**
53
+ * Later stages in the chain take priority; `pathtracer:color` is the
54
+ * baseline fallback that is always present.
55
+ */
56
+ _resolveSourceTexture( context ) {
57
+
58
+ return context.getTexture( 'bloom:output' )
59
+ || context.getTexture( 'edgeFiltering:output' )
60
+ || context.getTexture( 'asvgf:output' )
61
+ || context.getTexture( 'ssrc:output' )
62
+ || context.getTexture( 'pathtracer:color' );
63
+
64
+ }
65
+
66
+ render( context ) {
67
+
68
+ if ( ! this.enabled ) return;
69
+
70
+ const sourceTexture = this._resolveSourceTexture( context );
71
+ if ( ! sourceTexture ) return;
72
+
73
+ this._sourceTexNode.value = sourceTexture;
74
+
75
+ this.renderer.setRenderTarget( null );
76
+ this.compositorQuad.render( this.renderer );
77
+
78
+ }
79
+
80
+ setSaturation( value ) {
81
+
82
+ this.saturation.value = value;
83
+
84
+ }
85
+
86
+ setTransparentBackground( enabled ) {
87
+
88
+ this._transparentBackground.value = enabled ? 1 : 0;
89
+
90
+ }
91
+
92
+ dispose() {
93
+
94
+ this._sourceTexNode?.dispose();
95
+ this.compositorMaterial?.dispose();
96
+ // QuadMesh extends Mesh — no dispose method; material already released above.
97
+ this.compositorQuad = null;
98
+
99
+ }
100
+
101
+ }
@@ -95,6 +95,14 @@ export class NormalDepth extends RenderStage {
95
95
  this._bvhStorageNode = null;
96
96
  this._matStorageNode = null;
97
97
 
98
+ // Last-seen attribute identities. PathTracer replaces these in-place
99
+ // across model load / BVH rebuild; the compute's bind group is locked
100
+ // to whatever buffer was bound at pipeline compile time, so we rebuild
101
+ // when any of them swaps to a new object.
102
+ this._lastTriAttr = null;
103
+ this._lastBvhAttr = null;
104
+ this._lastMatAttr = null;
105
+
98
106
  // Compute node — built once when storage buffers are ready
99
107
  this._computeNode = null;
100
108
  this._computeBuilt = false;
@@ -138,50 +146,59 @@ export class NormalDepth extends RenderStage {
138
146
  const pt = this.pathTracer;
139
147
  if ( ! pt ) return false;
140
148
 
141
- // Triangle storage
149
+ const matStorageAttr = pt.materialData.materialStorageAttr;
150
+
151
+ // Detect attribute identity swap (PathTracer.setTriangleData /
152
+ // setBVHData replace the attribute object on growth). The compute
153
+ // node's bind group is locked to the buffer bound at compile time —
154
+ // updating the storage node's .value alone leaves the GPU binding
155
+ // pointing at the now-discarded buffer, so every traversal misses.
156
+ const triSwapped = pt.triangleStorageAttr && pt.triangleStorageAttr !== this._lastTriAttr;
157
+ const bvhSwapped = pt.bvhStorageAttr && pt.bvhStorageAttr !== this._lastBvhAttr;
158
+ const matSwapped = matStorageAttr && matStorageAttr !== this._lastMatAttr;
159
+
160
+ if ( triSwapped || bvhSwapped || matSwapped ) {
161
+
162
+ // Drop compute + storage nodes so they get rebuilt against the
163
+ // current buffers. Cheap: this only happens on model load.
164
+ this._computeNode?.dispose?.();
165
+ this._computeNode = null;
166
+ this._computeBuilt = false;
167
+ this._triStorageNode = null;
168
+ this._bvhStorageNode = null;
169
+ this._matStorageNode = null;
170
+ this._dirty = true;
171
+
172
+ }
173
+
142
174
  if ( pt.triangleStorageAttr && ! this._triStorageNode ) {
143
175
 
144
176
  this._triStorageNode = storage(
145
177
  pt.triangleStorageAttr, 'vec4', pt.triangleStorageAttr.count
146
178
  ).toReadOnly();
147
179
 
148
- } else if ( pt.triangleStorageAttr && this._triStorageNode ) {
149
-
150
- // Data changed (new model loaded) — update in-place
151
- this._triStorageNode.value = pt.triangleStorageAttr;
152
- this._triStorageNode.bufferCount = pt.triangleStorageAttr.count;
153
-
154
180
  }
155
181
 
156
- // BVH storage
157
182
  if ( pt.bvhStorageAttr && ! this._bvhStorageNode ) {
158
183
 
159
184
  this._bvhStorageNode = storage(
160
185
  pt.bvhStorageAttr, 'vec4', pt.bvhStorageAttr.count
161
186
  ).toReadOnly();
162
187
 
163
- } else if ( pt.bvhStorageAttr && this._bvhStorageNode ) {
164
-
165
- this._bvhStorageNode.value = pt.bvhStorageAttr;
166
- this._bvhStorageNode.bufferCount = pt.bvhStorageAttr.count;
167
-
168
188
  }
169
189
 
170
- // Material storage
171
- const matStorageAttr = pt.materialData.materialStorageAttr;
172
190
  if ( matStorageAttr && ! this._matStorageNode ) {
173
191
 
174
192
  this._matStorageNode = storage(
175
193
  matStorageAttr, 'vec4', matStorageAttr.count
176
194
  ).toReadOnly();
177
195
 
178
- } else if ( matStorageAttr && this._matStorageNode ) {
179
-
180
- this._matStorageNode.value = matStorageAttr;
181
- this._matStorageNode.bufferCount = matStorageAttr.count;
182
-
183
196
  }
184
197
 
198
+ this._lastTriAttr = pt.triangleStorageAttr || this._lastTriAttr;
199
+ this._lastBvhAttr = pt.bvhStorageAttr || this._lastBvhAttr;
200
+ this._lastMatAttr = matStorageAttr || this._lastMatAttr;
201
+
185
202
  return !! ( this._triStorageNode && this._bvhStorageNode && this._matStorageNode );
186
203
 
187
204
  }