rayzee 7.20.3 → 7.22.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 (42) hide show
  1. package/README.md +75 -4
  2. package/dist/rayzee.es.js +2821 -2854
  3. package/dist/rayzee.umd.js +60 -58
  4. package/package.json +1 -1
  5. package/src/EngineDefaults.js +9 -2
  6. package/src/PathTracerApp.js +83 -0
  7. package/src/Pipeline/BindingAudit.js +178 -0
  8. package/src/Pipeline/RenderPipeline.js +5 -2
  9. package/src/Processor/BVHBuilder.js +1 -4
  10. package/src/Processor/CameraOptimizer.js +0 -5
  11. package/src/Processor/KernelManager.js +17 -70
  12. package/src/Processor/PackedRayBuffer.js +33 -8
  13. package/src/Processor/QueueManager.js +4 -3
  14. package/src/Processor/SceneProcessor.js +10 -4
  15. package/src/Processor/TreeletOptimizer.js +0 -7
  16. package/src/RenderSettings.js +41 -6
  17. package/src/Stages/ASVGF.js +10 -10
  18. package/src/Stages/BilateralFilter.js +70 -11
  19. package/src/Stages/EdgeFilter.js +44 -6
  20. package/src/Stages/NormalDepth.js +26 -15
  21. package/src/Stages/PathTracer.js +36 -34
  22. package/src/Stages/PathTracerStage.js +15 -46
  23. package/src/TSL/BVHTraversal.js +1 -67
  24. package/src/TSL/CameraRay.js +141 -0
  25. package/src/TSL/Common.js +1 -47
  26. package/src/TSL/DebugKernel.js +4 -5
  27. package/src/TSL/EmissiveSampling.js +40 -44
  28. package/src/TSL/ExtendKernel.js +11 -3
  29. package/src/TSL/FinalWriteKernel.js +1 -14
  30. package/src/TSL/GenerateKernel.js +11 -8
  31. package/src/TSL/LightsCore.js +3 -0
  32. package/src/TSL/LightsDirect.js +4 -4
  33. package/src/TSL/LightsSampling.js +138 -327
  34. package/src/TSL/PathTracerCore.js +2 -2
  35. package/src/TSL/Random.js +2 -2
  36. package/src/TSL/ShadeKernel.js +39 -17
  37. package/src/TSL/SortGlobalKernels.js +52 -7
  38. package/src/TSL/Struct.js +0 -10
  39. package/src/index.js +5 -0
  40. package/src/managers/DenoisingManager.js +13 -0
  41. package/src/managers/EnvironmentManager.js +6 -8
  42. package/src/managers/UniformManager.js +12 -2
@@ -4,14 +4,14 @@
4
4
  * Pure TSL: Fn(), If(), Loop(), .toVar(), .assign() — NO wgslFn()
5
5
  *
6
6
  * Direct lighting combines:
7
- * - Stochastic discrete light / BRDF selection (area, point, spot, directional)
7
+ * - Discrete light NEE + BSDF-hit, both evaluated per vertex (area, point, spot, directional)
8
8
  * - Deterministic environment NEE (always runs, two-strategy Veach MIS with implicit miss)
9
9
  *
10
10
  * Contains:
11
11
  * - sampleRectAreaLight — rectangle area light sampling
12
12
  * - sampleSpotLightWithRadius — spot light sampling with radius
13
13
  * - samplePointLightWithAttenuation — point light sampling with attenuation
14
- * - sampleLightWithImportance — importance-weighted light selection (3-pass)
14
+ * - sampleLightWithImportance — importance-weighted light selection (reservoir)
15
15
  * - calculateMaterialPDF — material PDF for MIS
16
16
  * - calculateDirectLightingUnified — unified direct lighting (main entry)
17
17
  */
@@ -61,7 +61,7 @@ import {
61
61
  sphQuadSolidAngle,
62
62
  } from './LightsCore.js';
63
63
 
64
- import { MISStrategy, DotProducts, DirectLightingDual } from './Struct.js';
64
+ import { DotProducts, DirectLightingDual } from './Struct.js';
65
65
  import {
66
66
  calculateDirectionalLightImportance,
67
67
  estimateLightImportance,
@@ -75,7 +75,6 @@ import { evaluateMaterialResponseFromDots } from './MaterialEvaluation.js';
75
75
  import { calculateBSDFSamplingPDFFromMaterial } from './MaterialProperties.js';
76
76
  import { getRandomSample1D, getRandomSample2D, SAMPLER_DIM_AUX_BASE } from './Random.js';
77
77
  import {
78
- selectOptimalMISStrategy,
79
78
  PI,
80
79
  PI_INV,
81
80
  EPSILON,
@@ -223,6 +222,7 @@ export const sampleRectAreaLight = Fn( ( [ light, rayOrigin, ruv, lightSelection
223
222
  distance: ls_distance,
224
223
  pdf: ls_pdf,
225
224
  lightType: ls_lightType,
225
+ selectionTotalWeight: float( 0.0 ),
226
226
  } );
227
227
 
228
228
  } );
@@ -281,6 +281,7 @@ export const sampleSpotLightWithRadius = Fn( ( [ light, rayOrigin, lightSelectio
281
281
  distance: ls_distance,
282
282
  pdf: ls_pdf,
283
283
  lightType: ls_lightType,
284
+ selectionTotalWeight: float( 0.0 ),
284
285
  } );
285
286
 
286
287
  } );
@@ -324,6 +325,7 @@ export const samplePointLightWithAttenuation = Fn( ( [ light, rayOrigin, lightSe
324
325
  distance: ls_distance,
325
326
  pdf: ls_pdf,
326
327
  lightType: ls_lightType,
328
+ selectionTotalWeight: float( 0.0 ),
327
329
  } );
328
330
 
329
331
  } );
@@ -355,12 +357,19 @@ export const sampleLightWithImportance = Fn( ( [
355
357
  const r_distance = float( 0.0 ).toVar();
356
358
  const r_pdf = float( 0.0 ).toVar();
357
359
  const r_lightType = int( LIGHT_TYPE_POINT ).toVar();
360
+ const totalWeight = float( 0.0 ).toVar();
358
361
 
359
362
  const totalLights = numDirectionalLights.add( numAreaLights ).add( numPointLights ).add( numSpotLights ).toVar();
360
363
 
361
364
  If( totalLights.greaterThan( int( 0 ) ), () => {
362
365
 
363
- const totalWeight = float( 0.0 ).toVar();
366
+ // One sampler dimension per light in the scene: the reservoir's replacement tests must
367
+ // be mutually independent, and a per-type block hands light 16 of one type the same
368
+ // variate as light 0 of the next.
369
+ const dimDir = int( SAMPLER_DIM_AUX_BASE + 64 ).toVar();
370
+ const dimArea = dimDir.add( numDirectionalLights ).toVar();
371
+ const dimPoint = dimArea.add( numAreaLights ).toVar();
372
+ const dimSpot = dimPoint.add( numPointLights ).toVar();
364
373
 
365
374
  // Reservoir state: winning light's type/index/importance.
366
375
  const selectedType = int( - 1 ).toVar(); // 0=dir, 1=area, 2=point, 3=spot
@@ -375,22 +384,18 @@ export const sampleLightWithImportance = Fn( ( [
375
384
 
376
385
  Loop( { start: int( 0 ), end: numDirectionalLights, type: 'int', condition: '<' }, ( { i } ) => {
377
386
 
378
- If( i.lessThan( int( 16 ) ), () => {
379
-
380
- const light = DirectionalLight.wrap( getDirectionalLight( directionalLightsBuffer, i ) );
381
- const importance = calculateDirectionalLightImportance( light, normal, material, bounceIndex ).toVar();
382
- totalWeight.addAssign( importance );
383
- If( importance.greaterThan( 0.0 ).and(
384
- getRandomSample1D( pixelCoord, int( 0 ),
385
- int( SAMPLER_DIM_AUX_BASE + 64 + 0 * 16 ).add( i ), rngState, resolution, frame
386
- ).mul( totalWeight ).lessThan( importance )
387
- ), () => {
388
-
389
- selectedType.assign( 0 );
390
- selectedIdx.assign( i );
391
- selectedImportance.assign( importance );
392
-
393
- } );
387
+ const light = DirectionalLight.wrap( getDirectionalLight( directionalLightsBuffer, i ) );
388
+ const importance = calculateDirectionalLightImportance( light, normal, material, bounceIndex ).toVar();
389
+ totalWeight.addAssign( importance );
390
+ If( importance.greaterThan( 0.0 ).and(
391
+ getRandomSample1D( pixelCoord, int( 0 ),
392
+ dimDir.add( i ), rngState, resolution, frame
393
+ ).mul( totalWeight ).lessThan( importance )
394
+ ), () => {
395
+
396
+ selectedType.assign( 0 );
397
+ selectedIdx.assign( i );
398
+ selectedImportance.assign( importance );
394
399
 
395
400
  } );
396
401
 
@@ -402,22 +407,18 @@ export const sampleLightWithImportance = Fn( ( [
402
407
 
403
408
  Loop( { start: int( 0 ), end: numAreaLights, type: 'int', condition: '<' }, ( { i } ) => {
404
409
 
405
- If( i.lessThan( int( 16 ) ), () => {
406
-
407
- const light = AreaLight.wrap( getAreaLight( areaLightsBuffer, i ) );
408
- const importance = select( light.intensity.greaterThan( 0.0 ), estimateLightImportance( light, rayOrigin, normal, material ), float( 0.0 ) ).toVar();
409
- totalWeight.addAssign( importance );
410
- If( importance.greaterThan( 0.0 ).and(
411
- getRandomSample1D( pixelCoord, int( 0 ),
412
- int( SAMPLER_DIM_AUX_BASE + 64 + 1 * 16 ).add( i ), rngState, resolution, frame
413
- ).mul( totalWeight ).lessThan( importance )
414
- ), () => {
415
-
416
- selectedType.assign( 1 );
417
- selectedIdx.assign( i );
418
- selectedImportance.assign( importance );
419
-
420
- } );
410
+ const light = AreaLight.wrap( getAreaLight( areaLightsBuffer, i ) );
411
+ const importance = select( light.intensity.greaterThan( 0.0 ), estimateLightImportance( light, rayOrigin, normal, material ), float( 0.0 ) ).toVar();
412
+ totalWeight.addAssign( importance );
413
+ If( importance.greaterThan( 0.0 ).and(
414
+ getRandomSample1D( pixelCoord, int( 0 ),
415
+ dimArea.add( i ), rngState, resolution, frame
416
+ ).mul( totalWeight ).lessThan( importance )
417
+ ), () => {
418
+
419
+ selectedType.assign( 1 );
420
+ selectedIdx.assign( i );
421
+ selectedImportance.assign( importance );
421
422
 
422
423
  } );
423
424
 
@@ -429,22 +430,18 @@ export const sampleLightWithImportance = Fn( ( [
429
430
 
430
431
  Loop( { start: int( 0 ), end: numPointLights, type: 'int', condition: '<' }, ( { i } ) => {
431
432
 
432
- If( i.lessThan( int( 16 ) ), () => {
433
-
434
- const light = PointLight.wrap( getPointLight( pointLightsBuffer, i ) );
435
- const importance = calculatePointLightImportance( light, rayOrigin, normal, material ).toVar();
436
- totalWeight.addAssign( importance );
437
- If( importance.greaterThan( 0.0 ).and(
438
- getRandomSample1D( pixelCoord, int( 0 ),
439
- int( SAMPLER_DIM_AUX_BASE + 64 + 2 * 16 ).add( i ), rngState, resolution, frame
440
- ).mul( totalWeight ).lessThan( importance )
441
- ), () => {
442
-
443
- selectedType.assign( 2 );
444
- selectedIdx.assign( i );
445
- selectedImportance.assign( importance );
446
-
447
- } );
433
+ const light = PointLight.wrap( getPointLight( pointLightsBuffer, i ) );
434
+ const importance = calculatePointLightImportance( light, rayOrigin, normal, material ).toVar();
435
+ totalWeight.addAssign( importance );
436
+ If( importance.greaterThan( 0.0 ).and(
437
+ getRandomSample1D( pixelCoord, int( 0 ),
438
+ dimPoint.add( i ), rngState, resolution, frame
439
+ ).mul( totalWeight ).lessThan( importance )
440
+ ), () => {
441
+
442
+ selectedType.assign( 2 );
443
+ selectedIdx.assign( i );
444
+ selectedImportance.assign( importance );
448
445
 
449
446
  } );
450
447
 
@@ -456,22 +453,18 @@ export const sampleLightWithImportance = Fn( ( [
456
453
 
457
454
  Loop( { start: int( 0 ), end: numSpotLights, type: 'int', condition: '<' }, ( { i } ) => {
458
455
 
459
- If( i.lessThan( int( 16 ) ), () => {
460
-
461
- const light = SpotLight.wrap( getSpotLight( spotLightsBuffer, i ) );
462
- const importance = calculateSpotLightImportance( light, rayOrigin, normal, material ).toVar();
463
- totalWeight.addAssign( importance );
464
- If( importance.greaterThan( 0.0 ).and(
465
- getRandomSample1D( pixelCoord, int( 0 ),
466
- int( SAMPLER_DIM_AUX_BASE + 64 + 3 * 16 ).add( i ), rngState, resolution, frame
467
- ).mul( totalWeight ).lessThan( importance )
468
- ), () => {
469
-
470
- selectedType.assign( 3 );
471
- selectedIdx.assign( i );
472
- selectedImportance.assign( importance );
473
-
474
- } );
456
+ const light = SpotLight.wrap( getSpotLight( spotLightsBuffer, i ) );
457
+ const importance = calculateSpotLightImportance( light, rayOrigin, normal, material ).toVar();
458
+ totalWeight.addAssign( importance );
459
+ If( importance.greaterThan( 0.0 ).and(
460
+ getRandomSample1D( pixelCoord, int( 0 ),
461
+ dimSpot.add( i ), rngState, resolution, frame
462
+ ).mul( totalWeight ).lessThan( importance )
463
+ ), () => {
464
+
465
+ selectedType.assign( 3 );
466
+ selectedIdx.assign( i );
467
+ selectedImportance.assign( importance );
475
468
 
476
469
  } );
477
470
 
@@ -698,6 +691,7 @@ export const sampleLightWithImportance = Fn( ( [
698
691
  distance: r_distance,
699
692
  pdf: r_pdf,
700
693
  lightType: r_lightType,
694
+ selectionTotalWeight: totalWeight,
701
695
  } );
702
696
 
703
697
  } );
@@ -724,86 +718,6 @@ export const calculateMaterialPDF = Fn( ( [ viewDir, lightDir, normal, material
724
718
 
725
719
  } );
726
720
 
727
- // Total light-selection weight at a shading point, replicating the NEE reservoir's
728
- // importance accumulation (same per-type importance fns, same per-type 16-light cap + order).
729
- // Used to reconstruct the exact NEE selection pdf for MIS on the BSDF-hit path —
730
- // the analogue of Cycles' light_tree_pdf re-walk. Without it, the BSDF-hit MIS
731
- // partner assumes uniform 1/N selection, which disagrees with the importance-
732
- // weighted NEE selection and biases the power-heuristic weights.
733
- export const computeTotalLightImportance = Fn( ( [
734
- rayOrigin, normal, material, bounceIndex,
735
- directionalLightsBuffer, numDirectionalLights,
736
- areaLightsBuffer, numAreaLights,
737
- pointLightsBuffer, numPointLights,
738
- spotLightsBuffer, numSpotLights,
739
- ] ) => {
740
-
741
- const totalWeight = float( 0.0 ).toVar();
742
-
743
- If( numDirectionalLights.greaterThan( int( 0 ) ), () => {
744
-
745
- Loop( { start: int( 0 ), end: numDirectionalLights, type: 'int', condition: '<' }, ( { i } ) => {
746
-
747
- If( i.lessThan( int( 16 ) ), () => {
748
-
749
- const light = DirectionalLight.wrap( getDirectionalLight( directionalLightsBuffer, i ) );
750
- totalWeight.addAssign( calculateDirectionalLightImportance( light, normal, material, bounceIndex ) );
751
-
752
- } );
753
-
754
- } );
755
-
756
- } );
757
-
758
- If( numAreaLights.greaterThan( int( 0 ) ), () => {
759
-
760
- Loop( { start: int( 0 ), end: numAreaLights, type: 'int', condition: '<' }, ( { i } ) => {
761
-
762
- If( i.lessThan( int( 16 ) ), () => {
763
-
764
- const light = AreaLight.wrap( getAreaLight( areaLightsBuffer, i ) );
765
- totalWeight.addAssign( select( light.intensity.greaterThan( 0.0 ), estimateLightImportance( light, rayOrigin, normal, material ), float( 0.0 ) ) );
766
-
767
- } );
768
-
769
- } );
770
-
771
- } );
772
-
773
- If( numPointLights.greaterThan( int( 0 ) ), () => {
774
-
775
- Loop( { start: int( 0 ), end: numPointLights, type: 'int', condition: '<' }, ( { i } ) => {
776
-
777
- If( i.lessThan( int( 16 ) ), () => {
778
-
779
- const light = PointLight.wrap( getPointLight( pointLightsBuffer, i ) );
780
- totalWeight.addAssign( calculatePointLightImportance( light, rayOrigin, normal, material ) );
781
-
782
- } );
783
-
784
- } );
785
-
786
- } );
787
-
788
- If( numSpotLights.greaterThan( int( 0 ) ), () => {
789
-
790
- Loop( { start: int( 0 ), end: numSpotLights, type: 'int', condition: '<' }, ( { i } ) => {
791
-
792
- If( i.lessThan( int( 16 ) ), () => {
793
-
794
- const light = SpotLight.wrap( getSpotLight( spotLightsBuffer, i ) );
795
- totalWeight.addAssign( calculateSpotLightImportance( light, rayOrigin, normal, material ) );
796
-
797
- } );
798
-
799
- } );
800
-
801
- } );
802
-
803
- return totalWeight;
804
-
805
- } );
806
-
807
721
  // =============================================================================
808
722
  // Unified Direct Lighting System
809
723
  // =============================================================================
@@ -853,81 +767,21 @@ export const calculateDirectLightingUnified = Fn( ( [
853
767
  // Early exit for highly emissive surfaces
854
768
  If( material.emissiveIntensity.lessThanEqual( 10.0 ), () => {
855
769
 
856
- // Adaptive MIS Strategy Selection
857
- const currentThroughput = vec3( 1.0 ).toVar();
858
- const misResult = MISStrategy.wrap( selectOptimalMISStrategy(
859
- material.roughness, material.metalness, bounceIndex, currentThroughput
860
- ) );
861
-
862
- // Extract MIS fields to mutable variables
863
- // (env is handled deterministically below, not part of stochastic selection)
864
- const useBRDFSampling = misResult.useBRDFSampling.toVar();
865
- const useLightSampling = misResult.useLightSampling.toVar();
866
- const brdfWeight = misResult.brdfWeight.toVar();
867
- const lightWeight = misResult.lightWeight.toVar();
868
-
869
- // Adaptive light processing
770
+ // NEE and BSDF-hit both run at every vertex, one sample each, so the MIS weights are the
771
+ // plain power heuristic over the raw densities. Picking one strategy stochastically and
772
+ // dividing by the selection probability was unbiased but cost 3× RMSE on an area light.
870
773
  const totalLights = numDirectionalLights.add( numAreaLights ).add( numPointLights ).add( numSpotLights ).toVar();
871
-
872
- const importanceThreshold = float( 0.001 ).mul( float( 1.0 ).add( float( bounceIndex ).mul( 0.5 ) ) ).toVar();
873
-
874
- // Check if discrete lights exist
875
774
  const hasDiscreteLights = totalLights.greaterThan( int( 0 ) ).toVar();
876
775
 
877
- // Calculate total sampling weight for stochastic {lights, BRDF} selection
878
- const totalSamplingWeight = float( 0.0 ).toVar();
879
-
880
- If( useLightSampling.and( hasDiscreteLights ), () => {
881
-
882
- totalSamplingWeight.addAssign( lightWeight );
883
-
884
- } );
885
-
886
- If( useBRDFSampling, () => {
887
-
888
- totalSamplingWeight.addAssign( brdfWeight );
889
-
890
- } );
891
-
892
- If( totalSamplingWeight.lessThanEqual( 0.0 ), () => {
893
-
894
- totalSamplingWeight.assign( 1.0 );
895
- useBRDFSampling.assign( tslBool( true ) );
896
- brdfWeight.assign( 1.0 );
897
-
898
- } );
899
-
900
- // Determine sampling technique: stochastic {lights, BRDF}
901
- const rand = getRandomSample1D( pixelCoord, int( 0 ), dimBase, rngState, resolution, frame ).toVar();
902
- const sampleLights = tslBool( false ).toVar();
903
- const sampleBRDF = tslBool( false ).toVar();
904
-
905
- // Calculate effective weights for probability (only include light weight if lights exist)
906
- const effectiveLightWeight = select( hasDiscreteLights, lightWeight, float( 0.0 ) );
907
- // Guard division
908
- const invTotalSamplingWeight = float( 1.0 ).div( max( totalSamplingWeight, 1e-10 ) );
909
- const cumulativeLight = effectiveLightWeight.mul( invTotalSamplingWeight );
910
-
911
- If( rand.lessThan( cumulativeLight ).and( useLightSampling ).and( hasDiscreteLights ), () => {
912
-
913
- sampleLights.assign( tslBool( true ) );
914
-
915
- } ).ElseIf( useBRDFSampling, () => {
916
-
917
- sampleBRDF.assign( tslBool( true ) );
918
-
919
- } ).ElseIf( hasDiscreteLights, () => {
920
-
921
- // Fallback to light sampling only if lights exist
922
- sampleLights.assign( tslBool( true ) );
923
-
924
- } );
776
+ // Reservoir denominator from the NEE walk above; the BSDF-hit branch below reuses it
777
+ // rather than re-walking every light buffer to rebuild the same sum.
778
+ const neeTotalWeight = float( 0.0 ).toVar();
925
779
 
926
780
  // =====================================================================
927
781
  // LIGHT SAMPLING PATH
928
782
  // =====================================================================
929
783
 
930
- If( sampleLights, () => {
784
+ If( hasDiscreteLights, () => {
931
785
 
932
786
  // Importance-weighted light sampling
933
787
  const lightRandom = getRandomSample2D( pixelCoord, int( 0 ), dimBase.add( int( 2 ) ), rngState, resolution, frame ).toVar();
@@ -940,12 +794,13 @@ export const calculateDirectLightingUnified = Fn( ( [
940
794
  spotLightsBuffer, numSpotLights,
941
795
  ) );
942
796
 
797
+ neeTotalWeight.assign( lightSample.selectionTotalWeight );
798
+
943
799
  If( lightSample.valid.and( lightSample.pdf.greaterThan( 0.0 ) ), () => {
944
800
 
945
801
  const NoL = max( float( 0.0 ), dot( hitNormal, lightSample.direction ) ).toVar();
946
- const lightImportance = lightSample.emission.x.add( lightSample.emission.y ).add( lightSample.emission.z );
947
802
 
948
- If( NoL.greaterThan( 0.0 ).and( lightImportance.mul( NoL ).greaterThan( importanceThreshold ) ).and( isDirectionValid( { direction: lightSample.direction, surfaceNormal: hitNormal } ) ), () => {
803
+ If( NoL.greaterThan( 0.0 ).and( isDirectionValid( { direction: lightSample.direction, surfaceNormal: hitNormal } ) ), () => {
949
804
 
950
805
  const shadowDistance = min( lightSample.distance.sub( 0.001 ), float( 1000.0 ) );
951
806
  const visibility = shadow( rayOrigin, lightSample.direction, shadowDistance );
@@ -958,28 +813,19 @@ export const calculateDirectLightingUnified = Fn( ( [
958
813
  const brdfValue = evaluateMaterialResponseFromDots( material, sharedDots );
959
814
  const bPdf = calculateMaterialPDFFromDots( material, sharedDots ).toVar();
960
815
 
816
+ // Power heuristic only for area lights — they are the only type the BRDF
817
+ // path can intersect, so elsewhere MIS would just delete energy.
961
818
  const misW = float( 1.0 ).toVar();
962
819
 
963
- If( bPdf.greaterThan( 0.0 ).and( useBRDFSampling ), () => {
964
-
965
- const lightPdfWeighted = lightSample.pdf.mul( lightWeight );
966
- const brdfPdfWeighted = bPdf.mul( brdfWeight );
820
+ If( bPdf.greaterThan( 0.0 ).and( lightSample.lightType.equal( int( LIGHT_TYPE_AREA ) ) ), () => {
967
821
 
968
- // Apply power heuristic only for area lights — the BRDF path can
969
- // intersect area lights, so both strategies contribute and MIS is valid.
970
- // Point/spot/directional lights are delta or non-intersectable by the
971
- // BRDF path, so MIS would only reduce energy without compensation.
972
- If( lightSample.lightType.equal( int( LIGHT_TYPE_AREA ) ), () => {
973
-
974
- misW.assign( powerHeuristic( { pdf1: lightPdfWeighted, pdf2: brdfPdfWeighted } ) );
975
-
976
- } );
822
+ misW.assign( powerHeuristic( { pdf1: lightSample.pdf, pdf2: bPdf } ) );
977
823
 
978
824
  } );
979
825
 
980
826
  // Base contribution WITHOUT visibility; shadowed = base × visibility (identical to the
981
827
  // pre-dual-sum math), unoccluded = base × 1 (shadow-catcher reference only).
982
- const baseContribution = lightSample.emission.mul( brdfValue ).mul( NoL ).mul( misW ).div( max( lightSample.pdf, 1e-10 ) ).mul( totalSamplingWeight ).div( max( lightWeight, 1e-10 ) );
828
+ const baseContribution = lightSample.emission.mul( brdfValue ).mul( NoL ).mul( misW ).div( max( lightSample.pdf, 1e-10 ) );
983
829
  totalContribution.addAssign( baseContribution.mul( visibility ) );
984
830
  If( wantUnoccluded, () => {
985
831
 
@@ -999,129 +845,94 @@ export const calculateDirectLightingUnified = Fn( ( [
999
845
  // BRDF SAMPLING PATH
1000
846
  // =====================================================================
1001
847
 
1002
- If( sampleBRDF, () => {
1003
-
1004
- If( brdfSamplePdf.greaterThan( 0.0 ).and( useBRDFSampling ), () => {
1005
-
1006
- const NoL = max( float( 0.0 ), dot( hitNormal, brdfSampleDirection ) ).toVar();
1007
-
1008
- If( NoL.greaterThan( 0.0 ).and( isDirectionValid( { direction: brdfSampleDirection, surfaceNormal: hitNormal } ) ), () => {
848
+ If( brdfSamplePdf.greaterThan( 0.0 ).and( numAreaLights.greaterThan( int( 0 ) ) ), () => {
1009
849
 
1010
- // Check intersection with area lights
1011
- If( numAreaLights.greaterThan( int( 0 ) ), () => {
850
+ const NoL = max( float( 0.0 ), dot( hitNormal, brdfSampleDirection ) ).toVar();
1012
851
 
1013
- const foundIntersection = tslBool( false ).toVar();
1014
- const maxImportance = float( 0.0 ).toVar();
1015
- const maxImportanceLight = int( - 1 ).toVar();
852
+ If( NoL.greaterThan( 0.0 ).and( isDirectionValid( { direction: brdfSampleDirection, surfaceNormal: hitNormal } ) ), () => {
1016
853
 
1017
- // Track best match (no early break)
1018
- Loop( { start: int( 0 ), end: numAreaLights, type: 'int', condition: '<' }, ( { i } ) => {
854
+ // Nearest hit — the ray stops at the first emitter, so a brighter one behind it is
855
+ // the wrong emitter and the wrong pdf. No importance gate: skipping a light drops
856
+ // energy the NEE partner's MIS weight already counted on, and estimateLightImportance
857
+ // reads zero across the terminator band of a large light (its CENTRE is below the
858
+ // horizon while half of it is still visible).
859
+ const nearestT = float( 1e30 ).toVar();
860
+ const nearestLight = int( - 1 ).toVar();
1019
861
 
1020
- const light = AreaLight.wrap( getAreaLight( areaLightsBuffer, i ) );
862
+ Loop( { start: int( 0 ), end: numAreaLights, type: 'int', condition: '<' }, ( { i } ) => {
1021
863
 
1022
- If( light.intensity.greaterThan( 0.0 ), () => {
1023
-
1024
- const lightImp = estimateLightImportance( light, hitPoint, hitNormal, material ).toVar();
1025
-
1026
- If( lightImp.greaterThanEqual( importanceThreshold ), () => {
1027
-
1028
- const hitDistance = intersectAreaLight( light, rayOrigin, brdfSampleDirection ).toVar();
1029
-
1030
- If( hitDistance.greaterThan( 0.0 ), () => {
1031
-
1032
- If( lightImp.greaterThan( maxImportance ), () => {
864
+ const light = AreaLight.wrap( getAreaLight( areaLightsBuffer, i ) );
1033
865
 
1034
- maxImportance.assign( lightImp );
1035
- maxImportanceLight.assign( i );
866
+ If( light.intensity.greaterThan( 0.0 ), () => {
1036
867
 
1037
- } );
1038
- foundIntersection.assign( tslBool( true ) );
868
+ const hitDistance = intersectAreaLight( light, rayOrigin, brdfSampleDirection ).toVar();
1039
869
 
1040
- } );
870
+ If( hitDistance.greaterThan( 0.0 ).and( hitDistance.lessThan( nearestT ) ), () => {
1041
871
 
1042
- } );
1043
-
1044
- } );
872
+ nearestT.assign( hitDistance );
873
+ nearestLight.assign( i );
1045
874
 
1046
875
  } );
1047
876
 
1048
- If( foundIntersection.and( maxImportanceLight.greaterThanEqual( int( 0 ) ) ), () => {
1049
-
1050
- const light = AreaLight.wrap( getAreaLight( areaLightsBuffer, maxImportanceLight ) );
1051
- const hitDistance = intersectAreaLight( light, rayOrigin, brdfSampleDirection ).toVar();
1052
-
1053
- If( hitDistance.greaterThan( 0.0 ), () => {
1054
-
1055
- const shadowDistance = min( hitDistance.sub( 0.001 ), float( 1000.0 ) );
1056
- const visibility = shadow( rayOrigin, brdfSampleDirection, shadowDistance );
1057
-
1058
- If( visibility.greaterThan( 0.0 ).or( wantUnoccluded ), () => {
1059
-
1060
- const lightFacing = max( float( 0.0 ), dot( brdfSampleDirection, light.normal ).negate() ).toVar();
1061
-
1062
- If( lightFacing.greaterThan( 0.0 ), () => {
1063
-
1064
- const lightDistSq = hitDistance.mul( hitDistance );
1065
-
1066
- // Directional pdf must match the NEE sampler: 1/S (spherical-rect)
1067
- // for rectangles, dist²/(area·cos) for disk/ellipse + degenerate rects.
1068
- const corner = light.position.sub( light.u ).sub( light.v );
1069
- const sAngle = sphQuadSolidAngle( rayOrigin, corner, light.u.mul( 2.0 ), light.v.mul( 2.0 ) ).toVar();
1070
- const areaPdf = lightDistSq.div( max( light.area.mul( lightFacing ), EPSILON ) );
1071
- const dirPdf = select(
1072
- light.shape.lessThan( 0.5 ).and( sAngle.greaterThan( 1e-5 ) ),
1073
- float( 1.0 ).div( max( sAngle, 1e-10 ) ),
1074
- areaPdf,
1075
- ).toVar();
1076
-
1077
- // Selection pdf consistent with the importance-weighted NEE reservoir
1078
- // (Cycles light_tree_pdf re-walk). Falls back to uniform 1/N only when
1079
- // total importance is zero, matching the NEE fallback.
1080
- const selImp = estimateLightImportance( light, rayOrigin, hitNormal, material );
1081
- const totalImp = computeTotalLightImportance(
1082
- rayOrigin, hitNormal, material, bounceIndex,
1083
- directionalLightsBuffer, numDirectionalLights,
1084
- areaLightsBuffer, numAreaLights,
1085
- pointLightsBuffer, numPointLights,
1086
- spotLightsBuffer, numSpotLights,
1087
- ).toVar();
1088
- const selPdf = select(
1089
- totalImp.greaterThan( 0.0 ),
1090
- selImp.div( max( totalImp, 1e-10 ) ),
1091
- float( 1.0 ).div( max( float( totalLights ), 1.0 ) ),
1092
- );
1093
- const lightPdf = dirPdf.mul( selPdf ).toVar();
1094
-
1095
- const brdfPdfWeighted = brdfSamplePdf.mul( brdfWeight );
1096
- const lightPdfWeighted = lightPdf.mul( lightWeight );
1097
- const misW = powerHeuristic( { pdf1: brdfPdfWeighted, pdf2: lightPdfWeighted } ).toVar();
877
+ } );
1098
878
 
1099
- const lightEmission = areaLightRadiance( light ).mul( areaLightSpreadAttenuation( lightFacing, light.spread ) );
1100
- // Base contribution WITHOUT visibility (see discrete-light site).
1101
- const baseContribution = lightEmission.mul( brdfSampleValue ).mul( NoL ).mul( misW ).div( max( brdfSamplePdf, 1e-10 ) ).mul( totalSamplingWeight ).div( max( brdfWeight, 1e-10 ) );
1102
- totalContribution.addAssign( baseContribution.mul( visibility ) );
1103
- If( wantUnoccluded, () => {
879
+ } );
1104
880
 
1105
- unoccludedContribution.addAssign( baseContribution );
881
+ If( nearestLight.greaterThanEqual( int( 0 ) ), () => {
1106
882
 
1107
- } );
883
+ const light = AreaLight.wrap( getAreaLight( areaLightsBuffer, nearestLight ) );
884
+ const shadowDistance = min( nearestT.sub( 0.001 ), float( 1000.0 ) );
885
+ const visibility = shadow( rayOrigin, brdfSampleDirection, shadowDistance );
1108
886
 
1109
- } );
887
+ If( visibility.greaterThan( 0.0 ).or( wantUnoccluded ), () => {
1110
888
 
1111
- } );
889
+ const lightFacing = max( float( 0.0 ), dot( brdfSampleDirection, light.normal ).negate() ).toVar();
890
+
891
+ If( lightFacing.greaterThan( 0.0 ), () => {
892
+
893
+ // Directional pdf must match the NEE sampler: 1/S (spherical-rect) for
894
+ // rectangles, dist²/(area·cos) for disk/ellipse + degenerate rects.
895
+ const corner = light.position.sub( light.u ).sub( light.v );
896
+ const sAngle = sphQuadSolidAngle( rayOrigin, corner, light.u.mul( 2.0 ), light.v.mul( 2.0 ) ).toVar();
897
+ const areaPdf = nearestT.mul( nearestT ).div( max( light.area.mul( lightFacing ), EPSILON ) );
898
+ const dirPdf = select(
899
+ light.shape.lessThan( 0.5 ).and( sAngle.greaterThan( 1e-5 ) ),
900
+ float( 1.0 ).div( max( sAngle, 1e-10 ) ),
901
+ areaPdf,
902
+ ).toVar();
903
+
904
+ // The NEE partner's selection pdf, rebuilt from the reservoir denominator it
905
+ // already accumulated. Uniform 1/N only when total importance is zero, matching
906
+ // the reservoir's own fallback; 0 for a light NEE can never pick, which hands
907
+ // this strategy the full MIS weight — as it must.
908
+ const selImp = estimateLightImportance( light, rayOrigin, hitNormal, material );
909
+ const selPdf = select(
910
+ neeTotalWeight.greaterThan( 0.0 ),
911
+ selImp.div( max( neeTotalWeight, 1e-10 ) ),
912
+ float( 1.0 ).div( max( float( totalLights ), 1.0 ) ),
913
+ );
914
+ const lightPdf = dirPdf.mul( selPdf ).toVar();
915
+ const misW = powerHeuristic( { pdf1: brdfSamplePdf, pdf2: lightPdf } ).toVar();
916
+
917
+ const lightEmission = areaLightRadiance( light ).mul( areaLightSpreadAttenuation( lightFacing, light.spread ) );
918
+ // Base contribution WITHOUT visibility (see discrete-light site).
919
+ const baseContribution = lightEmission.mul( brdfSampleValue ).mul( NoL ).mul( misW ).div( max( brdfSamplePdf, 1e-10 ) );
920
+ totalContribution.addAssign( baseContribution.mul( visibility ) );
921
+ If( wantUnoccluded, () => {
922
+
923
+ unoccludedContribution.addAssign( baseContribution );
1112
924
 
1113
925
  } );
1114
926
 
1115
927
  } );
1116
928
 
1117
- } ); // End numAreaLights > 0
929
+ } );
1118
930
 
1119
931
  } );
1120
932
 
1121
933
  } );
1122
934
 
1123
935
  } );
1124
-
1125
936
  // =====================================================================
1126
937
  // DETERMINISTIC ENVIRONMENT NEE
1127
938
  // Always runs (not stochastic) — forms a two-strategy Veach MIS
@@ -243,7 +243,7 @@ export const computeNDCDepth = /*@__PURE__*/ wgslFn( `
243
243
  export const handleRussianRoulette = Fn( ( [
244
244
  depth, throughput, mc, rayDirection, rngState,
245
245
  pixelCoord, resolution, frame, dimBase,
246
- enableEnvironmentLight, useEnvMapIS,
246
+ enableEnvironmentLight,
247
247
  ] ) => {
248
248
 
249
249
  const result = float( 1.0 ).toVar();
@@ -317,7 +317,7 @@ export const handleRussianRoulette = Fn( ( [
317
317
  estMaterialImportance.assign( clamp( estMaterialImportance, 0.0, 1.0 ) );
318
318
 
319
319
  const directionImportance = float( 0.5 ).toVar();
320
- If( enableEnvironmentLight.and( useEnvMapIS ).and( throughputStrength.greaterThan( 0.01 ) ), () => {
320
+ If( enableEnvironmentLight.and( throughputStrength.greaterThan( 0.01 ) ), () => {
321
321
 
322
322
  const cosTheta = clamp( rayDirection.y, 0.0, 1.0 );
323
323
  directionImportance.assign( mix( float( 0.3 ), float( 0.8 ), cosTheta.mul( cosTheta ) ) );