rayzee 5.4.1 → 5.4.3

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.4.1",
3
+ "version": "5.4.3",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -1,4 +1,5 @@
1
1
  import { WebGPURenderer, RectAreaLightNode } from 'three/webgpu';
2
+ import { texture as _tslTexture, cubeTexture as _tslCubeTexture } from 'three/tsl';
2
3
  import {
3
4
  ACESFilmicToneMapping, Scene, EventDispatcher, TimestampQuery
4
5
  } from 'three';
@@ -400,22 +401,100 @@ export class PathTracerApp extends EventDispatcher {
400
401
 
401
402
  this.stopAnimation();
402
403
  clearTimeout( this._resizeDebounceTimer );
404
+ this._resizeDebounceTimer = null;
403
405
 
404
- // Remove all tracked listeners (app-owned subscriptions across managers/DOM)
405
406
  this._removeTrackedListeners();
406
-
407
407
  setStatusCallback( null );
408
408
 
409
+ this.interactionManager?.deselect?.();
410
+ this.transformManager?.detach?.();
411
+
409
412
  this.animationManager?.dispose();
410
413
  this.transformManager?.dispose();
411
414
  this.overlayManager?.dispose();
412
- this._sceneHelpers?.clear();
413
415
  this.lightManager?.dispose();
414
416
  this.denoisingManager?.dispose();
415
- this.pipeline?.dispose();
416
417
  this.interactionManager?.dispose();
417
418
  this.cameraManager?.dispose();
419
+
420
+ this.pipeline?.dispose();
421
+
422
+ // _sdf + assetLoader own the heaviest GPU allocations (material texture arrays,
423
+ // BVH/triangle buffers, loaded GLTF resources, BVH refit worker, loader caches).
424
+ // They are not referenced by the pipeline, so pipeline.dispose() does not reach them.
425
+ this._sdf?.dispose();
426
+ this._sdf = null;
427
+
428
+ this.assetLoader?.dispose();
429
+ this.assetLoader = null;
430
+
431
+ if ( this.meshScene ) {
432
+
433
+ this.meshScene.environment?.dispose();
434
+ this.meshScene.environment = null;
435
+
436
+ for ( const child of [ ...this.meshScene.children ] ) {
437
+
438
+ disposeObjectFromMemory( child );
439
+
440
+ }
441
+
442
+ this.meshScene.clear();
443
+ this.meshScene = null;
444
+
445
+ }
446
+
447
+ this._sceneHelpers?.clear();
448
+ this._sceneHelpers = null;
449
+
450
+ this.scene?.clear();
451
+ this.scene = null;
452
+
453
+ // Three.js 0.184 leaks (confirmed via heap-snapshot retainer analysis):
454
+ //
455
+ // 1) Renderer.dispose() does not remove the 'resize' listener it installs on
456
+ // _canvasTarget. The bound handler closes over the renderer, pinning the
457
+ // entire WebGPU graph (Backend, Nodes, Bindings, Pipelines, GPUDevice,
458
+ // every TSL node) alive indefinitely.
459
+ // See three/src/renderers/common/Renderer.js:292 (attach) and
460
+ // :2503 (dispose — missing removal).
461
+ //
462
+ // 2) Textures manager (one per renderer) registers a per-texture 'dispose'
463
+ // listener that closes over `this = Textures` — which transitively
464
+ // captures backend → renderer. These listeners are removed only when
465
+ // the texture itself is destroyed. For module-level singletons like
466
+ // EmptyTexture (new Texture in TextureNode.js) and its CubeTexture
467
+ // counterpart, the texture is never destroyed, so every renderer ever
468
+ // created leaks through the singleton's listener array.
469
+ //
470
+ // Both workarounds are safe when only a single PathTracerApp is active at a
471
+ // time. If you run multiple in parallel, reset listeners only on the renderer
472
+ // being disposed (not the shared singletons).
473
+ if ( this.renderer?._canvasTarget && this.renderer._onCanvasTargetResize ) {
474
+
475
+ this.renderer._canvasTarget.removeEventListener(
476
+ 'resize',
477
+ this.renderer._onCanvasTargetResize
478
+ );
479
+
480
+ }
481
+
482
+ try {
483
+
484
+ const emptyTex = _tslTexture().value;
485
+ const emptyCube = _tslCubeTexture().value;
486
+ if ( emptyTex?._listeners?.dispose ) emptyTex._listeners.dispose.length = 0;
487
+ if ( emptyCube?._listeners?.dispose ) emptyCube._listeners.dispose.length = 0;
488
+
489
+ } catch ( err ) {
490
+
491
+ console.warn( 'PathTracerApp: failed to clear TSL texture singleton listeners', err );
492
+
493
+ }
494
+
418
495
  this.renderer?.dispose();
496
+ if ( this.renderer ) this.renderer._canvasTarget = null;
497
+ this.renderer = null;
419
498
 
420
499
  if ( this._stats ) {
421
500
 
@@ -424,6 +503,7 @@ export class PathTracerApp extends EventDispatcher {
424
503
 
425
504
  }
426
505
 
506
+ this.stages = {};
427
507
  this.isInitialized = false;
428
508
 
429
509
  }
@@ -460,13 +540,9 @@ export class PathTracerApp extends EventDispatcher {
460
540
  this.interactionManager?.deselect();
461
541
  this.transformManager?.detach?.();
462
542
 
463
- // Dispose the loaded model (geometries, materials, textures)
464
- if ( this.assetLoader?.targetModel ) {
465
-
466
- disposeObjectFromMemory( this.assetLoader.targetModel );
467
- this.assetLoader.targetModel = null;
468
-
469
- }
543
+ // Release the loaded model. If loaded via loadObject3D(), the caller owns it —
544
+ // we only detach it from the scene. Otherwise dispose geometries/materials/textures.
545
+ this.assetLoader?.releaseTargetModel();
470
546
 
471
547
  // Clear lights in the WebGPU light scene
472
548
  this.lightManager?.clearLights?.();
@@ -1111,8 +1187,6 @@ export class PathTracerApp extends EventDispatcher {
1111
1187
  }
1112
1188
  } );
1113
1189
 
1114
- window.renderer = this.renderer; // For debugging
1115
-
1116
1190
  await this.renderer.init();
1117
1191
 
1118
1192
  RectAreaLightNode.setLTC( RectAreaLightTexturesLib.init() );
@@ -37,6 +37,7 @@ export class AssetLoader extends EventDispatcher {
37
37
  this.camera = camera;
38
38
  this.controls = controls;
39
39
  this.targetModel = null;
40
+ this._externalModel = null;
40
41
  this.floorPlane = null;
41
42
  this.sceneScale = 1.0;
42
43
  this.loaderCache = {};
@@ -46,6 +47,31 @@ export class AssetLoader extends EventDispatcher {
46
47
 
47
48
  }
48
49
 
50
+ /**
51
+ * Releases the current targetModel. If it was supplied by the caller via
52
+ * loadObject3D(), we only detach it from its parent — the caller still owns
53
+ * that Object3D and may reuse it. Otherwise we disposeObjectFromMemory() to
54
+ * free geometry/material/texture GPU resources.
55
+ */
56
+ releaseTargetModel() {
57
+
58
+ if ( ! this.targetModel ) return;
59
+
60
+ if ( this.targetModel === this._externalModel ) {
61
+
62
+ this.targetModel.parent?.remove( this.targetModel );
63
+
64
+ } else {
65
+
66
+ disposeObjectFromMemory( this.targetModel );
67
+
68
+ }
69
+
70
+ this.targetModel = null;
71
+ this._externalModel = null;
72
+
73
+ }
74
+
49
75
  setRenderer( renderer ) {
50
76
 
51
77
  this.renderer = renderer;
@@ -173,7 +199,11 @@ export class AssetLoader extends EventDispatcher {
173
199
 
174
200
  } else {
175
201
 
176
- const extension = envUrl.split( '.' ).pop().toLowerCase();
202
+ // Strip query string + fragment before extracting extension, otherwise
203
+ // URLs like ".../foo.hdr?v=2" get mis-detected and fall through to the
204
+ // regular TextureLoader, which can't parse HDR/EXR binary data.
205
+ const cleanPath = envUrl.split( /[?#]/ )[ 0 ];
206
+ const extension = cleanPath.split( '.' ).pop().toLowerCase();
177
207
  texture = await this.loadEnvironmentByExtension( envUrl, extension );
178
208
 
179
209
  }
@@ -479,7 +509,7 @@ export class AssetLoader extends EventDispatcher {
479
509
  loader.parse( gltfContent, '',
480
510
  gltf => {
481
511
 
482
- if ( this.targetModel ) disposeObjectFromMemory( this.targetModel );
512
+ this.releaseTargetModel();
483
513
  this.targetModel = gltf.scene;
484
514
  this.onModelLoad( this.targetModel ).then( () => resolve( gltf ) );
485
515
 
@@ -522,7 +552,7 @@ export class AssetLoader extends EventDispatcher {
522
552
  const object = objLoader.parse( objContent );
523
553
  object.name = filePath;
524
554
 
525
- if ( this.targetModel ) disposeObjectFromMemory( this.targetModel );
555
+ this.releaseTargetModel();
526
556
  this.targetModel = object;
527
557
  await this.onModelLoad( this.targetModel );
528
558
  return object;
@@ -603,7 +633,7 @@ export class AssetLoader extends EventDispatcher {
603
633
  const objContent = strFromU8( objFile.content );
604
634
  const object = objLoader.parse( objContent );
605
635
 
606
- if ( this.targetModel ) disposeObjectFromMemory( this.targetModel );
636
+ this.releaseTargetModel();
607
637
  this.targetModel = object;
608
638
  await this.onModelLoad( this.targetModel );
609
639
 
@@ -773,7 +803,7 @@ export class AssetLoader extends EventDispatcher {
773
803
  const data = await loader.loadAsync( modelUrl );
774
804
  updateLoading( { status: "Processing Data...", progress: 10 } );
775
805
 
776
- if ( this.targetModel ) disposeObjectFromMemory( this.targetModel );
806
+ this.releaseTargetModel();
777
807
 
778
808
  this.targetModel = data.scene;
779
809
  this.animations = data.animations || [];
@@ -806,7 +836,7 @@ export class AssetLoader extends EventDispatcher {
806
836
 
807
837
  const data = await loader.parseAsync( arrayBuffer, '' );
808
838
 
809
- if ( this.targetModel ) disposeObjectFromMemory( this.targetModel );
839
+ this.releaseTargetModel();
810
840
 
811
841
  this.targetModel = data.scene;
812
842
  this.animations = data.animations || [];
@@ -845,7 +875,7 @@ export class AssetLoader extends EventDispatcher {
845
875
  }
846
876
 
847
877
  const object = this.loaderCache.fbx.parse( arrayBuffer );
848
- if ( this.targetModel ) disposeObjectFromMemory( this.targetModel );
878
+ this.releaseTargetModel();
849
879
  this.targetModel = object;
850
880
 
851
881
  updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
@@ -882,7 +912,7 @@ export class AssetLoader extends EventDispatcher {
882
912
  const object = this.loaderCache.obj.parse( contents );
883
913
  object.name = filename;
884
914
 
885
- if ( this.targetModel ) disposeObjectFromMemory( this.targetModel );
915
+ this.releaseTargetModel();
886
916
  this.targetModel = object;
887
917
 
888
918
  updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
@@ -920,7 +950,7 @@ export class AssetLoader extends EventDispatcher {
920
950
  const mesh = new Mesh( geometry, material );
921
951
  mesh.name = filename;
922
952
 
923
- if ( this.targetModel ) disposeObjectFromMemory( this.targetModel );
953
+ this.releaseTargetModel();
924
954
  this.targetModel = mesh;
925
955
 
926
956
  updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
@@ -970,7 +1000,7 @@ export class AssetLoader extends EventDispatcher {
970
1000
  }
971
1001
 
972
1002
  object.name = filename;
973
- if ( this.targetModel ) disposeObjectFromMemory( this.targetModel );
1003
+ this.releaseTargetModel();
974
1004
  this.targetModel = object;
975
1005
 
976
1006
  updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
@@ -1007,7 +1037,7 @@ export class AssetLoader extends EventDispatcher {
1007
1037
  const collada = this.loaderCache.collada.parse( contents );
1008
1038
  collada.scene.name = filename;
1009
1039
 
1010
- if ( this.targetModel ) disposeObjectFromMemory( this.targetModel );
1040
+ this.releaseTargetModel();
1011
1041
  this.targetModel = collada.scene;
1012
1042
 
1013
1043
  updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
@@ -1042,7 +1072,7 @@ export class AssetLoader extends EventDispatcher {
1042
1072
 
1043
1073
  const object = this.loaderCache.threemf.parse( arrayBuffer );
1044
1074
 
1045
- if ( this.targetModel ) disposeObjectFromMemory( this.targetModel );
1075
+ this.releaseTargetModel();
1046
1076
  this.targetModel = object;
1047
1077
 
1048
1078
  updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
@@ -1078,7 +1108,7 @@ export class AssetLoader extends EventDispatcher {
1078
1108
  const object = this.loaderCache.usdz.parse( arrayBuffer );
1079
1109
  object.name = filename;
1080
1110
 
1081
- if ( this.targetModel ) disposeObjectFromMemory( this.targetModel );
1111
+ this.releaseTargetModel();
1082
1112
  this.targetModel = object;
1083
1113
 
1084
1114
  updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
@@ -1101,8 +1131,9 @@ export class AssetLoader extends EventDispatcher {
1101
1131
 
1102
1132
  object3d.name = object3d.name || name;
1103
1133
 
1104
- if ( this.targetModel ) disposeObjectFromMemory( this.targetModel );
1134
+ this.releaseTargetModel();
1105
1135
  this.targetModel = object3d;
1136
+ this._externalModel = object3d;
1106
1137
 
1107
1138
  updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
1108
1139
  await this.onModelLoad( this.targetModel );
@@ -1379,23 +1410,18 @@ export class AssetLoader extends EventDispatcher {
1379
1410
  }
1380
1411
 
1381
1412
  this.loaderCache = {};
1382
- super.dispose(); // Use EventDispatcher's dispose method
1383
-
1384
- if ( this.targetModel ) {
1385
1413
 
1386
- disposeObjectFromMemory( this.targetModel );
1387
- this.targetModel = null;
1388
-
1389
- }
1414
+ // Three.js EventDispatcher exposes no dispose()/removeAllEventListeners().
1415
+ // Clear the internal listener map directly so handlers don't retain references.
1416
+ this._listeners = undefined;
1390
1417
 
1391
- console.log( 'AssetLoader resources disposed' );
1418
+ this.releaseTargetModel();
1392
1419
 
1393
1420
  }
1394
1421
 
1395
1422
  removeAllEventListeners() {
1396
1423
 
1397
- // Use EventDispatcher's dispose method for backward compatibility
1398
- super.dispose();
1424
+ this._listeners = undefined;
1399
1425
 
1400
1426
  }
1401
1427
 
@@ -618,7 +618,22 @@ export class GeometryExtractor {
618
618
  this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_A_OFFSET + 0 ] = normalA.x;
619
619
  this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_A_OFFSET + 1 ] = normalA.y;
620
620
  this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_A_OFFSET + 2 ] = normalA.z;
621
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_A_OFFSET + 3 ] = 0; // vec4 padding
621
+ // Repurposed padding: opaque-blocker fast-path flag for shadow rays.
622
+ // 1.0 = surface fully blocks light (no alpha, transmission, or transparency) →
623
+ // traceShadowRay can skip the 7-slot getShadowMaterial fetch.
624
+ // 0.0 = requires full material evaluation.
625
+ {
626
+
627
+ const mat = this.materials[ materialIndex ];
628
+ const isOpaqueBlocker = mat
629
+ && ( mat.alphaMode | 0 ) === 0
630
+ && ( mat.transparent | 0 ) === 0
631
+ && ( mat.transmission || 0 ) === 0
632
+ && ( mat.opacity ?? 1 ) >= 1
633
+ ? 1.0 : 0.0;
634
+ this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_A_OFFSET + 3 ] = isOpaqueBlocker;
635
+
636
+ }
622
637
 
623
638
  this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_B_OFFSET + 0 ] = normalB.x;
624
639
  this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_B_OFFSET + 1 ] = normalB.y;
@@ -628,7 +643,9 @@ export class GeometryExtractor {
628
643
  this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_C_OFFSET + 0 ] = normalC.x;
629
644
  this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_C_OFFSET + 1 ] = normalC.y;
630
645
  this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_C_OFFSET + 2 ] = normalC.z;
631
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_C_OFFSET + 3 ] = 0; // vec4 padding
646
+ // Repurposed padding: per-triangle side flag (0=front, 1=back, 2=double).
647
+ // Lets BVH traversal do side culling without a material-buffer read per hit.
648
+ this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_C_OFFSET + 3 ] = this.materials[ materialIndex ]?.side ?? 0;
632
649
 
633
650
  // UVs and material index (2 vec4s = 8 floats)
634
651
  // First vec4: uvA.x, uvA.y, uvB.x, uvB.y
@@ -206,32 +206,33 @@ export function createRenderTargetHelper( renderer, renderTargetOrTexture, optio
206
206
 
207
207
  } );
208
208
 
209
- window.addEventListener( 'pointermove', ( e ) => {
209
+ function onPointerMove( e ) {
210
210
 
211
211
  if ( ! isDragging ) return;
212
212
 
213
213
  const newLeft = e.clientX - dragOffsetX;
214
214
  const newTop = e.clientY - dragOffsetY;
215
215
 
216
- // Keep within window bounds
217
216
  const maxX = window.innerWidth - container.offsetWidth;
218
217
  const maxY = window.innerHeight - container.offsetHeight;
219
218
 
220
219
  container.style.left = `${Math.max( 0, Math.min( newLeft, maxX ) )}px`;
221
220
  container.style.top = `${Math.max( 0, Math.min( newTop, maxY ) )}px`;
222
221
 
223
- // Reset position properties that would otherwise take precedence
224
222
  container.style.bottom = 'auto';
225
223
  container.style.right = 'auto';
226
224
 
227
- } );
225
+ }
228
226
 
229
- window.addEventListener( 'pointerup', () => {
227
+ function onPointerUp() {
230
228
 
231
229
  isDragging = false;
232
230
  document.body.style.userSelect = '';
233
231
 
234
- } );
232
+ }
233
+
234
+ window.addEventListener( 'pointermove', onPointerMove );
235
+ window.addEventListener( 'pointerup', onPointerUp );
235
236
 
236
237
  // Optimize resize handling
237
238
  function handleResize() {
@@ -353,19 +354,20 @@ export function createRenderTargetHelper( renderer, renderTargetOrTexture, optio
353
354
 
354
355
  };
355
356
 
356
- // Handle resize events
357
- container.addEventListener( 'mousedown', () => {
357
+ function onContainerMouseDown() {
358
358
 
359
359
  window.addEventListener( 'mousemove', handleResize );
360
360
 
361
- } );
361
+ }
362
362
 
363
- window.addEventListener( 'mouseup', () => {
363
+ function onMouseUp() {
364
364
 
365
365
  window.removeEventListener( 'mousemove', handleResize );
366
366
 
367
- } );
367
+ }
368
368
 
369
+ container.addEventListener( 'mousedown', onContainerMouseDown );
370
+ window.addEventListener( 'mouseup', onMouseUp );
369
371
  window.addEventListener( 'resize', handleResize );
370
372
 
371
373
  // Auto-update animation frame
@@ -456,20 +458,45 @@ export function createRenderTargetHelper( renderer, renderTargetOrTexture, optio
456
458
  */
457
459
  container.dispose = function dispose() {
458
460
 
459
- if ( config.autoUpdate && animFrameId ) {
461
+ if ( animFrameId ) {
460
462
 
461
463
  cancelAnimationFrame( animFrameId );
464
+ animFrameId = null;
462
465
 
463
466
  }
464
467
 
465
- // Remove from DOM if attached
468
+ // Remove window listeners these close over renderer/renderTarget/container
469
+ // and pin the entire helper graph alive until the page unloads if not cleaned up.
470
+ window.removeEventListener( 'pointermove', onPointerMove );
471
+ window.removeEventListener( 'pointerup', onPointerUp );
472
+ window.removeEventListener( 'mouseup', onMouseUp );
473
+ window.removeEventListener( 'mousemove', handleResize );
474
+ window.removeEventListener( 'resize', handleResize );
475
+
466
476
  if ( container.parentNode ) {
467
477
 
468
478
  container.parentNode.removeChild( container );
469
479
 
470
480
  }
471
481
 
472
- // Clear references
482
+ // Drop closures attached to the container. These close over `renderer`,
483
+ // `renderTargetOrTexture`, and the canvas — keeping them around after the
484
+ // owning stage has been disposed retains the entire Three.js WebGPU graph.
485
+ container.startAutoUpdate = null;
486
+ container.stopAutoUpdate = null;
487
+ container.show = null;
488
+ container.hide = null;
489
+ container.toggle = null;
490
+ container.update = null;
491
+ container.dispose = null;
492
+
493
+ if ( domCanvas ) {
494
+
495
+ domCanvas.width = 0;
496
+ domCanvas.height = 0;
497
+
498
+ }
499
+
473
500
  clampedPixels = null;
474
501
 
475
502
  };
@@ -485,6 +485,13 @@ export class AdaptiveSampling extends RenderStage {
485
485
  this.heatmapTarget?.dispose();
486
486
  this.helper?.dispose();
487
487
 
488
+ this._computeNode = null;
489
+ this._heatmapComputeNode = null;
490
+ this._heatmapStorageTex = null;
491
+ this._outputStorageTex = null;
492
+ this.heatmapTarget = null;
493
+ this.helper = null;
494
+
488
495
  }
489
496
 
490
497
  }
@@ -114,6 +114,19 @@ export class PathTracer extends RenderStage {
114
114
  // Initialize material data manager
115
115
  this.materialData = new MaterialDataManager( this.sdfs );
116
116
  this.materialData.callbacks.onReset = () => this.reset();
117
+ // Triangle data carries the per-triangle `side` flag (NORMAL_C.w). The
118
+ // authoritative CPU array is triangleStorageAttr.array (not sdfs.triangleData,
119
+ // which isn't populated on the PathTracerApp build path). The patch mutates
120
+ // the array in place — only a dirty flag is needed for GPU re-upload.
121
+ this.materialData.callbacks.getTriangleData = () => ( {
122
+ array: this.triangleStorageAttr?.array,
123
+ count: this.triangleCount,
124
+ } );
125
+ this.materialData.callbacks.onTriangleDataChanged = () => {
126
+
127
+ if ( this.triangleStorageAttr ) this.triangleStorageAttr.needsUpdate = true;
128
+
129
+ };
117
130
 
118
131
  // Initialize environment manager
119
132
  this.environment = new EnvironmentManager( this.scene, this.uniforms );
@@ -224,28 +224,34 @@ export const traverseBVH = Fn( ( [
224
224
  const u = triResult.y;
225
225
  const v = triResult.z;
226
226
 
227
- // Fetch normals + material data for visibility check (4 reads)
227
+ // Fetch normals for side-culling (3 reads). Slot 7 (uvData2,
228
+ // carries matIdx + meshIndex) is deferred to post-traversal —
229
+ // it's only needed for the one winning triangle, not per candidate.
230
+ // normalCData.w carries the per-triangle side flag (0/1/2).
228
231
  const nA = getDatafromStorageBuffer( triangleBuffer, triIndex, int( 3 ), int( TRI_STRIDE ) ).xyz;
229
232
  const nB = getDatafromStorageBuffer( triangleBuffer, triIndex, int( 4 ), int( TRI_STRIDE ) ).xyz;
230
- const nC = getDatafromStorageBuffer( triangleBuffer, triIndex, int( 5 ), int( TRI_STRIDE ) ).xyz;
231
- const uvData2 = getDatafromStorageBuffer( triangleBuffer, triIndex, int( 7 ), int( TRI_STRIDE ) );
232
-
233
- const matIdx = int( uvData2.z );
233
+ const normalCData = getDatafromStorageBuffer( triangleBuffer, triIndex, int( 5 ), int( TRI_STRIDE ) );
234
+ const nC = normalCData.xyz;
235
+ const side = int( normalCData.w ).toVar();
234
236
 
235
237
  // Interpolate normal
236
238
  const w = float( 1.0 ).sub( u ).sub( v );
237
239
  const normal = normalize( nA.mul( w ).add( nB.mul( u ) ).add( nC.mul( v ) ) ).toVar();
238
240
 
239
- // Side culling check (per-mesh visibility handled at BLAS-pointer level)
240
- If( passesSideCulling( matIdx, rayDirection, normal, materialBuffer ), () => {
241
+ // Side culling (inline; per-mesh visibility is at the BLAS-pointer level).
242
+ // 0=front (reject back-facing), 1=back (reject front-facing), 2=double (pass).
243
+ const rayDotNormal = rayDirection.dot( normal );
244
+ const sidePass = side.equal( int( 2 ) )
245
+ .or( side.equal( int( 0 ) ).and( rayDotNormal.lessThan( - 0.0001 ) ) )
246
+ .or( side.equal( int( 1 ) ).and( rayDotNormal.greaterThan( 0.0001 ) ) );
247
+ If( sidePass, () => {
241
248
 
242
249
  closestHit.didHit.assign( true );
243
250
  closestHit.dst.assign( t );
244
251
  closestHit.normal.assign( normal );
245
- closestHit.materialIndex.assign( matIdx );
246
- closestHit.meshIndex.assign( int( uvData2.w ) );
247
252
 
248
- // Defer hitPoint + UV computation to post-traversal
253
+ // Defer materialIndex/meshIndex/hitPoint/UV to post-traversal
254
+ // (all re-derived from closestTriIdx with a single uvData2 fetch below).
249
255
  closestTriIdx.assign( triIndex );
250
256
  closestU.assign( u );
251
257
  closestV.assign( v );
@@ -324,7 +330,7 @@ export const traverseBVH = Fn( ( [
324
330
 
325
331
  } );
326
332
 
327
- // Deferred: compute hitPoint and UVs once for the final closest hit
333
+ // Deferred: compute hitPoint, UVs, and fetch matIdx/meshIndex once for the final closest hit
328
334
  If( closestHit.didHit, () => {
329
335
 
330
336
  closestHit.hitPoint.assign( ray.origin.add( ray.direction.mul( closestHit.dst ) ) );
@@ -335,6 +341,8 @@ export const traverseBVH = Fn( ( [
335
341
  closestHit.uv.assign(
336
342
  uvData1.xy.mul( w ).add( uvData1.zw.mul( closestU ) ).add( uvData2.xy.mul( closestV ) )
337
343
  );
344
+ closestHit.materialIndex.assign( int( uvData2.z ) );
345
+ closestHit.meshIndex.assign( int( uvData2.w ) );
338
346
  closestHit.triangleIndex.assign( closestTriIdx );
339
347
 
340
348
  } );
@@ -130,6 +130,19 @@ export const traceShadowRay = Fn( ( [
130
130
 
131
131
  } );
132
132
 
133
+ // Opaque fast-path: check the per-triangle blocker flag (NORMAL_A.w, set at
134
+ // extraction time when alphaMode/transparent/transmission/opacity all indicate
135
+ // a fully opaque surface). Short-circuits the 7-slot getShadowMaterial fetch
136
+ // and the entire alpha/transmission/transparent decision tree below.
137
+ const TRI_STRIDE_SR = int( 8 );
138
+ const blocker = getDatafromStorageBuffer( triangleBuffer, shadowHit.triangleIndex, int( 3 ), TRI_STRIDE_SR ).w;
139
+ If( blocker.greaterThan( 0.5 ), () => {
140
+
141
+ transmittance.assign( 0.0 );
142
+ Break();
143
+
144
+ } );
145
+
133
146
  // Fetch material for the hit surface (thin reader: 7 slots instead of 27)
134
147
  const shadowMaterial = ShadowMaterial.wrap( getShadowMaterial( shadowHit.materialIndex, materialBuffer ) );
135
148
 
@@ -281,7 +281,7 @@ export const processBump = Fn( ( [ bumpMaps, currentNormal, material, uvCache ]
281
281
 
282
282
  const result = currentNormal.toVar();
283
283
 
284
- If( material.bumpMapIndex.greaterThanEqual( int( 0 ) ), () => {
284
+ If( material.bumpMapIndex.greaterThanEqual( int( 0 ) ).and( material.bumpScale.greaterThan( 0.0 ) ), () => {
285
285
 
286
286
  // Approximate texel size
287
287
  const texelSize = vec2( 1.0 / 1024.0 ).toVar();