rayzee 7.3.0 → 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.
@@ -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';
@@ -91,7 +91,7 @@ export function buildShadeKernel( params ) {
91
91
  spotLightsBuffer, numSpotLights,
92
92
  maxBounceCount, maxSubsurfaceSteps,
93
93
  currentBounce, // loop iteration = path length (advances on free bounces); drives RR/firefly/giScale
94
- transparentBackground, backgroundIntensity, showBackground,
94
+ transparentBackground, backgroundIntensity, backgroundColor, backgroundBlurriness, backgroundBlurSamples, showBackground,
95
95
  globalIlluminationIntensity,
96
96
  cameraProjectionMatrix, cameraViewMatrix,
97
97
  fireflyThreshold, frame, resolution,
@@ -99,10 +99,45 @@ export function buildShadeKernel( params ) {
99
99
  emissiveBoost, totalTriangleCount, enableEmissiveTriangleSampling,
100
100
  lightBVHNodeCount, reverseMapVec4Offset,
101
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,
102
105
  } = params;
103
106
 
107
+ const auxOn = auxGBufferEnabled.greaterThan( uint( 0 ) );
108
+
104
109
  const useEmissiveNEE = lightBuffer !== undefined;
105
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
+
106
141
  const computeFn = Fn( () => {
107
142
 
108
143
  const threadIdx = instanceIndex;
@@ -125,13 +160,19 @@ export function buildShadeKernel( params ) {
125
160
 
126
161
  } );
127
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
+
128
170
  const origin = readRayOrigin( rayBufferRW, rayID ).toVar();
129
171
  const direction = readRayDirection( rayBufferRW, rayID ).toVar();
130
172
  const throughput = readRayThroughput( rayBufferRW, rayID ).toVar();
131
173
  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 );
174
+ // One ray per pixel: rayID is the pixel index.
175
+ const pixelIndex = rayID;
135
176
  const rngState = rngBufferRW.element( rayID ).toVar();
136
177
 
137
178
  const hitDist = readHitDistance( hitBufferRO, rayID ).toVar();
@@ -146,7 +187,6 @@ export function buildShadeKernel( params ) {
146
187
  // path length = loop iteration (advances every bounce incl. transmissive/SSS); drives RR/firefly/giScale/MIS. Megakernel: loop counter i.
147
188
  const bounceIndex = int( currentBounce ).toVar();
148
189
  const sssSteps = readSssSteps( rayBufferRW, rayID ).toVar();
149
- const sampleIndex = int( rayID.div( maxRaysPerSample ) ).toVar();
150
190
 
151
191
  // ── Analytic ground-plane shadow catcher (primary ray only, no geometry) ──
152
192
  // A horizontal plane at y = groundCatcherHeight. For a bounce-0 ray that crosses it
@@ -219,22 +259,41 @@ export function buildShadeKernel( params ) {
219
259
  const catcherEnvDir = groundProjectedEnvDir(
220
260
  origin, direction, groundProjectionEnabled, groundProjectionRadius, groundProjectionHeight, groundProjectionLevel,
221
261
  ).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 ) );
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 );
231
290
  const outRgb = select( transparentBackground, vec3( 0.0 ), bgColor.mul( ratio ) );
232
291
  const outAlpha = select( transparentBackground, float( 1.0 ).sub( ratio ), float( 1.0 ) );
233
292
 
234
293
  // The catcher is a real ground surface for the denoiser — write the plane's normal/depth
235
294
  // + a neutral albedo (black albedo would break OIDN demodulation) and mark the pixel a
236
295
  // valid surface, so OIDN/ASVGF don't smear the caught shadow as a background miss.
237
- If( sampleIndex.equal( int( 0 ) ), () => {
296
+ If( auxOn, () => {
238
297
 
239
298
  const planeDepth = computeNDCDepth( { worldPos: planePoint, cameraProjectionMatrix, cameraViewMatrix } );
240
299
  writeGBuffer( gBufferRW, pixelIndex, planeN, planeDepth, vec3( 1.0 ) );
@@ -255,14 +314,26 @@ export function buildShadeKernel( params ) {
255
314
 
256
315
  If( hitDist.greaterThan( MISS_DIST ), () => {
257
316
 
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.
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.
264
335
  const envDir = direction.toVar();
265
- If( bounceIndex.equal( 0 ), () => {
336
+ If( isBackdropView, () => {
266
337
 
267
338
  envDir.assign( groundProjectedEnvDir(
268
339
  origin, direction, groundProjectionEnabled, groundProjectionRadius, groundProjectionHeight, groundProjectionLevel,
@@ -270,25 +341,32 @@ export function buildShadeKernel( params ) {
270
341
 
271
342
  } );
272
343
 
273
- const envColor = sampleEnvironment( {
274
- tex: envTexture,
275
- samp: sampler( envTexture ),
276
- direction: envDir,
277
- environmentMatrix: envMatrix,
278
- environmentIntensity,
279
- enableEnvironmentLight,
280
- } ).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 ) );
281
353
 
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() ), () => {
354
+ } ).Else( () => {
284
355
 
285
- 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 );
286
364
 
287
365
  } );
288
366
 
289
367
  // MIS weight for implicit env hit — prevents double-counting with NEE
290
368
  const envMisWeight = float( 1.0 ).toVar();
291
- If( bounceIndex.greaterThan( 0 ).and( useEnvMapIS ), () => {
369
+ If( isBackdropView.not().and( useEnvMapIS ), () => {
292
370
 
293
371
  const prevBouncePdf = readRayPdf( rayBufferRW, rayID );
294
372
  If( prevBouncePdf.greaterThan( 0.0 ), () => {
@@ -307,18 +385,23 @@ export function buildShadeKernel( params ) {
307
385
 
308
386
  } );
309
387
 
310
- const envGiScale = select( bounceIndex.greaterThan( 0 ), globalIlluminationIntensity, float( 1.0 ) );
311
- 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 ) );
312
390
 
313
391
  // Firefly-suppress the env contribution (megakernel parity: PathTracerCore.js:780). Without
314
392
  // this, indirect bounces escaping to a bright environment are unsuppressed spikes that OIDN
315
393
  // smears into white blobs. The miss branch Return()s before the hit-branch clamp (~line 712),
316
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 ) );
317
400
  currentRadiance.assign( vec4(
318
401
  currentRadiance.xyz.add(
319
402
  regularizePathContribution(
320
- throughput.mul( envColor.xyz ).mul( envScale ),
321
- float( bounceIndex ), fireflyThreshold, int( frame ),
403
+ throughput.mul( envColor ).mul( envScale ),
404
+ fireflyPathLen, fireflyThreshold, int( frame ),
322
405
  ),
323
406
  ),
324
407
  currentRadiance.w
@@ -326,6 +409,18 @@ export function buildShadeKernel( params ) {
326
409
 
327
410
  } );
328
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
+
329
424
  // Transparent-bg alpha: see-through only if the ray escaped WITHOUT ever hitting opaque
330
425
  // geometry (megakernel parity: PathTracerCore.js:784). A secondary bounce off an opaque
331
426
  // surface that escapes to env keeps alpha 1 (HAS_HIT_OPAQUE set), so glass-in-front-of-an-
@@ -402,6 +497,8 @@ export function buildShadeKernel( params ) {
402
497
  throughput.divAssign( rrP );
403
498
 
404
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 ) ) );
405
502
  writeRayOriginMeta( rayBufferRW, rayID, scatterPoint, cameraDepth, sssSteps );
406
503
  writeRayDirFlags( rayBufferRW, rayID, newDir, flags );
407
504
  // Free bounce: preserve prevBouncePdf (megakernel leaves it untouched across SSS scatter,
@@ -477,26 +574,22 @@ export function buildShadeKernel( params ) {
477
574
  } );
478
575
 
479
576
  // first-hit MRT data (bounce 0 only)
480
- If( bounceIndex.equal( 0 ), () => {
577
+ If( bounceIndex.equal( 0 ).and( auxOn ), () => {
481
578
 
482
579
  const linearDepth = computeNDCDepth( {
483
580
  worldPos: hitPoint,
484
581
  cameraProjectionMatrix,
485
582
  cameraViewMatrix,
486
583
  } );
487
- // 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).
488
585
  // Write the primary DEPTH now with the miss-default aux; the real normal/albedo are captured below
489
586
  // (aux-extend) and may extend through specular surfaces (gap #9). Glass rays Return at the transparency
490
587
  // block before that capture, so a glass-then-escape pixel keeps this default aux — megakernel parity
491
588
  // (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
- } );
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 ) );
500
593
 
501
594
  } );
502
595
 
@@ -640,6 +733,14 @@ export function buildShadeKernel( params ) {
640
733
 
641
734
  // SSS = free bounce (depth unchanged); transmission advances camera-bounce depth.
642
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
+ } );
643
744
  writeRayOriginMeta( rayBufferRW, rayID, newOrigin, cameraDepth, sssSteps );
644
745
  writeRayDirFlags( rayBufferRW, rayID, interaction.direction, flags );
645
746
  // Free bounce: preserve prevBouncePdf (megakernel keeps the last opaque-scatter pdf across
@@ -657,12 +758,17 @@ export function buildShadeKernel( params ) {
657
758
  // PathTracerCore.js:1042). Flag the chain so a later env-escape keeps alpha 1 (the gate in the
658
759
  // miss branch). Alpha itself already defaults to 1 from Generate in transparent-bg mode, so there
659
760
  // 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 ) ) );
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 ) ) );
661
764
 
662
765
  const emissive = matSamples.emissive.toVar();
663
766
  If( length( emissive ).greaterThan( 0.0 ), () => {
664
767
 
665
- 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 ) );
666
772
 
667
773
  // MIS weight vs emissive-triangle NEE (megakernel parity: PathTracerCore.js:1117). On a secondary
668
774
  // hit (bounceIndex>0) the prior bounce's NEE also sampled this emitter — power-heuristic balances the
@@ -734,7 +840,7 @@ export function buildShadeKernel( params ) {
734
840
  // visible (the surface reflected in a mirror / seen behind glass), not the specular surface. Glass
735
841
  // Returns at the transparency block above, so its aux is replaced by the surface behind it. Depth
736
842
  // 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 ) ) ), () => {
843
+ If( flags.bitAnd( uint( RAY_FLAG.AUX_LOCKED ) ).equal( uint( 0 ) ).and( auxOn ), () => {
738
844
 
739
845
  const primaryDepth = gbDecodeNormalDepth( readGBuffer( gBufferRW, pixelIndex ) ).w;
740
846
  writeGBuffer( gBufferRW, pixelIndex, N, primaryDepth, albedo.xyz );
@@ -754,13 +860,13 @@ export function buildShadeKernel( params ) {
754
860
  material.clearcoat, material.emissive, material.subsurface,
755
861
  ) ).toVar();
756
862
 
757
- // STBN keyed on (pixel, bounceIndex, frame); sampleIndex gives each sub-sample a distinct tap
863
+ // STBN keyed on (pixel, bounceIndex, frame).
758
864
  const _resX = int( resolution.x ).toVar();
759
865
  const _pixelCoord = vec2(
760
866
  float( int( pixelIndex ).mod( _resX ) ).add( 0.5 ),
761
867
  float( int( pixelIndex ).div( _resX ) ).add( 0.5 ),
762
868
  );
763
- 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();
764
870
  const emptyWeights = BRDFWeights( {
765
871
  specular: float( 0.0 ), diffuse: float( 0.0 ), sheen: float( 0.0 ),
766
872
  clearcoat: float( 0.0 ), transmission: float( 0.0 ), iridescence: float( 0.0 ),
@@ -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 );
@@ -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