rayzee 6.0.1 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -0
  2. package/dist/assets/TexturesWorker-DBqGmVdR.js.map +1 -1
  3. package/dist/rayzee.es.js +2419 -2078
  4. package/dist/rayzee.es.js.map +1 -1
  5. package/dist/rayzee.umd.js +48 -52
  6. package/dist/rayzee.umd.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/EngineDefaults.js +3 -0
  9. package/src/PathTracerApp.js +18 -8
  10. package/src/Pipeline/RenderStage.js +3 -0
  11. package/src/Processor/IESParser.js +340 -0
  12. package/src/Processor/LightSerializer.js +32 -4
  13. package/src/Processor/SceneProcessor.js +0 -1
  14. package/src/Processor/ShaderBuilder.js +40 -1
  15. package/src/Processor/Workers/TexturesWorker.js +1 -1
  16. package/src/RenderSettings.js +3 -0
  17. package/src/Stages/NormalDepth.js +3 -19
  18. package/src/Stages/PathTracer.js +15 -9
  19. package/src/TSL/BVHTraversal.js +4 -6
  20. package/src/TSL/Common.js +1 -1
  21. package/src/TSL/Debugger.js +0 -2
  22. package/src/TSL/EmissiveSampling.js +20 -22
  23. package/src/TSL/Environment.js +60 -14
  24. package/src/TSL/Fresnel.js +13 -4
  25. package/src/TSL/LightsCore.js +238 -5
  26. package/src/TSL/LightsDirect.js +16 -5
  27. package/src/TSL/LightsIndirect.js +4 -37
  28. package/src/TSL/LightsSampling.js +119 -185
  29. package/src/TSL/MaterialEvaluation.js +25 -14
  30. package/src/TSL/MaterialProperties.js +14 -34
  31. package/src/TSL/MaterialTransmission.js +18 -37
  32. package/src/TSL/PathTracer.js +5 -4
  33. package/src/TSL/PathTracerCore.js +144 -139
  34. package/src/TSL/Struct.js +7 -1
  35. package/src/TSL/TextureSampling.js +2 -2
  36. package/src/index.js +2 -0
  37. package/src/managers/AnimationManager.js +3 -6
  38. package/src/managers/DenoisingManager.js +1 -1
  39. package/src/managers/GoboManager.js +277 -0
  40. package/src/managers/IESManager.js +268 -0
  41. package/src/managers/LightManager.js +33 -1
  42. package/src/managers/TransformManager.js +3 -3
  43. package/src/managers/UniformManager.js +5 -5
@@ -21,9 +21,7 @@ import {
21
21
  float,
22
22
  vec2,
23
23
  vec3,
24
- vec4,
25
24
  int,
26
- uint,
27
25
  bool as tslBool,
28
26
  max,
29
27
  min,
@@ -34,14 +32,10 @@ import {
34
32
  dot,
35
33
  cross,
36
34
  normalize,
37
- length,
38
- clamp,
39
35
  mix,
40
36
  select,
41
37
  If,
42
38
  Loop,
43
- Break,
44
- texture,
45
39
  } from 'three/tsl';
46
40
 
47
41
  import {
@@ -59,9 +53,12 @@ import {
59
53
  getDistanceAttenuation,
60
54
  getSpotAttenuation,
61
55
  intersectAreaLight,
56
+ sampleSpotGoboMask,
57
+ sampleDirectionalGoboMask,
58
+ sampleIESProfile,
62
59
  } from './LightsCore.js';
63
60
 
64
- import { MISStrategy } from './Struct.js';
61
+ import { MISStrategy, DotProducts } from './Struct.js';
65
62
  import {
66
63
  calculateDirectionalLightImportance,
67
64
  estimateLightImportance,
@@ -71,7 +68,7 @@ import {
71
68
  } from './LightsDirect.js';
72
69
 
73
70
  import { traverseBVHShadow } from './BVHTraversal.js';
74
- import { evaluateMaterialResponse } from './MaterialEvaluation.js';
71
+ import { evaluateMaterialResponseFromDots } from './MaterialEvaluation.js';
75
72
  import { calculateVNDFPDF } from './MaterialProperties.js';
76
73
  import { RandomValue } from './Random.js';
77
74
  import {
@@ -79,13 +76,12 @@ import {
79
76
  PI,
80
77
  PI_INV,
81
78
  EPSILON,
82
- MIN_PDF,
83
79
  powerHeuristic,
84
80
  balanceHeuristic,
81
+ computeDotProducts,
85
82
  } from './Common.js';
86
83
  import {
87
84
  sampleEquirectProbability,
88
- sampleEquirect,
89
85
  } from './Environment.js';
90
86
 
91
87
  const TWO_PI = 2.0 * PI;
@@ -151,7 +147,7 @@ export const sampleRectAreaLight = Fn( ( [ light, rayOrigin, ruv, lightSelection
151
147
  } );
152
148
 
153
149
  // Enhanced spot light sampling with radius support
154
- export const sampleSpotLightWithRadius = Fn( ( [ light, rayOrigin, ruv, lightSelectionPdf ] ) => {
150
+ export const sampleSpotLightWithRadius = Fn( ( [ light, rayOrigin, lightSelectionPdf ] ) => {
155
151
 
156
152
  const ls_valid = tslBool( false ).toVar();
157
153
  const ls_direction = vec3( 0.0, 1.0, 0.0 ).toVar();
@@ -161,11 +157,13 @@ export const sampleSpotLightWithRadius = Fn( ( [ light, rayOrigin, ruv, lightSel
161
157
  const ls_lightType = int( LIGHT_TYPE_SPOT ).toVar();
162
158
 
163
159
  const toLight = light.position.sub( rayOrigin ).toVar();
164
- const lightDist = length( toLight ).toVar();
160
+ // Guard via lengthSq so the sqrt is skipped on rejected (zero-distance) samples.
161
+ const lightDistSq = dot( toLight, toLight ).toVar();
165
162
 
166
163
  // Guard against zero distance
167
- If( lightDist.greaterThanEqual( 1e-10 ), () => {
164
+ If( lightDistSq.greaterThanEqual( 1e-20 ), () => {
168
165
 
166
+ const lightDist = sqrt( lightDistSq ).toVar();
169
167
  const lightDir = toLight.div( lightDist ).toVar();
170
168
 
171
169
  // Check cone attenuation
@@ -185,7 +183,11 @@ export const sampleSpotLightWithRadius = Fn( ( [ light, rayOrigin, ruv, lightSel
185
183
  const coneAttenuation = getSpotAttenuation( { coneCosine: coneCosAngle, penumbraCosine: penumbraCosAngle, angleCosine: spotCosAngle } );
186
184
  const distanceAttenuation = getDistanceAttenuation( { lightDistance: lightDist, cutoffDistance: light.distance, decayExponent: light.decay } );
187
185
 
188
- ls_emission.assign( light.color.mul( light.intensity ).mul( distanceAttenuation ).mul( coneAttenuation ) );
186
+ // Gobo projection mask + IES photometric profile — both 1.0 when not assigned.
187
+ const goboMask = sampleSpotGoboMask( light, lightDir );
188
+ const iesProfile = sampleIESProfile( light, lightDir );
189
+
190
+ ls_emission.assign( light.color.mul( light.intensity ).mul( distanceAttenuation ).mul( coneAttenuation ).mul( goboMask ).mul( iesProfile ) );
189
191
 
190
192
  } );
191
193
 
@@ -213,11 +215,13 @@ export const samplePointLightWithAttenuation = Fn( ( [ light, rayOrigin, lightSe
213
215
  const ls_lightType = int( LIGHT_TYPE_POINT ).toVar();
214
216
 
215
217
  const toLight = light.position.sub( rayOrigin ).toVar();
216
- const lightDist = length( toLight ).toVar();
218
+ // Guard via lengthSq so the sqrt is skipped on rejected (zero-distance) samples.
219
+ const lightDistSq = dot( toLight, toLight ).toVar();
217
220
 
218
221
  // Guard against zero distance
219
- If( lightDist.greaterThanEqual( 1e-10 ), () => {
222
+ If( lightDistSq.greaterThanEqual( 1e-20 ), () => {
220
223
 
224
+ const lightDist = sqrt( lightDistSq ).toVar();
221
225
  const lightDir = toLight.div( lightDist ).toVar();
222
226
 
223
227
  // Calculate distance attenuation using the light's actual distance and decay properties
@@ -247,8 +251,12 @@ export const samplePointLightWithAttenuation = Fn( ( [ light, rayOrigin, lightSe
247
251
  // Importance-Weighted Light Sampling
248
252
  // =============================================================================
249
253
 
250
- // ANGLE-optimized: No early returns in loops, full initialization
251
- // 3-pass approach: 1) calculate total weight, 2) select light via CDF, 3) sample selected light
254
+ // Single-pass weighted-reservoir sampling: each light's importance is evaluated
255
+ // exactly once. Compared to the previous 3-pass CDF (sum-then-walk-then-sample)
256
+ // this halves the importance evaluations and storage-buffer reads per NEE call.
257
+ // Selection rule: with seen_weight = sum of weights so far, replace current
258
+ // winner with this candidate with probability w_i / seen_weight. Result is
259
+ // unbiased; PDF = winnerImportance / totalWeight, identical to the CDF form.
252
260
  export const sampleLightWithImportance = Fn( ( [
253
261
  rayOrigin, normal, material, randomSeed, bounceIndex, rngState,
254
262
  // Light buffers + counts
@@ -273,8 +281,13 @@ export const sampleLightWithImportance = Fn( ( [
273
281
  const totalWeight = float( 0.0 ).toVar();
274
282
  const lightIndex = int( 0 ).toVar();
275
283
 
284
+ // Reservoir state: winning light's type/index/importance.
285
+ const selectedType = int( - 1 ).toVar(); // 0=dir, 1=area, 2=point, 3=spot
286
+ const selectedIdx = int( - 1 ).toVar();
287
+ const selectedImportance = float( 0.0 ).toVar();
288
+
276
289
  // =====================================================================
277
- // PASS 1: Calculate Total Weight (no early exits)
290
+ // SINGLE PASS: reservoir-sample across all four light type buffers
278
291
  // =====================================================================
279
292
 
280
293
  If( numDirectionalLights.greaterThan( int( 0 ) ), () => {
@@ -284,7 +297,17 @@ export const sampleLightWithImportance = Fn( ( [
284
297
  If( lightIndex.lessThan( int( 16 ) ), () => {
285
298
 
286
299
  const light = DirectionalLight.wrap( getDirectionalLight( directionalLightsBuffer, i ) );
287
- totalWeight.addAssign( calculateDirectionalLightImportance( light, rayOrigin, normal, material, bounceIndex ) );
300
+ const importance = calculateDirectionalLightImportance( light, normal, material, bounceIndex ).toVar();
301
+ totalWeight.addAssign( importance );
302
+ If( importance.greaterThan( 0.0 ).and(
303
+ RandomValue( rngState ).mul( totalWeight ).lessThan( importance )
304
+ ), () => {
305
+
306
+ selectedType.assign( 0 );
307
+ selectedIdx.assign( i );
308
+ selectedImportance.assign( importance );
309
+
310
+ } );
288
311
  lightIndex.addAssign( 1 );
289
312
 
290
313
  } );
@@ -300,8 +323,17 @@ export const sampleLightWithImportance = Fn( ( [
300
323
  If( lightIndex.lessThan( int( 16 ) ), () => {
301
324
 
302
325
  const light = AreaLight.wrap( getAreaLight( areaLightsBuffer, i ) );
303
- const importance = select( light.intensity.greaterThan( 0.0 ), estimateLightImportance( light, rayOrigin, normal, material ), float( 0.0 ) );
326
+ const importance = select( light.intensity.greaterThan( 0.0 ), estimateLightImportance( light, rayOrigin, normal, material ), float( 0.0 ) ).toVar();
304
327
  totalWeight.addAssign( importance );
328
+ If( importance.greaterThan( 0.0 ).and(
329
+ RandomValue( rngState ).mul( totalWeight ).lessThan( importance )
330
+ ), () => {
331
+
332
+ selectedType.assign( 1 );
333
+ selectedIdx.assign( i );
334
+ selectedImportance.assign( importance );
335
+
336
+ } );
305
337
  lightIndex.addAssign( 1 );
306
338
 
307
339
  } );
@@ -317,7 +349,17 @@ export const sampleLightWithImportance = Fn( ( [
317
349
  If( lightIndex.lessThan( int( 16 ) ), () => {
318
350
 
319
351
  const light = PointLight.wrap( getPointLight( pointLightsBuffer, i ) );
320
- totalWeight.addAssign( calculatePointLightImportance( light, rayOrigin, normal, material ) );
352
+ const importance = calculatePointLightImportance( light, rayOrigin, normal, material ).toVar();
353
+ totalWeight.addAssign( importance );
354
+ If( importance.greaterThan( 0.0 ).and(
355
+ RandomValue( rngState ).mul( totalWeight ).lessThan( importance )
356
+ ), () => {
357
+
358
+ selectedType.assign( 2 );
359
+ selectedIdx.assign( i );
360
+ selectedImportance.assign( importance );
361
+
362
+ } );
321
363
  lightIndex.addAssign( 1 );
322
364
 
323
365
  } );
@@ -333,7 +375,17 @@ export const sampleLightWithImportance = Fn( ( [
333
375
  If( lightIndex.lessThan( int( 16 ) ), () => {
334
376
 
335
377
  const light = SpotLight.wrap( getSpotLight( spotLightsBuffer, i ) );
336
- totalWeight.addAssign( calculateSpotLightImportance( light, rayOrigin, normal, material ) );
378
+ const importance = calculateSpotLightImportance( light, rayOrigin, normal, material ).toVar();
379
+ totalWeight.addAssign( importance );
380
+ If( importance.greaterThan( 0.0 ).and(
381
+ RandomValue( rngState ).mul( totalWeight ).lessThan( importance )
382
+ ), () => {
383
+
384
+ selectedType.assign( 3 );
385
+ selectedIdx.assign( i );
386
+ selectedImportance.assign( importance );
387
+
388
+ } );
337
389
  lightIndex.addAssign( 1 );
338
390
 
339
391
  } );
@@ -366,8 +418,9 @@ export const sampleLightWithImportance = Fn( ( [
366
418
 
367
419
  If( light.intensity.greaterThan( 0.0 ), () => {
368
420
 
421
+ const dirGoboMask = sampleDirectionalGoboMask( light, rayOrigin );
369
422
  r_direction.assign( normalize( light.direction ) );
370
- r_emission.assign( light.color.mul( light.intensity ) );
423
+ r_emission.assign( light.color.mul( light.intensity ).mul( dirGoboMask ) );
371
424
  r_distance.assign( 1e6 );
372
425
  r_lightType.assign( int( LIGHT_TYPE_DIRECTIONAL ) );
373
426
  r_valid.assign( tslBool( true ) );
@@ -440,8 +493,7 @@ export const sampleLightWithImportance = Fn( ( [
440
493
 
441
494
  If( light.intensity.greaterThan( 0.0 ), () => {
442
495
 
443
- const uv = vec2( randomSeed.y, RandomValue( rngState ) ).toVar();
444
- const spotSample = LightSample.wrap( sampleSpotLightWithRadius( light, rayOrigin, uv, lightSelectionPdf ) );
496
+ const spotSample = LightSample.wrap( sampleSpotLightWithRadius( light, rayOrigin, lightSelectionPdf ) );
445
497
  r_valid.assign( spotSample.valid );
446
498
  r_direction.assign( spotSample.direction );
447
499
  r_emission.assign( spotSample.emission );
@@ -459,128 +511,8 @@ export const sampleLightWithImportance = Fn( ( [
459
511
  } ).Else( () => {
460
512
 
461
513
  // =================================================================
462
- // PASS 2: Select and Sample Light (no early returns in loops)
463
- // =================================================================
464
-
465
- const selectionValue = randomSeed.x.mul( totalWeight ).toVar();
466
- const cumulative = float( 0.0 ).toVar();
467
- lightIndex.assign( 0 );
468
-
469
- // Track which light was selected
470
- const selectedType = int( - 1 ).toVar(); // 0=dir, 1=area, 2=point, 3=spot
471
- const selectedIdx = int( - 1 ).toVar();
472
- const selectedImportance = float( 0.0 ).toVar();
473
-
474
- // Directional lights
475
- If( numDirectionalLights.greaterThan( int( 0 ) ), () => {
476
-
477
- Loop( { start: int( 0 ), end: numDirectionalLights, type: 'int', condition: '<' }, ( { i } ) => {
478
-
479
- If( lightIndex.lessThan( int( 16 ) ).and( selectedType.lessThan( int( 0 ) ) ), () => {
480
-
481
- const light = DirectionalLight.wrap( getDirectionalLight( directionalLightsBuffer, i ) );
482
- const importance = calculateDirectionalLightImportance( light, rayOrigin, normal, material, bounceIndex ).toVar();
483
- const prevCumulative = cumulative.toVar();
484
- cumulative.addAssign( importance );
485
-
486
- If( selectionValue.greaterThan( prevCumulative ).and( selectionValue.lessThanEqual( cumulative ) ), () => {
487
-
488
- selectedType.assign( 0 );
489
- selectedIdx.assign( i );
490
- selectedImportance.assign( importance );
491
-
492
- } );
493
-
494
- } );
495
- lightIndex.addAssign( 1 );
496
-
497
- } );
498
-
499
- } );
500
-
501
- // Area lights
502
- If( numAreaLights.greaterThan( int( 0 ) ), () => {
503
-
504
- Loop( { start: int( 0 ), end: numAreaLights, type: 'int', condition: '<' }, ( { i } ) => {
505
-
506
- If( lightIndex.lessThan( int( 16 ) ).and( selectedType.lessThan( int( 0 ) ) ), () => {
507
-
508
- const light = AreaLight.wrap( getAreaLight( areaLightsBuffer, i ) );
509
- const importance = select( light.intensity.greaterThan( 0.0 ), estimateLightImportance( light, rayOrigin, normal, material ), float( 0.0 ) ).toVar();
510
- const prevCumulative = cumulative.toVar();
511
- cumulative.addAssign( importance );
512
-
513
- If( selectionValue.greaterThan( prevCumulative ).and( selectionValue.lessThanEqual( cumulative ) ), () => {
514
-
515
- selectedType.assign( 1 );
516
- selectedIdx.assign( i );
517
- selectedImportance.assign( importance );
518
-
519
- } );
520
-
521
- } );
522
- lightIndex.addAssign( 1 );
523
-
524
- } );
525
-
526
- } );
527
-
528
- // Point lights
529
- If( numPointLights.greaterThan( int( 0 ) ), () => {
530
-
531
- Loop( { start: int( 0 ), end: numPointLights, type: 'int', condition: '<' }, ( { i } ) => {
532
-
533
- If( lightIndex.lessThan( int( 16 ) ).and( selectedType.lessThan( int( 0 ) ) ), () => {
534
-
535
- const light = PointLight.wrap( getPointLight( pointLightsBuffer, i ) );
536
- const importance = calculatePointLightImportance( light, rayOrigin, normal, material ).toVar();
537
- const prevCumulative = cumulative.toVar();
538
- cumulative.addAssign( importance );
539
-
540
- If( selectionValue.greaterThan( prevCumulative ).and( selectionValue.lessThanEqual( cumulative ) ), () => {
541
-
542
- selectedType.assign( 2 );
543
- selectedIdx.assign( i );
544
- selectedImportance.assign( importance );
545
-
546
- } );
547
-
548
- } );
549
- lightIndex.addAssign( 1 );
550
-
551
- } );
552
-
553
- } );
554
-
555
- // Spot lights
556
- If( numSpotLights.greaterThan( int( 0 ) ), () => {
557
-
558
- Loop( { start: int( 0 ), end: numSpotLights, type: 'int', condition: '<' }, ( { i } ) => {
559
-
560
- If( lightIndex.lessThan( int( 16 ) ).and( selectedType.lessThan( int( 0 ) ) ), () => {
561
-
562
- const light = SpotLight.wrap( getSpotLight( spotLightsBuffer, i ) );
563
- const importance = calculateSpotLightImportance( light, rayOrigin, normal, material ).toVar();
564
- const prevCumulative = cumulative.toVar();
565
- cumulative.addAssign( importance );
566
-
567
- If( selectionValue.greaterThan( prevCumulative ).and( selectionValue.lessThanEqual( cumulative ) ), () => {
568
-
569
- selectedType.assign( 3 );
570
- selectedIdx.assign( i );
571
- selectedImportance.assign( importance );
572
-
573
- } );
574
-
575
- } );
576
- lightIndex.addAssign( 1 );
577
-
578
- } );
579
-
580
- } );
581
-
582
- // =================================================================
583
- // PASS 3: Sample the selected light (outside loops)
514
+ // Sample the reservoir-selected light. selectedType / selectedIdx /
515
+ // selectedImportance were populated during the single-pass walk above.
584
516
  // =================================================================
585
517
 
586
518
  // Guard division by zero
@@ -617,8 +549,9 @@ export const sampleLightWithImportance = Fn( ( [
617
549
 
618
550
  } );
619
551
 
552
+ const dirGoboMask = sampleDirectionalGoboMask( light, rayOrigin );
620
553
  r_direction.assign( direction );
621
- r_emission.assign( light.color.mul( light.intensity ) );
554
+ r_emission.assign( light.color.mul( light.intensity ).mul( dirGoboMask ) );
622
555
  r_distance.assign( 1e6 );
623
556
  r_pdf.assign( dirPdf.mul( pdf ) );
624
557
  r_lightType.assign( int( LIGHT_TYPE_DIRECTIONAL ) );
@@ -659,8 +592,7 @@ export const sampleLightWithImportance = Fn( ( [
659
592
  If( selectedType.equal( int( 3 ) ).and( selectedIdx.greaterThanEqual( int( 0 ) ) ), () => {
660
593
 
661
594
  const light = SpotLight.wrap( getSpotLight( spotLightsBuffer, selectedIdx ) );
662
- const uv = vec2( randomSeed.y, RandomValue( rngState ) ).toVar();
663
- const spotSample = LightSample.wrap( sampleSpotLightWithRadius( light, rayOrigin, uv, pdf ) );
595
+ const spotSample = LightSample.wrap( sampleSpotLightWithRadius( light, rayOrigin, pdf ) );
664
596
  r_valid.assign( spotSample.valid );
665
597
  r_direction.assign( spotSample.direction );
666
598
  r_emission.assign( spotSample.emission );
@@ -689,14 +621,14 @@ export const sampleLightWithImportance = Fn( ( [
689
621
  // Material PDF Calculation for MIS
690
622
  // =============================================================================
691
623
 
692
- // Helper function to calculate material PDF for a given direction
693
- export const calculateMaterialPDF = Fn( ( [ viewDir, lightDir, normal, material ] ) => {
624
+ // PDF computation given precomputed dot products. Use this when the caller
625
+ // already has dots from a paired evaluateMaterialResponseFromDots invocation
626
+ // to avoid recomputing H + dots.
627
+ export const calculateMaterialPDFFromDots = Fn( ( [ material, dots ] ) => {
694
628
 
695
- const NoV = max( float( 0.0 ), dot( normal, viewDir ) ).toVar();
696
- const NoL = max( float( 0.0 ), dot( normal, lightDir ) ).toVar();
697
- const H = normalize( viewDir.add( lightDir ) ).toVar();
698
- const NoH = max( float( 0.0 ), dot( normal, H ) ).toVar();
699
- const VoH = max( float( 0.0 ), dot( viewDir, H ) ).toVar();
629
+ const NoV = dots.NoV.toVar();
630
+ const NoL = dots.NoL.toVar();
631
+ const NoH = dots.NoH.toVar();
700
632
 
701
633
  // Calculate lobe weights
702
634
  const diffuseWeight = float( 1.0 ).sub( material.metalness ).mul(
@@ -739,6 +671,15 @@ export const calculateMaterialPDF = Fn( ( [ viewDir, lightDir, normal, material
739
671
 
740
672
  } );
741
673
 
674
+ // Wrapper that computes dots internally. Use this when the caller doesn't
675
+ // already have dots; otherwise prefer calculateMaterialPDFFromDots.
676
+ export const calculateMaterialPDF = Fn( ( [ viewDir, lightDir, normal, material ] ) => {
677
+
678
+ const dots = DotProducts.wrap( computeDotProducts( normal, viewDir, lightDir ) );
679
+ return calculateMaterialPDFFromDots( material, dots );
680
+
681
+ } );
682
+
742
683
  // =============================================================================
743
684
  // Unified Direct Lighting System
744
685
  // =============================================================================
@@ -752,7 +693,7 @@ export const calculateDirectLightingUnified = Fn( ( [
752
693
  // BRDF sample (DirectionSample fields)
753
694
  brdfSampleDirection, brdfSamplePdf, brdfSampleValue,
754
695
  // Tracing context
755
- sampleIndex, bounceIndex, rngState,
696
+ bounceIndex, rngState,
756
697
  // Light data
757
698
  directionalLightsBuffer, numDirectionalLights,
758
699
  areaLightsBuffer, numAreaLights,
@@ -772,13 +713,18 @@ export const calculateDirectLightingUnified = Fn( ( [
772
713
  const totalContribution = vec3( 0.0 ).toVar();
773
714
  const rayOrigin = hitPoint.add( hitNormal.mul( 0.001 ) ).toVar();
774
715
 
716
+ // Binds BVH params so shadow-ray sites at varying call depths use a 3-arg call
717
+ const shadow = Fn( ( [ origin, dir, maxDist ] ) =>
718
+ traceShadowRay( origin, dir, maxDist, traverseBVHShadow, bvhBuffer, triangleBuffer, materialBuffer )
719
+ );
720
+
775
721
  // Early exit for highly emissive surfaces
776
722
  If( material.emissiveIntensity.lessThanEqual( 10.0 ), () => {
777
723
 
778
724
  // Adaptive MIS Strategy Selection
779
725
  const currentThroughput = vec3( 1.0 ).toVar();
780
726
  const misResult = MISStrategy.wrap( selectOptimalMISStrategy(
781
- material.roughness, material.metalness, material.transmission, bounceIndex, currentThroughput
727
+ material.roughness, material.metalness, bounceIndex, currentThroughput
782
728
  ) );
783
729
 
784
730
  // Extract MIS fields to mutable variables
@@ -872,18 +818,15 @@ export const calculateDirectLightingUnified = Fn( ( [
872
818
  If( NoL.greaterThan( 0.0 ).and( lightImportance.mul( NoL ).greaterThan( importanceThreshold ) ).and( isDirectionValid( { direction: lightSample.direction, surfaceNormal: hitNormal } ) ), () => {
873
819
 
874
820
  const shadowDistance = min( lightSample.distance.sub( 0.001 ), float( 1000.0 ) ).toVar();
875
- const visibility = traceShadowRay(
876
- rayOrigin, lightSample.direction, shadowDistance, rngState,
877
- traverseBVHShadow,
878
- bvhBuffer,
879
- triangleBuffer,
880
- materialBuffer,
881
- );
821
+ const visibility = shadow( rayOrigin, lightSample.direction, shadowDistance );
882
822
 
883
823
  If( visibility.greaterThan( 0.0 ), () => {
884
824
 
885
- const brdfValue = evaluateMaterialResponse( viewDir, lightSample.direction, hitNormal, material );
886
- const bPdf = calculateMaterialPDF( viewDir, lightSample.direction, hitNormal, material ).toVar();
825
+ // Share H + dot products between BRDF eval and PDF — otherwise each
826
+ // would recompute normalize(V+L) + 5 dot products independently.
827
+ const sharedDots = DotProducts.wrap( computeDotProducts( hitNormal, viewDir, lightSample.direction ) );
828
+ const brdfValue = evaluateMaterialResponseFromDots( material, sharedDots );
829
+ const bPdf = calculateMaterialPDFFromDots( material, sharedDots ).toVar();
887
830
 
888
831
  const misW = float( 1.0 ).toVar();
889
832
 
@@ -974,13 +917,7 @@ export const calculateDirectLightingUnified = Fn( ( [
974
917
  If( hitDistance.greaterThan( 0.0 ), () => {
975
918
 
976
919
  const shadowDistance = min( hitDistance.sub( 0.001 ), float( 1000.0 ) ).toVar();
977
- const visibility = traceShadowRay(
978
- rayOrigin, brdfSampleDirection, shadowDistance, rngState,
979
- traverseBVHShadow,
980
- bvhBuffer,
981
- triangleBuffer,
982
- materialBuffer,
983
- );
920
+ const visibility = shadow( rayOrigin, brdfSampleDirection, shadowDistance );
984
921
 
985
922
  If( visibility.greaterThan( 0.0 ), () => {
986
923
 
@@ -1046,18 +983,15 @@ export const calculateDirectLightingUnified = Fn( ( [
1046
983
 
1047
984
  If( NoL.greaterThan( 0.0 ).and( isDirectionValid( { direction: envDirection, surfaceNormal: hitNormal } ) ), () => {
1048
985
 
1049
- const visibility = traceShadowRay(
1050
- rayOrigin, envDirection, float( 1000.0 ), rngState,
1051
- traverseBVHShadow,
1052
- bvhBuffer,
1053
- triangleBuffer,
1054
- materialBuffer,
1055
- );
986
+ const visibility = shadow( rayOrigin, envDirection, float( 1000.0 ) );
1056
987
 
1057
988
  If( visibility.greaterThan( 0.0 ), () => {
1058
989
 
1059
- const brdfValue = evaluateMaterialResponse( viewDir, envDirection, hitNormal, material );
1060
- const bPdf = calculateMaterialPDF( viewDir, envDirection, hitNormal, material ).toVar();
990
+ // Share H + dots between env BRDF/PDF — same redundancy fix as the
991
+ // discrete-light path above.
992
+ const envDots = DotProducts.wrap( computeDotProducts( hitNormal, viewDir, envDirection ) );
993
+ const brdfValue = evaluateMaterialResponseFromDots( material, envDots );
994
+ const bPdf = calculateMaterialPDFFromDots( material, envDots ).toVar();
1061
995
 
1062
996
  // Balance heuristic for env MIS — optimal for MIS-compensated PDFs (Karlík et al. 2019).
1063
997
  // The implicit path uses material combinedPdf as prevBouncePdf at the miss check.
@@ -3,14 +3,14 @@ import {
3
3
  If, max, min, clamp, mix
4
4
  } from 'three/tsl';
5
5
 
6
- import { DotProducts } from './Struct.js';
6
+ import { DotProducts, DFGResult } from './Struct.js';
7
7
  import {
8
8
  PI, PI_INV, EPSILON, MIN_CLEARCOAT_ROUGHNESS,
9
9
  computeDotProducts,
10
10
  } from './Common.js';
11
11
  import { fresnelSchlick, fresnelSchlickFloat, dielectricF0 } from './Fresnel.js';
12
12
  import {
13
- DistributionGGX, SheenDistribution, GeometrySmith, multiscatterCompensation, specularDirectionalAlbedo,
13
+ DistributionGGX, SheenDistribution, GeometrySmith, evaluateDFG,
14
14
  } from './MaterialProperties.js';
15
15
  import { evalIridescence } from './MaterialProperties.js';
16
16
 
@@ -22,7 +22,10 @@ import { evalIridescence } from './MaterialProperties.js';
22
22
  // Main Material Response Evaluation
23
23
  // -----------------------------------------------------------------------------
24
24
 
25
- export const evaluateMaterialResponse = Fn( ( [ V, L, N, material ] ) => {
25
+ // Body of evaluateMaterialResponse taking precomputed dot products. Callers
26
+ // that also need calculateMaterialPDF for the same (V, L, N) should share dots
27
+ // to save one computeDotProducts call.
28
+ export const evaluateMaterialResponseFromDots = Fn( ( [ material, dots ] ) => {
26
29
 
27
30
  const result = vec3( 0.0 ).toVar();
28
31
 
@@ -37,9 +40,6 @@ export const evaluateMaterialResponse = Fn( ( [ V, L, N, material ] ) => {
37
40
 
38
41
  } ).Else( () => {
39
42
 
40
- // Calculate all dot products once
41
- const dots = DotProducts.wrap( computeDotProducts( N, V, L ) );
42
-
43
43
  // Calculate base F0 with specular parameters, clamped to physically valid range
44
44
  const F0 = clamp(
45
45
  mix( dielectricF0( material.ior ).mul( material.specularColor ), material.color.rgb, material.metalness )
@@ -82,12 +82,13 @@ export const evaluateMaterialResponse = Fn( ( [ V, L, N, material ] ) => {
82
82
  // Single-scatter specular BRDF
83
83
  const specularSS = D.mul( G ).mul( F ).div( max( float( 4.0 ).mul( dots.NoV ).mul( dots.NoL ), EPSILON ) );
84
84
 
85
- // Kulla-Conty multiscatter energy compensation for rough surfaces
86
- const specular = specularSS.mul( multiscatterCompensation( F0, dots.NoV, material.roughness ) );
85
+ // Shared DFG evaluation compensation factor and total directional albedo
86
+ // come from the same polynomial.
87
+ const dfg = DFGResult.wrap( evaluateDFG( F0, dots.NoV, material.roughness ) );
88
+ const specular = specularSS.mul( dfg.compensation );
87
89
 
88
90
  // Diffuse energy budget from hemisphere-integrated specular albedo (includes multiscatter)
89
- const E_total = specularDirectionalAlbedo( F0, dots.NoV, material.roughness );
90
- const kD = vec3( 1.0 ).sub( E_total ).mul( float( 1.0 ).sub( material.metalness ) );
91
+ const kD = vec3( 1.0 ).sub( dfg.E_total ).mul( float( 1.0 ).sub( material.metalness ) );
91
92
  const diffuse = kD.mul( materialColor ).mul( PI_INV );
92
93
 
93
94
  const baseLayer = diffuse.add( specular ).toVar();
@@ -118,6 +119,15 @@ export const evaluateMaterialResponse = Fn( ( [ V, L, N, material ] ) => {
118
119
 
119
120
  } );
120
121
 
122
+ // Wrapper that computes dot products internally. Use this when you don't already
123
+ // have dots; otherwise prefer evaluateMaterialResponseFromDots to share the work.
124
+ export const evaluateMaterialResponse = Fn( ( [ V, L, N, material ] ) => {
125
+
126
+ const dots = DotProducts.wrap( computeDotProducts( N, V, L ) );
127
+ return evaluateMaterialResponseFromDots( material, dots );
128
+
129
+ } );
130
+
121
131
  // -----------------------------------------------------------------------------
122
132
  // Layered BRDF Evaluation (for clearcoat)
123
133
  // -----------------------------------------------------------------------------
@@ -138,12 +148,13 @@ export const evaluateLayeredBRDF = Fn( ( [ dots, material ] ) => {
138
148
  const F = fresnelSchlick( dots.VoH, F0 ).toVar();
139
149
  const baseBRDFSS = D.mul( G ).mul( F ).div( max( float( 4.0 ).mul( dots.NoV ).mul( dots.NoL ), EPSILON ) );
140
150
 
141
- // Kulla-Conty multiscatter energy compensation for rough surfaces
142
- const baseBRDF = baseBRDFSS.mul( multiscatterCompensation( F0, dots.NoV, material.roughness ) );
151
+ // Shared DFG evaluation compensation factor and total directional albedo
152
+ // come from the same polynomial.
153
+ const dfg = DFGResult.wrap( evaluateDFG( F0, dots.NoV, material.roughness ) );
154
+ const baseBRDF = baseBRDFSS.mul( dfg.compensation );
143
155
 
144
156
  // Diffuse energy budget from hemisphere-integrated specular albedo (includes multiscatter)
145
- const E_total = specularDirectionalAlbedo( F0, dots.NoV, material.roughness );
146
- const kD = vec3( 1.0 ).sub( E_total ).mul( float( 1.0 ).sub( material.metalness ) );
157
+ const kD = vec3( 1.0 ).sub( dfg.E_total ).mul( float( 1.0 ).sub( material.metalness ) );
147
158
  const diffuse = kD.mul( material.color.rgb ).div( PI );
148
159
  const baseLayer = diffuse.add( baseBRDF );
149
160