rayzee 7.20.3 → 7.21.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.20.3",
3
+ "version": "7.21.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",
@@ -21,7 +21,6 @@ export const ENGINE_DEFAULTS = {
21
21
  enableEnvironment: true,
22
22
  showBackground: true,
23
23
  transparentBackground: false,
24
- useImportanceSampledEnvironment: true,
25
24
  environmentIntensity: 1,
26
25
  backgroundIntensity: 1,
27
26
  // Solid backdrop color shown on camera-ray misses in 'color' background mode
@@ -164,7 +163,9 @@ export const ENGINE_DEFAULTS = {
164
163
  asvgfPhiDepth: 1.0,
165
164
  asvgfVarianceBoost: 1.0,
166
165
  asvgfMaxAccumFrames: 32,
167
- asvgfGradientStrength: 0.0,
166
+ // Must be > 0: the gradient also rejects fireflies before the EMA smears them across
167
+ // ~1/alpha frames. At 0 the denoiser measures ~3x worse than no denoiser at 1 spp.
168
+ asvgfGradientStrength: 1.0,
168
169
  asvgfGradientSigmaScale: 2.0,
169
170
  asvgfGradientNoiseFloor: 0.0,
170
171
  asvgfDebugMode: 0,
@@ -1562,6 +1562,11 @@ export class PathTracerApp extends EventDispatcher {
1562
1562
  this.cameraManager?.setAutoFocusMode( 'manual' );
1563
1563
  if ( this.stages.autoExposure ) this.stages.autoExposure.enabled = false;
1564
1564
 
1565
+ // The seed axis free-runs across accumulation resets so a camera drag gets fresh
1566
+ // sequences; offline rendering needs the opposite. Pinning it makes seedFrame track
1567
+ // frameCount, so N samples reproduce bit-for-bit. reset() below zeroes the tick.
1568
+ stage._pinSeedToFrame = true;
1569
+
1565
1570
  this._deterministic = true;
1566
1571
  this._dispatchPinned = pinDispatch;
1567
1572
 
@@ -1580,6 +1585,8 @@ export class PathTracerApp extends EventDispatcher {
1580
1585
 
1581
1586
  }
1582
1587
 
1588
+ stage._pinSeedToFrame = false;
1589
+
1583
1590
  this._deterministicRestore = null;
1584
1591
  this._deterministic = false;
1585
1592
  this._dispatchPinned = false;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Dev-only guard against texture-binding aliasing.
3
+ *
4
+ * Two TextureNodes still holding the default EmptyTexture when a stage's kernel is first
5
+ * compiled can share one GPU binding, which then resolves to whichever is assigned last.
6
+ * Nothing throws — the aliased node reads a real texture, just not its own. BilateralFilter
7
+ * shipped this way, reading ASVGF's history counter as its variance.
8
+ *
9
+ * Rule enforced: every TextureNode a kernel reads must hold its real texture before that
10
+ * kernel is first dispatched. StorageTexture-typed nodes are exempt — `textureLoad` codegen
11
+ * only emits the required `level` parameter while the node still holds EmptyTexture, so those
12
+ * are deliberately bound after compile (see ASVGF.render).
13
+ *
14
+ * Off by default; the bench harness turns it on.
15
+ */
16
+
17
+ let enabled = false;
18
+ const findings = [];
19
+
20
+ /** Per-stage audit state. WeakMap so a disposed stage does not pin its entry. */
21
+ const stageState = new WeakMap();
22
+
23
+ /**
24
+ * @param {boolean} on
25
+ */
26
+ export function setBindingAudit( on ) {
27
+
28
+ enabled = !! on;
29
+
30
+ }
31
+
32
+ export function isBindingAuditEnabled() {
33
+
34
+ return enabled;
35
+
36
+ }
37
+
38
+ /** @returns {Array<{stage: string, nodes: string[], unboundAtCompile: string[], message: string}>} */
39
+ export function getBindingAuditFindings() {
40
+
41
+ return findings.slice();
42
+
43
+ }
44
+
45
+ export function clearBindingAuditFindings() {
46
+
47
+ findings.length = 0;
48
+
49
+ }
50
+
51
+ /** Own enumerable properties holding a TSL TextureNode. */
52
+ function textureNodeKeys( stage ) {
53
+
54
+ const keys = [];
55
+
56
+ for ( const key of Object.keys( stage ) ) {
57
+
58
+ if ( stage[ key ]?.isTextureNode === true ) keys.push( key );
59
+
60
+ }
61
+
62
+ return keys;
63
+
64
+ }
65
+
66
+ // EmptyTexture (the TextureNode default) is a bare `new Texture()`, so its image is null.
67
+ // Every real texture the stages bind — RenderTarget textures and StorageTextures alike —
68
+ // carries an image with a nonzero width.
69
+ const isUnbound = ( texture ) => ! texture || ! texture.image || ! ( texture.image.width > 0 );
70
+
71
+ /**
72
+ * Runs a stage's render with the audit wrapped around it.
73
+ *
74
+ * The snapshot must be taken when the stage first calls `renderer.compute`, not before or
75
+ * after render() — the binding assignments and the compile are interleaved inside that one
76
+ * call, so by the time it returns everything is bound. Sampling after render() was the first
77
+ * version of this guard and it silently caught nothing.
78
+ *
79
+ * A later frame then resolves: a candidate now holding a StorageTexture is the sanctioned
80
+ * pattern; anything else was bound too late.
81
+ *
82
+ * @param {Object} stage
83
+ * @param {Object} renderer
84
+ * @param {function(): void} renderFn
85
+ */
86
+ export function auditStageRender( stage, renderer, renderFn ) {
87
+
88
+ if ( ! enabled ) {
89
+
90
+ renderFn();
91
+ return;
92
+
93
+ }
94
+
95
+ let state = stageState.get( stage );
96
+
97
+ if ( ! state ) {
98
+
99
+ state = { phase: 'pending', unboundAtCompile: [] };
100
+ stageState.set( stage, state );
101
+
102
+ }
103
+
104
+ if ( state.phase !== 'pending' ) {
105
+
106
+ renderFn();
107
+ if ( state.phase === 'watch' ) resolve( stage, state );
108
+ return;
109
+
110
+ }
111
+
112
+ // Shadow the prototype method for the duration of this stage's render only.
113
+ const hadOwn = Object.prototype.hasOwnProperty.call( renderer, 'compute' );
114
+ const original = renderer.compute;
115
+
116
+ renderer.compute = function ( ...args ) {
117
+
118
+ if ( state.phase === 'pending' ) {
119
+
120
+ state.unboundAtCompile = textureNodeKeys( stage )
121
+ .filter( ( key ) => isUnbound( stage[ key ].value ) );
122
+
123
+ // A single unbound node has nothing to alias with, so it cannot produce this bug.
124
+ state.phase = state.unboundAtCompile.length < 2 ? 'done' : 'watch';
125
+
126
+ }
127
+
128
+ return original.apply( this, args );
129
+
130
+ };
131
+
132
+ try {
133
+
134
+ renderFn();
135
+
136
+ } finally {
137
+
138
+ if ( hadOwn ) renderer.compute = original;
139
+ else delete renderer.compute;
140
+
141
+ }
142
+
143
+ }
144
+
145
+ function resolve( stage, state ) {
146
+
147
+ const lateBound = [];
148
+
149
+ for ( const key of state.unboundAtCompile ) {
150
+
151
+ const texture = stage[ key ]?.value;
152
+
153
+ // Still unassigned — the stage may bind it on a later frame (or never, which is also
154
+ // not this bug). Leave the audit open rather than guessing.
155
+ if ( isUnbound( texture ) ) return;
156
+
157
+ if ( texture.isStorageTexture !== true ) lateBound.push( key );
158
+
159
+ }
160
+
161
+ state.phase = 'done';
162
+
163
+ if ( lateBound.length === 0 ) return;
164
+
165
+ findings.push( {
166
+ stage: stage.name,
167
+ nodes: lateBound,
168
+ unboundAtCompile: state.unboundAtCompile.slice(),
169
+ message:
170
+ `${stage.name}: ${lateBound.join( ', ' )} ` +
171
+ `${lateBound.length === 1 ? 'was' : 'were'} still holding EmptyTexture when the stage ` +
172
+ `first dispatched, alongside ${state.unboundAtCompile.length - lateBound.length} ` +
173
+ 'other unbound node(s), and later received a non-storage texture. Two nodes unbound at ' +
174
+ 'compile time can share one GPU binding and then both resolve to whichever is assigned ' +
175
+ 'last. Assign these before the first dispatch.',
176
+ } );
177
+
178
+ }
@@ -1,5 +1,6 @@
1
1
  import { PipelineContext } from './PipelineContext.js';
2
2
  import { EventDispatcher } from './EventDispatcher.js';
3
+ import { auditStageRender } from './BindingAudit.js';
3
4
 
4
5
  /**
5
6
  * RenderPipeline - Orchestrates execution of pipeline stages
@@ -183,8 +184,10 @@ export class RenderPipeline {
183
184
 
184
185
  const stageStartTime = this.stats.enabled ? performance.now() : 0;
185
186
 
186
- // Execute stage
187
- stage.render( this.context, writeBuffer );
187
+ // Pass-through unless setBindingAudit(true) — see BindingAudit.js.
188
+ auditStageRender(
189
+ stage, this.renderer, () => stage.render( this.context, writeBuffer )
190
+ );
188
191
 
189
192
  // Track timing
190
193
  if ( this.stats.enabled ) {
@@ -24,7 +24,6 @@ export class CameraOptimizer {
24
24
  // Enhanced interaction mode settings for reduced quality during interaction
25
25
  this.interactionQualitySettings = {
26
26
  maxBounceCount: 1,
27
- useEnvMapIS: false,
28
27
  // pixelRatio: 0.25,
29
28
  enableAccumulation: false,
30
29
  ...settings.qualitySettings
@@ -303,25 +302,21 @@ export class CameraOptimizer {
303
302
  const presets = {
304
303
  'ultra-low': {
305
304
  maxBounceCount: 1,
306
- useEnvMapIS: false,
307
305
  pixelRatio: 0.125,
308
306
  enableAccumulation: false
309
307
  },
310
308
  'low': {
311
309
  maxBounceCount: 1,
312
- useEnvMapIS: false,
313
310
  pixelRatio: 0.25,
314
311
  enableAccumulation: false
315
312
  },
316
313
  'medium': {
317
314
  maxBounceCount: 2,
318
- useEnvMapIS: true,
319
315
  pixelRatio: 0.5,
320
316
  enableAccumulation: false
321
317
  },
322
318
  'high': {
323
319
  maxBounceCount: 3,
324
- useEnvMapIS: true,
325
320
  pixelRatio: 0.75,
326
321
  enableAccumulation: true
327
322
  }
@@ -5,7 +5,7 @@
5
5
 
6
6
  import {
7
7
  storage, uintBitsToFloat, floatBitsToUint, vec2, vec3, vec4, uvec4, uint, int, float, clamp,
8
- packSnorm2x16, packUnorm2x16, unpackSnorm2x16, unpackUnorm2x16,
8
+ packSnorm2x16, packUnorm2x16, unpackSnorm2x16, unpackUnorm2x16, select, floor, log2, exp2, max,
9
9
  } from 'three/tsl';
10
10
  import { StorageInstancedBufferAttribute } from 'three/webgpu';
11
11
  import { createLogger, fmt } from '../utils/Logger.js';
@@ -186,13 +186,38 @@ export const gbDecodeAlbedo = ( packed ) =>
186
186
  // .w packs per-ray bounce state: perRayBounces (bits 0-7) | sssSteps (bits 8-15) | transparentCount
187
187
  // (bits 16-21), each masked so an overrun can't bleed into its neighbour. pixelIndex is NOT stored —
188
188
  // it equals rayID (one ray per pixel).
189
- export const writeRayOriginMeta = ( buf, id, origin, bounces, sssSteps, transparentCount = uint( 0 ) ) =>
190
- buf.element( soa( id, RAY.ORIGIN_META ) )
191
- .assign( vec4( origin, uintBitsToFloat(
192
- uint( bounces ).bitAnd( uint( 0xFF ) )
193
- .bitOr( uint( sssSteps ).bitAnd( uint( 0xFF ) ).shiftLeft( 8 ) )
194
- .bitOr( uint( transparentCount ).bitAnd( uint( 0x3F ) ).shiftLeft( 16 ) )
195
- ) ) );
189
+ // Bits 22-31 of ORIGIN_META.w: alpha-passthrough distance since the last real scatter (Cycles'
190
+ // mis_ray_t), as a 10-bit float (4e6m, bias 8, ~0.004-500 range, ≤1.6% rel err). The emissive-hit
191
+ // MIS partner pdf must be evaluated at the scatter point that produced prevBouncePdf, not at the
192
+ // last skip surface — feeding it the skip origin collapses dist² and lets full-Le BSDF hits
193
+ // through nearly unweighted (fireflies on emitters inside alpha-blend shades).
194
+ const encodeMisRayT = ( t ) => {
195
+
196
+ const e = clamp( floor( log2( max( t, 1e-6 ) ) ), - 8.0, 7.0 );
197
+ const m = clamp( float( t ).mul( exp2( e.negate() ) ).sub( 1.0 ).mul( 64.0 ), 0.0, 63.0 );
198
+ return select( float( t ).lessThan( 0.004 ), uint( 0 ), uint( e.add( 8.0 ) ).shiftLeft( 6 ).bitOr( uint( m ) ) );
199
+
200
+ };
201
+
202
+ export const readMisRayT = ( buf, id ) => {
203
+
204
+ const bits = floatBitsToUint( buf.element( soa( id, RAY.ORIGIN_META ) ).w ).shiftRight( 22 );
205
+ const e = float( bits.shiftRight( 6 ).bitAnd( uint( 15 ) ) ).sub( 8.0 );
206
+ const m = float( bits.bitAnd( uint( 63 ) ) ).div( 64.0 ).add( 1.0 );
207
+ return select( bits.equal( uint( 0 ) ), float( 0.0 ), m.mul( exp2( e ) ) );
208
+
209
+ };
210
+
211
+ export const writeRayOriginMeta = ( buf, id, origin, bounces, sssSteps, transparentCount = uint( 0 ), misRayT = null ) => {
212
+
213
+ let packed = uint( bounces ).bitAnd( uint( 0xFF ) )
214
+ .bitOr( uint( sssSteps ).bitAnd( uint( 0xFF ) ).shiftLeft( 8 ) )
215
+ .bitOr( uint( transparentCount ).bitAnd( uint( 0x3F ) ).shiftLeft( 16 ) );
216
+ if ( misRayT !== null ) packed = packed.bitOr( encodeMisRayT( misRayT ).shiftLeft( 22 ) );
217
+ return buf.element( soa( id, RAY.ORIGIN_META ) )
218
+ .assign( vec4( origin, uintBitsToFloat( packed ) ) );
219
+
220
+ };
196
221
 
197
222
  export const writeRayDirFlags = ( buf, id, direction, bounceFlags ) =>
198
223
  buf.element( soa( id, RAY.DIR_FLAGS ) )
@@ -40,7 +40,8 @@ export class ASVGF extends RenderStage {
40
40
  this.renderer = renderer;
41
41
 
42
42
  this.temporalAlpha = uniform( options.temporalAlpha ?? 0.0 );
43
- this.gradientStrength = uniform( options.gradientStrength ?? 0.0 );
43
+ // > 0 is required for firefly rejection, not just anti-lag — see ENGINE_DEFAULTS.
44
+ this.gradientStrength = uniform( options.gradientStrength ?? 1.0 );
44
45
  // σ multiplier for the per-pixel noise floor (NRD luminanceSigmaScale ≈ 2).
45
46
  this.gradientSigmaScale = uniform( options.gradientSigmaScale ?? 2.0 );
46
47
  // Secondary relative floor on the normalised gradient (0 = rely on σ alone).
@@ -380,7 +381,12 @@ export class ASVGF extends RenderStage {
380
381
  If( gx.lessThan( int( resW ) ).and( gy.lessThan( int( resH ) ) ), () => {
381
382
 
382
383
  const coord = ivec2( gx, gy );
383
- const currentColor = textureLoad( colorTex, coord ).xyz;
384
+ const currentSample = textureLoad( colorTex, coord );
385
+ const currentColor = currentSample.xyz;
386
+ // Coverage from the path tracer — 0 on background rays when
387
+ // transparentBackground is on. Forcing 1.0 here made the whole frame opaque,
388
+ // so a transparent render composited as black.
389
+ const currentAlpha = currentSample.w;
384
390
  const currentAlbedo = textureLoad( albedoTex, coord ).xyz;
385
391
 
386
392
  // Same safeAlbedo on both demod and re-mod sides → exact
@@ -390,7 +396,7 @@ export class ASVGF extends RenderStage {
390
396
 
391
397
  // Defaults = fresh sample (no temporal blend).
392
398
  const demodResult = vec4( currentLighting, 1.0 ).toVar();
393
- const modulatedResult = vec4( currentColor, 1.0 ).toVar();
399
+ const modulatedResult = vec4( currentColor, currentAlpha ).toVar();
394
400
 
395
401
  // forceResetU skips the blend for one frame after asvgf:reset → re-anchors
396
402
  // history to the current (post-reset) scene instead of the stale ping-pong.
@@ -491,7 +497,7 @@ export class ASVGF extends RenderStage {
491
497
  const newHistory = min( prevHistory.add( 1.0 ), maxAccumFrames );
492
498
 
493
499
  demodResult.assign( vec4( blendedLighting, newHistory ) );
494
- modulatedResult.assign( vec4( blendedLighting.mul( safeAlbedo ), 1.0 ) );
500
+ modulatedResult.assign( vec4( blendedLighting.mul( safeAlbedo ), currentAlpha ) );
495
501
 
496
502
  } );
497
503
 
@@ -643,12 +649,6 @@ export class ASVGF extends RenderStage {
643
649
 
644
650
  this.on( 'asvgf:reset', () => this.resetTemporalData() );
645
651
 
646
- this.on( 'asvgf:setTemporal', ( data ) => {
647
-
648
- if ( data && data.enabled !== undefined ) this.setTemporalEnabled( data.enabled );
649
-
650
- } );
651
-
652
652
  this.on( 'asvgf:updateParameters', ( data ) => this.updateParameters( data ) );
653
653
 
654
654
  }
@@ -24,7 +24,9 @@ const bilateralWeight = /*@__PURE__*/ wgslFn( `
24
24
  let lumW = exp( -abs( centerLum - sLum ) / sigmaL );
25
25
  // clamp dot to [0,1]: miss-ray normals decode to (-1,-1,-1) with
26
26
  // dot=3 → pow saturates to +inf → inf*0 = NaN. See project_tsl_pitfalls.
27
- let normW = pow( clamp( dot( centerNormal, sNormal ), 0.0, 1.0 ), phiNorm );
27
+ // max(phiNorm, 1e-6): pow(0.0, 0.0) is undefined in WGSL and returned NaN on ~12%
28
+ // of pixels when phiNormal was set to 0.
29
+ let normW = pow( clamp( dot( centerNormal, sNormal ), 0.0, 1.0 ), max( phiNorm, 1e-6 ) );
28
30
  let depW = exp( -abs( centerDepth - sDepth ) / max( centerDepth * phiDep, 0.001 ) );
29
31
  let maxDiff = max( max( abs( centerColor.x - sColor.x ),
30
32
  abs( centerColor.y - sColor.y ) ),
@@ -79,6 +81,8 @@ export class BilateralFilter extends RenderStage {
79
81
  this.stepSizeU = uniform( 1, 'int' );
80
82
  // 1 on the final iteration → multiply by albedo to remodulate.
81
83
  this.isLastIterationU = uniform( 0, 'int' );
84
+ // 1 on pass 0 → seed variance from variance:output; later passes read the carried value.
85
+ this.isFirstIterationU = uniform( 1, 'int' );
82
86
  this.resW = uniform( options.width || 1 );
83
87
  this.resH = uniform( options.height || 1 );
84
88
 
@@ -87,6 +91,8 @@ export class BilateralFilter extends RenderStage {
87
91
  this._shadingNormalTexNode = new TextureNode();
88
92
  this._albedoTexNode = new TextureNode();
89
93
  this._varianceTexNode = new TextureNode();
94
+ // pathtracer:color, read only for its alpha (coverage) on the final pass.
95
+ this._srcAlphaTexNode = new TextureNode();
90
96
 
91
97
  const w = options.width || 1;
92
98
  const h = options.height || 1;
@@ -144,6 +150,7 @@ export class BilateralFilter extends RenderStage {
144
150
  const snTexNode = this._shadingNormalTexNode;
145
151
  const albedoTexNode = this._albedoTexNode;
146
152
  const varTexNode = this._varianceTexNode;
153
+ const srcAlphaTexNode = this._srcAlphaTexNode;
147
154
  const phiColor = this.phiColor;
148
155
  const phiNormal = this.phiNormal;
149
156
  const phiDepth = this.phiDepth;
@@ -151,6 +158,7 @@ export class BilateralFilter extends RenderStage {
151
158
  const spatialVarianceWeight = this.spatialVarianceWeight;
152
159
  const stepSize = this.stepSizeU;
153
160
  const isLastIterationU = this.isLastIterationU;
161
+ const isFirstIterationU = this.isFirstIterationU;
154
162
  const resW = this.resW;
155
163
  const resH = this.resH;
156
164
 
@@ -173,7 +181,8 @@ export class BilateralFilter extends RenderStage {
173
181
  If( gx.lessThan( int( resW ) ).and( gy.lessThan( int( resH ) ) ), () => {
174
182
 
175
183
  const coord = ivec2( gx, gy );
176
- const centerColor = textureLoad( readTexNode, coord ).xyz;
184
+ const centerSample = textureLoad( readTexNode, coord );
185
+ const centerColor = centerSample.xyz;
177
186
  const centerND = textureLoad( ndTexNode, coord );
178
187
  // Normal edge-stop reads the mapped (shading) normal; depth gate stays geometric.
179
188
  const centerNormal = textureLoad( snTexNode, coord ).xyz.mul( 2.0 ).sub( 1.0 );
@@ -182,22 +191,43 @@ export class BilateralFilter extends RenderStage {
182
191
  const centerSafeAlbedo = max( textureLoad( albedoTexNode, coord ).xyz, vec3( ALBEDO_EPS ) );
183
192
  const centerAlbedoLum = max( luminance( centerSafeAlbedo ), float( ALBEDO_EPS ) ).toVar();
184
193
 
194
+ // Variance is filtered alongside colour and carried between passes in the
195
+ // ping-pong's spare alpha, so sigma_l tightens as the signal smooths. Without
196
+ // it every pass re-read the unfiltered variance:output and filter strength
197
+ // never backed off as the render converged.
198
+ //
199
+ // Stored as the ROOT: converged variance reaches ~1e-6, a half-float subnormal
200
+ // that flushes to zero on some drivers. select() rather than If() — an If
201
+ // around a texture read lets the inactive branch contaminate the result.
202
+ // .z = temporal, .w = spatial 3×3; blending toward max widens sigma_l where
203
+ // history is thin.
204
+ const isFirst = isFirstIterationU.equal( int( 1 ) );
205
+ const seedVariance = ( at ) => {
206
+
207
+ const v = textureLoad( varTexNode, at );
208
+ return mix( v.z, max( v.z, v.w ), spatialVarianceWeight );
209
+
210
+ };
211
+
212
+ const centerVariance = isFirst.select(
213
+ seedVariance( coord ),
214
+ centerSample.w.mul( centerSample.w )
215
+ ).toVar();
216
+
185
217
  // sigma_l = phiLum * √variance / albedoLum + ε. Dividing by
186
218
  // albedoLum compensates for the 1/albedo noise amplification
187
219
  // from demodulation — otherwise dark materials get an
188
220
  // under-estimated sigma → over-strict luminance gate → no
189
221
  // blending → silhouette dark-outline artifact.
190
- // .z = temporal variance, .w = spatial (3×3) variance. Blend toward
191
- // max(temporal, spatial) so disoccluded/low-history pixels widen sigma_l.
192
- const vSample = textureLoad( varTexNode, coord );
193
- const variance = mix( vSample.z, max( vSample.z, vSample.w ), spatialVarianceWeight );
194
222
  const sigmaL = phiLuminance
195
- .mul( sqrt( max( variance, float( 0.0 ) ) ) )
223
+ .mul( sqrt( max( centerVariance, float( 0.0 ) ) ) )
196
224
  .div( centerAlbedoLum )
197
225
  .add( float( 0.0001 ) );
198
226
 
199
227
  const colorSum = vec3( 0.0 ).toVar();
200
228
  const weightSum = float( 0.0 ).toVar();
229
+ // Squared weights, normalised by weightSum² — the variance of a weighted mean.
230
+ const varianceSum = float( 0.0 ).toVar();
201
231
 
202
232
  // 5×5 à-trous kernel (Gaussian-approx, Σ=1)
203
233
  for ( let iy = 0; iy < 5; iy ++ ) {
@@ -213,7 +243,8 @@ export class BilateralFilter extends RenderStage {
213
243
  const sy = gy.add( stepSize.mul( dy ) )
214
244
  .clamp( int( 0 ), int( resH ).sub( 1 ) );
215
245
 
216
- const sColor = textureLoad( readTexNode, ivec2( sx, sy ) ).xyz;
246
+ const sSample = textureLoad( readTexNode, ivec2( sx, sy ) );
247
+ const sColor = sSample.xyz;
217
248
  const sND = textureLoad( ndTexNode, ivec2( sx, sy ) );
218
249
  const sNormal = textureLoad( snTexNode, ivec2( sx, sy ) ).xyz.mul( 2.0 ).sub( 1.0 );
219
250
  const sDepth = sND.w;
@@ -228,24 +259,39 @@ export class BilateralFilter extends RenderStage {
228
259
  sigmaL, phiNormal, phiDepth, phiColor
229
260
  );
230
261
 
262
+ const sVariance = isFirst.select(
263
+ seedVariance( ivec2( sx, sy ) ),
264
+ sSample.w.mul( sSample.w )
265
+ );
266
+
231
267
  colorSum.addAssign( sColor.mul( w ) );
232
268
  weightSum.addAssign( w );
269
+ varianceSum.addAssign( w.mul( w ).mul( max( sVariance, float( 0.0 ) ) ) );
233
270
 
234
271
  }
235
272
 
236
273
  }
237
274
 
238
275
  const filtered = colorSum.div( max( weightSum, float( 0.0001 ) ) );
276
+ const filteredVariance = varianceSum
277
+ .div( max( weightSum.mul( weightSum ), float( 1e-8 ) ) );
239
278
 
240
279
  // Remodulate by albedo only on the final iteration so the
241
280
  // inner ping-pong stays in demodulated space.
242
281
  const isLast = isLastIterationU.equal( int( 1 ) );
243
282
  const output = isLast.select( filtered.mul( centerSafeAlbedo ), filtered );
244
283
 
284
+ // The last pass restores the path tracer's coverage — Compositor reads .w as
285
+ // image alpha when transparentBackground is on.
286
+ const carriedAlpha = isLast.select(
287
+ textureLoad( srcAlphaTexNode, coord ).w,
288
+ sqrt( max( filteredVariance, float( 0.0 ) ) )
289
+ );
290
+
245
291
  textureStore(
246
292
  writeStorageTex,
247
293
  uvec2( uint( gx ), uint( gy ) ),
248
- vec4( output, 1.0 )
294
+ vec4( output, carriedAlpha )
249
295
  ).toWriteOnly();
250
296
 
251
297
  } );
@@ -271,6 +317,7 @@ export class BilateralFilter extends RenderStage {
271
317
  const snTex = context.getTexture( this.shadingNormalTextureName ) || ndTex;
272
318
  const albedoTex = context.getTexture( this.albedoTextureName );
273
319
  const varTex = context.getTexture( this.varianceTextureName );
320
+ const srcAlphaTex = context.getTexture( 'pathtracer:color' );
274
321
 
275
322
  if ( ! inputTex ) return;
276
323
 
@@ -291,6 +338,18 @@ export class BilateralFilter extends RenderStage {
291
338
  if ( ndTex ) this._normalDepthTexNode.value = ndTex;
292
339
  if ( snTex ) this._shadingNormalTexNode.value = snTex;
293
340
  if ( albedoTex ) this._albedoTexNode.value = albedoTex;
341
+ // Bind these BEFORE compile too: two TextureNodes still holding the default empty
342
+ // texture when the node builder runs share one GPU binding, which then resolves to
343
+ // whichever is assigned last. That aliased varTexNode onto the colour input.
344
+ //
345
+ // Safe only because inputTex is a RenderTarget texture, which produces the same
346
+ // `textureLoad(t, coord, level)` codegen as EmptyTexture — the form the StorageTexture
347
+ // ping-pong needs on passes 1+. Binding an actual StorageTexture here would emit the
348
+ // no-level form and later reads would return zero. EdgeFilter must NOT do this: its
349
+ // pass-0 input IS a StorageTexture.
350
+ this._readTexNode.value = inputTex;
351
+ if ( varTex ) this._varianceTexNode.value = varTex;
352
+ if ( srcAlphaTex ) this._srcAlphaTexNode.value = srcAlphaTex;
294
353
 
295
354
  // First-frame compile while StorageTexture-typed nodes still hold
296
355
  // EmptyTexture — codegen then emits textureLoad with the level
@@ -303,8 +362,6 @@ export class BilateralFilter extends RenderStage {
303
362
 
304
363
  }
305
364
 
306
- if ( varTex ) this._varianceTexNode.value = varTex;
307
-
308
365
  // À-trous iterations: step size 2^i, ping-pong write direction.
309
366
  // Last iteration multiplies by albedo to remodulate.
310
367
  let readTex = inputTex;
@@ -316,6 +373,7 @@ export class BilateralFilter extends RenderStage {
316
373
  this.stepSizeU.value = 1 << i;
317
374
  this._readTexNode.value = readTex;
318
375
  this.isLastIterationU.value = ( i === this.iterations - 1 ) ? 1 : 0;
376
+ this.isFirstIterationU.value = i === 0 ? 1 : 0;
319
377
 
320
378
  this.renderer.compute( writeNode );
321
379
 
@@ -420,6 +478,7 @@ export class BilateralFilter extends RenderStage {
420
478
  this._shadingNormalTexNode?.dispose();
421
479
  this._albedoTexNode?.dispose();
422
480
  this._varianceTexNode?.dispose();
481
+ this._srcAlphaTexNode?.dispose();
423
482
 
424
483
  }
425
484