rayzee 7.8.0 → 7.9.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": "7.8.0",
3
+ "version": "7.9.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",
@@ -54,4 +54,5 @@ export const EngineEvents = {
54
54
 
55
55
  // Lifecycle
56
56
  DISPOSE: 'engine:dispose',
57
+ DEVICE_LOST: 'engine:deviceLost',
57
58
  };
@@ -142,6 +142,9 @@ export class OIDNDenoiser extends EventDispatcher {
142
142
 
143
143
  this.currentTZAUrl = null;
144
144
  this.unet = null;
145
+ // A start() requested while the UNet is still loading is deferred here and fired
146
+ // once loading finishes, instead of being silently dropped.
147
+ this._pendingStart = false;
145
148
 
146
149
  // Initialize asynchronously
147
150
  this._initialize().catch( error => {
@@ -257,6 +260,15 @@ export class OIDNDenoiser extends EventDispatcher {
257
260
 
258
261
  this.state.isLoading = false;
259
262
 
263
+ // Fire a start() that arrived mid-load. Guard on unet+enabled so a failed load
264
+ // or a disable during loading doesn't kick off a denoise.
265
+ if ( this._pendingStart && this.unet && this.enabled ) {
266
+
267
+ this._pendingStart = false;
268
+ this.start();
269
+
270
+ }
271
+
260
272
  }
261
273
 
262
274
  }
@@ -302,8 +314,17 @@ export class OIDNDenoiser extends EventDispatcher {
302
314
 
303
315
  async start() {
304
316
 
305
- if ( ! this.enabled || this.state.isDenoising || this.state.isLoading ) {
317
+ if ( ! this.enabled || this.state.isDenoising ) {
318
+
319
+ return false;
320
+
321
+ }
306
322
 
323
+ // UNet weights still loading — defer the start (fired from _setupUNetDenoiser's
324
+ // finally) rather than silently dropping this frame's denoise request.
325
+ if ( this.state.isLoading ) {
326
+
327
+ this._pendingStart = true;
307
328
  return false;
308
329
 
309
330
  }
@@ -870,6 +891,9 @@ export class OIDNDenoiser extends EventDispatcher {
870
891
 
871
892
  abort() {
872
893
 
894
+ // Cancel any start deferred during loading so a reset supersedes it.
895
+ this._pendingStart = false;
896
+
873
897
  if ( ! this.enabled || ! this.state.isDenoising ) return;
874
898
 
875
899
  // Signal abort to current operation
@@ -157,6 +157,7 @@ export class PathTracerApp extends EventDispatcher {
157
157
  // Tracked listeners for clean dispose()
158
158
  this._trackedListeners = [];
159
159
  this._disposed = false;
160
+ this._deviceLost = false;
160
161
 
161
162
  }
162
163
 
@@ -230,6 +231,9 @@ export class PathTracerApp extends EventDispatcher {
230
231
  */
231
232
  animate() {
232
233
 
234
+ // Device lost: stop the loop rather than rescheduling render() on a dead device.
235
+ if ( this._deviceLost ) return;
236
+
233
237
  this.animationManagerId = requestAnimationFrame( () => this.animate() );
234
238
 
235
239
  if ( this._loadingInProgress || this._sdf?.isProcessing ) {
@@ -368,9 +372,25 @@ export class PathTracerApp extends EventDispatcher {
368
372
 
369
373
  }
370
374
 
375
+ /**
376
+ * Handle GPU device loss: halt the render loop and notify hosts so they can surface a
377
+ * "renderer lost — reload" prompt. Full auto-recovery would require rebuilding every GPU
378
+ * resource, so this deliberately stops cleanly rather than attempting to re-init.
379
+ */
380
+ _handleDeviceLost( info ) {
381
+
382
+ if ( this._deviceLost ) return;
383
+ this._deviceLost = true;
384
+ console.error( `WebGPU device lost (${info?.reason || 'unknown'}): ${info?.message || ''}` );
385
+ this.stopAnimation();
386
+ this.dispatchEvent( { type: EngineEvents.DEVICE_LOST, reason: info?.reason, message: info?.message } );
387
+
388
+ }
389
+
371
390
  /** Wakes the animation loop if it was stopped due to idle. */
372
391
  wake() {
373
392
 
393
+ if ( this._deviceLost ) return;
374
394
  if ( ! this.animationManagerId && this.isInitialized && ! this._paused ) this.animate();
375
395
 
376
396
  }
@@ -1385,6 +1405,22 @@ export class PathTracerApp extends EventDispatcher {
1385
1405
 
1386
1406
  await this.renderer.init();
1387
1407
 
1408
+ // Detect GPU device loss (dGPU/iGPU switch, driver reset, TDR watchdog on heavy
1409
+ // compute). Without this the rAF loop keeps calling render() on a dead device,
1410
+ // spewing errors forever. reason 'destroyed' during dispose() is intentional teardown.
1411
+ const gpuDevice = this.renderer.backend?.device;
1412
+ if ( gpuDevice?.lost ) {
1413
+
1414
+ gpuDevice.lost.then( ( info ) => {
1415
+
1416
+ if ( this._disposed ) return;
1417
+ this._handleDeviceLost( info );
1418
+
1419
+ } );
1420
+ gpuDevice.onuncapturederror = ( event ) => console.error( 'WebGPU uncaptured error:', event.error );
1421
+
1422
+ }
1423
+
1388
1424
  RectAreaLightNode.setLTC( RectAreaLightTexturesLib.init() );
1389
1425
 
1390
1426
  this.renderer.outputColorSpace = SRGBColorSpace;
@@ -292,6 +292,9 @@ export class AssetLoader extends EventDispatcher {
292
292
 
293
293
  if ( ! this.loaderCache.texture ) this.loaderCache.texture = new TextureLoader();
294
294
  texture = await this.loaderCache.texture.loadAsync( url );
295
+ // LDR env maps (jpg/png/webp) are authored in sRGB; tag them so the backend
296
+ // decodes to linear. HDR/EXR are already linear and keep the loader's setting.
297
+ texture.colorSpace = SRGBColorSpace;
295
298
 
296
299
  }
297
300
 
@@ -45,8 +45,12 @@ export class GeometryExtractor {
45
45
 
46
46
  this.resetArrays();
47
47
 
48
- // Single-pass: allocate with initial capacity, grow dynamically as needed
49
- this._triangleCapacity = 1024;
48
+ // Pre-count the exact triangle total so the buffer is allocated once.
49
+ // Doubling-with-copy rounded up to the next power of two (wasting ~1GB on
50
+ // an 8.5M-tri scene) and held old+new during the copy (~3GB transient),
51
+ // which overflowed the browser's ArrayBuffer allocator. Exact avoids both.
52
+ const totalTriangles = this._countTriangles( object );
53
+ this._triangleCapacity = Math.max( 1024, totalTriangles );
50
54
  this.triangleData = new Float32Array( this._triangleCapacity * TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE );
51
55
  this.currentTriangleIndex = 0;
52
56
 
@@ -58,19 +62,41 @@ export class GeometryExtractor {
58
62
 
59
63
  }
60
64
 
61
- // Ensure triangleData has capacity for at least `needed` triangles
65
+ // Sum the exact triangle count over every mesh extract() will process
66
+ // (mirrors traverseObject/processMesh guards) so we can allocate once.
67
+ _countTriangles( object ) {
68
+
69
+ let count = 0;
70
+
71
+ if ( object.isMesh && object.geometry && object.material ) {
72
+
73
+ const indices = object.geometry.index;
74
+ const positions = object.geometry.attributes.position;
75
+ if ( indices ) count += Math.ceil( indices.count / 3 );
76
+ else if ( positions ) count += Math.ceil( positions.count / 3 );
77
+
78
+ }
79
+
80
+ if ( object.children ) {
81
+
82
+ for ( const child of object.children ) count += this._countTriangles( child );
83
+
84
+ }
85
+
86
+ return count;
87
+
88
+ }
89
+
90
+ // Safety net only: extract() pre-counts and allocates exactly, so this should
91
+ // not fire. Grow to exactly `needed` (never double) to avoid a memory spike.
62
92
  _ensureCapacity( needed ) {
63
93
 
64
94
  if ( needed <= this._triangleCapacity ) return;
65
95
 
66
- // Double until sufficient
67
- let newCapacity = this._triangleCapacity;
68
- while ( newCapacity < needed ) newCapacity *= 2;
69
-
70
- const newData = new Float32Array( newCapacity * TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE );
96
+ const newData = new Float32Array( needed * TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE );
71
97
  newData.set( this.triangleData );
72
98
  this.triangleData = newData;
73
- this._triangleCapacity = newCapacity;
99
+ this._triangleCapacity = needed;
74
100
 
75
101
  }
76
102
 
@@ -38,6 +38,16 @@ let _cap = 0;
38
38
 
39
39
  const soa = ( id, slot ) => ( slot === 0 ? id : id.add( slot * _cap ) );
40
40
 
41
+ // Free a compute storage attribute's GPU buffer. Nulling the JS reference alone leaks it —
42
+ // these attributes hang off no geometry, so nothing triggers the renderer's attribute cleanup.
43
+ // Deleting from the renderer's attribute map calls backend.destroyAttribute(). Guarded no-op
44
+ // when the attribute was never uploaded or no renderer was provided (e.g. unit tests).
45
+ export function freeStorageAttribute( renderer, attr ) {
46
+
47
+ if ( attr ) renderer?._attributes?.delete?.( attr );
48
+
49
+ }
50
+
41
51
  export class PackedRayBuffer {
42
52
 
43
53
  // Capacity maxRays would allocate (mirrors allocate()/resize()). 1.25× headroom, NO pow2 rounding —
@@ -49,9 +59,10 @@ export class PackedRayBuffer {
49
59
 
50
60
  }
51
61
 
52
- constructor( maxRays = 0 ) {
62
+ constructor( maxRays = 0, renderer = null ) {
53
63
 
54
64
  this.capacity = 0;
65
+ this._renderer = renderer;
55
66
  this._attrs = {};
56
67
 
57
68
  // Each: { rw: StorageBufferNode, ro: StorageBufferNode } over one shared GPU buffer.
@@ -118,6 +129,9 @@ export class PackedRayBuffer {
118
129
 
119
130
  dispose() {
120
131
 
132
+ freeStorageAttribute( this._renderer, this._attrs.ray );
133
+ freeStorageAttribute( this._renderer, this._attrs.rng );
134
+ freeStorageAttribute( this._renderer, this._attrs.hit );
121
135
  this._attrs = {};
122
136
  this.rayBuffer = null;
123
137
  this.rngBuffer = null;
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { storage } from 'three/tsl';
6
6
  import { StorageInstancedBufferAttribute } from 'three/webgpu';
7
+ import { freeStorageAttribute } from './PackedRayBuffer.js';
7
8
 
8
9
  /** Counter indices — must match ResetCounters kernel */
9
10
  export const COUNTER = {
@@ -33,8 +34,9 @@ export class QueueManager {
33
34
  /**
34
35
  * @param {number} maxRays - Maximum number of rays (typically width * height)
35
36
  */
36
- constructor( maxRays = 0 ) {
37
+ constructor( maxRays = 0, renderer = null ) {
37
38
 
39
+ this._renderer = renderer;
38
40
  this.capacity = 0;
39
41
  this.counters = null;
40
42
  // A/B alternate: one read by current bounce, other written by compaction
@@ -197,6 +199,16 @@ export class QueueManager {
197
199
 
198
200
  dispose() {
199
201
 
202
+ // Free the GPU buffers before dropping the node references (nulling alone leaks them).
203
+ freeStorageAttribute( this._renderer, this._countersAttr );
204
+ freeStorageAttribute( this._renderer, this._bounceCountsAttr );
205
+ freeStorageAttribute( this._renderer, this._attrA );
206
+ freeStorageAttribute( this._renderer, this._attrB );
207
+ freeStorageAttribute( this._renderer, this._sortAttr );
208
+ freeStorageAttribute( this._renderer, this._sortGlobalHistAttr );
209
+ this._countersAttr = this._bounceCountsAttr = null;
210
+ this._attrA = this._attrB = this._sortAttr = this._sortGlobalHistAttr = null;
211
+
200
212
  this.counters = null;
201
213
  this.activeIndices = null;
202
214
  this.activeIndicesRO = null;
@@ -422,7 +422,13 @@ export class SceneProcessor {
422
422
  enabled: this.bvhBuilder.enableReinsertionOptimization,
423
423
  batchSizeRatio: this.bvhBuilder.reinsertionBatchSizeRatio,
424
424
  maxIterations: this.bvhBuilder.reinsertionMaxIterations
425
- }
425
+ },
426
+ // BVH build params — previously omitted, so the pool path built at the
427
+ // BVHBuilder default (leaf 8) instead of the configured value.
428
+ maxLeafSize: this.bvhBuilder.maxLeafSize,
429
+ numBins: this.bvhBuilder.numBins,
430
+ maxBins: this.bvhBuilder.maxBins,
431
+ minBins: this.bvhBuilder.minBins,
426
432
  };
427
433
 
428
434
  const totalTasks = poolTasks.length + parallelTasks.length;
@@ -630,6 +636,10 @@ export class SceneProcessor {
630
636
  sharedReorderBuffer: null,
631
637
  treeletOptimization: treeletOpts,
632
638
  reinsertionOptimization: opts.reinsertionOptimization,
639
+ maxLeafSize: opts.maxLeafSize,
640
+ numBins: opts.numBins,
641
+ maxBins: opts.maxBins,
642
+ minBins: opts.minBins,
633
643
  }, [ meshTriData.buffer ] );
634
644
 
635
645
  };
@@ -537,9 +537,6 @@ export class TextureCreator {
537
537
  case 'worker-direct':
538
538
  result = await this.processWithWorkerDirect( normalized );
539
539
  break;
540
- case 'worker-chunked':
541
- result = await this.processWithWorkerChunked( normalized, strategy.chunkSize );
542
- break;
543
540
  case 'main-batch':
544
541
  result = await this.processOnMainThreadBatch( normalized, strategy.batchSize );
545
542
  break;
@@ -587,10 +584,10 @@ export class TextureCreator {
587
584
 
588
585
  if ( this.capabilities.workers && estimatedMemory > MEMORY_CONSTANTS.MAX_TEXTURE_MEMORY ) {
589
586
 
590
- return {
591
- method: 'worker-chunked',
592
- chunkSize: Math.max( 1, Math.floor( textures.length / 4 ) )
593
- };
587
+ // Very large texture set: stream on the main thread (GC-yielding, builds the
588
+ // full array correctly). The former 'worker-chunked' path combined its chunks
589
+ // by returning only the first, silently dropping the rest removed.
590
+ return { method: 'main-streaming' };
594
591
 
595
592
  } else if ( this.capabilities.workers && totalPixels > 2097152 ) {
596
593
 
@@ -710,8 +707,18 @@ export class TextureCreator {
710
707
 
711
708
  try {
712
709
 
713
- // Option 1: Direct ImageBitmap transfer (when supported)
714
- if ( typeof createImageBitmap !== 'undefined' && texture.image instanceof HTMLImageElement ) {
710
+ // Option 1: Direct ImageBitmap transfer (when supported). Covers HTMLImageElement
711
+ // AND ImageBitmap (what GLTFLoader's ImageBitmapLoader produces) and canvases the
712
+ // worker extracts pixels on its OffscreenCanvas, so this keeps the full-res
713
+ // getImageData off the main thread (Option 2 blocks it per texture).
714
+ const img = texture.image;
715
+ const canDirect = typeof createImageBitmap !== 'undefined' && (
716
+ img instanceof HTMLImageElement
717
+ || ( typeof ImageBitmap !== 'undefined' && img instanceof ImageBitmap )
718
+ || ( typeof HTMLCanvasElement !== 'undefined' && img instanceof HTMLCanvasElement )
719
+ || ( typeof OffscreenCanvas !== 'undefined' && img instanceof OffscreenCanvas )
720
+ );
721
+ if ( canDirect ) {
715
722
 
716
723
  const bitmap = await createImageBitmap( texture.image, {
717
724
  imageOrientation: flipY ? 'flipY' : 'none',
@@ -760,21 +767,6 @@ export class TextureCreator {
760
767
 
761
768
  }
762
769
 
763
- async processWithWorkerChunked( textures, chunkSize ) {
764
-
765
- const results = [];
766
- for ( let i = 0; i < textures.length; i += chunkSize ) {
767
-
768
- const chunk = textures.slice( i, i + chunkSize );
769
- const chunkResult = await this.processWithWorkerDirect( chunk );
770
- results.push( chunkResult );
771
-
772
- }
773
-
774
- return this.combineTextureResults( results );
775
-
776
- }
777
-
778
770
  async processOnMainThreadBatch( textures, batchSize ) {
779
771
 
780
772
  const validTextures = textures.filter( tex => tex?.image );
@@ -1451,14 +1443,6 @@ export class TextureCreator {
1451
1443
 
1452
1444
  }
1453
1445
 
1454
- combineTextureResults( results ) {
1455
-
1456
- // Combine multiple texture array results into one
1457
- // This is a simplified implementation - you may need more sophisticated merging
1458
- return results[ 0 ]; // For now, return first result
1459
-
1460
- }
1461
-
1462
1446
  dispose() {
1463
1447
 
1464
1448
  this.canvasPool.dispose();
@@ -184,8 +184,13 @@ function handleAssemble( data ) {
184
184
 
185
185
  function handleFullBuild( data ) {
186
186
 
187
- const { triangleData, triangleByteOffset, triangleByteLength, depth, reportProgress, treeletOptimization, reinsertionOptimization, sharedReorderBuffer } = data;
187
+ const { triangleData, triangleByteOffset, triangleByteLength, depth, reportProgress, treeletOptimization, reinsertionOptimization, sharedReorderBuffer, maxLeafSize, numBins, maxBins, minBins } = data;
188
188
  const builder = new BVHBuilder();
189
+ // Honor the build params forwarded from SceneProcessor (previously ignored → default leaf 8).
190
+ if ( maxLeafSize !== undefined ) builder.maxLeafSize = maxLeafSize;
191
+ if ( numBins !== undefined ) builder.numBins = numBins;
192
+ if ( maxBins !== undefined ) builder.maxBins = maxBins;
193
+ if ( minBins !== undefined ) builder.minBins = minBins;
189
194
 
190
195
  try {
191
196
 
@@ -105,10 +105,11 @@ export class EdgeFilter extends RenderStage {
105
105
  const center = textureLoad( inputTex, coord ).xyz;
106
106
  const centerLum = dot( center, REC709_LUMINANCE_COEFFICIENTS );
107
107
 
108
- // NormalDepth writes (0,0,0, 1e6) for miss rays. Decoded normal
108
+ // NormalDepth writes (0,0,0, 65504) for miss rays. Decoded normal
109
109
  // (-1,-1,-1) is non-unit and explodes pow(dot, phi); use the depth
110
- // sentinel as a 0/1 hit flag and zero out cross-kind weights.
111
- const MISS_THRESHOLD = 1e5;
110
+ // sentinel as a 0/1 hit flag and zero out cross-kind weights. Threshold
111
+ // sits below the 65504 sentinel (and above any real depth).
112
+ const MISS_THRESHOLD = 6e4;
112
113
  const centerND = textureLoad( ndTex, coord );
113
114
  const centerNormal = centerND.xyz.mul( 2.0 ).sub( 1.0 );
114
115
  const centerDepth = centerND.w;
@@ -172,8 +172,8 @@ export class MotionVector extends RenderStage {
172
172
 
173
173
  const result = vec4( 0.0, 0.0, linearDepth, 1.0 ).toVar();
174
174
 
175
- // Sky / background (depth >= 1e5) — no motion
176
- If( linearDepth.lessThan( float( 1e5 ) ), () => {
175
+ // Sky / background (depth >= 65504 sentinel) — no motion
176
+ If( linearDepth.lessThan( float( 6e4 ) ), () => {
177
177
 
178
178
  // Pixel coordinate → NDC
179
179
  // Negate Y to match PathTracer convention
@@ -275,8 +275,8 @@ export class MotionVector extends RenderStage {
275
275
 
276
276
  const result = vec4( 0.0, 0.0, 0.0, 0.0 ).toVar();
277
277
 
278
- // Skip first frame and sky
279
- If( isFirstFrameU.lessThan( 0.5 ).and( linearDepth.lessThan( float( 1e5 ) ) ), () => {
278
+ // Skip first frame and sky (depth >= 65504 sentinel)
279
+ If( isFirstFrameU.lessThan( 0.5 ).and( linearDepth.lessThan( float( 6e4 ) ) ), () => {
280
280
 
281
281
  // Pixel coordinate → NDC
282
282
  // Negate Y to match PathTracer convention
@@ -12,7 +12,8 @@ import { computeUVCache, processNormal, processBump, buildBucketTextureNodes, re
12
12
  /**
13
13
  * NormalDepth — primary-ray G-buffer for SVGF gates.
14
14
  *
15
- * RGB = geometric world normal · 0.5 + 0.5, A = linear ray distance (sky=1e6).
15
+ * RGB = geometric world normal · 0.5 + 0.5, A = linear ray distance (sky=65504, the
16
+ * max HalfFloat value — a finite sentinel so miss−miss depth diffs stay 0, not Inf−Inf=NaN).
16
17
  * Geometric (not shading) normals because shading normals carry sub-pixel
17
18
  * jitter that breaks the temporal gate's same-pixel-across-frames comparison.
18
19
  * The path tracer's MRT already carries shading normals for OIDN; this stage
@@ -237,7 +238,7 @@ export class NormalDepth extends RenderStage {
237
238
 
238
239
  const result = hit.didHit.select(
239
240
  vec4( encodedNormal, depth ),
240
- vec4( 0.0, 0.0, 0.0, float( 1e6 ) )
241
+ vec4( 0.0, 0.0, 0.0, float( 65504.0 ) )
241
242
  );
242
243
 
243
244
  textureStore(
@@ -262,7 +263,7 @@ export class NormalDepth extends RenderStage {
262
263
 
263
264
  const shadingResult = hit.didHit.select(
264
265
  vec4( shadingNormal.mul( 0.5 ).add( 0.5 ), depth ),
265
- vec4( 0.0, 0.0, 0.0, float( 1e6 ) )
266
+ vec4( 0.0, 0.0, 0.0, float( 65504.0 ) )
266
267
  );
267
268
 
268
269
  textureStore(
@@ -7,7 +7,7 @@
7
7
  import { uniform, texture, storage } from 'three/tsl';
8
8
  import { StorageInstancedBufferAttribute } from 'three/webgpu';
9
9
  import { PathTracerStage } from './PathTracerStage.js';
10
- import { PackedRayBuffer, GBUFFER_STRIDE } from '../Processor/PackedRayBuffer.js';
10
+ import { PackedRayBuffer, GBUFFER_STRIDE, freeStorageAttribute } from '../Processor/PackedRayBuffer.js';
11
11
  import { QueueManager, COUNTER } from '../Processor/QueueManager.js';
12
12
  import { VRAMTracker } from '../Processor/VRAMTracker.js';
13
13
  import { KernelManager } from '../Processor/KernelManager.js';
@@ -524,7 +524,7 @@ export class PathTracer extends PathTracerStage {
524
524
 
525
525
  if ( ! this._packedBuffers ) {
526
526
 
527
- this._packedBuffers = new PackedRayBuffer( maxRays );
527
+ this._packedBuffers = new PackedRayBuffer( maxRays, this.renderer );
528
528
 
529
529
  } else {
530
530
 
@@ -538,13 +538,14 @@ export class PathTracer extends PathTracerStage {
538
538
  // per-pixel, written by Generate/Shade bounce-0 and read only by FinalWrite.
539
539
  // 1.25× margin (same as the per-ray buffers) so it survives the in-place-resize range.
540
540
  const gBufferVec4s = PackedRayBuffer.requiredCapacity( maxRays ) * GBUFFER_STRIDE;
541
+ freeStorageAttribute( this.renderer, this._gBufferAttr ); // free the outgoing G-buffer on capacity growth
541
542
  this._gBufferAttr = new StorageInstancedBufferAttribute( new Uint32Array( gBufferVec4s * 4 ), 4 );
542
543
  const gBufferRW = storage( this._gBufferAttr, 'uvec4' );
543
544
  const gBufferRO = storage( this._gBufferAttr, 'uvec4' ).toReadOnly();
544
545
 
545
546
  if ( ! this._queueManager ) {
546
547
 
547
- this._queueManager = new QueueManager( this._packedBuffers.capacity );
548
+ this._queueManager = new QueueManager( this._packedBuffers.capacity, this.renderer );
548
549
 
549
550
  } else {
550
551
 
@@ -58,7 +58,11 @@ export function buildGenerateKernel( params ) {
58
58
  const baseSeed = getDecorrelatedSeed( { pixelCoord, rayIndex: int( 0 ), frame } ).toVar();
59
59
  const seed = pcgHash( { state: baseSeed } ).toVar();
60
60
 
61
- const stratifiedJitter = getStratifiedSample( pixelCoord, int( 0 ), int( 1 ), seed, resolution, frame ).toVar();
61
+ // Sample index 1 (not 0) so the AA sub-pixel jitter draws a DIFFERENT STBN cell
62
+ // than the first-bounce BSDF sample (ShadeKernel uses sampleIndex 0). Every bounce
63
+ // samples at index 0, so index 1 is collision-free — this decorrelates the sub-pixel
64
+ // position from the first scatter direction (they were reading the identical cell).
65
+ const stratifiedJitter = getStratifiedSample( pixelCoord, int( 1 ), int( 1 ), seed, resolution, frame ).toVar();
62
66
 
63
67
  const jitterScale = vec2( 2.0 ).div( resolution );
64
68
  const jitter = stratifiedJitter.sub( 0.5 ).mul( jitterScale );
@@ -290,6 +290,9 @@ export const calculateIndirectLighting = Fn( ( [
290
290
  const sampleDir = vec3( 0.0 ).toVar();
291
291
  const samplePdf = float( 0.0 ).toVar();
292
292
  const sampleBrdfValue = vec3( 0.0 ).toVar();
293
+ // Transmission lobe uses this weight instead of a reflection-BRDF eval, which is
294
+ // invalid for the below-surface refraction direction.
295
+ const transColorWeight = vec3( 1.0 ).toVar();
293
296
 
294
297
  // Execute selected strategy (chained If/ElseIf/Else for exclusive branches)
295
298
  // Environment removed — handled via deterministic NEE in direct lighting
@@ -312,13 +315,16 @@ export const calculateIndirectLighting = Fn( ( [
312
315
 
313
316
  // Strategy 3: Transmission
314
317
  const entering = dot( V, N ).greaterThan( 0.0 );
315
- // pathWavelength=0 — MIS evaluation reads only direction/PDF, no spectral tint
316
318
  const mtResult = MicrofacetTransmissionResult.wrap( sampleMicrofacetTransmission(
317
319
  V, N, material.ior, material.roughness, entering, material.dispersion, sampleRand, rngState, float( 0.0 )
318
320
  ).toVar() );
319
321
  sampleDir.assign( mtResult.direction );
320
322
  samplePdf.assign( mtResult.pdf );
321
- sampleBrdfValue.assign( evaluateMaterialResponse( V, sampleDir, N, material ) );
323
+ // evaluateMaterialResponse is reflection-only and returns a non-physical value for
324
+ // the below-surface refraction direction (its dots are floored to 0.001). Carry the
325
+ // sampler's spectral tint and apply an energy-consistent transmission throughput
326
+ // below (mirrors handleTransmission's material.color × colorWeight convention).
327
+ transColorWeight.assign( mtResult.colorWeight );
322
328
 
323
329
  } ).Else( () => {
324
330
 
@@ -329,10 +335,7 @@ export const calculateIndirectLighting = Fn( ( [
329
335
 
330
336
  } );
331
337
 
332
- // For transmission directions (below surface), use |cos| instead of max(cos, 0)
333
- const rawNoL = dot( N, sampleDir ).toVar();
334
- const NoL = max( rawNoL, 0.0 ).toVar();
335
- const absNoL = abs( rawNoL );
338
+ const NoL = max( dot( N, sampleDir ), 0.0 ).toVar();
336
339
 
337
340
  // Calculate combined PDF for MIS (material strategies only)
338
341
  const combinedPdf = float( 0.0 ).toVar();
@@ -380,11 +383,14 @@ export const calculateIndirectLighting = Fn( ( [
380
383
  // MIS weight calculation
381
384
  const misWeight = samplePdf.div( combinedPdf ).toVar();
382
385
 
383
- // Throughput calculation: use |cos| for transmission, max(cos,0) for reflection strategies
384
- const cosineWeight = select( selectedStrategy.equal( int( 3 ) ), absNoL, NoL );
386
+ // Reflection lobes: f·NoL·mis/pdf. Transmission lobe: the sampler's energy-consistent
387
+ // weight (material tint × dispersion colorWeight × mis) the reflection BRDF value is
388
+ // invalid below the surface, so it is not used for transmission.
389
+ const reflThroughput = sampleBrdfValue.mul( NoL ).mul( misWeight ).div( samplePdf );
390
+ const transThroughput = material.color.xyz.mul( transColorWeight ).mul( misWeight );
385
391
 
386
392
  r_direction.assign( sampleDir );
387
- r_throughput.assign( sampleBrdfValue.mul( cosineWeight ).mul( misWeight ).div( samplePdf ) );
393
+ r_throughput.assign( select( selectedStrategy.equal( int( 3 ) ), transThroughput, reflThroughput ) );
388
394
  r_misWeight.assign( misWeight );
389
395
  r_pdf.assign( samplePdf );
390
396
  r_combinedPdf.assign( combinedPdf );