rayzee 7.28.0 → 7.28.2

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.
@@ -24,7 +24,6 @@ import {
24
24
  int,
25
25
  bool as tslBool,
26
26
  max,
27
- min,
28
27
  abs,
29
28
  sqrt,
30
29
  cos,
@@ -33,6 +32,7 @@ import {
33
32
  dot,
34
33
  cross,
35
34
  normalize,
35
+ length,
36
36
  mix,
37
37
  select,
38
38
  If,
@@ -87,6 +87,9 @@ import {
87
87
  } from './Environment.js';
88
88
 
89
89
  const TWO_PI = 2.0 * PI;
90
+ // Visibility rays stop this fraction short of the sampled light point: unit-free, and ≫ float
91
+ // error, so an emitter coplanar with its fixture is never occluded by it.
92
+ const SHADOW_END = 1.0 - 1e-4;
90
93
 
91
94
  // =============================================================================
92
95
  // Light Sampling Functions
@@ -768,7 +771,7 @@ export const calculateMaterialPDF = Fn( ( [ viewDir, lightDir, normal, material
768
771
  // Optimized direct lighting function with importance-based sampling and better MIS
769
772
  export const calculateDirectLightingUnified = Fn( ( [
770
773
  // Surface hit data
771
- hitPoint, hitNormal, material,
774
+ hitPoint, hitNormal, geomNormal, material,
772
775
  // View direction
773
776
  viewDir,
774
777
  // BRDF sample (DirectionSample fields)
@@ -800,81 +803,80 @@ export const calculateDirectLightingUnified = Fn( ( [
800
803
  const totalContribution = vec3( 0.0 ).toVar();
801
804
  // Unoccluded reference (visibility forced to 1) — only filled when wantUnoccluded.
802
805
  const unoccludedContribution = vec3( 0.0 ).toVar();
803
- const rayOrigin = hitPoint.add( hitNormal.mul( 0.001 ) ).toVar();
806
+ const rayOrigin = hitPoint.add( geomNormal.mul( 0.001 ) ).toVar();
804
807
 
805
808
  // Binds BVH params so shadow-ray sites at varying call depths use a 3-arg call
806
809
  const shadow = Fn( ( [ origin, dir, maxDist ] ) =>
807
810
  traceShadowRay( origin, dir, maxDist, traverseBVHShadow, bvhBuffer, triangleBuffer, materialBuffer )
808
811
  );
809
812
 
810
- // Early exit for highly emissive surfaces
811
- If( material.emissiveIntensity.lessThanEqual( 10.0 ), () => {
812
813
 
813
- // NEE and BSDF-hit both run at every vertex, one sample each, so the MIS weights are the
814
- // plain power heuristic over the raw densities. Picking one strategy stochastically and
815
- // dividing by the selection probability was unbiased but cost 3× RMSE on an area light.
816
- const totalLights = numDirectionalLights.add( numAreaLights ).add( numPointLights ).add( numSpotLights ).toVar();
817
- const hasDiscreteLights = totalLights.greaterThan( int( 0 ) ).toVar();
814
+ // NEE and BSDF-hit both run at every vertex, one sample each, so the MIS weights are the
815
+ // plain power heuristic over the raw densities. Picking one strategy stochastically and
816
+ // dividing by the selection probability was unbiased but cost 3× RMSE on an area light.
817
+ const totalLights = numDirectionalLights.add( numAreaLights ).add( numPointLights ).add( numSpotLights ).toVar();
818
+ const hasDiscreteLights = totalLights.greaterThan( int( 0 ) ).toVar();
818
819
 
819
- // Reservoir denominator from the NEE walk above; the BSDF-hit branch below reuses it
820
- // rather than re-walking every light buffer to rebuild the same sum.
821
- const neeTotalWeight = float( 0.0 ).toVar();
820
+ // Reservoir denominator from the NEE walk above; the BSDF-hit branch below reuses it
821
+ // rather than re-walking every light buffer to rebuild the same sum.
822
+ const neeTotalWeight = float( 0.0 ).toVar();
822
823
 
823
- // =====================================================================
824
- // LIGHT SAMPLING PATH
825
- // =====================================================================
824
+ // =====================================================================
825
+ // LIGHT SAMPLING PATH
826
+ // =====================================================================
826
827
 
827
- If( hasDiscreteLights, () => {
828
-
829
- // Importance-weighted light sampling
830
- const lightRandom = getRandomSample2D( pixelCoord, int( 0 ), dimBase.add( int( 2 ) ), rngState, resolution, frame ).toVar();
831
- const lightSample = LightSample.wrap( sampleLightWithImportance(
832
- rayOrigin, hitNormal, material, lightRandom, bounceIndex, rngState,
833
- pixelCoord, resolution, frame, dimBase,
834
- directionalLightsBuffer, numDirectionalLights,
835
- areaLightsBuffer, numAreaLights,
836
- pointLightsBuffer, numPointLights,
837
- spotLightsBuffer, numSpotLights,
838
- ) );
828
+ If( hasDiscreteLights, () => {
839
829
 
840
- neeTotalWeight.assign( lightSample.selectionTotalWeight );
830
+ // Importance-weighted light sampling
831
+ const lightRandom = getRandomSample2D( pixelCoord, int( 0 ), dimBase.add( int( 2 ) ), rngState, resolution, frame ).toVar();
832
+ const lightSample = LightSample.wrap( sampleLightWithImportance(
833
+ hitPoint, hitNormal, material, lightRandom, bounceIndex, rngState,
834
+ pixelCoord, resolution, frame, dimBase,
835
+ directionalLightsBuffer, numDirectionalLights,
836
+ areaLightsBuffer, numAreaLights,
837
+ pointLightsBuffer, numPointLights,
838
+ spotLightsBuffer, numSpotLights,
839
+ ) );
841
840
 
842
- If( lightSample.valid.and( lightSample.pdf.greaterThan( 0.0 ) ), () => {
841
+ neeTotalWeight.assign( lightSample.selectionTotalWeight );
843
842
 
844
- const NoL = max( float( 0.0 ), dot( hitNormal, lightSample.direction ) ).toVar();
843
+ If( lightSample.valid.and( lightSample.pdf.greaterThan( 0.0 ) ), () => {
845
844
 
846
- If( NoL.greaterThan( 0.0 ).and( isDirectionValid( { direction: lightSample.direction, surfaceNormal: hitNormal } ) ), () => {
845
+ const NoL = max( float( 0.0 ), dot( hitNormal, lightSample.direction ) ).toVar();
847
846
 
848
- const shadowDistance = min( lightSample.distance.sub( 0.001 ), float( 1000.0 ) );
849
- const visibility = shadow( rayOrigin, lightSample.direction, shadowDistance );
847
+ If( NoL.greaterThan( 0.0 ).and( isDirectionValid( { direction: lightSample.direction, surfaceNormal: geomNormal } ) ), () => {
850
848
 
851
- If( visibility.greaterThan( 0.0 ).or( wantUnoccluded ), () => {
849
+ // Light geometry is measured from the hit point; only the visibility ray starts on the lifted
850
+ // origin, aimed at the sampled point and stopped a relative hair short of it.
851
+ const toSample = hitPoint.add( lightSample.direction.mul( lightSample.distance ) ).sub( rayOrigin ).toVar();
852
+ const shadowDistance = length( toSample ).toVar();
853
+ const visibility = shadow( rayOrigin, toSample.div( shadowDistance ), shadowDistance.mul( SHADOW_END ) );
852
854
 
853
- // Share H + dot products between BRDF eval and PDF — otherwise each
854
- // would recompute normalize(V+L) + 5 dot products independently.
855
- const sharedDots = DotProducts.wrap( computeDotProductsAniso( hitNormal, viewDir, lightSample.direction, material ) );
856
- const brdfValue = evaluateMaterialResponseFromDots( material, sharedDots );
857
- const bPdf = calculateMaterialPDFFromDots( material, sharedDots ).toVar();
855
+ If( visibility.greaterThan( 0.0 ).or( wantUnoccluded ), () => {
858
856
 
859
- // Power heuristic only for area lights — they are the only type the BRDF
860
- // path can intersect, so elsewhere MIS would just delete energy.
861
- const misW = float( 1.0 ).toVar();
857
+ // Share H + dot products between BRDF eval and PDF — otherwise each
858
+ // would recompute normalize(V+L) + 5 dot products independently.
859
+ const sharedDots = DotProducts.wrap( computeDotProductsAniso( hitNormal, viewDir, lightSample.direction, material ) );
860
+ const brdfValue = evaluateMaterialResponseFromDots( material, sharedDots );
861
+ const bPdf = calculateMaterialPDFFromDots( material, sharedDots ).toVar();
862
862
 
863
- If( bPdf.greaterThan( 0.0 ).and( lightSample.lightType.equal( int( LIGHT_TYPE_AREA ) ) ), () => {
863
+ // Power heuristic only for area lights — they are the only type the BRDF
864
+ // path can intersect, so elsewhere MIS would just delete energy.
865
+ const misW = float( 1.0 ).toVar();
864
866
 
865
- misW.assign( powerHeuristic( { pdf1: lightSample.pdf, pdf2: bPdf } ) );
867
+ If( bPdf.greaterThan( 0.0 ).and( lightSample.lightType.equal( int( LIGHT_TYPE_AREA ) ) ), () => {
866
868
 
867
- } );
869
+ misW.assign( powerHeuristic( { pdf1: lightSample.pdf, pdf2: bPdf } ) );
868
870
 
869
- // Base contribution WITHOUT visibility; shadowed = base × visibility (identical to the
870
- // pre-dual-sum math), unoccluded = base × 1 (shadow-catcher reference only).
871
- const baseContribution = lightSample.emission.mul( brdfValue ).mul( NoL ).mul( misW ).div( max( lightSample.pdf, 1e-10 ) );
872
- totalContribution.addAssign( baseContribution.mul( visibility ) );
873
- If( wantUnoccluded, () => {
871
+ } );
874
872
 
875
- unoccludedContribution.addAssign( baseContribution );
873
+ // Base contribution WITHOUT visibility; shadowed = base × visibility (identical to the
874
+ // pre-dual-sum math), unoccluded = base × 1 (shadow-catcher reference only).
875
+ const baseContribution = lightSample.emission.mul( brdfValue ).mul( NoL ).mul( misW ).div( max( lightSample.pdf, 1e-10 ) );
876
+ totalContribution.addAssign( baseContribution.mul( visibility ) );
877
+ If( wantUnoccluded, () => {
876
878
 
877
- } );
879
+ unoccludedContribution.addAssign( baseContribution );
878
880
 
879
881
  } );
880
882
 
@@ -884,88 +886,89 @@ export const calculateDirectLightingUnified = Fn( ( [
884
886
 
885
887
  } );
886
888
 
887
- // =====================================================================
888
- // BRDF SAMPLING PATH
889
- // =====================================================================
889
+ } );
890
890
 
891
- If( brdfSamplePdf.greaterThan( 0.0 ).and( numAreaLights.greaterThan( int( 0 ) ) ), () => {
891
+ // =====================================================================
892
+ // BRDF SAMPLING PATH
893
+ // =====================================================================
892
894
 
893
- const NoL = max( float( 0.0 ), dot( hitNormal, brdfSampleDirection ) ).toVar();
895
+ If( brdfSamplePdf.greaterThan( 0.0 ).and( numAreaLights.greaterThan( int( 0 ) ) ), () => {
894
896
 
895
- If( NoL.greaterThan( 0.0 ).and( isDirectionValid( { direction: brdfSampleDirection, surfaceNormal: hitNormal } ) ), () => {
897
+ const NoL = max( float( 0.0 ), dot( hitNormal, brdfSampleDirection ) ).toVar();
896
898
 
897
- // Nearest hit — the ray stops at the first emitter, so a brighter one behind it is
898
- // the wrong emitter and the wrong pdf. No importance gate: skipping a light drops
899
- // energy the NEE partner's MIS weight already counted on, and estimateLightImportance
900
- // reads zero across the terminator band of a large light (its CENTRE is below the
901
- // horizon while half of it is still visible).
902
- const nearestT = float( 1e30 ).toVar();
903
- const nearestLight = int( - 1 ).toVar();
899
+ If( NoL.greaterThan( 0.0 ).and( isDirectionValid( { direction: brdfSampleDirection, surfaceNormal: geomNormal } ) ), () => {
904
900
 
905
- Loop( { start: int( 0 ), end: numAreaLights, type: 'int', condition: '<' }, ( { i } ) => {
901
+ // Nearest hit — the ray stops at the first emitter, so a brighter one behind it is
902
+ // the wrong emitter and the wrong pdf. No importance gate: skipping a light drops
903
+ // energy the NEE partner's MIS weight already counted on, and estimateLightImportance
904
+ // reads zero across the terminator band of a large light (its CENTRE is below the
905
+ // horizon while half of it is still visible).
906
+ const nearestT = float( 1e30 ).toVar();
907
+ const nearestLight = int( - 1 ).toVar();
906
908
 
907
- const light = AreaLight.wrap( getAreaLight( areaLightsBuffer, i ) );
909
+ Loop( { start: int( 0 ), end: numAreaLights, type: 'int', condition: '<' }, ( { i } ) => {
908
910
 
909
- If( light.intensity.greaterThan( 0.0 ), () => {
911
+ const light = AreaLight.wrap( getAreaLight( areaLightsBuffer, i ) );
910
912
 
911
- const hitDistance = intersectAreaLight( light, rayOrigin, brdfSampleDirection ).toVar();
913
+ If( light.intensity.greaterThan( 0.0 ), () => {
912
914
 
913
- If( hitDistance.greaterThan( 0.0 ).and( hitDistance.lessThan( nearestT ) ), () => {
915
+ const hitDistance = intersectAreaLight( light, hitPoint, brdfSampleDirection ).toVar();
914
916
 
915
- nearestT.assign( hitDistance );
916
- nearestLight.assign( i );
917
+ If( hitDistance.greaterThan( 0.0 ).and( hitDistance.lessThan( nearestT ) ), () => {
917
918
 
918
- } );
919
+ nearestT.assign( hitDistance );
920
+ nearestLight.assign( i );
919
921
 
920
922
  } );
921
923
 
922
924
  } );
923
925
 
924
- If( nearestLight.greaterThanEqual( int( 0 ) ), () => {
925
-
926
- const light = AreaLight.wrap( getAreaLight( areaLightsBuffer, nearestLight ) );
927
- const shadowDistance = min( nearestT.sub( 0.001 ), float( 1000.0 ) );
928
- const visibility = shadow( rayOrigin, brdfSampleDirection, shadowDistance );
926
+ } );
929
927
 
930
- If( visibility.greaterThan( 0.0 ).or( wantUnoccluded ), () => {
928
+ If( nearestLight.greaterThanEqual( int( 0 ) ), () => {
931
929
 
932
- const lightFacing = max( float( 0.0 ), dot( brdfSampleDirection, light.normal ).negate() ).toVar();
930
+ const light = AreaLight.wrap( getAreaLight( areaLightsBuffer, nearestLight ) );
931
+ const toLight = hitPoint.add( brdfSampleDirection.mul( nearestT ) ).sub( rayOrigin ).toVar();
932
+ const shadowDistance = length( toLight ).toVar();
933
+ const visibility = shadow( rayOrigin, toLight.div( shadowDistance ), shadowDistance.mul( SHADOW_END ) );
933
934
 
934
- If( lightFacing.greaterThan( 0.0 ), () => {
935
+ If( visibility.greaterThan( 0.0 ).or( wantUnoccluded ), () => {
935
936
 
936
- // Directional pdf must match the NEE sampler: 1/S (spherical-rect) for
937
- // rectangles, dist²/(area·cos) for disk/ellipse + degenerate rects.
938
- const corner = light.position.sub( light.u ).sub( light.v );
939
- const sAngle = sphQuadSolidAngle( rayOrigin, corner, light.u.mul( 2.0 ), light.v.mul( 2.0 ) ).toVar();
940
- const areaPdf = nearestT.mul( nearestT ).div( max( light.area.mul( lightFacing ), EPSILON ) );
941
- const dirPdf = select(
942
- light.shape.lessThan( 0.5 ).and( sAngle.greaterThan( 1e-5 ) ),
943
- float( 1.0 ).div( max( sAngle, 1e-10 ) ),
944
- areaPdf,
945
- ).toVar();
937
+ const lightFacing = max( float( 0.0 ), dot( brdfSampleDirection, light.normal ).negate() ).toVar();
946
938
 
947
- // The NEE partner's selection pdf, rebuilt from the reservoir denominator it
948
- // already accumulated. Uniform 1/N only when total importance is zero, matching
949
- // the reservoir's own fallback; 0 for a light NEE can never pick, which hands
950
- // this strategy the full MIS weight — as it must.
951
- const selImp = estimateLightImportance( light, rayOrigin, hitNormal, material );
952
- const selPdf = select(
953
- neeTotalWeight.greaterThan( 0.0 ),
954
- selImp.div( max( neeTotalWeight, 1e-10 ) ),
955
- float( 1.0 ).div( max( float( totalLights ), 1.0 ) ),
956
- );
957
- const lightPdf = dirPdf.mul( selPdf ).toVar();
958
- const misW = powerHeuristic( { pdf1: brdfSamplePdf, pdf2: lightPdf } ).toVar();
939
+ If( lightFacing.greaterThan( 0.0 ), () => {
959
940
 
960
- const lightEmission = areaLightRadiance( light ).mul( areaLightSpreadAttenuation( lightFacing, light.spread ) );
961
- // Base contribution WITHOUT visibility (see discrete-light site).
962
- const baseContribution = lightEmission.mul( brdfSampleValue ).mul( NoL ).mul( misW ).div( max( brdfSamplePdf, 1e-10 ) );
963
- totalContribution.addAssign( baseContribution.mul( visibility ) );
964
- If( wantUnoccluded, () => {
941
+ // Directional pdf must match the NEE sampler: 1/S (spherical-rect) for
942
+ // rectangles, dist²/(area·cos) for disk/ellipse + degenerate rects.
943
+ const corner = light.position.sub( light.u ).sub( light.v );
944
+ const sAngle = sphQuadSolidAngle( hitPoint, corner, light.u.mul( 2.0 ), light.v.mul( 2.0 ) ).toVar();
945
+ const areaPdf = nearestT.mul( nearestT ).div( max( light.area.mul( lightFacing ), EPSILON ) );
946
+ const dirPdf = select(
947
+ light.shape.lessThan( 0.5 ).and( sAngle.greaterThan( 1e-5 ) ),
948
+ float( 1.0 ).div( max( sAngle, 1e-10 ) ),
949
+ areaPdf,
950
+ ).toVar();
965
951
 
966
- unoccludedContribution.addAssign( baseContribution );
952
+ // The NEE partner's selection pdf, rebuilt from the reservoir denominator it
953
+ // already accumulated. Uniform 1/N only when total importance is zero, matching
954
+ // the reservoir's own fallback; 0 for a light NEE can never pick, which hands
955
+ // this strategy the full MIS weight — as it must.
956
+ const selImp = estimateLightImportance( light, hitPoint, hitNormal, material );
957
+ const selPdf = select(
958
+ neeTotalWeight.greaterThan( 0.0 ),
959
+ selImp.div( max( neeTotalWeight, 1e-10 ) ),
960
+ float( 1.0 ).div( max( float( totalLights ), 1.0 ) ),
961
+ );
962
+ const lightPdf = dirPdf.mul( selPdf ).toVar();
963
+ const misW = powerHeuristic( { pdf1: brdfSamplePdf, pdf2: lightPdf } ).toVar();
964
+
965
+ const lightEmission = areaLightRadiance( light ).mul( areaLightSpreadAttenuation( lightFacing, light.spread ) );
966
+ // Base contribution WITHOUT visibility (see discrete-light site).
967
+ const baseContribution = lightEmission.mul( brdfSampleValue ).mul( NoL ).mul( misW ).div( max( brdfSamplePdf, 1e-10 ) );
968
+ totalContribution.addAssign( baseContribution.mul( visibility ) );
969
+ If( wantUnoccluded, () => {
967
970
 
968
- } );
971
+ unoccludedContribution.addAssign( baseContribution );
969
972
 
970
973
  } );
971
974
 
@@ -976,58 +979,58 @@ export const calculateDirectLightingUnified = Fn( ( [
976
979
  } );
977
980
 
978
981
  } );
979
- // =====================================================================
980
- // DETERMINISTIC ENVIRONMENT NEE
981
- // Always runs (not stochastic) — forms a two-strategy Veach MIS
982
- // estimator together with the implicit miss check in the main loop.
983
- // =====================================================================
984
982
 
985
- If( enableEnvironmentLight, () => {
983
+ } );
984
+ // =====================================================================
985
+ // DETERMINISTIC ENVIRONMENT NEE
986
+ // Always runs (not stochastic) — forms a two-strategy Veach MIS
987
+ // estimator together with the implicit miss check in the main loop.
988
+ // =====================================================================
986
989
 
987
- const envRandom = getRandomSample2D( pixelCoord, int( 0 ), dimBase.add( int( 1 ) ), rngState, resolution, frame ).toVar();
988
- const envColor = vec3( 0.0 ).toVar();
990
+ If( enableEnvironmentLight, () => {
989
991
 
990
- // Sample direction + PDF + color from importance-sampled environment
991
- const envSampleResult = sampleEquirectProbability(
992
- envTexture, envCDFTexture,
993
- envMatrix, environmentIntensity, envTotalSum, envCompensationDelta, envResolution, envRandom, envColor
994
- ).toVar();
992
+ const envRandom = getRandomSample2D( pixelCoord, int( 0 ), dimBase.add( int( 1 ) ), rngState, resolution, frame ).toVar();
993
+ const envColor = vec3( 0.0 ).toVar();
995
994
 
996
- const envDirection = envSampleResult.xyz.toVar();
997
- const envPdf = envSampleResult.w.toVar();
995
+ // Sample direction + PDF + color from importance-sampled environment
996
+ const envSampleResult = sampleEquirectProbability(
997
+ envTexture, envCDFTexture,
998
+ envMatrix, environmentIntensity, envTotalSum, envCompensationDelta, envResolution, envRandom, envColor
999
+ ).toVar();
998
1000
 
999
- If( envPdf.greaterThan( 0.0 ), () => {
1001
+ const envDirection = envSampleResult.xyz.toVar();
1002
+ const envPdf = envSampleResult.w.toVar();
1000
1003
 
1001
- const NoL = max( float( 0.0 ), dot( hitNormal, envDirection ) ).toVar();
1004
+ If( envPdf.greaterThan( 0.0 ), () => {
1002
1005
 
1003
- If( NoL.greaterThan( 0.0 ).and( isDirectionValid( { direction: envDirection, surfaceNormal: hitNormal } ) ), () => {
1006
+ const NoL = max( float( 0.0 ), dot( hitNormal, envDirection ) ).toVar();
1004
1007
 
1005
- const visibility = shadow( rayOrigin, envDirection, float( 1000.0 ) );
1008
+ If( NoL.greaterThan( 0.0 ).and( isDirectionValid( { direction: envDirection, surfaceNormal: geomNormal } ) ), () => {
1006
1009
 
1007
- If( visibility.greaterThan( 0.0 ).or( wantUnoccluded ), () => {
1010
+ const visibility = shadow( rayOrigin, envDirection, float( 1e20 ) );
1008
1011
 
1009
- // Share H + dots between env BRDF/PDF — same redundancy fix as the
1010
- // discrete-light path above.
1011
- const envDots = DotProducts.wrap( computeDotProductsAniso( hitNormal, viewDir, envDirection, material ) );
1012
- const brdfValue = evaluateMaterialResponseFromDots( material, envDots );
1013
- const bPdf = calculateMaterialPDFFromDots( material, envDots ).toVar();
1012
+ If( visibility.greaterThan( 0.0 ).or( wantUnoccluded ), () => {
1014
1013
 
1015
- // Balance heuristic for env MIS — optimal for MIS-compensated PDFs (Karlík et al. 2019).
1016
- // The implicit path uses material combinedPdf as prevBouncePdf at the miss check.
1017
- const misW = select(
1018
- bPdf.greaterThan( 0.0 ),
1019
- balanceHeuristic( { pdf1: envPdf, pdf2: bPdf } ),
1020
- float( 1.0 )
1021
- ).toVar();
1014
+ // Share H + dots between env BRDF/PDF — same redundancy fix as the
1015
+ // discrete-light path above.
1016
+ const envDots = DotProducts.wrap( computeDotProductsAniso( hitNormal, viewDir, envDirection, material ) );
1017
+ const brdfValue = evaluateMaterialResponseFromDots( material, envDots );
1018
+ const bPdf = calculateMaterialPDFFromDots( material, envDots ).toVar();
1022
1019
 
1023
- // Base contribution WITHOUT visibility (deterministic estimator; no stochastic scaling).
1024
- const baseContribution = envColor.mul( brdfValue ).mul( NoL ).mul( misW ).div( max( envPdf, 1e-10 ) );
1025
- totalContribution.addAssign( baseContribution.mul( visibility ) );
1026
- If( wantUnoccluded, () => {
1020
+ // Balance heuristic for env MIS — optimal for MIS-compensated PDFs (Karlík et al. 2019).
1021
+ // The implicit path uses material combinedPdf as prevBouncePdf at the miss check.
1022
+ const misW = select(
1023
+ bPdf.greaterThan( 0.0 ),
1024
+ balanceHeuristic( { pdf1: envPdf, pdf2: bPdf } ),
1025
+ float( 1.0 )
1026
+ ).toVar();
1027
1027
 
1028
- unoccludedContribution.addAssign( baseContribution );
1028
+ // Base contribution WITHOUT visibility (deterministic estimator; no stochastic scaling).
1029
+ const baseContribution = envColor.mul( brdfValue ).mul( NoL ).mul( misW ).div( max( envPdf, 1e-10 ) );
1030
+ totalContribution.addAssign( baseContribution.mul( visibility ) );
1031
+ If( wantUnoccluded, () => {
1029
1032
 
1030
- } );
1033
+ unoccludedContribution.addAssign( baseContribution );
1031
1034
 
1032
1035
  } );
1033
1036
 
@@ -1037,7 +1040,8 @@ export const calculateDirectLightingUnified = Fn( ( [
1037
1040
 
1038
1041
  } );
1039
1042
 
1040
- } ); // End emissiveIntensity check
1043
+ } );
1044
+
1041
1045
 
1042
1046
  // EMISSIVE TRIANGLE DIRECT LIGHTING
1043
1047
  // NOTE: Emissive triangle sampling is handled separately in pathtracer_core.fs
@@ -155,6 +155,7 @@ export function buildShadeKernel( params ) {
155
155
  If( threadIdx.equal( uint( 0 ) ), () => {
156
156
 
157
157
  atomicStore( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), uint( 0 ) );
158
+ atomicStore( counters.element( uint( COUNTER.ACTIVE_ENERGY ) ), uint( 0 ) );
158
159
 
159
160
  } );
160
161
 
@@ -255,7 +256,7 @@ export function buildShadeKernel( params ) {
255
256
  // Reuse the full NEE estimator; the diffuse BRDF is constant and cancels in the
256
257
  // ratio, so this yields an irradiance-weighted shadow density across all lights + env.
257
258
  const dual = DirectLightingDual.wrap( calculateDirectLightingUnified(
258
- planePoint, planeN, planeMat, planeV,
259
+ planePoint, planeN, planeN, planeMat, planeV,
259
260
  bDir, bPdf, bVal,
260
261
  bounceIndex, rngState,
261
262
  _pixelCoord, resolution, frame, dimBase,
@@ -1044,16 +1045,24 @@ export function buildShadeKernel( params ) {
1044
1045
 
1045
1046
  // BRDF sample (needed by both direct + indirect)
1046
1047
  const V = direction.negate().toVar();
1048
+ // face-forwarded geometric normal: horizon guard for NEE and the bounce continuation
1049
+ const Ngeo = normalize( hitNormal );
1050
+ const NgeoFF = select( dot( Ngeo, V ).lessThan( 0.0 ), Ngeo.negate(), Ngeo ).toVar();
1047
1051
 
1048
- // Two-sided shading: opaque path only (transmissive/SSS already continued), so this never disturbs
1049
- // dielectric enter/exit. Flip N toward the viewer when back-facing — rescues double-sided / inward-
1050
- // normal imported meshes (GLB/PBRT) that otherwise shade black (NoL collapses). Megakernel: PathTracerCore.js:1054.
1051
- If( dot( N, V ).lessThan( 0.0 ), () => {
1052
+ // Two-sided shading: opaque path only (transmissive/SSS already continued). Decide the flip on the
1053
+ // GEOMETRIC normal — an inward-normal / double-sided mesh (GLB/PBRT) faces away as a whole — so a
1054
+ // normal-map overshoot is not mistaken for a back face and folded into the surface.
1055
+ If( dot( Ngeo, V ).lessThan( 0.0 ), () => {
1052
1056
 
1053
1057
  N.assign( N.negate() );
1054
1058
 
1055
1059
  } );
1056
1060
 
1061
+ // A normal-mapped N may still face away from the viewer at grazing angles. Like Cycles' diffuse
1062
+ // closure it is used as is: NEE and the bounce continuation already reject directions below the
1063
+ // geometric horizon. (Cycles raises N for glossy closures only — ensure_valid_specular_reflection;
1064
+ // with one shared N that would lift the diffuse term on every away-facing flank, measured +17.6 %.)
1065
+
1057
1066
  // ─── DDFA opaque aux decision: commit at the first diffuse-enough surface, defer through smooth
1058
1067
  // mirror/metal so the guide describes what the mirror reflects, not the mirror itself. N is already
1059
1068
  // viewer-facing (two-sided flip above); depth read back + re-packed (idempotent snorm — no drift). ───
@@ -1139,7 +1148,7 @@ export function buildShadeKernel( params ) {
1139
1148
  } );
1140
1149
 
1141
1150
  const directLight = DirectLightingDual.wrap( calculateDirectLightingUnified(
1142
- hitPoint, N, material, V,
1151
+ hitPoint, N, NgeoFF, material, V,
1143
1152
  brdfDir, brdfPdf, brdfValue,
1144
1153
  bounceIndex, rngState,
1145
1154
  _pixelCoord, resolution, frame, dimBase,
@@ -1205,9 +1214,9 @@ export function buildShadeKernel( params ) {
1205
1214
 
1206
1215
  const NoL = max( float( 0.0 ), dot( N, emissiveSample.direction ) );
1207
1216
 
1208
- If( NoL.greaterThan( 0.0 ), () => {
1217
+ If( NoL.greaterThan( 0.0 ).and( dot( emissiveSample.direction, NgeoFF ).greaterThan( 0.0 ) ), () => {
1209
1218
 
1210
- const rayOffset = calculateRayOffset( hitPoint, N, material );
1219
+ const rayOffset = calculateRayOffset( hitPoint, NgeoFF, material );
1211
1220
  const rayOrigin = hitPoint.add( rayOffset );
1212
1221
  const shadowDist = emissiveSample.distance.sub( 0.001 );
1213
1222
  const visibility = traceShadowRayWrapped(
@@ -1248,7 +1257,7 @@ export function buildShadeKernel( params ) {
1248
1257
  } ).Else( () => {
1249
1258
 
1250
1259
  const emissiveLight = calculateEmissiveTriangleContribution(
1251
- hitPoint, N, V, material,
1260
+ hitPoint, N, NgeoFF, V, material,
1252
1261
  bounceIndex, rngState,
1253
1262
  _pixelCoord, resolution, frame, dimBase,
1254
1263
  emissiveBoost,
@@ -1291,7 +1300,6 @@ export function buildShadeKernel( params ) {
1291
1300
 
1292
1301
  // Shading-normal leak guard: a normal-mapped lobe can sample below the geometric surface;
1293
1302
  // tracing that ray tunnels through single-sided shells onto whatever sits behind them.
1294
- const NgeoFF = select( dot( hitNormal, V ).lessThan( 0.0 ), hitNormal.negate(), hitNormal );
1295
1303
  If( brdfIsTransmission.not().and( dot( bounceDir, NgeoFF ).lessThanEqual( 0.0 ) ), () => {
1296
1304
 
1297
1305
  commitDeferredAux( N );