rayzee 7.2.1 → 7.4.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.
@@ -6,15 +6,17 @@
6
6
 
7
7
  import {
8
8
  Fn, float, vec2, vec3, vec4, int, uint,
9
- If, normalize, max, exp, log, clamp, dot, length, select,
9
+ bool as tslBool,
10
+ If, Loop, normalize, max, exp, log, clamp, dot, length, select,
10
11
  instanceIndex,
11
12
  sampler,
12
13
  atomicAdd, atomicLoad, uintBitsToFloat,
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';
@@ -23,7 +25,7 @@ import { traverseBVHShadow } from './BVHTraversal.js';
23
25
  import { handleMaterialTransparency, MaterialInteractionResult } from './MaterialTransmission.js';
24
26
  import { sampleChromaticCollision, sampleHenyeyGreenstein, subsurfaceCoefficients, CollisionSample, MediumCoeffs } from './Subsurface.js';
25
27
  import { calculateIndirectLighting } from './LightsIndirect.js';
26
- import { IndirectLightingResult } from './LightsCore.js';
28
+ import { IndirectLightingResult, sampleCone } from './LightsCore.js';
27
29
  import { regularizePathContribution, generateSampledDirection, computeNDCDepth, handleRussianRoulette } from './PathTracerCore.js';
28
30
  import { getImportanceSamplingInfo } from './MaterialProperties.js';
29
31
  import { sampleClearcoat, ClearcoatResult } from './Clearcoat.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,
@@ -79,7 +91,7 @@ export function buildShadeKernel( params ) {
79
91
  spotLightsBuffer, numSpotLights,
80
92
  maxBounceCount, maxSubsurfaceSteps,
81
93
  currentBounce, // loop iteration = path length (advances on free bounces); drives RR/firefly/giScale
82
- transparentBackground, backgroundIntensity, showBackground,
94
+ transparentBackground, backgroundIntensity, backgroundColor, backgroundBlurriness, backgroundBlurSamples, showBackground,
83
95
  globalIlluminationIntensity,
84
96
  cameraProjectionMatrix, cameraViewMatrix,
85
97
  fireflyThreshold, frame, resolution,
@@ -87,10 +99,45 @@ export function buildShadeKernel( params ) {
87
99
  emissiveBoost, totalTriangleCount, enableEmissiveTriangleSampling,
88
100
  lightBVHNodeCount, reverseMapVec4Offset,
89
101
  maxRayCount,
102
+ // Aux G-buffer (normal/depth/albedo + surface ID) feeds only the denoiser/OIDN MRT. Gated by a
103
+ // live uniform (1 = denoiser on) so the wavefront skips these writes when nothing consumes them.
104
+ auxGBufferEnabled,
90
105
  } = params;
91
106
 
107
+ const auxOn = auxGBufferEnabled.greaterThan( uint( 0 ) );
108
+
92
109
  const useEmissiveNEE = lightBuffer !== undefined;
93
110
 
111
+ // Stochastic cone-jitter blur of an env backdrop lookup. Plain JS inliner (NOT a Fn — an rng Fn-param
112
+ // would freeze; see TSL pitfalls) so it mutates the caller's rngState .toVar() directly. Shared by the
113
+ // miss branch and the shadow catcher so their blur stays in lockstep (no horizon seam). normalize() the
114
+ // center direction — ground projection can return a non-unit vector, which would skew sampleCone's basis.
115
+ // Clamp the tap count to ≥1 so a 0 forced via the engine API can't produce a 0/0 NaN backdrop.
116
+ const sampleEnvBlurred = ( centerDir, halfAngle, samples, rng ) => {
117
+
118
+ const axis = normalize( centerDir ).toVar();
119
+ const n = max( samples, int( 1 ) ).toVar();
120
+ const acc = vec3( 0.0 ).toVar();
121
+ Loop( { start: int( 0 ), end: n, type: 'int', condition: '<' }, () => {
122
+
123
+ // per-component .toVar(): vec2(RandomValue, RandomValue) would collapse to u==v (TSL pitfall)
124
+ const u1 = RandomValue( rng ).toVar();
125
+ const u2 = RandomValue( rng ).toVar();
126
+ const jDir = sampleCone( axis, halfAngle, vec2( u1, u2 ) ).toVar();
127
+ acc.addAssign( sampleEnvironment( {
128
+ tex: envTexture,
129
+ samp: sampler( envTexture ),
130
+ direction: jDir,
131
+ environmentMatrix: envMatrix,
132
+ environmentIntensity,
133
+ enableEnvironmentLight: float( 1.0 ),
134
+ } ).xyz );
135
+
136
+ } );
137
+ return acc.div( float( n ) );
138
+
139
+ };
140
+
94
141
  const computeFn = Fn( () => {
95
142
 
96
143
  const threadIdx = instanceIndex;
@@ -113,13 +160,19 @@ export function buildShadeKernel( params ) {
113
160
 
114
161
  } );
115
162
 
163
+ // Backdrop-view = the ray still travels the original camera direction (only alpha/transparent passthrough
164
+ // since the camera, REDIRECTED still clear). Captured at ARRIVAL (before the opaque/redirect bitOr below)
165
+ // so it stays valid for both the miss branch and the emissive-hit scale. This — not bounceIndex==0 — is
166
+ // the correct "is the env/emitter here a direct view" test, so env/emitters through alpha-cutout holes
167
+ // are treated like the open backdrop, not a GI bounce.
168
+ const isBackdropView = flags.bitAnd( uint( RAY_FLAG.REDIRECTED ) ).equal( uint( 0 ) ).toVar();
169
+
116
170
  const origin = readRayOrigin( rayBufferRW, rayID ).toVar();
117
171
  const direction = readRayDirection( rayBufferRW, rayID ).toVar();
118
172
  const throughput = readRayThroughput( rayBufferRW, rayID ).toVar();
119
173
  const currentRadiance = readRayRadiance( rayBufferRW, rayID ).toVar();
120
- // pixelIndex + sampleIndex are derived from rayID (= subSample*maxRaysPerSample + pixelIndex; GenerateKernel.js:64), not stored.
121
- const maxRaysPerSample = uint( resolution.x ).mul( uint( resolution.y ) ).toVar();
122
- const pixelIndex = rayID.mod( maxRaysPerSample );
174
+ // One ray per pixel: rayID is the pixel index.
175
+ const pixelIndex = rayID;
123
176
  const rngState = rngBufferRW.element( rayID ).toVar();
124
177
 
125
178
  const hitDist = readHitDistance( hitBufferRO, rayID ).toVar();
@@ -134,43 +187,186 @@ export function buildShadeKernel( params ) {
134
187
  // path length = loop iteration (advances every bounce incl. transmissive/SSS); drives RR/firefly/giScale/MIS. Megakernel: loop counter i.
135
188
  const bounceIndex = int( currentBounce ).toVar();
136
189
  const sssSteps = readSssSteps( rayBufferRW, rayID ).toVar();
137
- const sampleIndex = int( rayID.div( maxRaysPerSample ) ).toVar();
138
190
 
139
- If( hitDist.greaterThan( MISS_DIST ), () => {
191
+ // ── Analytic ground-plane shadow catcher (primary ray only, no geometry) ──
192
+ // A horizontal plane at y = groundCatcherHeight. For a bounce-0 ray that crosses it
193
+ // closer than any BVH hit (hitDist is MISS_DIST on sky rays, so the plane wins over the
194
+ // bare environment), shade it as a diffuse Lambertian holdout: output a monochrome shadow
195
+ // ratio in alpha (rgb = 0) so it composites as bg·ratio over a transparent background.
196
+ // Shadows are caught by the EXISTING NEE shadow ray into real geometry — the plane itself
197
+ // never enters the BVH. Secondary bounces ignore it entirely.
198
+ If( enableGroundCatcher.and( bounceIndex.equal( 0 ) ), () => {
199
+
200
+ const dirY = direction.y.toVar();
201
+ If( dirY.abs().greaterThan( float( EPSILON ) ), () => {
202
+
203
+ const tPlane = groundCatcherHeight.sub( origin.y ).div( dirY ).toVar();
204
+ If( tPlane.greaterThan( float( CATCHER_T_MIN ) ).and( tPlane.lessThan( hitDist ) ), () => {
205
+
206
+ const planePoint = origin.add( direction.mul( tPlane ) ).toVar();
207
+ const planeN = vec3( 0.0, 1.0, 0.0 );
208
+ const planeV = direction.negate().toVar();
209
+ const planeMat = RayTracingMaterial.wrap( diffuseGroundMaterial() ).toVar();
210
+
211
+ // Cosine-weighted hemisphere BRDF sample about the plane normal (0,1,0); the helper
212
+ // puts cosθ along N, so bDir.y == cosθ. Per-component .toVar() on the two randoms
213
+ // (vec2(RandomValue,RandomValue) would collapse to u==v — TSL pitfall).
214
+ const u1 = RandomValue( rngState ).toVar();
215
+ const u2 = RandomValue( rngState ).toVar();
216
+ const bDir = cosineWeightedSample( planeN, vec2( u1, u2 ) ).toVar();
217
+ const bPdf = bDir.y.mul( PI_INV ).toVar(); // cosθ / π
218
+ const bVal = vec3( PI_INV ); // albedo(1) / π
219
+
220
+ // Reuse the full NEE estimator; the diffuse BRDF is constant and cancels in the
221
+ // ratio, so this yields an irradiance-weighted shadow density across all lights + env.
222
+ const dual = DirectLightingDual.wrap( calculateDirectLightingUnified(
223
+ planePoint, planeN, planeMat, planeV,
224
+ bDir, bPdf, bVal,
225
+ bounceIndex, rngState,
226
+ directionalLightsBuffer, numDirectionalLights,
227
+ areaLightsBuffer, numAreaLights,
228
+ pointLightsBuffer, numPointLights,
229
+ spotLightsBuffer, numSpotLights,
230
+ bvhBuffer, triangleBuffer, materialBuffer,
231
+ envTexture, environmentIntensity, envMatrix,
232
+ envCDFTexture,
233
+ envTotalSum, envCompensationDelta, envResolution,
234
+ enableEnvironmentLight,
235
+ tslBool( true ), // wantUnoccluded
236
+ ) ).toVar();
237
+
238
+ const lumShad = max( dot( dual.shadowed, REC709_LUMINANCE_COEFFICIENTS ), float( 0.0 ) );
239
+ const lumLit = max( dot( dual.unoccluded, REC709_LUMINANCE_COEFFICIENTS ), float( 0.0 ) ).toVar();
240
+ const ratio = clamp( lumShad.div( max( lumLit, float( CATCHER_LUMA_FLOOR ) ) ), 0.0, 1.0 ).toVar();
241
+ // Coverage gate: where no light reaches the plane there is no shadow to catch —
242
+ // force ratio=1 (no darkening) so unlit ground reads as plain background, not spurious black.
243
+ If( lumLit.lessThan( float( CATCHER_COVERAGE_MIN ) ), () => {
244
+
245
+ ratio.assign( 1.0 );
246
+
247
+ } );
248
+
249
+ // Adaptive output (matches Arnold's scene_background / Cycles shadow catcher): the catcher
250
+ // respects the current background mode instead of forcing transparency.
251
+ // • Transparent background → emit a matte (rgb=0, alpha=1−ratio); the shadow rides alpha
252
+ // and composites as backplate·ratio over an external plate.
253
+ // • Visible background → composite the shadow into RGB: show the environment this
254
+ // camera ray would otherwise see, darkened by the shadow ratio (alpha=1). The HDRI stays
255
+ // visible; at the horizon ratio→1 so the catcher meets the sky with no seam.
256
+ // Sample the background via the SAME shared helper the miss branch uses, so the
257
+ // catcher seamlessly continues the visible environment (with ground projection on it
258
+ // must bend identically, else a bright horizon seam / mismatched ground appears).
259
+ const catcherEnvDir = groundProjectedEnvDir(
260
+ origin, direction, groundProjectionEnabled, groundProjectionRadius, groundProjectionHeight, groundProjectionLevel,
261
+ ).toVar();
262
+ // force-enable the sampler (pass 1.0): the visible backdrop is decoupled from env-lighting,
263
+ // so the catcher continues the HDRI even when the environment isn't used as a light.
264
+ // Only sample the env where it's actually shown as the catcher backdrop (showBackground); in
265
+ // color/transparent mode the catcher composites over backgroundColor / alpha, so the (up to
266
+ // N-tap) env work would be discarded. Blur matches the miss branch via the shared helper.
267
+ const envBehind = vec3( 0.0 ).toVar();
268
+ If( showBackground, () => {
269
+
270
+ If( backgroundBlurriness.greaterThan( 0.0 ), () => {
271
+
272
+ envBehind.assign( sampleEnvBlurred( catcherEnvDir, backgroundBlurriness.mul( 1.3 ), backgroundBlurSamples, rngState ) );
273
+
274
+ } ).Else( () => {
275
+
276
+ envBehind.assign( sampleEnvironment( {
277
+ tex: envTexture,
278
+ samp: sampler( envTexture ),
279
+ direction: catcherEnvDir,
280
+ environmentMatrix: envMatrix,
281
+ environmentIntensity,
282
+ enableEnvironmentLight: float( 1.0 ),
283
+ } ).xyz );
284
+
285
+ } );
286
+
287
+ } );
288
+ // Background mode: env image (showBackground) or the solid backgroundColor (color mode).
289
+ const bgColor = select( showBackground, envBehind.mul( backgroundIntensity ), backgroundColor );
290
+ const outRgb = select( transparentBackground, vec3( 0.0 ), bgColor.mul( ratio ) );
291
+ const outAlpha = select( transparentBackground, float( 1.0 ).sub( ratio ), float( 1.0 ) );
292
+
293
+ // The catcher is a real ground surface for the denoiser — write the plane's normal/depth
294
+ // + a neutral albedo (black albedo would break OIDN demodulation) and mark the pixel a
295
+ // valid surface, so OIDN/ASVGF don't smear the caught shadow as a background miss.
296
+ If( auxOn, () => {
297
+
298
+ const planeDepth = computeNDCDepth( { worldPos: planePoint, cameraProjectionMatrix, cameraViewMatrix } );
299
+ writeGBuffer( gBufferRW, pixelIndex, planeN, planeDepth, vec3( 1.0 ) );
300
+ writeGBufferSurfaceID( gBufferRW, pixelIndex, uint( CATCHER_SURFACE_ID ), uint( CATCHER_SURFACE_ID ), 0.5, 0.5, uint( 1 ) );
301
+
302
+ } );
303
+
304
+ writeRayRadiance( rayBufferRW, rayID, vec4( outRgb, outAlpha ) );
305
+ writeRayDirFlags( rayBufferRW, rayID, direction, flags.bitAnd( uint( ~ RAY_FLAG.ACTIVE ) ) );
306
+ rngBufferRW.element( rayID ).assign( rngState );
307
+ Return();
308
+
309
+ } );
310
+
311
+ } );
312
+
313
+ } );
140
314
 
141
- If( enableEnvironmentLight, () => {
315
+ If( hitDist.greaterThan( MISS_DIST ), () => {
142
316
 
143
- // Ground projection bends the primary ray's background lookup onto a
144
- // projected sphere+disk so the lower env hemisphere reads as a ground
145
- // plane. Primary ray only; secondary bounces see the raw envmap as a light.
317
+ // Background and environment-lighting are decoupled (independent axes):
318
+ // Visible backdrop: a PRIMARY ray draws the env image only when showBackground — regardless
319
+ // of enableEnvironmentLight (so you can show the HDRI without it lighting the scene).
320
+ // • Env as a light: SECONDARY bounces add the env (implicit MIS hit) only when enableEnvironmentLight.
321
+ // Backdrop-view = the ray still travels the original camera direction (only alpha/transparent
322
+ // passthrough since the camera). This — NOT bounceIndex==0 — is the correct test for "the env here
323
+ // is the direct backdrop", so env seen through alpha-cutout foliage holes is treated identically to
324
+ // the open sky (blur, intensity, show/hide, color-mode, ground projection all match).
325
+ // isBackdropView was captured at arrival (above) so it survives the REDIRECTED bitOr on opaque hits.
326
+ const wantBackdrop = isBackdropView.and( showBackground ); // draw env image as backdrop
327
+ const wantEnvLight = isBackdropView.not().and( enableEnvironmentLight ); // env as light on redirected bounces
328
+
329
+ If( wantBackdrop.or( wantEnvLight ), () => {
330
+
331
+ // Ground projection bends the backdrop-view env lookup onto a projected sphere+disk so the lower
332
+ // env hemisphere reads as a ground plane. Backdrop-view only (incl. through alpha-cutout holes,
333
+ // since they keep the camera direction); redirected bounces see the raw envmap as a light. The
334
+ // shared helper (also used by the shadow catcher) keeps the two in lockstep.
146
335
  const envDir = direction.toVar();
147
- If( bounceIndex.equal( 0 ).and( groundProjectionEnabled ), () => {
336
+ If( isBackdropView, () => {
148
337
 
149
- envDir.assign( getGroundProjectedDirection(
150
- origin, direction, groundProjectionRadius, groundProjectionHeight,
338
+ envDir.assign( groundProjectedEnvDir(
339
+ origin, direction, groundProjectionEnabled, groundProjectionRadius, groundProjectionHeight, groundProjectionLevel,
151
340
  ) );
152
341
 
153
342
  } );
154
343
 
155
- const envColor = sampleEnvironment( {
156
- tex: envTexture,
157
- samp: sampler( envTexture ),
158
- direction: envDir,
159
- environmentMatrix: envMatrix,
160
- environmentIntensity,
161
- enableEnvironmentLight,
162
- } ).toVar();
344
+ // Backdrop-view rays blur the env (cone jitter, shared helper); redirected env-light bounces take
345
+ // the sharp Else. force-enable the sampler (pass 1.0): the wantBackdrop/wantEnvLight gate above
346
+ // already decided visibility, so the backdrop shows the HDRI even when env-lighting is off.
347
+ // Direction-space jitter keeps the blur free of equirect pole/seam artifacts; accumulation
348
+ // converges the noise. Opt-in — blurriness 0 takes the sharp Else (zero cost).
349
+ const envColor = vec3( 0.0 ).toVar();
350
+ If( isBackdropView.and( backgroundBlurriness.greaterThan( 0.0 ) ), () => {
351
+
352
+ envColor.assign( sampleEnvBlurred( envDir, backgroundBlurriness.mul( 1.3 ), backgroundBlurSamples, rngState ) );
163
353
 
164
- // Hide the background for primary rays when showBackground is off; secondary bounces still see the envmap as a light.
165
- If( bounceIndex.equal( 0 ).and( showBackground.not() ), () => {
354
+ } ).Else( () => {
166
355
 
167
- envColor.assign( vec4( 0.0 ) );
356
+ envColor.assign( sampleEnvironment( {
357
+ tex: envTexture,
358
+ samp: sampler( envTexture ),
359
+ direction: envDir,
360
+ environmentMatrix: envMatrix,
361
+ environmentIntensity,
362
+ enableEnvironmentLight: float( 1.0 ),
363
+ } ).xyz );
168
364
 
169
365
  } );
170
366
 
171
367
  // MIS weight for implicit env hit — prevents double-counting with NEE
172
368
  const envMisWeight = float( 1.0 ).toVar();
173
- If( bounceIndex.greaterThan( 0 ).and( useEnvMapIS ), () => {
369
+ If( isBackdropView.not().and( useEnvMapIS ), () => {
174
370
 
175
371
  const prevBouncePdf = readRayPdf( rayBufferRW, rayID );
176
372
  If( prevBouncePdf.greaterThan( 0.0 ), () => {
@@ -189,18 +385,23 @@ export function buildShadeKernel( params ) {
189
385
 
190
386
  } );
191
387
 
192
- const envGiScale = select( bounceIndex.greaterThan( 0 ), globalIlluminationIntensity, float( 1.0 ) );
193
- const envScale = select( bounceIndex.equal( 0 ), backgroundIntensity, envMisWeight.mul( envGiScale ) );
388
+ const envGiScale = select( isBackdropView.not(), globalIlluminationIntensity, float( 1.0 ) );
389
+ const envScale = select( isBackdropView, backgroundIntensity, envMisWeight.mul( envGiScale ) );
194
390
 
195
391
  // Firefly-suppress the env contribution (megakernel parity: PathTracerCore.js:780). Without
196
392
  // this, indirect bounces escaping to a bright environment are unsuppressed spikes that OIDN
197
393
  // smears into white blobs. The miss branch Return()s before the hit-branch clamp (~line 712),
198
394
  // so it must be applied here.
395
+ // Firefly path length: a backdrop view (incl. through alpha-cutout holes) is a DIRECT view of the
396
+ // sky → use 0 (loosest clamp, same as the open-sky bounce-0 backdrop) so a bright HDRI sun doesn't
397
+ // read dimmer behind foliage cutouts than beside them. Only redirected GI bounces get the tighter
398
+ // path-length threshold.
399
+ const fireflyPathLen = select( isBackdropView, float( 0.0 ), float( bounceIndex ) );
199
400
  currentRadiance.assign( vec4(
200
401
  currentRadiance.xyz.add(
201
402
  regularizePathContribution(
202
- throughput.mul( envColor.xyz ).mul( envScale ),
203
- float( bounceIndex ), fireflyThreshold, int( frame ),
403
+ throughput.mul( envColor ).mul( envScale ),
404
+ fireflyPathLen, fireflyThreshold, int( frame ),
204
405
  ),
205
406
  ),
206
407
  currentRadiance.w
@@ -208,6 +409,18 @@ export function buildShadeKernel( params ) {
208
409
 
209
410
  } );
210
411
 
412
+ // Solid-color backdrop ('color' mode): a primary ray that doesn't show the env image and isn't
413
+ // transparent fills with backgroundColor (default black). Tinted by throughput so it reads
414
+ // correctly behind colored glass, matching the env-backdrop path.
415
+ If( isBackdropView.and( showBackground.not() ).and( transparentBackground.not() ), () => {
416
+
417
+ currentRadiance.assign( vec4(
418
+ currentRadiance.xyz.add( throughput.mul( backgroundColor ) ),
419
+ currentRadiance.w
420
+ ) );
421
+
422
+ } );
423
+
211
424
  // Transparent-bg alpha: see-through only if the ray escaped WITHOUT ever hitting opaque
212
425
  // geometry (megakernel parity: PathTracerCore.js:784). A secondary bounce off an opaque
213
426
  // surface that escapes to env keeps alpha 1 (HAS_HIT_OPAQUE set), so glass-in-front-of-an-
@@ -284,6 +497,8 @@ export function buildShadeKernel( params ) {
284
497
  throughput.divAssign( rrP );
285
498
 
286
499
  // free-bounce continuation: ray stays in the same medium, so medium stack + coeffs persist
500
+ // SSS scatter changes direction → no longer the direct backdrop view.
501
+ flags.assign( flags.bitOr( uint( RAY_FLAG.REDIRECTED ) ) );
287
502
  writeRayOriginMeta( rayBufferRW, rayID, scatterPoint, cameraDepth, sssSteps );
288
503
  writeRayDirFlags( rayBufferRW, rayID, newDir, flags );
289
504
  // Free bounce: preserve prevBouncePdf (megakernel leaves it untouched across SSS scatter,
@@ -359,26 +574,22 @@ export function buildShadeKernel( params ) {
359
574
  } );
360
575
 
361
576
  // first-hit MRT data (bounce 0 only)
362
- If( bounceIndex.equal( 0 ), () => {
577
+ If( bounceIndex.equal( 0 ).and( auxOn ), () => {
363
578
 
364
579
  const linearDepth = computeNDCDepth( {
365
580
  worldPos: hitPoint,
366
581
  cameraProjectionMatrix,
367
582
  cameraViewMatrix,
368
583
  } );
369
- // G-buffer is per-pixel — only sub-sample 0 writes it (FinalWrite reads sub-sample 0). writeGBuffer half-packs (normal/depth/albedo).
584
+ // G-buffer is per-pixel (rayID == pixelIndex). writeGBuffer half-packs (normal/depth/albedo).
370
585
  // Write the primary DEPTH now with the miss-default aux; the real normal/albedo are captured below
371
586
  // (aux-extend) and may extend through specular surfaces (gap #9). Glass rays Return at the transparency
372
587
  // block before that capture, so a glass-then-escape pixel keeps this default aux — megakernel parity
373
588
  // (objectNormal/objectColor stay at their init for transmissive-then-miss).
374
- If( sampleIndex.equal( int( 0 ) ), () => {
375
-
376
- writeGBuffer( gBufferRW, pixelIndex, vec3( 0.0, 0.0, 1.0 ), linearDepth, vec3( 0.0 ) );
377
- // Persist the primary-hit surface ID (Tier-1 A-SVGF correlated re-projection). Hit-only
378
- // branch (misses Return above), so this marks the pixel valid; bary from the bounce-0 hit.
379
- writeGBufferSurfaceID( gBufferRW, pixelIndex, hitTriIdx, readHitMeshIndex( hitBufferRO, rayID ), hitUV.x, hitUV.y, uint( 1 ) );
380
-
381
- } );
589
+ writeGBuffer( gBufferRW, pixelIndex, vec3( 0.0, 0.0, 1.0 ), linearDepth, vec3( 0.0 ) );
590
+ // Persist the primary-hit surface ID (Tier-1 A-SVGF correlated re-projection). Hit-only
591
+ // branch (misses Return above), so this marks the pixel valid; bary from the bounce-0 hit.
592
+ writeGBufferSurfaceID( gBufferRW, pixelIndex, hitTriIdx, readHitMeshIndex( hitBufferRO, rayID ), hitUV.x, hitUV.y, uint( 1 ) );
382
593
 
383
594
  } );
384
595
 
@@ -522,6 +733,14 @@ export function buildShadeKernel( params ) {
522
733
 
523
734
  // SSS = free bounce (depth unchanged); transmission advances camera-bounce depth.
524
735
  // Transmissive / alpha-skip / SSS-boundary are all FREE bounces — they do NOT advance camera depth (megakernel parity, gap #4). cameraDepth advances only on opaque scatter (below).
736
+ // Backdrop-view survives a pure alpha/transparent passthrough (direction unchanged) but is cleared by
737
+ // any redirection (refraction/reflection/SSS boundary), so env through a leaf hole stays the blurred
738
+ // backdrop while env through glass becomes sharp redirected light.
739
+ If( interaction.isAlphaSkip.not(), () => {
740
+
741
+ flags.assign( flags.bitOr( uint( RAY_FLAG.REDIRECTED ) ) );
742
+
743
+ } );
525
744
  writeRayOriginMeta( rayBufferRW, rayID, newOrigin, cameraDepth, sssSteps );
526
745
  writeRayDirFlags( rayBufferRW, rayID, interaction.direction, flags );
527
746
  // Free bounce: preserve prevBouncePdf (megakernel keeps the last opaque-scatter pdf across
@@ -539,12 +758,17 @@ export function buildShadeKernel( params ) {
539
758
  // PathTracerCore.js:1042). Flag the chain so a later env-escape keeps alpha 1 (the gate in the
540
759
  // miss branch). Alpha itself already defaults to 1 from Generate in transparent-bg mode, so there
541
760
  // is nothing to set here — a ray dying inside geometry (SSS walk) stays solid without reaching this.
542
- flags.assign( flags.bitOr( uint( RAY_FLAG.HAS_HIT_OPAQUE ) ) );
761
+ // Hit opaque geometry: set HAS_HIT_OPAQUE and mark REDIRECTED (this is a real surface scatter,
762
+ // so any later env-escape is redirected light, not the direct backdrop). Single positive bitOr.
763
+ flags.assign( flags.bitOr( uint( RAY_FLAG.HAS_HIT_OPAQUE | RAY_FLAG.REDIRECTED ) ) );
543
764
 
544
765
  const emissive = matSamples.emissive.toVar();
545
766
  If( length( emissive ).greaterThan( 0.0 ), () => {
546
767
 
547
- const emissiveGiScale = select( bounceIndex.greaterThan( 0 ), globalIlluminationIntensity, float( 1.0 ) );
768
+ // Key on backdrop-view (not bounceIndex>0) so an emitter seen DIRECTLY through an alpha-cutout hole
769
+ // renders at full intensity (1.0) like a direct view, consistent with env-through-hole — instead of
770
+ // being GI-scaled as if it were an indirect bounce. (MIS below already self-guards via prevBouncePdf.)
771
+ const emissiveGiScale = select( isBackdropView.not(), globalIlluminationIntensity, float( 1.0 ) );
548
772
 
549
773
  // MIS weight vs emissive-triangle NEE (megakernel parity: PathTracerCore.js:1117). On a secondary
550
774
  // hit (bounceIndex>0) the prior bounce's NEE also sampled this emitter — power-heuristic balances the
@@ -616,7 +840,7 @@ export function buildShadeKernel( params ) {
616
840
  // visible (the surface reflected in a mirror / seen behind glass), not the specular surface. Glass
617
841
  // Returns at the transparency block above, so its aux is replaced by the surface behind it. Depth
618
842
  // stays at the primary hit (read back + re-packed; the snorm depth re-pack is idempotent — no drift).
619
- If( sampleIndex.equal( int( 0 ) ).and( flags.bitAnd( uint( RAY_FLAG.AUX_LOCKED ) ).equal( uint( 0 ) ) ), () => {
843
+ If( flags.bitAnd( uint( RAY_FLAG.AUX_LOCKED ) ).equal( uint( 0 ) ).and( auxOn ), () => {
620
844
 
621
845
  const primaryDepth = gbDecodeNormalDepth( readGBuffer( gBufferRW, pixelIndex ) ).w;
622
846
  writeGBuffer( gBufferRW, pixelIndex, N, primaryDepth, albedo.xyz );
@@ -636,13 +860,13 @@ export function buildShadeKernel( params ) {
636
860
  material.clearcoat, material.emissive, material.subsurface,
637
861
  ) ).toVar();
638
862
 
639
- // STBN keyed on (pixel, bounceIndex, frame); sampleIndex gives each sub-sample a distinct tap
863
+ // STBN keyed on (pixel, bounceIndex, frame).
640
864
  const _resX = int( resolution.x ).toVar();
641
865
  const _pixelCoord = vec2(
642
866
  float( int( pixelIndex ).mod( _resX ) ).add( 0.5 ),
643
867
  float( int( pixelIndex ).div( _resX ) ).add( 0.5 ),
644
868
  );
645
- const xi = getRandomSample( _pixelCoord, sampleIndex, bounceIndex, rngState, int( - 1 ), resolution, frame ).toVar();
869
+ const xi = getRandomSample( _pixelCoord, int( 0 ), bounceIndex, rngState, int( - 1 ), resolution, frame ).toVar();
646
870
  const emptyWeights = BRDFWeights( {
647
871
  specular: float( 0.0 ), diffuse: float( 0.0 ), sheen: float( 0.0 ),
648
872
  clearcoat: float( 0.0 ), transmission: float( 0.0 ), iridescence: float( 0.0 ),
@@ -689,7 +913,7 @@ export function buildShadeKernel( params ) {
689
913
 
690
914
  } );
691
915
 
692
- const directLight = calculateDirectLightingUnified(
916
+ const directLight = DirectLightingDual.wrap( calculateDirectLightingUnified(
693
917
  hitPoint, N, material, V,
694
918
  brdfDir, brdfPdf, brdfValue,
695
919
  bounceIndex, rngState,
@@ -702,7 +926,8 @@ export function buildShadeKernel( params ) {
702
926
  envCDFTexture,
703
927
  envTotalSum, envCompensationDelta, envResolution,
704
928
  enableEnvironmentLight,
705
- );
929
+ tslBool( false ), // wantUnoccluded: false on real surfaces — dead-codes the unoccluded sum
930
+ ) ).shadowed.toVar();
706
931
 
707
932
  const giScale = select( bounceIndex.greaterThan( 0 ), globalIlluminationIntensity, float( 1.0 ) );
708
933
  // 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',
@@ -365,6 +365,11 @@ export class DenoisingManager extends EventDispatcher {
365
365
 
366
366
  }
367
367
 
368
+ // PathTracer's aux MRT (normalDepth + albedo) is consumed by the real-time denoisers (ASVGF/
369
+ // BilateralFilter read albedo; ASVGF/SSRC/EdgeFilter read normalDepth) and by OIDN (reads the
370
+ // MRT read-targets directly). When none are active the wavefront skips those writes entirely.
371
+ s.pathTracer?.setAuxGBufferEnabled?.( normalNeeded || !! this.denoiser?.enabled );
372
+
368
373
  }
369
374
 
370
375
  // ── Render Completion Chain ───────────────────────────────────
@@ -595,6 +600,8 @@ export class DenoisingManager extends EventDispatcher {
595
600
  setOIDNEnabled( enabled ) {
596
601
 
597
602
  if ( this.denoiser ) this.denoiser.enabled = enabled;
603
+ // OIDN reads the PathTracer aux MRT; re-sync so the wavefront produces it while OIDN is on.
604
+ this._syncGBufferStages();
598
605
 
599
606
  }
600
607
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { uniform, uniformArray } from 'three/tsl';
9
- import { Vector2, Matrix4, Vector3 } from 'three';
9
+ import { Vector2, Matrix4, Vector3, Color } from 'three';
10
10
  import { samplingTechniqueUniform } from '../TSL/Random.js';
11
11
  import { ENGINE_DEFAULTS as DEFAULT_STATE } from '../EngineDefaults.js';
12
12
 
@@ -161,7 +161,6 @@ export class UniformManager {
161
161
  // Frame and sampling
162
162
  u( 'frame', 0, 'uint' );
163
163
  u( 'maxBounces', DEFAULT_STATE.bounces, 'int' );
164
- u( 'samplesPerPixel', DEFAULT_STATE.samplesPerPixel, 'int' );
165
164
  u( 'maxSamples', DEFAULT_STATE.maxSamples, 'int' );
166
165
  u( 'transmissiveBounces', DEFAULT_STATE.transmissiveBounces, 'int' );
167
166
  u( 'maxSubsurfaceSteps', DEFAULT_STATE.maxSubsurfaceSteps, 'int' );
@@ -177,6 +176,9 @@ export class UniformManager {
177
176
  // Environment
178
177
  u( 'environmentIntensity', DEFAULT_STATE.environmentIntensity, 'float' );
179
178
  u( 'backgroundIntensity', DEFAULT_STATE.backgroundIntensity, 'float' );
179
+ u( 'backgroundColor', new Color( 0, 0, 0 ), 'color' ); // linear; solid backdrop in 'color' mode
180
+ u( 'backgroundBlurriness', DEFAULT_STATE.backgroundBlurriness, 'float' );
181
+ u( 'backgroundBlurSamples', DEFAULT_STATE.backgroundBlurSamples, 'int' );
180
182
  ub( 'showBackground', DEFAULT_STATE.showBackground );
181
183
  ub( 'transparentBackground', DEFAULT_STATE.transparentBackground );
182
184
  ub( 'enableEnvironment', DEFAULT_STATE.enableEnvironment );
@@ -188,6 +190,9 @@ export class UniformManager {
188
190
  ub( 'groundProjectionEnabled', DEFAULT_STATE.groundProjectionEnabled );
189
191
  u( 'groundProjectionRadius', DEFAULT_STATE.groundProjectionRadius, 'float' );
190
192
  u( 'groundProjectionHeight', DEFAULT_STATE.groundProjectionHeight, 'float' );
193
+ u( 'groundProjectionLevel', DEFAULT_STATE.groundProjectionLevel, 'float' );
194
+ ub( 'enableGroundCatcher', DEFAULT_STATE.enableGroundCatcher );
195
+ u( 'groundCatcherHeight', DEFAULT_STATE.groundCatcherHeight, 'float' );
191
196
 
192
197
  // Sun parameters
193
198
  u( 'sunDirection', new Vector3( 0, 1, 0 ), 'vec3' );
@@ -277,7 +277,6 @@ export class VideoRenderManager {
277
277
  return {
278
278
  maxSamples: app.settings.get( 'maxSamples' ),
279
279
  maxBounces: app.settings.get( 'maxBounces' ),
280
- samplesPerPixel: app.settings.get( 'samplesPerPixel' ),
281
280
  transmissiveBounces: app.settings.get( 'transmissiveBounces' ),
282
281
  renderMode: app.stages.pathTracer?.renderMode?.value,
283
282
  controlsEnabled: app.cameraManager.controls?.enabled,
@@ -300,7 +299,6 @@ export class VideoRenderManager {
300
299
  app.settings.setMany( {
301
300
  maxSamples: state.maxSamples,
302
301
  maxBounces: state.maxBounces,
303
- samplesPerPixel: state.samplesPerPixel,
304
302
  transmissiveBounces: state.transmissiveBounces,
305
303
  }, { silent: true } );
306
304