rayzee 7.2.0 → 7.3.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.
@@ -14,6 +14,8 @@ import {
14
14
  dot,
15
15
  sqrt,
16
16
  max,
17
+ clamp,
18
+ select,
17
19
  normalize,
18
20
  cross,
19
21
  length,
@@ -29,6 +31,8 @@ import {
29
31
  sampleSphericalTriangle,
30
32
  barycentricFromPoint,
31
33
  useSphericalSampling,
34
+ sphericalTriangleSolidAngle,
35
+ triangleArea,
32
36
  SphericalTriangleSampleResult,
33
37
  } from './EmissiveSampling.js';
34
38
 
@@ -37,6 +41,58 @@ const LBVH_STRIDE = 4; // 4 vec4s per node
37
41
  const EMISSIVE_STRIDE = 2; // 2 vec4s per emissive entry (matches EmissiveSampling.js)
38
42
  const MAX_LBVH_DEPTH = 32;
39
43
 
44
+ // ================================================================================
45
+ // LIGHT-BVH NODE IMPORTANCE (Conty-Estevez & Kulla 2018 / PBRT-v4 BVHLightSampler)
46
+ // ================================================================================
47
+ // cos((thetaA - thetaB) clamped to >= 0). cosA > cosB ⇒ thetaA < thetaB ⇒ diff clamped to 0.
48
+ const cosSubClamped = Fn( ( [ sinThetaA, cosThetaA, sinThetaB, cosThetaB ] ) => {
49
+
50
+ return select( cosThetaA.greaterThan( cosThetaB ), float( 1.0 ), cosThetaA.mul( cosThetaB ).add( sinThetaA.mul( sinThetaB ) ) );
51
+
52
+ } );
53
+
54
+ // sin((thetaA - thetaB) clamped to >= 0).
55
+ const sinSubClamped = Fn( ( [ sinThetaA, cosThetaA, sinThetaB, cosThetaB ] ) => {
56
+
57
+ return select( cosThetaA.greaterThan( cosThetaB ), float( 0.0 ), sinThetaA.mul( cosThetaB ).sub( cosThetaA.mul( sinThetaB ) ) );
58
+
59
+ } );
60
+
61
+ // Importance of a Light-BVH node for a shading point (power × orientation × inverse-square),
62
+ // θ_e = π/2 (diffuse emitters). cosThetaO = -1 ⇒ whole-sphere cone (never culled by orientation).
63
+ // SHARED by both the stochastic descent and the MIS pdf re-walk — they MUST stay byte-identical.
64
+ export const lbvhNodeImportance = Fn( ( [ nMin, power, nMax, coneAxis, cosThetaO, hitPoint ] ) => {
65
+
66
+ const center = nMin.add( nMax ).mul( 0.5 );
67
+ const diagLen = length( nMax.sub( nMin ) );
68
+ const toCenter = hitPoint.sub( center );
69
+ const d2c = max( dot( toCenter, toCenter ), float( 1e-12 ) );
70
+ // PBRT distance clamp (note: clamps to diagLen/2, a length not a square — matches PBRT)
71
+ const d2 = max( d2c, diagLen.mul( 0.5 ) );
72
+
73
+ const wi = toCenter.div( sqrt( d2c ) ); // direction from cluster center to shading point
74
+ const cosThetaW = dot( coneAxis, wi );
75
+ const sinThetaW = sqrt( max( float( 1.0 ).sub( cosThetaW.mul( cosThetaW ) ), float( 0.0 ) ) );
76
+
77
+ // Half-angle subtended by the cluster's bounding sphere from the shading point.
78
+ const r2 = diagLen.mul( diagLen ).mul( 0.25 );
79
+ const sin2ThetaB = clamp( r2.div( d2c ), float( 0.0 ), float( 1.0 ) );
80
+ const cosThetaB = select( d2c.lessThan( r2 ), float( - 1.0 ), sqrt( max( float( 1.0 ).sub( sin2ThetaB ), float( 0.0 ) ) ) );
81
+ const sinThetaB = sqrt( max( float( 1.0 ).sub( cosThetaB.mul( cosThetaB ) ), float( 0.0 ) ) );
82
+
83
+ const sinThetaO = sqrt( max( float( 1.0 ).sub( cosThetaO.mul( cosThetaO ) ), float( 0.0 ) ) );
84
+
85
+ // cosThetap = cos( (theta_w - theta_o - theta_b) clamped >= 0 )
86
+ const cosThetaX = cosSubClamped( sinThetaW, cosThetaW, sinThetaO, cosThetaO );
87
+ const sinThetaX = sinSubClamped( sinThetaW, cosThetaW, sinThetaO, cosThetaO );
88
+ const cosThetap = cosSubClamped( sinThetaX, cosThetaX, sinThetaB, cosThetaB );
89
+
90
+ // θ_e = π/2 ⇒ cosThetaE = 0; cluster cannot illuminate the point when cosThetap <= 0.
91
+ const imp = select( cosThetap.greaterThan( float( 0.0 ) ), power.mul( cosThetap ).div( d2 ), float( 0.0 ) );
92
+ return max( imp, float( 0.0 ) );
93
+
94
+ } );
95
+
40
96
  /**
41
97
  * Sample one emissive triangle using the Light BVH for spatially-aware importance sampling.
42
98
  *
@@ -105,31 +161,12 @@ export const sampleLightBVHTriangle = Fn( ( [
105
161
  const rd0 = lbvhBuffer.element( rBase ); // [minX, minY, minZ, totalPower]
106
162
  const rd1 = lbvhBuffer.element( rBase.add( int( 1 ) ) ); // [maxX, maxY, maxZ, isLeaf]
107
163
 
108
- // Compute center of each child's AABB
109
- const lCenter = vec3(
110
- ld0.x.add( ld1.x ).mul( 0.5 ),
111
- ld0.y.add( ld1.y ).mul( 0.5 ),
112
- ld0.z.add( ld1.z ).mul( 0.5 )
113
- );
114
- const rCenter = vec3(
115
- rd0.x.add( rd1.x ).mul( 0.5 ),
116
- rd0.y.add( rd1.y ).mul( 0.5 ),
117
- rd0.z.add( rd1.z ).mul( 0.5 )
118
- );
119
-
120
- // Compute squared distance from hitPoint to each child center
121
- const lDiff = lCenter.sub( hitPoint );
122
- const rDiff = rCenter.sub( hitPoint );
123
- const lDistSq = max( dot( lDiff, lDiff ), float( 0.01 ) );
124
- const rDistSq = max( dot( rDiff, rDiff ), float( 0.01 ) );
125
-
126
- // Child power
127
- const lPower = max( ld0.w, float( 0.0 ) );
128
- const rPower = max( rd0.w, float( 0.0 ) );
129
-
130
- // Importance = power / dist²
131
- const lImportance = lPower.div( lDistSq );
132
- const rImportance = rPower.div( rDistSq );
164
+ // Conty-Kulla importance: power × orientation-cone × inverse-square (shared with the MIS re-walk).
165
+ // d3 = [coneAxis, cosThetaO]; cosThetaO = -1 ⇒ whole-sphere cone (never culled by orientation).
166
+ const ld3 = lbvhBuffer.element( lBase.add( int( 3 ) ) );
167
+ const rd3 = lbvhBuffer.element( rBase.add( int( 3 ) ) );
168
+ const lImportance = lbvhNodeImportance( ld0.xyz, ld0.w, ld1.xyz, ld3.xyz, ld3.w, hitPoint );
169
+ const rImportance = lbvhNodeImportance( rd0.xyz, rd0.w, rd1.xyz, rd3.xyz, rd3.w, hitPoint );
133
170
  const totalImportance = lImportance.add( rImportance );
134
171
 
135
172
  If( totalImportance.lessThanEqual( float( 0.0 ) ), () => {
@@ -307,3 +344,157 @@ export const sampleLightBVHTriangle = Fn( ( [
307
344
  return result;
308
345
 
309
346
  } );
347
+
348
+ // ================================================================================
349
+ // LIGHT-BVH MIS PDF (re-walk)
350
+ // ================================================================================
351
+ // Computes the solid-angle pdf that sampleLightBVHTriangle WOULD assign to a given emissive
352
+ // triangle hit by a BSDF bounce, by re-walking the exact stochastic descent along the triangle's
353
+ // stored bit-trail. Required so the bounce-hit MIS weight uses the SAME light pdf the NEE sampler
354
+ // used — without this the MIS partition-of-unity breaks (a real bias).
355
+ //
356
+ // lightBuffer packs [ LBVH nodes | emissive entries | bit-trail map ]. The bit-trail map holds one
357
+ // float per absolute triangleIndex (4 packed per vec4): the root→leaf left(0)/right(1) choices.
358
+ export const calculateLightBVHPdf = Fn( ( [
359
+ triangleIndex, hitDistance, rayDir, shadingPoint,
360
+ lightBuffer, emissiveVec4Offset, reverseMapVec4Offset, triangleBuffer,
361
+ ] ) => {
362
+
363
+ const result = float( 0.0 ).toVar();
364
+
365
+ const triIdx = int( triangleIndex ).toVar();
366
+
367
+ // Fetch this triangle's bit-trail (4 packed per vec4). -1 ⇒ not in the BVH (shouldn't happen on
368
+ // an emissive hit) → leave pdf 0 (MIS falls back to BSDF-only).
369
+ const packed = lightBuffer.element( reverseMapVec4Offset.add( triIdx.shiftRight( int( 2 ) ) ) );
370
+ const lane = triIdx.bitAnd( int( 3 ) );
371
+ const trailF = select( lane.equal( int( 0 ) ), packed.x,
372
+ select( lane.equal( int( 1 ) ), packed.y,
373
+ select( lane.equal( int( 2 ) ), packed.z, packed.w ) ) ).toVar();
374
+
375
+ If( trailF.greaterThanEqual( float( 0.0 ) ), () => {
376
+
377
+ const trail = int( trailF ).toVar();
378
+ const selectionPdf = float( 1.0 ).toVar();
379
+ const nodeIndex = int( 0 ).toVar();
380
+ const depth = int( 0 ).toVar();
381
+ const foundLeaf = tslBool( false ).toVar();
382
+
383
+ Loop( MAX_LBVH_DEPTH, () => {
384
+
385
+ const base = nodeIndex.mul( int( LBVH_STRIDE ) );
386
+ const d1 = lightBuffer.element( base.add( int( 1 ) ) ); // [max, isLeaf]
387
+
388
+ If( d1.w.greaterThan( 0.5 ), () => {
389
+
390
+ foundLeaf.assign( tslBool( true ) );
391
+ Break();
392
+
393
+ } );
394
+
395
+ const d2 = lightBuffer.element( base.add( int( 2 ) ) ); // [left, right]
396
+ const leftChildIdx = int( d2.x );
397
+ const rightChildIdx = int( d2.y );
398
+
399
+ const lBase = leftChildIdx.mul( int( LBVH_STRIDE ) );
400
+ const ld0 = lightBuffer.element( lBase );
401
+ const ld1 = lightBuffer.element( lBase.add( int( 1 ) ) );
402
+ const ld3 = lightBuffer.element( lBase.add( int( 3 ) ) );
403
+ const rBase = rightChildIdx.mul( int( LBVH_STRIDE ) );
404
+ const rd0 = lightBuffer.element( rBase );
405
+ const rd1 = lightBuffer.element( rBase.add( int( 1 ) ) );
406
+ const rd3 = lightBuffer.element( rBase.add( int( 3 ) ) );
407
+
408
+ const lImp = lbvhNodeImportance( ld0.xyz, ld0.w, ld1.xyz, ld3.xyz, ld3.w, shadingPoint );
409
+ const rImp = lbvhNodeImportance( rd0.xyz, rd0.w, rd1.xyz, rd3.xyz, rd3.w, shadingPoint );
410
+ const totalImp = lImp.add( rImp );
411
+
412
+ // Trail bit at this depth: 0 → left, 1 → right. Mirror the descent's choice logic EXACTLY.
413
+ const goRight = trail.shiftRight( depth ).bitAnd( int( 1 ) ).equal( int( 1 ) );
414
+
415
+ If( totalImp.lessThanEqual( float( 0.0 ) ), () => {
416
+
417
+ // Descent forces left with no pdf update; if the trail goes right it is unreachable.
418
+ If( goRight, () => {
419
+
420
+ selectionPdf.assign( float( 0.0 ) );
421
+ nodeIndex.assign( rightChildIdx );
422
+
423
+ } ).Else( () => {
424
+
425
+ nodeIndex.assign( leftChildIdx );
426
+
427
+ } );
428
+
429
+ } ).Else( () => {
430
+
431
+ const pLeft = lImp.div( totalImp );
432
+ If( goRight, () => {
433
+
434
+ selectionPdf.mulAssign( float( 1.0 ).sub( pLeft ) );
435
+ nodeIndex.assign( rightChildIdx );
436
+
437
+ } ).Else( () => {
438
+
439
+ selectionPdf.mulAssign( pLeft );
440
+ nodeIndex.assign( leftChildIdx );
441
+
442
+ } );
443
+
444
+ } );
445
+
446
+ depth.addAssign( int( 1 ) );
447
+
448
+ } );
449
+
450
+ If( foundLeaf.and( selectionPdf.greaterThan( float( 0.0 ) ) ), () => {
451
+
452
+ // Leaf: power-weighted within-leaf selection prob for the target triangle (matches the sampler).
453
+ const base = nodeIndex.mul( int( LBVH_STRIDE ) );
454
+ const d0 = lightBuffer.element( base );
455
+ const d2 = lightBuffer.element( base.add( int( 2 ) ) );
456
+ const emissiveStart = int( d2.x );
457
+ const emissiveCount = int( d2.y );
458
+ const leafTotalPower = max( d0.w, float( 1e-10 ) );
459
+
460
+ const targetPower = float( 0.0 ).toVar();
461
+ Loop( { start: int( 0 ), end: emissiveCount }, ( { i } ) => {
462
+
463
+ const entryIdx = emissiveStart.add( i );
464
+ const emData0 = lightBuffer.element( emissiveVec4Offset.add( entryIdx.mul( int( EMISSIVE_STRIDE ) ) ) );
465
+ If( int( emData0.r ).equal( triIdx ), () => {
466
+
467
+ targetPower.assign( max( emData0.g, float( 0.0 ) ) );
468
+ Break();
469
+
470
+ } );
471
+
472
+ } );
473
+
474
+ selectionPdf.mulAssign( targetPower.div( leafTotalPower ) );
475
+
476
+ // Convert selection pdf → solid-angle measure using the SAME heuristic as the sampler.
477
+ const triData = TriangleData.wrap( fetchTriangleData( triIdx, triangleBuffer ) );
478
+ If( useSphericalSampling( triData.v0, triData.v1, triData.v2, shadingPoint ), () => {
479
+
480
+ const solidAngle = sphericalTriangleSolidAngle( triData.v0, triData.v1, triData.v2, shadingPoint );
481
+ result.assign( selectionPdf.div( max( solidAngle, float( 1e-10 ) ) ) );
482
+
483
+ } ).Else( () => {
484
+
485
+ const geoNormal = normalize( cross( triData.v1.sub( triData.v0 ), triData.v2.sub( triData.v0 ) ) );
486
+ const cosLight = max( dot( rayDir.negate(), geoNormal ), float( 0.001 ) );
487
+ const area = triangleArea( triData.v0, triData.v1, triData.v2 );
488
+ const distSq = hitDistance.mul( hitDistance );
489
+ const pdfArea = selectionPdf.div( max( area, float( 1e-10 ) ) );
490
+ result.assign( pdfArea.mul( distSq ).div( cosLight ) );
491
+
492
+ } );
493
+
494
+ } );
495
+
496
+ } );
497
+
498
+ return max( result, MIN_PDF );
499
+
500
+ } );
@@ -58,7 +58,7 @@ import {
58
58
  sampleIESProfile,
59
59
  } from './LightsCore.js';
60
60
 
61
- import { MISStrategy, DotProducts } from './Struct.js';
61
+ import { MISStrategy, DotProducts, DirectLightingDual } from './Struct.js';
62
62
  import {
63
63
  calculateDirectionalLightImportance,
64
64
  estimateLightImportance,
@@ -707,9 +707,14 @@ export const calculateDirectLightingUnified = Fn( ( [
707
707
  envCDFTexture,
708
708
  envTotalSum, envCompensationDelta, envResolution,
709
709
  enableEnvironmentLight,
710
+ // Shadow catcher: when true, also accumulate the unoccluded (visibility=1) reference
711
+ // at every light/env site so the caller can form a shadow ratio. Dead path otherwise.
712
+ wantUnoccluded,
710
713
  ] ) => {
711
714
 
712
715
  const totalContribution = vec3( 0.0 ).toVar();
716
+ // Unoccluded reference (visibility forced to 1) — only filled when wantUnoccluded.
717
+ const unoccludedContribution = vec3( 0.0 ).toVar();
713
718
  const rayOrigin = hitPoint.add( hitNormal.mul( 0.001 ) ).toVar();
714
719
 
715
720
  // Binds BVH params so shadow-ray sites at varying call depths use a 3-arg call
@@ -819,7 +824,7 @@ export const calculateDirectLightingUnified = Fn( ( [
819
824
  const shadowDistance = min( lightSample.distance.sub( 0.001 ), float( 1000.0 ) );
820
825
  const visibility = shadow( rayOrigin, lightSample.direction, shadowDistance );
821
826
 
822
- If( visibility.greaterThan( 0.0 ), () => {
827
+ If( visibility.greaterThan( 0.0 ).or( wantUnoccluded ), () => {
823
828
 
824
829
  // Share H + dot products between BRDF eval and PDF — otherwise each
825
830
  // would recompute normalize(V+L) + 5 dot products independently.
@@ -846,9 +851,15 @@ export const calculateDirectLightingUnified = Fn( ( [
846
851
 
847
852
  } );
848
853
 
849
- // Guard division
850
- const lightContribution = lightSample.emission.mul( brdfValue ).mul( NoL ).mul( visibility ).mul( misW ).div( max( lightSample.pdf, 1e-10 ) );
851
- totalContribution.addAssign( lightContribution.mul( totalSamplingWeight ).div( max( lightWeight, 1e-10 ) ) );
854
+ // Base contribution WITHOUT visibility; shadowed = base × visibility (identical to the
855
+ // pre-dual-sum math), unoccluded = base × 1 (shadow-catcher reference only).
856
+ const baseContribution = lightSample.emission.mul( brdfValue ).mul( NoL ).mul( misW ).div( max( lightSample.pdf, 1e-10 ) ).mul( totalSamplingWeight ).div( max( lightWeight, 1e-10 ) );
857
+ totalContribution.addAssign( baseContribution.mul( visibility ) );
858
+ If( wantUnoccluded, () => {
859
+
860
+ unoccludedContribution.addAssign( baseContribution );
861
+
862
+ } );
852
863
 
853
864
  } );
854
865
 
@@ -918,7 +929,7 @@ export const calculateDirectLightingUnified = Fn( ( [
918
929
  const shadowDistance = min( hitDistance.sub( 0.001 ), float( 1000.0 ) );
919
930
  const visibility = shadow( rayOrigin, brdfSampleDirection, shadowDistance );
920
931
 
921
- If( visibility.greaterThan( 0.0 ), () => {
932
+ If( visibility.greaterThan( 0.0 ).or( wantUnoccluded ), () => {
922
933
 
923
934
  const lightFacing = max( float( 0.0 ), dot( brdfSampleDirection, light.normal ).negate() ).toVar();
924
935
 
@@ -934,9 +945,14 @@ export const calculateDirectLightingUnified = Fn( ( [
934
945
  const misW = powerHeuristic( { pdf1: brdfPdfWeighted, pdf2: lightPdfWeighted } ).toVar();
935
946
 
936
947
  const lightEmission = light.color.mul( light.intensity );
937
- // Guard division
938
- const brdfContribution = lightEmission.mul( brdfSampleValue ).mul( NoL ).mul( visibility ).mul( misW ).div( max( brdfSamplePdf, 1e-10 ) );
939
- totalContribution.addAssign( brdfContribution.mul( totalSamplingWeight ).div( max( brdfWeight, 1e-10 ) ) );
948
+ // Base contribution WITHOUT visibility (see discrete-light site).
949
+ const baseContribution = lightEmission.mul( brdfSampleValue ).mul( NoL ).mul( misW ).div( max( brdfSamplePdf, 1e-10 ) ).mul( totalSamplingWeight ).div( max( brdfWeight, 1e-10 ) );
950
+ totalContribution.addAssign( baseContribution.mul( visibility ) );
951
+ If( wantUnoccluded, () => {
952
+
953
+ unoccludedContribution.addAssign( baseContribution );
954
+
955
+ } );
940
956
 
941
957
  } );
942
958
 
@@ -984,7 +1000,7 @@ export const calculateDirectLightingUnified = Fn( ( [
984
1000
 
985
1001
  const visibility = shadow( rayOrigin, envDirection, float( 1000.0 ) );
986
1002
 
987
- If( visibility.greaterThan( 0.0 ), () => {
1003
+ If( visibility.greaterThan( 0.0 ).or( wantUnoccluded ), () => {
988
1004
 
989
1005
  // Share H + dots between env BRDF/PDF — same redundancy fix as the
990
1006
  // discrete-light path above.
@@ -1000,9 +1016,14 @@ export const calculateDirectLightingUnified = Fn( ( [
1000
1016
  float( 1.0 )
1001
1017
  ).toVar();
1002
1018
 
1003
- // Guard division no stochastic scaling needed (deterministic estimator)
1004
- const envContribution = envColor.mul( brdfValue ).mul( NoL ).mul( visibility ).mul( misW ).div( max( envPdf, 1e-10 ) );
1005
- totalContribution.addAssign( envContribution );
1019
+ // Base contribution WITHOUT visibility (deterministic estimator; no stochastic scaling).
1020
+ const baseContribution = envColor.mul( brdfValue ).mul( NoL ).mul( misW ).div( max( envPdf, 1e-10 ) );
1021
+ totalContribution.addAssign( baseContribution.mul( visibility ) );
1022
+ If( wantUnoccluded, () => {
1023
+
1024
+ unoccludedContribution.addAssign( baseContribution );
1025
+
1026
+ } );
1006
1027
 
1007
1028
  } );
1008
1029
 
@@ -1018,6 +1039,6 @@ export const calculateDirectLightingUnified = Fn( ( [
1018
1039
  // NOTE: Emissive triangle sampling is handled separately in pathtracer_core.fs
1019
1040
  // to bypass firefly suppression. Do not add it here to avoid double-counting.
1020
1041
 
1021
- return totalContribution;
1042
+ return DirectLightingDual( { shadowed: totalContribution, unoccluded: unoccludedContribution } );
1022
1043
 
1023
1044
  } );
@@ -6,6 +6,7 @@
6
6
 
7
7
  import {
8
8
  Fn, float, vec2, vec3, vec4, int, uint,
9
+ bool as tslBool,
9
10
  If, normalize, max, exp, log, clamp, dot, length, select,
10
11
  instanceIndex,
11
12
  sampler,
@@ -13,8 +14,9 @@ import {
13
14
  Return,
14
15
  } from 'three/tsl';
15
16
 
16
- import { sampleEnvironment, sampleEquirectProbability, sampleEquirect, getGroundProjectedDirection } from './Environment.js';
17
- import { getMaterial, powerHeuristic, balanceHeuristic, classifyMaterial } from './Common.js';
17
+ import { sampleEnvironment, sampleEquirectProbability, sampleEquirect, groundProjectedEnvDir } from './Environment.js';
18
+ import { getMaterial, powerHeuristic, balanceHeuristic, classifyMaterial, REC709_LUMINANCE_COEFFICIENTS, PI_INV, EPSILON, diffuseGroundMaterial } from './Common.js';
19
+ import { cosineWeightedSample } from './MaterialSampling.js';
18
20
  import { sampleAllMaterialTextures } from './TextureSampling.js';
19
21
  import { evaluateMaterialResponse } from './MaterialEvaluation.js';
20
22
  import { calculateDirectLightingUnified, calculateMaterialPDF } from './LightsSampling.js';
@@ -29,7 +31,7 @@ import { getImportanceSamplingInfo } from './MaterialProperties.js';
29
31
  import { sampleClearcoat, ClearcoatResult } from './Clearcoat.js';
30
32
  import { refineDisplacedIntersection, DisplacementResult } from './Displacement.js';
31
33
  import { calculateEmissiveTriangleContribution, calculateEmissiveLightPdf, EmissiveSample } from './EmissiveSampling.js';
32
- import { sampleLightBVHTriangle } from './LightBVHSampling.js';
34
+ import { sampleLightBVHTriangle, calculateLightBVHPdf } from './LightBVHSampling.js';
33
35
  import {
34
36
  Ray,
35
37
  HitInfo,
@@ -40,6 +42,7 @@ import {
40
42
  MaterialClassification,
41
43
  BRDFWeights,
42
44
  MaterialCache,
45
+ DirectLightingDual,
43
46
  } from './Struct.js';
44
47
  import { RandomValue, getRandomSample } from './Random.js';
45
48
  import { RAY_FLAG, COUNTER } from '../Processor/QueueManager.js';
@@ -57,6 +60,14 @@ import {
57
60
  const WG_SIZE = 256;
58
61
  const MISS_DIST = 1e19;
59
62
 
63
+ // Shadow-catcher thresholds (named so the two distinct 1e-4 uses don't read as one value)
64
+ const CATCHER_T_MIN = 1e-4; // min ray-t for the analytic plane hit (skip behind/at the camera)
65
+ const CATCHER_LUMA_FLOOR = 1e-4; // denominator floor for the shadow ratio
66
+ const CATCHER_COVERAGE_MIN = 1e-3; // below this incident luma there is no shadow to catch
67
+ // Reserved G-buffer surface ID for catcher pixels (no real triangle); stable across frames so the
68
+ // denoiser treats the analytic plane as one coherent surface, not a background miss.
69
+ const CATCHER_SURFACE_ID = 0xFFFFFFFE;
70
+
60
71
  export function buildShadeKernel( params ) {
61
72
 
62
73
  const {
@@ -71,7 +82,8 @@ export function buildShadeKernel( params ) {
71
82
  displacementMaps,
72
83
  envTexture, environmentIntensity, envMatrix,
73
84
  enableEnvironmentLight, useEnvMapIS,
74
- groundProjectionEnabled, groundProjectionRadius, groundProjectionHeight,
85
+ groundProjectionEnabled, groundProjectionRadius, groundProjectionHeight, groundProjectionLevel,
86
+ enableGroundCatcher, groundCatcherHeight,
75
87
  envTotalSum, envCompensationDelta, envResolution,
76
88
  directionalLightsBuffer, numDirectionalLights,
77
89
  areaLightsBuffer, numAreaLights,
@@ -85,7 +97,7 @@ export function buildShadeKernel( params ) {
85
97
  fireflyThreshold, frame, resolution,
86
98
  emissiveTriangleCount, emissiveVec4Offset, emissiveTotalPower,
87
99
  emissiveBoost, totalTriangleCount, enableEmissiveTriangleSampling,
88
- lightBVHNodeCount,
100
+ lightBVHNodeCount, reverseMapVec4Offset,
89
101
  maxRayCount,
90
102
  } = params;
91
103
 
@@ -136,6 +148,111 @@ export function buildShadeKernel( params ) {
136
148
  const sssSteps = readSssSteps( rayBufferRW, rayID ).toVar();
137
149
  const sampleIndex = int( rayID.div( maxRaysPerSample ) ).toVar();
138
150
 
151
+ // ── Analytic ground-plane shadow catcher (primary ray only, no geometry) ──
152
+ // A horizontal plane at y = groundCatcherHeight. For a bounce-0 ray that crosses it
153
+ // closer than any BVH hit (hitDist is MISS_DIST on sky rays, so the plane wins over the
154
+ // bare environment), shade it as a diffuse Lambertian holdout: output a monochrome shadow
155
+ // ratio in alpha (rgb = 0) so it composites as bg·ratio over a transparent background.
156
+ // Shadows are caught by the EXISTING NEE shadow ray into real geometry — the plane itself
157
+ // never enters the BVH. Secondary bounces ignore it entirely.
158
+ If( enableGroundCatcher.and( bounceIndex.equal( 0 ) ), () => {
159
+
160
+ const dirY = direction.y.toVar();
161
+ If( dirY.abs().greaterThan( float( EPSILON ) ), () => {
162
+
163
+ const tPlane = groundCatcherHeight.sub( origin.y ).div( dirY ).toVar();
164
+ If( tPlane.greaterThan( float( CATCHER_T_MIN ) ).and( tPlane.lessThan( hitDist ) ), () => {
165
+
166
+ const planePoint = origin.add( direction.mul( tPlane ) ).toVar();
167
+ const planeN = vec3( 0.0, 1.0, 0.0 );
168
+ const planeV = direction.negate().toVar();
169
+ const planeMat = RayTracingMaterial.wrap( diffuseGroundMaterial() ).toVar();
170
+
171
+ // Cosine-weighted hemisphere BRDF sample about the plane normal (0,1,0); the helper
172
+ // puts cosθ along N, so bDir.y == cosθ. Per-component .toVar() on the two randoms
173
+ // (vec2(RandomValue,RandomValue) would collapse to u==v — TSL pitfall).
174
+ const u1 = RandomValue( rngState ).toVar();
175
+ const u2 = RandomValue( rngState ).toVar();
176
+ const bDir = cosineWeightedSample( planeN, vec2( u1, u2 ) ).toVar();
177
+ const bPdf = bDir.y.mul( PI_INV ).toVar(); // cosθ / π
178
+ const bVal = vec3( PI_INV ); // albedo(1) / π
179
+
180
+ // Reuse the full NEE estimator; the diffuse BRDF is constant and cancels in the
181
+ // ratio, so this yields an irradiance-weighted shadow density across all lights + env.
182
+ const dual = DirectLightingDual.wrap( calculateDirectLightingUnified(
183
+ planePoint, planeN, planeMat, planeV,
184
+ bDir, bPdf, bVal,
185
+ bounceIndex, rngState,
186
+ directionalLightsBuffer, numDirectionalLights,
187
+ areaLightsBuffer, numAreaLights,
188
+ pointLightsBuffer, numPointLights,
189
+ spotLightsBuffer, numSpotLights,
190
+ bvhBuffer, triangleBuffer, materialBuffer,
191
+ envTexture, environmentIntensity, envMatrix,
192
+ envCDFTexture,
193
+ envTotalSum, envCompensationDelta, envResolution,
194
+ enableEnvironmentLight,
195
+ tslBool( true ), // wantUnoccluded
196
+ ) ).toVar();
197
+
198
+ const lumShad = max( dot( dual.shadowed, REC709_LUMINANCE_COEFFICIENTS ), float( 0.0 ) );
199
+ const lumLit = max( dot( dual.unoccluded, REC709_LUMINANCE_COEFFICIENTS ), float( 0.0 ) ).toVar();
200
+ const ratio = clamp( lumShad.div( max( lumLit, float( CATCHER_LUMA_FLOOR ) ) ), 0.0, 1.0 ).toVar();
201
+ // Coverage gate: where no light reaches the plane there is no shadow to catch —
202
+ // force ratio=1 (no darkening) so unlit ground reads as plain background, not spurious black.
203
+ If( lumLit.lessThan( float( CATCHER_COVERAGE_MIN ) ), () => {
204
+
205
+ ratio.assign( 1.0 );
206
+
207
+ } );
208
+
209
+ // Adaptive output (matches Arnold's scene_background / Cycles shadow catcher): the catcher
210
+ // respects the current background mode instead of forcing transparency.
211
+ // • Transparent background → emit a matte (rgb=0, alpha=1−ratio); the shadow rides alpha
212
+ // and composites as backplate·ratio over an external plate.
213
+ // • Visible background → composite the shadow into RGB: show the environment this
214
+ // camera ray would otherwise see, darkened by the shadow ratio (alpha=1). The HDRI stays
215
+ // visible; at the horizon ratio→1 so the catcher meets the sky with no seam.
216
+ // Sample the background via the SAME shared helper the miss branch uses, so the
217
+ // catcher seamlessly continues the visible environment (with ground projection on it
218
+ // must bend identically, else a bright horizon seam / mismatched ground appears).
219
+ const catcherEnvDir = groundProjectedEnvDir(
220
+ origin, direction, groundProjectionEnabled, groundProjectionRadius, groundProjectionHeight, groundProjectionLevel,
221
+ ).toVar();
222
+ const envBehind = sampleEnvironment( {
223
+ tex: envTexture,
224
+ samp: sampler( envTexture ),
225
+ direction: catcherEnvDir,
226
+ environmentMatrix: envMatrix,
227
+ environmentIntensity,
228
+ enableEnvironmentLight,
229
+ } ).xyz.toVar();
230
+ const bgColor = select( showBackground, envBehind.mul( backgroundIntensity ), vec3( 0.0 ) );
231
+ const outRgb = select( transparentBackground, vec3( 0.0 ), bgColor.mul( ratio ) );
232
+ const outAlpha = select( transparentBackground, float( 1.0 ).sub( ratio ), float( 1.0 ) );
233
+
234
+ // The catcher is a real ground surface for the denoiser — write the plane's normal/depth
235
+ // + a neutral albedo (black albedo would break OIDN demodulation) and mark the pixel a
236
+ // valid surface, so OIDN/ASVGF don't smear the caught shadow as a background miss.
237
+ If( sampleIndex.equal( int( 0 ) ), () => {
238
+
239
+ const planeDepth = computeNDCDepth( { worldPos: planePoint, cameraProjectionMatrix, cameraViewMatrix } );
240
+ writeGBuffer( gBufferRW, pixelIndex, planeN, planeDepth, vec3( 1.0 ) );
241
+ writeGBufferSurfaceID( gBufferRW, pixelIndex, uint( CATCHER_SURFACE_ID ), uint( CATCHER_SURFACE_ID ), 0.5, 0.5, uint( 1 ) );
242
+
243
+ } );
244
+
245
+ writeRayRadiance( rayBufferRW, rayID, vec4( outRgb, outAlpha ) );
246
+ writeRayDirFlags( rayBufferRW, rayID, direction, flags.bitAnd( uint( ~ RAY_FLAG.ACTIVE ) ) );
247
+ rngBufferRW.element( rayID ).assign( rngState );
248
+ Return();
249
+
250
+ } );
251
+
252
+ } );
253
+
254
+ } );
255
+
139
256
  If( hitDist.greaterThan( MISS_DIST ), () => {
140
257
 
141
258
  If( enableEnvironmentLight, () => {
@@ -143,11 +260,12 @@ export function buildShadeKernel( params ) {
143
260
  // Ground projection bends the primary ray's background lookup onto a
144
261
  // projected sphere+disk so the lower env hemisphere reads as a ground
145
262
  // plane. Primary ray only; secondary bounces see the raw envmap as a light.
263
+ // Shared helper (also used by the shadow catcher) keeps the two in lockstep.
146
264
  const envDir = direction.toVar();
147
- If( bounceIndex.equal( 0 ).and( groundProjectionEnabled ), () => {
265
+ If( bounceIndex.equal( 0 ), () => {
148
266
 
149
- envDir.assign( getGroundProjectedDirection(
150
- origin, direction, groundProjectionRadius, groundProjectionHeight,
267
+ envDir.assign( groundProjectedEnvDir(
268
+ origin, direction, groundProjectionEnabled, groundProjectionRadius, groundProjectionHeight, groundProjectionLevel,
151
269
  ) );
152
270
 
153
271
  } );
@@ -560,10 +678,25 @@ export function buildShadeKernel( params ) {
560
678
  const prevBouncePdf = readRayPdf( rayBufferRW, rayID );
561
679
  If( prevBouncePdf.greaterThan( 0.0 ), () => {
562
680
 
563
- const lightPdf = calculateEmissiveLightPdf(
564
- int( hitTriIdx ), hitDist, direction, origin,
565
- triangleBuffer, materialBuffer, emissiveTotalPower,
566
- );
681
+ // MIS partner pdf MUST match the actual NEE sampler: re-walk the Light BVH descent
682
+ // when it is active, else use the flat-CDF pdf. Mismatching them breaks MIS
683
+ // partition-of-unity → a real bias (see calculateLightBVHPdf).
684
+ const lightPdf = float( 0.0 ).toVar();
685
+ If( lightBVHNodeCount.greaterThan( int( 0 ) ), () => {
686
+
687
+ lightPdf.assign( calculateLightBVHPdf(
688
+ int( hitTriIdx ), hitDist, direction, origin,
689
+ lightBuffer, emissiveVec4Offset, reverseMapVec4Offset, triangleBuffer,
690
+ ) );
691
+
692
+ } ).Else( () => {
693
+
694
+ lightPdf.assign( calculateEmissiveLightPdf(
695
+ int( hitTriIdx ), hitDist, direction, origin,
696
+ triangleBuffer, materialBuffer, emissiveTotalPower,
697
+ ) );
698
+
699
+ } );
567
700
  emissiveMISWeight.assign( powerHeuristic( { pdf1: prevBouncePdf, pdf2: lightPdf } ) );
568
701
 
569
702
  } );
@@ -674,7 +807,7 @@ export function buildShadeKernel( params ) {
674
807
 
675
808
  } );
676
809
 
677
- const directLight = calculateDirectLightingUnified(
810
+ const directLight = DirectLightingDual.wrap( calculateDirectLightingUnified(
678
811
  hitPoint, N, material, V,
679
812
  brdfDir, brdfPdf, brdfValue,
680
813
  bounceIndex, rngState,
@@ -687,7 +820,8 @@ export function buildShadeKernel( params ) {
687
820
  envCDFTexture,
688
821
  envTotalSum, envCompensationDelta, envResolution,
689
822
  enableEnvironmentLight,
690
- );
823
+ tslBool( false ), // wantUnoccluded: false on real surfaces — dead-codes the unoccluded sum
824
+ ) ).shadowed.toVar();
691
825
 
692
826
  const giScale = select( bounceIndex.greaterThan( 0 ), globalIlluminationIntensity, float( 1.0 ) );
693
827
  // Per-term firefly suppression (megakernel parity: PathTracerCore.js:1164) — wrap the direct-light add
package/src/TSL/Struct.js CHANGED
@@ -73,6 +73,14 @@ export const ShadowMaterial = struct( {
73
73
  albedoTransform: 'mat3',
74
74
  } );
75
75
 
76
+ // Dual direct-lighting result: shadowed (the normal NEE estimate) + unoccluded
77
+ // (same estimator with visibility forced to 1). Used by the shadow catcher to
78
+ // derive a shadow ratio = luma(shadowed) / luma(unoccluded).
79
+ export const DirectLightingDual = struct( {
80
+ shadowed: 'vec3',
81
+ unoccluded: 'vec3',
82
+ } );
83
+
76
84
  export const Sphere = struct( {
77
85
  position: 'vec3',
78
86
  radius: 'float',
@@ -188,6 +188,9 @@ export class UniformManager {
188
188
  ub( 'groundProjectionEnabled', DEFAULT_STATE.groundProjectionEnabled );
189
189
  u( 'groundProjectionRadius', DEFAULT_STATE.groundProjectionRadius, 'float' );
190
190
  u( 'groundProjectionHeight', DEFAULT_STATE.groundProjectionHeight, 'float' );
191
+ u( 'groundProjectionLevel', DEFAULT_STATE.groundProjectionLevel, 'float' );
192
+ ub( 'enableGroundCatcher', DEFAULT_STATE.enableGroundCatcher );
193
+ u( 'groundCatcherHeight', DEFAULT_STATE.groundCatcherHeight, 'float' );
191
194
 
192
195
  // Sun parameters
193
196
  u( 'sunDirection', new Vector3( 0, 1, 0 ), 'vec3' );
@@ -242,6 +245,10 @@ export class UniformManager {
242
245
  // Offset (in vec4 elements) within the packed light buffer where emissive
243
246
  // triangle data starts. Equals lightBVHNodeCount * LBVH_STRIDE; computed on upload.
244
247
  u( 'emissiveVec4Offset', 0, 'int' );
248
+ // Offset (in vec4 elements) within the packed light buffer where the per-triangle
249
+ // bit-trail map starts (4 trails packed per vec4); computed on upload. Used by the
250
+ // bounce-hit MIS path to re-walk the Light BVH descent pdf.
251
+ u( 'reverseMapVec4Offset', 0, 'int' );
245
252
 
246
253
  // Render mode
247
254
  u( 'renderMode', DEFAULT_STATE.renderMode, 'int' );