rayzee 7.2.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.2.1",
3
+ "version": "7.3.0",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -23,6 +23,12 @@ export const ENGINE_DEFAULTS = {
23
23
  groundProjectionEnabled: false,
24
24
  groundProjectionRadius: 100,
25
25
  groundProjectionHeight: 15,
26
+ // World Y of the projected ground plane; auto-seeded to the scene floor (min-Y) on model
27
+ // load so models that aren't authored at y=0 sit ON the ground instead of sinking into it.
28
+ groundProjectionLevel: 0,
29
+ // Analytic ground-plane shadow catcher (primary-ray holdout; no geometry)
30
+ enableGroundCatcher: false,
31
+ groundCatcherHeight: 0,
26
32
  globalIlluminationIntensity: 1,
27
33
 
28
34
  // Environment Mode System
@@ -1,7 +1,7 @@
1
1
  import { WebGPURenderer, RectAreaLightNode, SRGBColorSpace } from 'three/webgpu';
2
2
  import { texture as _tslTexture, cubeTexture as _tslCubeTexture } from 'three/tsl';
3
3
  import {
4
- ACESFilmicToneMapping, Scene, EventDispatcher
4
+ ACESFilmicToneMapping, Scene, EventDispatcher, Box3
5
5
  } from 'three';
6
6
  import { RectAreaLightTexturesLib } from 'three/addons/lights/RectAreaLightTexturesLib.js';
7
7
  import { SceneHelpers } from './SceneHelpers.js';
@@ -777,6 +777,12 @@ export class PathTracerApp extends EventDispatcher {
777
777
 
778
778
  }
779
779
 
780
+ // Seed the ground-projection plane AND the shadow-catcher plane to the scene floor so models
781
+ // that aren't authored at y=0 sit on the ground (not sunk) — auto-updates on every model change.
782
+ const sceneMinY = this.getSceneMinY();
783
+ this.settings.set( 'groundProjectionLevel', sceneMinY, { reset: false } );
784
+ this.settings.set( 'groundCatcherHeight', sceneMinY, { reset: false } );
785
+
780
786
  // Apply all settings to stages in one shot
781
787
  timer.start( 'Apply settings' );
782
788
  this.settings.applyAll();
@@ -1178,6 +1184,19 @@ export class PathTracerApp extends EventDispatcher {
1178
1184
  * @param {string} property
1179
1185
  * @param {*} value
1180
1186
  */
1187
+ /**
1188
+ * World-space minimum Y of the loaded scene (the floor). Used to seed the
1189
+ * analytic ground-plane shadow catcher height. Returns 0 if no scene is loaded.
1190
+ * @returns {number}
1191
+ */
1192
+ getSceneMinY() {
1193
+
1194
+ if ( ! this.meshScene ) return 0;
1195
+ const box = new Box3().setFromObject( this.meshScene );
1196
+ return Number.isFinite( box.min.y ) ? box.min.y : 0;
1197
+
1198
+ }
1199
+
1181
1200
  setMaterialProperty( materialIndex, property, value ) {
1182
1201
 
1183
1202
  this.stages.pathTracer?.materialData.updateMaterialProperty( materialIndex, property, value );
@@ -26,6 +26,9 @@ const SETTING_ROUTES = {
26
26
  groundProjectionEnabled: { uniform: 'groundProjectionEnabled', reset: true },
27
27
  groundProjectionRadius: { uniform: 'groundProjectionRadius', reset: true },
28
28
  groundProjectionHeight: { uniform: 'groundProjectionHeight', reset: true },
29
+ groundProjectionLevel: { uniform: 'groundProjectionLevel', reset: true },
30
+ enableGroundCatcher: { uniform: 'enableGroundCatcher', reset: true },
31
+ groundCatcherHeight: { uniform: 'groundCatcherHeight', reset: true },
29
32
  globalIlluminationIntensity: { uniform: 'globalIlluminationIntensity', reset: true },
30
33
  enableDOF: { uniform: 'enableDOF', reset: true },
31
34
  focusDistance: { uniform: 'focusDistance', reset: false },
@@ -726,6 +726,9 @@ export class PathTracer extends PathTracerStage {
726
726
  groundProjectionEnabled: this.groundProjectionEnabled,
727
727
  groundProjectionRadius: this.groundProjectionRadius,
728
728
  groundProjectionHeight: this.groundProjectionHeight,
729
+ groundProjectionLevel: this.groundProjectionLevel,
730
+ enableGroundCatcher: this.enableGroundCatcher,
731
+ groundCatcherHeight: this.groundCatcherHeight,
729
732
  envTotalSum: this.envTotalSum,
730
733
  envResolution: this.envResolution,
731
734
  directionalLightsBuffer: this.directionalLightsBufferNode,
package/src/TSL/Common.js CHANGED
@@ -400,6 +400,68 @@ export const getMaterial = Fn( ( [ materialIndex, materialBuffer ] ) => {
400
400
 
401
401
  } );
402
402
 
403
+ // Synthetic diffuse-white material for the analytic ground-plane shadow catcher.
404
+ // No geometry/material buffer is involved — the plane is shaded as a matte Lambertian
405
+ // so the direct-lighting estimator yields an irradiance-weighted shadow ratio (the
406
+ // diffuse BRDF is constant and cancels in shadowed/unoccluded). All fields are set to
407
+ // inert defaults; only color/roughness/metalness/transmission affect the lighting path.
408
+ export const diffuseGroundMaterial = Fn( () => {
409
+
410
+ const idn = mat3( 1, 0, 0, 0, 1, 0, 0, 0, 1 );
411
+ return RayTracingMaterial( {
412
+ color: vec4( 1.0, 1.0, 1.0, 1.0 ),
413
+ emissive: vec3( 0.0 ),
414
+ emissiveIntensity: float( 0.0 ),
415
+ roughness: float( 1.0 ),
416
+ metalness: float( 0.0 ),
417
+ ior: float( 1.5 ),
418
+ transmission: float( 0.0 ),
419
+ thickness: float( 0.0 ),
420
+ clearcoat: float( 0.0 ),
421
+ clearcoatRoughness: float( 0.0 ),
422
+ opacity: float( 1.0 ),
423
+ transparent: float( 0.0 ),
424
+ attenuationColor: vec3( 1.0 ),
425
+ attenuationDistance: float( 0.0 ),
426
+ dispersion: float( 0.0 ),
427
+ sheen: float( 0.0 ),
428
+ sheenRoughness: float( 1.0 ),
429
+ sheenColor: vec3( 0.0 ),
430
+ specularIntensity: float( 1.0 ),
431
+ specularColor: vec3( 1.0 ),
432
+ alphaTest: float( 0.0 ),
433
+ alphaMode: int( 0 ),
434
+ side: int( 0 ),
435
+ depthWrite: int( 1 ),
436
+ albedoMapIndex: int( - 1 ),
437
+ emissiveMapIndex: int( - 1 ),
438
+ normalMapIndex: int( - 1 ),
439
+ bumpMapIndex: int( - 1 ),
440
+ bumpScale: float( 1.0 ),
441
+ displacementScale: float( 0.0 ),
442
+ metalnessMapIndex: int( - 1 ),
443
+ roughnessMapIndex: int( - 1 ),
444
+ displacementMapIndex: int( - 1 ),
445
+ normalScale: vec2( 1.0, 1.0 ),
446
+ albedoTransform: idn,
447
+ emissiveTransform: idn,
448
+ normalTransform: idn,
449
+ bumpTransform: idn,
450
+ metalnessTransform: idn,
451
+ roughnessTransform: idn,
452
+ displacementTransform: idn,
453
+ iridescence: float( 0.0 ),
454
+ iridescenceIOR: float( 1.3 ),
455
+ iridescenceThicknessRange: vec2( 100.0, 400.0 ),
456
+ subsurface: float( 0.0 ),
457
+ subsurfaceColor: vec3( 1.0 ),
458
+ subsurfaceRadius: vec3( 1.0, 0.2, 0.1 ),
459
+ subsurfaceRadiusScale: float( 1.0 ),
460
+ subsurfaceAnisotropy: float( 0.0 ),
461
+ } );
462
+
463
+ } );
464
+
403
465
  // ── Shadow material thin reader (7 slot reads instead of 27) ─────────────
404
466
  // Only fetches fields needed by traceShadowRay: alpha, transmission, attenuation, albedo transform.
405
467
 
@@ -1,4 +1,4 @@
1
- import { Fn, wgslFn, vec2, vec4, ivec2, float, int, If, texture, dot, sin, sqrt, floor, fract, min, max, mix, clamp } from 'three/tsl';
1
+ import { Fn, wgslFn, vec2, vec3, vec4, ivec2, float, int, If, texture, dot, sin, sqrt, floor, fract, min, max, mix, clamp } from 'three/tsl';
2
2
 
3
3
  import { REC709_LUMINANCE_COEFFICIENTS } from './Common.js';
4
4
 
@@ -211,3 +211,24 @@ export const getGroundProjectedDirection = Fn( ( [ rayOrigin, rayDirection, radi
211
211
  return projected;
212
212
 
213
213
  } );
214
+
215
+ // Primary-ray environment lookup direction: bends onto the ground-projection sphere/disk when
216
+ // ground projection is enabled, else returns the ray direction unchanged. Shared by the background
217
+ // miss branch AND the shadow catcher so they can never disagree on the projection (the source of a
218
+ // past horizon-seam bug when only one path bent the direction).
219
+ export const groundProjectedEnvDir = Fn( ( [ rayOrigin, rayDirection, enabled, radius, height, level ] ) => {
220
+
221
+ const dir = rayDirection.toVar();
222
+ If( enabled, () => {
223
+
224
+ // Relocate the projected ground plane from y=0 to world y=level (the scene floor) so a model
225
+ // authored off the origin still sits ON the ground instead of sinking. Shifting the projection
226
+ // origin down by `level` puts the disk at y=level and the sphere at y=level+height; the internal
227
+ // horizontal radius test stays correct because the shifted disk point lands at y'=0.
228
+ const shiftedOrigin = vec3( rayOrigin.x, rayOrigin.y.sub( level ), rayOrigin.z );
229
+ dir.assign( getGroundProjectedDirection( shiftedOrigin, rayDirection, radius, height ) );
230
+
231
+ } );
232
+ return dir;
233
+
234
+ } );
@@ -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';
@@ -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,
@@ -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
  } );
@@ -689,7 +807,7 @@ export function buildShadeKernel( params ) {
689
807
 
690
808
  } );
691
809
 
692
- const directLight = calculateDirectLightingUnified(
810
+ const directLight = DirectLightingDual.wrap( calculateDirectLightingUnified(
693
811
  hitPoint, N, material, V,
694
812
  brdfDir, brdfPdf, brdfValue,
695
813
  bounceIndex, rngState,
@@ -702,7 +820,8 @@ export function buildShadeKernel( params ) {
702
820
  envCDFTexture,
703
821
  envTotalSum, envCompensationDelta, envResolution,
704
822
  enableEnvironmentLight,
705
- );
823
+ tslBool( false ), // wantUnoccluded: false on real surfaces — dead-codes the unoccluded sum
824
+ ) ).shadowed.toVar();
706
825
 
707
826
  const giScale = select( bounceIndex.greaterThan( 0 ), globalIlluminationIntensity, float( 1.0 ) );
708
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' );