rayzee 7.28.0 → 7.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.28.0",
3
+ "version": "7.28.1",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -10,7 +10,7 @@
10
10
  */
11
11
  export const RENDER_PROFILES = Object.freeze( {
12
12
  viewer: Object.freeze( {
13
- areaLightIntensityScale: 0.1, // authored RectAreaLight watts from glTF placeholders
13
+ areaLightIntensityScale: 0.1, // scales glTF placeholder area-light power (viewer tuning)
14
14
  environmentRotation: 270.0, // degrees
15
15
  toneMapping: 4, // ACESFilmicToneMapping
16
16
  saturation: 1.2, // compensates for ACES desaturation — see Compositor
@@ -1759,20 +1759,23 @@ export class AssetLoader extends EventDispatcher {
1759
1759
 
1760
1760
  if ( userData.type === 'RectAreaLight' ) {
1761
1761
 
1762
- // Intensity is authored in Watts (Blender-style); emitted radiance
1763
- // is computed at render time as power/(π·area). Blender emission
1764
- // defaults: power-normalized, full Lambertian spread (π), rectangle.
1765
- // Viewer tuning, not physics — see RENDER_PROFILES.
1762
+ // Authored intensity is three.js radiance (these files also carry power = intensity·w·h·π);
1763
+ // convert to the engine's radiant power through the world area so Normalize reproduces it.
1764
+ const worldScale = object.getWorldScale( new Vector3() );
1765
+ const normalize = userData.normalize ?? true;
1766
+ const shape = userData.shape ?? 'rectangle';
1767
+ const shapeFactor = shape === 'ellipse' || shape === 'disk' ? Math.PI / 4 : 1;
1768
+ const worldArea = shapeFactor * userData.width * worldScale.x * userData.height * worldScale.y;
1769
+ const power = userData.intensity * Math.PI * ( normalize ? worldArea : 1 );
1766
1770
  const light = new RectAreaLight(
1767
1771
  new Color( ...userData.color ),
1768
- userData.intensity * this._profile.areaLightIntensityScale,
1772
+ power * this._profile.areaLightIntensityScale,
1769
1773
  userData.width,
1770
1774
  userData.height
1771
1775
  );
1772
- light.userData.normalize = userData.normalize ?? true;
1776
+ light.userData.normalize = normalize;
1773
1777
  light.userData.spread = Number.isFinite( userData.spread ) ? userData.spread : Math.PI;
1774
- light.userData.shape = userData.shape ?? 'rectangle';
1775
- light.position.z = - 2;
1778
+ light.userData.shape = shape;
1776
1779
  light.name = userData.name;
1777
1780
  object.add( light );
1778
1781
  visitedAreaLights.push( light.uuid );
@@ -527,7 +527,7 @@ export const sampleEmissiveTriangle = Fn( ( [
527
527
  // products with the PDF call below.
528
528
 
529
529
  export const calculateEmissiveTriangleContributionDebug = Fn( ( [
530
- hitPoint, normal, viewDir, material,
530
+ hitPoint, normal, geomNormal, viewDir, material,
531
531
  bounceIndex, rngState,
532
532
  pixelCoord, resolution, frame, dimBase,
533
533
  emissiveBoost,
@@ -565,10 +565,10 @@ export const calculateEmissiveTriangleContributionDebug = Fn( ( [
565
565
  // Check geometric validity
566
566
  const NoL = max( float( 0.0 ), dot( normal, emissiveSample.direction ) );
567
567
 
568
- If( NoL.greaterThan( 0.0 ), () => {
568
+ If( NoL.greaterThan( 0.0 ).and( dot( emissiveSample.direction, geomNormal ).greaterThan( 0.0 ) ), () => {
569
569
 
570
570
  // Calculate ray offset for shadow ray
571
- const rayOffset = calculateRayOffsetFn( hitPoint, normal, material );
571
+ const rayOffset = calculateRayOffsetFn( hitPoint, geomNormal, material );
572
572
  const rayOrigin = hitPoint.add( rayOffset );
573
573
 
574
574
  // Trace shadow ray
@@ -609,7 +609,7 @@ export const calculateEmissiveTriangleContributionDebug = Fn( ( [
609
609
 
610
610
  // Wrapper that returns just the contribution vec3
611
611
  export const calculateEmissiveTriangleContribution = Fn( ( [
612
- hitPoint, normal, viewDir, material,
612
+ hitPoint, normal, geomNormal, viewDir, material,
613
613
  bounceIndex, rngState,
614
614
  pixelCoord, resolution, frame, dimBase,
615
615
  emissiveBoost,
@@ -620,7 +620,7 @@ export const calculateEmissiveTriangleContribution = Fn( ( [
620
620
  ] ) => {
621
621
 
622
622
  const result = EmissiveContributionResult.wrap( calculateEmissiveTriangleContributionDebug(
623
- hitPoint, normal, viewDir, material,
623
+ hitPoint, normal, geomNormal, viewDir, material,
624
624
  bounceIndex, rngState,
625
625
  pixelCoord, resolution, frame, dimBase,
626
626
  emissiveBoost,
@@ -341,8 +341,10 @@ export const estimateLightImportance = Fn( ( [ light, hitPoint, normal, material
341
341
 
342
342
  If( lightFacing.greaterThan( 0.0 ), () => {
343
343
 
344
- const solidAngle = light.area.div( max( distSq, 0.1 ) );
345
- const power = light.intensity.mul( dot( light.color, REC709_LUMINANCE_COEFFICIENTS ) ).mul( light.area );
344
+ // Diffuse irradiance ∝ L·Ω·cosθ·cosθ′, Ω bounded by the hemisphere. Unit-free.
345
+ const solidAngle = min( light.area.div( max( distSq, 1e-12 ) ), float( 2.0 * Math.PI ) );
346
+ const invArea = select( light.normalize.greaterThan( 0.5 ), float( 1.0 ).div( max( light.area, 1e-10 ) ), float( 1.0 ) );
347
+ const radiance = light.intensity.mul( dot( light.color, REC709_LUMINANCE_COEFFICIENTS ) ).mul( invArea );
346
348
 
347
349
  // Material-aware weighting
348
350
  const materialFactor = float( 1.0 ).toVar();
@@ -358,20 +360,13 @@ export const estimateLightImportance = Fn( ( [ light, hitPoint, normal, material
358
360
 
359
361
  } );
360
362
 
361
- If( material.roughness.greaterThan( 0.6 ).and( material.metalness.lessThan( 0.3 ) ), () => {
362
-
363
- const sizeBoost = min( light.area.mul( 2.0 ), float( 2.0 ) );
364
- materialFactor.mulAssign( sizeBoost );
365
-
366
- } );
367
-
368
363
  If( material.transmission.greaterThan( 0.5 ), () => {
369
364
 
370
365
  materialFactor.mulAssign( float( 1.0 ).add( material.transmission.mul( 0.3 ) ) );
371
366
 
372
367
  } );
373
368
 
374
- result.assign( power.mul( solidAngle ).mul( NoL ).mul( lightFacing ).mul( materialFactor ) );
369
+ result.assign( radiance.mul( solidAngle ).mul( NoL ).mul( lightFacing ).mul( materialFactor ) );
375
370
 
376
371
  } );
377
372
 
@@ -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
@@ -255,7 +255,7 @@ export function buildShadeKernel( params ) {
255
255
  // Reuse the full NEE estimator; the diffuse BRDF is constant and cancels in the
256
256
  // ratio, so this yields an irradiance-weighted shadow density across all lights + env.
257
257
  const dual = DirectLightingDual.wrap( calculateDirectLightingUnified(
258
- planePoint, planeN, planeMat, planeV,
258
+ planePoint, planeN, planeN, planeMat, planeV,
259
259
  bDir, bPdf, bVal,
260
260
  bounceIndex, rngState,
261
261
  _pixelCoord, resolution, frame, dimBase,
@@ -1044,16 +1044,24 @@ export function buildShadeKernel( params ) {
1044
1044
 
1045
1045
  // BRDF sample (needed by both direct + indirect)
1046
1046
  const V = direction.negate().toVar();
1047
+ // face-forwarded geometric normal: horizon guard for NEE and the bounce continuation
1048
+ const Ngeo = normalize( hitNormal );
1049
+ const NgeoFF = select( dot( Ngeo, V ).lessThan( 0.0 ), Ngeo.negate(), Ngeo ).toVar();
1047
1050
 
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 ), () => {
1051
+ // Two-sided shading: opaque path only (transmissive/SSS already continued). Decide the flip on the
1052
+ // GEOMETRIC normal — an inward-normal / double-sided mesh (GLB/PBRT) faces away as a whole — so a
1053
+ // normal-map overshoot is not mistaken for a back face and folded into the surface.
1054
+ If( dot( Ngeo, V ).lessThan( 0.0 ), () => {
1052
1055
 
1053
1056
  N.assign( N.negate() );
1054
1057
 
1055
1058
  } );
1056
1059
 
1060
+ // A normal-mapped N may still face away from the viewer at grazing angles. Like Cycles' diffuse
1061
+ // closure it is used as is: NEE and the bounce continuation already reject directions below the
1062
+ // geometric horizon. (Cycles raises N for glossy closures only — ensure_valid_specular_reflection;
1063
+ // with one shared N that would lift the diffuse term on every away-facing flank, measured +17.6 %.)
1064
+
1057
1065
  // ─── DDFA opaque aux decision: commit at the first diffuse-enough surface, defer through smooth
1058
1066
  // mirror/metal so the guide describes what the mirror reflects, not the mirror itself. N is already
1059
1067
  // viewer-facing (two-sided flip above); depth read back + re-packed (idempotent snorm — no drift). ───
@@ -1139,7 +1147,7 @@ export function buildShadeKernel( params ) {
1139
1147
  } );
1140
1148
 
1141
1149
  const directLight = DirectLightingDual.wrap( calculateDirectLightingUnified(
1142
- hitPoint, N, material, V,
1150
+ hitPoint, N, NgeoFF, material, V,
1143
1151
  brdfDir, brdfPdf, brdfValue,
1144
1152
  bounceIndex, rngState,
1145
1153
  _pixelCoord, resolution, frame, dimBase,
@@ -1205,9 +1213,9 @@ export function buildShadeKernel( params ) {
1205
1213
 
1206
1214
  const NoL = max( float( 0.0 ), dot( N, emissiveSample.direction ) );
1207
1215
 
1208
- If( NoL.greaterThan( 0.0 ), () => {
1216
+ If( NoL.greaterThan( 0.0 ).and( dot( emissiveSample.direction, NgeoFF ).greaterThan( 0.0 ) ), () => {
1209
1217
 
1210
- const rayOffset = calculateRayOffset( hitPoint, N, material );
1218
+ const rayOffset = calculateRayOffset( hitPoint, NgeoFF, material );
1211
1219
  const rayOrigin = hitPoint.add( rayOffset );
1212
1220
  const shadowDist = emissiveSample.distance.sub( 0.001 );
1213
1221
  const visibility = traceShadowRayWrapped(
@@ -1248,7 +1256,7 @@ export function buildShadeKernel( params ) {
1248
1256
  } ).Else( () => {
1249
1257
 
1250
1258
  const emissiveLight = calculateEmissiveTriangleContribution(
1251
- hitPoint, N, V, material,
1259
+ hitPoint, N, NgeoFF, V, material,
1252
1260
  bounceIndex, rngState,
1253
1261
  _pixelCoord, resolution, frame, dimBase,
1254
1262
  emissiveBoost,
@@ -1291,7 +1299,6 @@ export function buildShadeKernel( params ) {
1291
1299
 
1292
1300
  // Shading-normal leak guard: a normal-mapped lobe can sample below the geometric surface;
1293
1301
  // 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
1302
  If( brdfIsTransmission.not().and( dot( bounceDir, NgeoFF ).lessThanEqual( 0.0 ) ), () => {
1296
1303
 
1297
1304
  commitDeferredAux( N );