rayzee 7.3.0 → 7.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  import {
8
8
  Fn, float, vec2, vec3, vec4, int, uint,
9
9
  bool as tslBool,
10
- If, normalize, max, exp, log, clamp, dot, length, select,
10
+ If, Loop, normalize, max, exp, log, clamp, dot, length, select,
11
11
  instanceIndex,
12
12
  sampler,
13
13
  atomicAdd, atomicLoad, uintBitsToFloat,
@@ -25,7 +25,7 @@ import { traverseBVHShadow } from './BVHTraversal.js';
25
25
  import { handleMaterialTransparency, MaterialInteractionResult } from './MaterialTransmission.js';
26
26
  import { sampleChromaticCollision, sampleHenyeyGreenstein, subsurfaceCoefficients, CollisionSample, MediumCoeffs } from './Subsurface.js';
27
27
  import { calculateIndirectLighting } from './LightsIndirect.js';
28
- import { IndirectLightingResult } from './LightsCore.js';
28
+ import { IndirectLightingResult, sampleCone } from './LightsCore.js';
29
29
  import { regularizePathContribution, generateSampledDirection, computeNDCDepth, handleRussianRoulette } from './PathTracerCore.js';
30
30
  import { getImportanceSamplingInfo } from './MaterialProperties.js';
31
31
  import { sampleClearcoat, ClearcoatResult } from './Clearcoat.js';
@@ -51,9 +51,9 @@ import {
51
51
  readMediumStack, writeMediumStack, readMediumSigmaA, writeMediumSigmaA,
52
52
  readPathBounces, readSssSteps, readSSSMedium, writeSSSMedium,
53
53
  readHitDistance, readHitBarycentrics, readHitNormal,
54
- readHitMaterialIndex, readHitTriangleIndex, readHitMeshIndex,
54
+ readHitMaterialIndex, readHitTriangleIndex,
55
55
  writeRayOriginMeta, writeRayDirFlags, writeRayThroughputPdf, writeRayRadiance,
56
- writeGBuffer, writeGBufferSurfaceID, readGBuffer, gbDecodeNormalDepth,
56
+ writeGBuffer, readGBuffer, gbDecodeNormalDepth,
57
57
  readRayRadiance,
58
58
  } from '../Processor/PackedRayBuffer.js';
59
59
 
@@ -64,9 +64,6 @@ const MISS_DIST = 1e19;
64
64
  const CATCHER_T_MIN = 1e-4; // min ray-t for the analytic plane hit (skip behind/at the camera)
65
65
  const CATCHER_LUMA_FLOOR = 1e-4; // denominator floor for the shadow ratio
66
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
67
 
71
68
  export function buildShadeKernel( params ) {
72
69
 
@@ -91,7 +88,7 @@ export function buildShadeKernel( params ) {
91
88
  spotLightsBuffer, numSpotLights,
92
89
  maxBounceCount, maxSubsurfaceSteps,
93
90
  currentBounce, // loop iteration = path length (advances on free bounces); drives RR/firefly/giScale
94
- transparentBackground, backgroundIntensity, showBackground,
91
+ transparentBackground, backgroundIntensity, backgroundColor, backgroundBlurriness, backgroundBlurSamples, showBackground,
95
92
  globalIlluminationIntensity,
96
93
  cameraProjectionMatrix, cameraViewMatrix,
97
94
  fireflyThreshold, frame, resolution,
@@ -99,10 +96,45 @@ export function buildShadeKernel( params ) {
99
96
  emissiveBoost, totalTriangleCount, enableEmissiveTriangleSampling,
100
97
  lightBVHNodeCount, reverseMapVec4Offset,
101
98
  maxRayCount,
99
+ // Aux G-buffer (normal/depth/albedo + surface ID) feeds only the denoiser/OIDN MRT. Gated by a
100
+ // live uniform (1 = denoiser on) so the wavefront skips these writes when nothing consumes them.
101
+ auxGBufferEnabled,
102
102
  } = params;
103
103
 
104
+ const auxOn = auxGBufferEnabled.greaterThan( uint( 0 ) );
105
+
104
106
  const useEmissiveNEE = lightBuffer !== undefined;
105
107
 
108
+ // Stochastic cone-jitter blur of an env backdrop lookup. Plain JS inliner (NOT a Fn — an rng Fn-param
109
+ // would freeze; see TSL pitfalls) so it mutates the caller's rngState .toVar() directly. Shared by the
110
+ // miss branch and the shadow catcher so their blur stays in lockstep (no horizon seam). normalize() the
111
+ // center direction — ground projection can return a non-unit vector, which would skew sampleCone's basis.
112
+ // Clamp the tap count to ≥1 so a 0 forced via the engine API can't produce a 0/0 NaN backdrop.
113
+ const sampleEnvBlurred = ( centerDir, halfAngle, samples, rng ) => {
114
+
115
+ const axis = normalize( centerDir ).toVar();
116
+ const n = max( samples, int( 1 ) ).toVar();
117
+ const acc = vec3( 0.0 ).toVar();
118
+ Loop( { start: int( 0 ), end: n, type: 'int', condition: '<' }, () => {
119
+
120
+ // per-component .toVar(): vec2(RandomValue, RandomValue) would collapse to u==v (TSL pitfall)
121
+ const u1 = RandomValue( rng ).toVar();
122
+ const u2 = RandomValue( rng ).toVar();
123
+ const jDir = sampleCone( axis, halfAngle, vec2( u1, u2 ) ).toVar();
124
+ acc.addAssign( sampleEnvironment( {
125
+ tex: envTexture,
126
+ samp: sampler( envTexture ),
127
+ direction: jDir,
128
+ environmentMatrix: envMatrix,
129
+ environmentIntensity,
130
+ enableEnvironmentLight: float( 1.0 ),
131
+ } ).xyz );
132
+
133
+ } );
134
+ return acc.div( float( n ) );
135
+
136
+ };
137
+
106
138
  const computeFn = Fn( () => {
107
139
 
108
140
  const threadIdx = instanceIndex;
@@ -125,13 +157,19 @@ export function buildShadeKernel( params ) {
125
157
 
126
158
  } );
127
159
 
160
+ // Backdrop-view = the ray still travels the original camera direction (only alpha/transparent passthrough
161
+ // since the camera, REDIRECTED still clear). Captured at ARRIVAL (before the opaque/redirect bitOr below)
162
+ // so it stays valid for both the miss branch and the emissive-hit scale. This — not bounceIndex==0 — is
163
+ // the correct "is the env/emitter here a direct view" test, so env/emitters through alpha-cutout holes
164
+ // are treated like the open backdrop, not a GI bounce.
165
+ const isBackdropView = flags.bitAnd( uint( RAY_FLAG.REDIRECTED ) ).equal( uint( 0 ) ).toVar();
166
+
128
167
  const origin = readRayOrigin( rayBufferRW, rayID ).toVar();
129
168
  const direction = readRayDirection( rayBufferRW, rayID ).toVar();
130
169
  const throughput = readRayThroughput( rayBufferRW, rayID ).toVar();
131
170
  const currentRadiance = readRayRadiance( rayBufferRW, rayID ).toVar();
132
- // pixelIndex + sampleIndex are derived from rayID (= subSample*maxRaysPerSample + pixelIndex; GenerateKernel.js:64), not stored.
133
- const maxRaysPerSample = uint( resolution.x ).mul( uint( resolution.y ) ).toVar();
134
- const pixelIndex = rayID.mod( maxRaysPerSample );
171
+ // One ray per pixel: rayID is the pixel index.
172
+ const pixelIndex = rayID;
135
173
  const rngState = rngBufferRW.element( rayID ).toVar();
136
174
 
137
175
  const hitDist = readHitDistance( hitBufferRO, rayID ).toVar();
@@ -146,7 +184,6 @@ export function buildShadeKernel( params ) {
146
184
  // path length = loop iteration (advances every bounce incl. transmissive/SSS); drives RR/firefly/giScale/MIS. Megakernel: loop counter i.
147
185
  const bounceIndex = int( currentBounce ).toVar();
148
186
  const sssSteps = readSssSteps( rayBufferRW, rayID ).toVar();
149
- const sampleIndex = int( rayID.div( maxRaysPerSample ) ).toVar();
150
187
 
151
188
  // ── Analytic ground-plane shadow catcher (primary ray only, no geometry) ──
152
189
  // A horizontal plane at y = groundCatcherHeight. For a bounce-0 ray that crosses it
@@ -219,26 +256,44 @@ export function buildShadeKernel( params ) {
219
256
  const catcherEnvDir = groundProjectedEnvDir(
220
257
  origin, direction, groundProjectionEnabled, groundProjectionRadius, groundProjectionHeight, groundProjectionLevel,
221
258
  ).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 ) );
259
+ // force-enable the sampler (pass 1.0): the visible backdrop is decoupled from env-lighting,
260
+ // so the catcher continues the HDRI even when the environment isn't used as a light.
261
+ // Only sample the env where it's actually shown as the catcher backdrop (showBackground); in
262
+ // color/transparent mode the catcher composites over backgroundColor / alpha, so the (up to
263
+ // N-tap) env work would be discarded. Blur matches the miss branch via the shared helper.
264
+ const envBehind = vec3( 0.0 ).toVar();
265
+ If( showBackground, () => {
266
+
267
+ If( backgroundBlurriness.greaterThan( 0.0 ), () => {
268
+
269
+ envBehind.assign( sampleEnvBlurred( catcherEnvDir, backgroundBlurriness.mul( 1.3 ), backgroundBlurSamples, rngState ) );
270
+
271
+ } ).Else( () => {
272
+
273
+ envBehind.assign( sampleEnvironment( {
274
+ tex: envTexture,
275
+ samp: sampler( envTexture ),
276
+ direction: catcherEnvDir,
277
+ environmentMatrix: envMatrix,
278
+ environmentIntensity,
279
+ enableEnvironmentLight: float( 1.0 ),
280
+ } ).xyz );
281
+
282
+ } );
283
+
284
+ } );
285
+ // Background mode: env image (showBackground) or the solid backgroundColor (color mode).
286
+ const bgColor = select( showBackground, envBehind.mul( backgroundIntensity ), backgroundColor );
231
287
  const outRgb = select( transparentBackground, vec3( 0.0 ), bgColor.mul( ratio ) );
232
288
  const outAlpha = select( transparentBackground, float( 1.0 ).sub( ratio ), float( 1.0 ) );
233
289
 
234
290
  // The catcher is a real ground surface for the denoiser — write the plane's normal/depth
235
291
  // + a neutral albedo (black albedo would break OIDN demodulation) and mark the pixel a
236
292
  // valid surface, so OIDN/ASVGF don't smear the caught shadow as a background miss.
237
- If( sampleIndex.equal( int( 0 ) ), () => {
293
+ If( auxOn, () => {
238
294
 
239
295
  const planeDepth = computeNDCDepth( { worldPos: planePoint, cameraProjectionMatrix, cameraViewMatrix } );
240
296
  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
297
 
243
298
  } );
244
299
 
@@ -255,14 +310,26 @@ export function buildShadeKernel( params ) {
255
310
 
256
311
  If( hitDist.greaterThan( MISS_DIST ), () => {
257
312
 
258
- If( enableEnvironmentLight, () => {
259
-
260
- // Ground projection bends the primary ray's background lookup onto a
261
- // projected sphere+disk so the lower env hemisphere reads as a ground
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.
313
+ // Background and environment-lighting are decoupled (independent axes):
314
+ // • Visible backdrop: a PRIMARY ray draws the env image only when showBackground — regardless
315
+ // of enableEnvironmentLight (so you can show the HDRI without it lighting the scene).
316
+ // Env as a light: SECONDARY bounces add the env (implicit MIS hit) only when enableEnvironmentLight.
317
+ // Backdrop-view = the ray still travels the original camera direction (only alpha/transparent
318
+ // passthrough since the camera). This — NOT bounceIndex==0 — is the correct test for "the env here
319
+ // is the direct backdrop", so env seen through alpha-cutout foliage holes is treated identically to
320
+ // the open sky (blur, intensity, show/hide, color-mode, ground projection all match).
321
+ // isBackdropView was captured at arrival (above) so it survives the REDIRECTED bitOr on opaque hits.
322
+ const wantBackdrop = isBackdropView.and( showBackground ); // draw env image as backdrop
323
+ const wantEnvLight = isBackdropView.not().and( enableEnvironmentLight ); // env as light on redirected bounces
324
+
325
+ If( wantBackdrop.or( wantEnvLight ), () => {
326
+
327
+ // Ground projection bends the backdrop-view env lookup onto a projected sphere+disk so the lower
328
+ // env hemisphere reads as a ground plane. Backdrop-view only (incl. through alpha-cutout holes,
329
+ // since they keep the camera direction); redirected bounces see the raw envmap as a light. The
330
+ // shared helper (also used by the shadow catcher) keeps the two in lockstep.
264
331
  const envDir = direction.toVar();
265
- If( bounceIndex.equal( 0 ), () => {
332
+ If( isBackdropView, () => {
266
333
 
267
334
  envDir.assign( groundProjectedEnvDir(
268
335
  origin, direction, groundProjectionEnabled, groundProjectionRadius, groundProjectionHeight, groundProjectionLevel,
@@ -270,25 +337,32 @@ export function buildShadeKernel( params ) {
270
337
 
271
338
  } );
272
339
 
273
- const envColor = sampleEnvironment( {
274
- tex: envTexture,
275
- samp: sampler( envTexture ),
276
- direction: envDir,
277
- environmentMatrix: envMatrix,
278
- environmentIntensity,
279
- enableEnvironmentLight,
280
- } ).toVar();
340
+ // Backdrop-view rays blur the env (cone jitter, shared helper); redirected env-light bounces take
341
+ // the sharp Else. force-enable the sampler (pass 1.0): the wantBackdrop/wantEnvLight gate above
342
+ // already decided visibility, so the backdrop shows the HDRI even when env-lighting is off.
343
+ // Direction-space jitter keeps the blur free of equirect pole/seam artifacts; accumulation
344
+ // converges the noise. Opt-in — blurriness 0 takes the sharp Else (zero cost).
345
+ const envColor = vec3( 0.0 ).toVar();
346
+ If( isBackdropView.and( backgroundBlurriness.greaterThan( 0.0 ) ), () => {
347
+
348
+ envColor.assign( sampleEnvBlurred( envDir, backgroundBlurriness.mul( 1.3 ), backgroundBlurSamples, rngState ) );
281
349
 
282
- // Hide the background for primary rays when showBackground is off; secondary bounces still see the envmap as a light.
283
- If( bounceIndex.equal( 0 ).and( showBackground.not() ), () => {
350
+ } ).Else( () => {
284
351
 
285
- envColor.assign( vec4( 0.0 ) );
352
+ envColor.assign( sampleEnvironment( {
353
+ tex: envTexture,
354
+ samp: sampler( envTexture ),
355
+ direction: envDir,
356
+ environmentMatrix: envMatrix,
357
+ environmentIntensity,
358
+ enableEnvironmentLight: float( 1.0 ),
359
+ } ).xyz );
286
360
 
287
361
  } );
288
362
 
289
363
  // MIS weight for implicit env hit — prevents double-counting with NEE
290
364
  const envMisWeight = float( 1.0 ).toVar();
291
- If( bounceIndex.greaterThan( 0 ).and( useEnvMapIS ), () => {
365
+ If( isBackdropView.not().and( useEnvMapIS ), () => {
292
366
 
293
367
  const prevBouncePdf = readRayPdf( rayBufferRW, rayID );
294
368
  If( prevBouncePdf.greaterThan( 0.0 ), () => {
@@ -307,18 +381,23 @@ export function buildShadeKernel( params ) {
307
381
 
308
382
  } );
309
383
 
310
- const envGiScale = select( bounceIndex.greaterThan( 0 ), globalIlluminationIntensity, float( 1.0 ) );
311
- const envScale = select( bounceIndex.equal( 0 ), backgroundIntensity, envMisWeight.mul( envGiScale ) );
384
+ const envGiScale = select( isBackdropView.not(), globalIlluminationIntensity, float( 1.0 ) );
385
+ const envScale = select( isBackdropView, backgroundIntensity, envMisWeight.mul( envGiScale ) );
312
386
 
313
387
  // Firefly-suppress the env contribution (megakernel parity: PathTracerCore.js:780). Without
314
388
  // this, indirect bounces escaping to a bright environment are unsuppressed spikes that OIDN
315
389
  // smears into white blobs. The miss branch Return()s before the hit-branch clamp (~line 712),
316
390
  // so it must be applied here.
391
+ // Firefly path length: a backdrop view (incl. through alpha-cutout holes) is a DIRECT view of the
392
+ // sky → use 0 (loosest clamp, same as the open-sky bounce-0 backdrop) so a bright HDRI sun doesn't
393
+ // read dimmer behind foliage cutouts than beside them. Only redirected GI bounces get the tighter
394
+ // path-length threshold.
395
+ const fireflyPathLen = select( isBackdropView, float( 0.0 ), float( bounceIndex ) );
317
396
  currentRadiance.assign( vec4(
318
397
  currentRadiance.xyz.add(
319
398
  regularizePathContribution(
320
- throughput.mul( envColor.xyz ).mul( envScale ),
321
- float( bounceIndex ), fireflyThreshold, int( frame ),
399
+ throughput.mul( envColor ).mul( envScale ),
400
+ fireflyPathLen, fireflyThreshold, int( frame ),
322
401
  ),
323
402
  ),
324
403
  currentRadiance.w
@@ -326,6 +405,18 @@ export function buildShadeKernel( params ) {
326
405
 
327
406
  } );
328
407
 
408
+ // Solid-color backdrop ('color' mode): a primary ray that doesn't show the env image and isn't
409
+ // transparent fills with backgroundColor (default black). Tinted by throughput so it reads
410
+ // correctly behind colored glass, matching the env-backdrop path.
411
+ If( isBackdropView.and( showBackground.not() ).and( transparentBackground.not() ), () => {
412
+
413
+ currentRadiance.assign( vec4(
414
+ currentRadiance.xyz.add( throughput.mul( backgroundColor ) ),
415
+ currentRadiance.w
416
+ ) );
417
+
418
+ } );
419
+
329
420
  // Transparent-bg alpha: see-through only if the ray escaped WITHOUT ever hitting opaque
330
421
  // geometry (megakernel parity: PathTracerCore.js:784). A secondary bounce off an opaque
331
422
  // surface that escapes to env keeps alpha 1 (HAS_HIT_OPAQUE set), so glass-in-front-of-an-
@@ -402,6 +493,8 @@ export function buildShadeKernel( params ) {
402
493
  throughput.divAssign( rrP );
403
494
 
404
495
  // free-bounce continuation: ray stays in the same medium, so medium stack + coeffs persist
496
+ // SSS scatter changes direction → no longer the direct backdrop view.
497
+ flags.assign( flags.bitOr( uint( RAY_FLAG.REDIRECTED ) ) );
405
498
  writeRayOriginMeta( rayBufferRW, rayID, scatterPoint, cameraDepth, sssSteps );
406
499
  writeRayDirFlags( rayBufferRW, rayID, newDir, flags );
407
500
  // Free bounce: preserve prevBouncePdf (megakernel leaves it untouched across SSS scatter,
@@ -477,26 +570,19 @@ export function buildShadeKernel( params ) {
477
570
  } );
478
571
 
479
572
  // first-hit MRT data (bounce 0 only)
480
- If( bounceIndex.equal( 0 ), () => {
573
+ If( bounceIndex.equal( 0 ).and( auxOn ), () => {
481
574
 
482
575
  const linearDepth = computeNDCDepth( {
483
576
  worldPos: hitPoint,
484
577
  cameraProjectionMatrix,
485
578
  cameraViewMatrix,
486
579
  } );
487
- // G-buffer is per-pixel — only sub-sample 0 writes it (FinalWrite reads sub-sample 0). writeGBuffer half-packs (normal/depth/albedo).
580
+ // G-buffer is per-pixel (rayID == pixelIndex). writeGBuffer half-packs (normal/depth/albedo).
488
581
  // Write the primary DEPTH now with the miss-default aux; the real normal/albedo are captured below
489
582
  // (aux-extend) and may extend through specular surfaces (gap #9). Glass rays Return at the transparency
490
583
  // block before that capture, so a glass-then-escape pixel keeps this default aux — megakernel parity
491
584
  // (objectNormal/objectColor stay at their init for transmissive-then-miss).
492
- If( sampleIndex.equal( int( 0 ) ), () => {
493
-
494
- writeGBuffer( gBufferRW, pixelIndex, vec3( 0.0, 0.0, 1.0 ), linearDepth, vec3( 0.0 ) );
495
- // Persist the primary-hit surface ID (Tier-1 A-SVGF correlated re-projection). Hit-only
496
- // branch (misses Return above), so this marks the pixel valid; bary from the bounce-0 hit.
497
- writeGBufferSurfaceID( gBufferRW, pixelIndex, hitTriIdx, readHitMeshIndex( hitBufferRO, rayID ), hitUV.x, hitUV.y, uint( 1 ) );
498
-
499
- } );
585
+ writeGBuffer( gBufferRW, pixelIndex, vec3( 0.0, 0.0, 1.0 ), linearDepth, vec3( 0.0 ) );
500
586
 
501
587
  } );
502
588
 
@@ -640,6 +726,14 @@ export function buildShadeKernel( params ) {
640
726
 
641
727
  // SSS = free bounce (depth unchanged); transmission advances camera-bounce depth.
642
728
  // 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).
729
+ // Backdrop-view survives a pure alpha/transparent passthrough (direction unchanged) but is cleared by
730
+ // any redirection (refraction/reflection/SSS boundary), so env through a leaf hole stays the blurred
731
+ // backdrop while env through glass becomes sharp redirected light.
732
+ If( interaction.isAlphaSkip.not(), () => {
733
+
734
+ flags.assign( flags.bitOr( uint( RAY_FLAG.REDIRECTED ) ) );
735
+
736
+ } );
643
737
  writeRayOriginMeta( rayBufferRW, rayID, newOrigin, cameraDepth, sssSteps );
644
738
  writeRayDirFlags( rayBufferRW, rayID, interaction.direction, flags );
645
739
  // Free bounce: preserve prevBouncePdf (megakernel keeps the last opaque-scatter pdf across
@@ -657,12 +751,17 @@ export function buildShadeKernel( params ) {
657
751
  // PathTracerCore.js:1042). Flag the chain so a later env-escape keeps alpha 1 (the gate in the
658
752
  // miss branch). Alpha itself already defaults to 1 from Generate in transparent-bg mode, so there
659
753
  // is nothing to set here — a ray dying inside geometry (SSS walk) stays solid without reaching this.
660
- flags.assign( flags.bitOr( uint( RAY_FLAG.HAS_HIT_OPAQUE ) ) );
754
+ // Hit opaque geometry: set HAS_HIT_OPAQUE and mark REDIRECTED (this is a real surface scatter,
755
+ // so any later env-escape is redirected light, not the direct backdrop). Single positive bitOr.
756
+ flags.assign( flags.bitOr( uint( RAY_FLAG.HAS_HIT_OPAQUE | RAY_FLAG.REDIRECTED ) ) );
661
757
 
662
758
  const emissive = matSamples.emissive.toVar();
663
759
  If( length( emissive ).greaterThan( 0.0 ), () => {
664
760
 
665
- const emissiveGiScale = select( bounceIndex.greaterThan( 0 ), globalIlluminationIntensity, float( 1.0 ) );
761
+ // Key on backdrop-view (not bounceIndex>0) so an emitter seen DIRECTLY through an alpha-cutout hole
762
+ // renders at full intensity (1.0) like a direct view, consistent with env-through-hole — instead of
763
+ // being GI-scaled as if it were an indirect bounce. (MIS below already self-guards via prevBouncePdf.)
764
+ const emissiveGiScale = select( isBackdropView.not(), globalIlluminationIntensity, float( 1.0 ) );
666
765
 
667
766
  // MIS weight vs emissive-triangle NEE (megakernel parity: PathTracerCore.js:1117). On a secondary
668
767
  // hit (bounceIndex>0) the prior bounce's NEE also sampled this emitter — power-heuristic balances the
@@ -734,7 +833,7 @@ export function buildShadeKernel( params ) {
734
833
  // visible (the surface reflected in a mirror / seen behind glass), not the specular surface. Glass
735
834
  // Returns at the transparency block above, so its aux is replaced by the surface behind it. Depth
736
835
  // stays at the primary hit (read back + re-packed; the snorm depth re-pack is idempotent — no drift).
737
- If( sampleIndex.equal( int( 0 ) ).and( flags.bitAnd( uint( RAY_FLAG.AUX_LOCKED ) ).equal( uint( 0 ) ) ), () => {
836
+ If( flags.bitAnd( uint( RAY_FLAG.AUX_LOCKED ) ).equal( uint( 0 ) ).and( auxOn ), () => {
738
837
 
739
838
  const primaryDepth = gbDecodeNormalDepth( readGBuffer( gBufferRW, pixelIndex ) ).w;
740
839
  writeGBuffer( gBufferRW, pixelIndex, N, primaryDepth, albedo.xyz );
@@ -754,13 +853,13 @@ export function buildShadeKernel( params ) {
754
853
  material.clearcoat, material.emissive, material.subsurface,
755
854
  ) ).toVar();
756
855
 
757
- // STBN keyed on (pixel, bounceIndex, frame); sampleIndex gives each sub-sample a distinct tap
856
+ // STBN keyed on (pixel, bounceIndex, frame).
758
857
  const _resX = int( resolution.x ).toVar();
759
858
  const _pixelCoord = vec2(
760
859
  float( int( pixelIndex ).mod( _resX ) ).add( 0.5 ),
761
860
  float( int( pixelIndex ).div( _resX ) ).add( 0.5 ),
762
861
  );
763
- const xi = getRandomSample( _pixelCoord, sampleIndex, bounceIndex, rngState, int( - 1 ), resolution, frame ).toVar();
862
+ const xi = getRandomSample( _pixelCoord, int( 0 ), bounceIndex, rngState, int( - 1 ), resolution, frame ).toVar();
764
863
  const emptyWeights = BRDFWeights( {
765
864
  specular: float( 0.0 ), diffuse: float( 0.0 ), sheen: float( 0.0 ),
766
865
  clearcoat: float( 0.0 ), transmission: float( 0.0 ), iridescence: float( 0.0 ),
@@ -365,6 +365,21 @@ 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
+
373
+ // Reclaim VRAM: free the big 2048² StorageTextures of any denoiser/G-buffer stage that ended up
374
+ // disabled (lazily re-created on the next dispatch after re-enable). Every strategy/denoiser
375
+ // toggle funnels through here after the enabled flags above are settled, so this is the one
376
+ // choke point. dispose() is idempotent, so re-running it for an already-released stage is a no-op.
377
+ for ( const stage of [ s.asvgf, s.variance, s.bilateralFilter, s.ssrc, s.edgeFilter, nd, mv ] ) {
378
+
379
+ if ( stage && ! stage.enabled ) stage.releaseGPUMemory?.();
380
+
381
+ }
382
+
368
383
  }
369
384
 
370
385
  // ── Render Completion Chain ───────────────────────────────────
@@ -595,6 +610,8 @@ export class DenoisingManager extends EventDispatcher {
595
610
  setOIDNEnabled( enabled ) {
596
611
 
597
612
  if ( this.denoiser ) this.denoiser.enabled = enabled;
613
+ // OIDN reads the PathTracer aux MRT; re-sync so the wavefront produces it while OIDN is on.
614
+ this._syncGBufferStages();
598
615
 
599
616
  }
600
617
 
@@ -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 );
@@ -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