rayzee 7.10.2 → 7.11.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.
@@ -1,23 +1,52 @@
1
- import { Fn, vec4, float, int, uint, ivec2, uvec2, uniform,
2
- If, dot, max, abs, mix, pow, step,
1
+ import { Fn, vec3, vec4, float, int, uint, ivec2, uvec2, uniform,
2
+ If, dot, max, mix, pow, sqrt, select,
3
3
  textureLoad, textureStore, localId, workgroupId } from 'three/tsl';
4
4
  import { RenderTarget, TextureNode, StorageTexture } from 'three/webgpu';
5
- import { HalfFloatType, RGBAFormat, NearestFilter, Box2, Vector2 } from 'three';
5
+ import { FloatType, RGBAFormat, LinearFilter, Box2, Vector2 } from 'three';
6
6
  import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
7
- import { REC709_LUMINANCE_COEFFICIENTS } from '../TSL/Common.js';
8
- import { MAX_STORAGE_TEXTURE_SIZE } from '../EngineDefaults.js';
7
+ import { luminance } from '../TSL/Common.js';
8
+ import { ALBEDO_EPS, MAX_STORAGE_TEXTURE_SIZE } from '../EngineDefaults.js';
9
+
10
+ // NormalDepth writes depth ≈ 65504 for miss (env/background) rays. This sits below
11
+ // the sentinel and above any real ray distance, so it's a clean hit/miss flag.
12
+ const MISS_THRESHOLD = 6e4;
13
+
14
+ // NaN/±Inf guard on demodulated lighting: dividing color by a near-zero albedo can
15
+ // blow up, and one poisoned tap would smear through the à-trous passes. Per-channel:
16
+ // NaN (x≠x) → 0, ±Inf clamped to a bounded HDR range. Ceiling matches ASVGF/Variance
17
+ // (1e7); the FloatType ping-pong textures hold it without HalfFloat's 65k clip.
18
+ const sanitize1 = ( x ) => select( x.equal( x ), x, float( 0.0 ) ).clamp( 0.0, 1e7 );
19
+ const sanitizeRGB = ( c ) => vec3( sanitize1( c.x ), sanitize1( c.y ), sanitize1( c.z ) );
20
+
21
+ // 5×5 à-trous kernel weights (B3-spline / Gaussian approx, Σ = 1.0).
22
+ const ATROUS_KERNEL = [
23
+ 1.0 / 256.0, 4.0 / 256.0, 6.0 / 256.0, 4.0 / 256.0, 1.0 / 256.0,
24
+ 4.0 / 256.0, 16.0 / 256.0, 24.0 / 256.0, 16.0 / 256.0, 4.0 / 256.0,
25
+ 6.0 / 256.0, 24.0 / 256.0, 36.0 / 256.0, 24.0 / 256.0, 6.0 / 256.0,
26
+ 4.0 / 256.0, 16.0 / 256.0, 24.0 / 256.0, 16.0 / 256.0, 4.0 / 256.0,
27
+ 1.0 / 256.0, 4.0 / 256.0, 6.0 / 256.0, 4.0 / 256.0, 1.0 / 256.0,
28
+ ];
9
29
 
10
30
  /**
11
31
  * WebGPU Edge-Aware Filtering Stage (Compute Shader).
12
32
  *
13
- * Geometry-guided bilateral filter (8-dir × 2-dist kernel). Edge weights
14
- * combine luminance, surface normal, and ray distancesee Dammertz 2010
15
- * "Edge-Avoiding À-Trous" for the structure. Strength decays over iterations
16
- * so the filter is strongest on early frames and fades as accumulation
17
- * converges. Single-pass; no temporal reuse.
33
+ * A spatial-only SVGF à-trous denoiser (Dammertz 2010 + Schied 2017 edge-stops)
34
+ * that runs directly on the progressively-accumulated frameno temporal
35
+ * reprojection, motion vectors, or history buffers, so it is free of the
36
+ * ghosting/lag artefacts of the ASVGF chain. Intended for static-camera
37
+ * progressive rendering where a light, artefact-free spatial filter is wanted.
18
38
  *
19
- * Reads: pathtracer:color (or asvgf:output / bilateralFiltering:output)
20
- * and pathtracer:normalDepth
39
+ * Pipeline per frame:
40
+ * 1. Demodulate: lighting = color / albedo (miss rays use a neutral albedo so
41
+ * bright HDR sky neither overflows the divide nor gets zeroed on remodulate).
42
+ * 2. À-trous: `iterations` ping-pong passes with step size 2^i over a dense
43
+ * 5×5 Gaussian kernel, growing the effective footprint to tens of pixels.
44
+ * Edge-stops: variance-guided luminance σ (from variance:output), a
45
+ * shading-normal cone, and a scale-invariant relative depth gate.
46
+ * 3. Remodulate + strength blend on the final pass.
47
+ *
48
+ * Reads: pathtracer:color, pathtracer:normalDepth, pathtracer:shadingNormal,
49
+ * pathtracer:albedo, variance:output
21
50
  * Publishes: edgeFiltering:output
22
51
  * Mode: PER_CYCLE
23
52
  */
@@ -32,45 +61,58 @@ export class EdgeFilter extends RenderStage {
32
61
 
33
62
  this.renderer = renderer;
34
63
 
35
- // filterStrength: 0 = passthrough, 1 = fully filtered.
36
- // strengthDecaySpeed: per-iteration falloff toward passthrough.
37
- // edgeThreshold: luminance σ. phiNormal: dot(n,n) exponent. phiDepth: depth σ.
38
- this.filterStrength = uniform( options.filterStrength ?? 0.75 );
39
- this.strengthDecaySpeed = uniform( options.strengthDecaySpeed ?? 0.05 );
40
- this.edgeThreshold = uniform( options.edgeThreshold ?? 1.0 );
41
- this.phiNormal = uniform( options.phiNormal ?? 128.0 );
42
- this.phiDepth = uniform( options.phiDepth ?? 1.0 );
43
- this.iterationCount = uniform( 0.0 );
64
+ // filterStrength: final blend, 0 = raw color, 1 = fully filtered.
65
+ // phiLuminance: variance-scaled luminance edge-stop (bigger = more blending).
66
+ // phiNormal: normal cone exponent (bigger = tighter, preserves more edges).
67
+ // phiDepth: RELATIVE depth tolerance (fraction of ray distance, scale-invariant).
68
+ this.filterStrength = uniform( options.filterStrength ?? 1.0 );
69
+ this.phiLuminance = uniform( options.phiLuminance ?? 4.0 );
70
+ this.phiNormal = uniform( options.phiNormal ?? 64.0 );
71
+ this.phiDepth = uniform( options.phiDepth ?? 0.1 );
72
+ this.iterations = options.atrousIterations ?? options.edgeAtrousIterations ?? 5;
73
+
74
+ this.stepSizeU = uniform( 1, 'int' );
75
+ this.isLastIterationU = uniform( 0, 'int' );
44
76
  this.resW = uniform( options.width || 1 );
45
77
  this.resH = uniform( options.height || 1 );
46
78
 
47
- this._iterations = 0;
48
-
49
- this._inputTexNode = new TextureNode();
79
+ // Input texture nodes (RenderTarget-backed — safe to bind pre-compile).
80
+ this._colorTexNode = new TextureNode();
50
81
  this._ndTexNode = new TextureNode();
82
+ this._snTexNode = new TextureNode();
83
+ this._albedoTexNode = new TextureNode();
84
+ this._varTexNode = new TextureNode();
85
+
86
+ // À-trous read node: defaults to EmptyTexture at compile time so the WGSL
87
+ // codegen emits the `level` parameter required for later StorageTexture reads.
88
+ this._readTexNode = new TextureNode();
51
89
 
52
- // Pre-allocate StorageTexture at max — defensive against three.js #33061
53
- // (TSL compute pipeline re-compile returns zeros after resize).
54
90
  const w = options.width || 1;
55
91
  const h = options.height || 1;
56
92
 
57
- this._outputStorageTex = new StorageTexture( MAX_STORAGE_TEXTURE_SIZE, MAX_STORAGE_TEXTURE_SIZE );
58
- this._outputStorageTex.type = HalfFloatType;
59
- this._outputStorageTex.format = RGBAFormat;
60
- this._outputStorageTex.minFilter = NearestFilter;
61
- this._outputStorageTex.magFilter = NearestFilter;
93
+ // Pre-allocate StorageTextures at max defensive against three.js #33061
94
+ // (TSL compute pipeline keeps a stale GPUTextureView after setSize()).
95
+ // LinearFilter required for textureLoad codegen on StorageTextures.
96
+ this._demodTex = this._makeStorageTex(); // demodulated lighting (pre-pass output)
97
+ this._storageTexA = this._makeStorageTex(); // à-trous ping
98
+ this._storageTexB = this._makeStorageTex(); // à-trous pong
62
99
 
63
100
  this._srcRegion = new Box2( new Vector2( 0, 0 ), new Vector2( 0, 0 ) );
64
101
 
102
+ // Active-size RT published downstream (UV-sampled); the over-allocated
103
+ // StorageTexture must not be published — UV sampling reads the wrong region.
104
+ // FloatType to match the storage textures so copyTextureToTexture is format-compatible.
65
105
  this.outputTarget = new RenderTarget( w, h, {
66
- type: HalfFloatType,
106
+ type: FloatType,
67
107
  format: RGBAFormat,
68
- minFilter: NearestFilter,
69
- magFilter: NearestFilter,
108
+ minFilter: LinearFilter,
109
+ magFilter: LinearFilter,
70
110
  depthBuffer: false,
71
111
  stencilBuffer: false
72
112
  } );
73
113
 
114
+ this._compiled = false;
115
+
74
116
  this._dispatchX = Math.ceil( w / 16 );
75
117
  this._dispatchY = Math.ceil( h / 16 );
76
118
 
@@ -78,17 +120,88 @@ export class EdgeFilter extends RenderStage {
78
120
 
79
121
  }
80
122
 
123
+ _makeStorageTex() {
124
+
125
+ // FloatType: demodulated lighting on dark materials (color / 0.01) exceeds
126
+ // HalfFloat's 65k cap on HDR — same reason ASVGF's ping-pong is FloatType.
127
+ const tex = new StorageTexture( MAX_STORAGE_TEXTURE_SIZE, MAX_STORAGE_TEXTURE_SIZE );
128
+ tex.type = FloatType;
129
+ tex.format = RGBAFormat;
130
+ tex.minFilter = LinearFilter;
131
+ tex.magFilter = LinearFilter;
132
+ return tex;
133
+
134
+ }
135
+
81
136
  _buildCompute() {
82
137
 
83
- const inputTex = this._inputTexNode;
138
+ this._computeDemod = this._buildDemod();
139
+ // One à-trous node per ping-pong write direction.
140
+ this._computeAtrousA = this._buildAtrous( this._storageTexA );
141
+ this._computeAtrousB = this._buildAtrous( this._storageTexB );
142
+
143
+ }
144
+
145
+ // Demodulate pass: lighting = color / albedo, written to _demodTex.
146
+ _buildDemod() {
147
+
148
+ const colorTex = this._colorTexNode;
149
+ const ndTex = this._ndTexNode;
150
+ const albedoTex = this._albedoTexNode;
151
+ const demodTex = this._demodTex;
152
+ const resW = this.resW;
153
+ const resH = this.resH;
154
+
155
+ const WG_SIZE = 16;
156
+
157
+ const computeFn = Fn( () => {
158
+
159
+ const gx = int( workgroupId.x ).mul( WG_SIZE ).add( int( localId.x ) );
160
+ const gy = int( workgroupId.y ).mul( WG_SIZE ).add( int( localId.y ) );
161
+
162
+ If( gx.lessThan( int( resW ) ).and( gy.lessThan( int( resH ) ) ), () => {
163
+
164
+ const coord = ivec2( gx, gy );
165
+ const color = textureLoad( colorTex, coord ).xyz;
166
+ const depth = textureLoad( ndTex, coord ).w;
167
+ const isHit = depth.lessThan( float( MISS_THRESHOLD ) );
168
+
169
+ // Miss rays (env/background) have black aux albedo — dividing by it would
170
+ // overflow HalfFloat on bright HDR sky and remodulate back to zero. Use a
171
+ // neutral albedo of 1 for misses so the demod/remod round-trip is identity.
172
+ const albedo = textureLoad( albedoTex, coord ).xyz;
173
+ const demodAlbedo = select( isHit, max( albedo, vec3( ALBEDO_EPS ) ), vec3( 1.0 ) );
174
+
175
+ const lighting = sanitizeRGB( color.div( demodAlbedo ) );
176
+
177
+ textureStore( demodTex, uvec2( uint( gx ), uint( gy ) ), vec4( lighting, 1.0 ) ).toWriteOnly();
178
+
179
+ } );
180
+
181
+ } );
182
+
183
+ return computeFn().compute(
184
+ [ this._dispatchX, this._dispatchY, 1 ],
185
+ [ WG_SIZE, WG_SIZE, 1 ]
186
+ );
187
+
188
+ }
189
+
190
+ // À-trous pass over demodulated lighting, writing to `writeStorageTex`.
191
+ _buildAtrous( writeStorageTex ) {
192
+
193
+ const readTex = this._readTexNode;
194
+ const colorTex = this._colorTexNode;
84
195
  const ndTex = this._ndTexNode;
85
- const outputStorageTex = this._outputStorageTex;
196
+ const snTex = this._snTexNode;
197
+ const albedoTex = this._albedoTexNode;
198
+ const varTex = this._varTexNode;
86
199
  const filterStrength = this.filterStrength;
87
- const decaySpeed = this.strengthDecaySpeed;
88
- const threshold = this.edgeThreshold;
200
+ const phiLuminance = this.phiLuminance;
89
201
  const phiNormal = this.phiNormal;
90
202
  const phiDepth = this.phiDepth;
91
- const iterCount = this.iterationCount;
203
+ const stepSize = this.stepSizeU;
204
+ const isLastIterationU = this.isLastIterationU;
92
205
  const resW = this.resW;
93
206
  const resH = this.resH;
94
207
 
@@ -102,65 +215,68 @@ export class EdgeFilter extends RenderStage {
102
215
  If( gx.lessThan( int( resW ) ).and( gy.lessThan( int( resH ) ) ), () => {
103
216
 
104
217
  const coord = ivec2( gx, gy );
105
- const center = textureLoad( inputTex, coord ).xyz;
106
- const centerLum = dot( center, REC709_LUMINANCE_COEFFICIENTS );
107
-
108
- // NormalDepth writes (0,0,0, 65504) for miss rays. Decoded normal
109
- // (-1,-1,-1) is non-unit and explodes pow(dot, phi); use the depth
110
- // sentinel as a 0/1 hit flag and zero out cross-kind weights. Threshold
111
- // sits below the 65504 sentinel (and above any real depth).
112
- const MISS_THRESHOLD = 6e4;
218
+
219
+ const centerLighting = textureLoad( readTex, coord ).xyz;
220
+ const centerLum = luminance( centerLighting );
113
221
  const centerND = textureLoad( ndTex, coord );
114
- const centerNormal = centerND.xyz.mul( 2.0 ).sub( 1.0 );
115
222
  const centerDepth = centerND.w;
116
- const centerIsHit = step( float( MISS_THRESHOLD ), centerDepth ).oneMinus();
223
+ // Shading (bump-perturbed) normal so normal/bump detail isn't collapsed.
224
+ const centerNormal = textureLoad( snTex, coord ).xyz.mul( 2.0 ).sub( 1.0 );
225
+ const centerIsHitBool = centerDepth.lessThan( float( MISS_THRESHOLD ) );
226
+ const centerIsHit = select( centerIsHitBool, float( 1.0 ), float( 0.0 ) );
117
227
 
118
- const effectiveStrength = filterStrength.sub( iterCount.mul( decaySpeed ) ).clamp( 0.0, 1.0 );
228
+ const centerAlbedo = textureLoad( albedoTex, coord ).xyz;
229
+ const demodAlbedo = select( centerIsHitBool, max( centerAlbedo, vec3( ALBEDO_EPS ) ), vec3( 1.0 ) );
230
+ const centerAlbedoLum = max( luminance( demodAlbedo ), float( ALBEDO_EPS ) );
119
231
 
120
- const colorSum = center.toVar();
121
- const weightSum = float( 1.0 ).toVar();
232
+ // Variance-guided luminance σ (SVGF). .z = temporal, .w = spatial variance;
233
+ // max() falls back to the spatial estimate as temporal collapses on a
234
+ // converging accumulation buffer. Dividing by albedoLum rescales the
235
+ // modulated-space variance into demodulated space (std ∝ 1/albedo).
236
+ const vSample = textureLoad( varTex, coord );
237
+ const variance = max( vSample.z, vSample.w );
238
+ const sigmaL = phiLuminance.mul( sqrt( max( variance, float( 0.0 ) ) ) )
239
+ .div( centerAlbedoLum ).add( float( 0.0001 ) );
122
240
 
123
- const dirs = [
124
- [ 1, 0 ], [ 0, 1 ], [ - 1, 0 ], [ 0, - 1 ],
125
- [ 1, 1 ], [ - 1, 1 ], [ - 1, - 1 ], [ 1, - 1 ],
126
- ];
241
+ const colorSum = vec3( 0.0 ).toVar();
242
+ const weightSum = float( 0.0 ).toVar();
127
243
 
128
- for ( const [ dx, dy ] of dirs ) {
244
+ for ( let iy = 0; iy < 5; iy ++ ) {
129
245
 
130
- for ( const dist of [ 1, 2 ] ) {
246
+ for ( let ix = 0; ix < 5; ix ++ ) {
131
247
 
132
- const sx = gx.add( dx * dist ).clamp( int( 0 ), int( resW ).sub( 1 ) );
133
- const sy = gy.add( dy * dist ).clamp( int( 0 ), int( resH ).sub( 1 ) );
134
- const sCoord = ivec2( sx, sy );
248
+ const dx = ix - 2;
249
+ const dy = iy - 2;
250
+ const kw = ATROUS_KERNEL[ iy * 5 + ix ];
135
251
 
136
- const sColor = textureLoad( inputTex, sCoord ).xyz;
137
- const sLum = dot( sColor, REC709_LUMINANCE_COEFFICIENTS );
252
+ const sx = gx.add( stepSize.mul( dx ) ).clamp( int( 0 ), int( resW ).sub( 1 ) );
253
+ const sy = gy.add( stepSize.mul( dy ) ).clamp( int( 0 ), int( resH ).sub( 1 ) );
254
+ const sCoord = ivec2( sx, sy );
138
255
 
256
+ const sLighting = textureLoad( readTex, sCoord ).xyz;
257
+ const sLum = luminance( sLighting );
139
258
  const sND = textureLoad( ndTex, sCoord );
140
- const sNormal = sND.xyz.mul( 2.0 ).sub( 1.0 );
141
259
  const sDepth = sND.w;
142
- const sampleIsHit = step( float( MISS_THRESHOLD ), sDepth ).oneMinus();
260
+ const sNormal = textureLoad( snTex, sCoord ).xyz.mul( 2.0 ).sub( 1.0 );
261
+ const sampleIsHit = select( sDepth.lessThan( float( MISS_THRESHOLD ) ), float( 1.0 ), float( 0.0 ) );
143
262
 
144
- const lumW = abs( centerLum.sub( sLum ) ).div( max( threshold, float( 0.001 ) ) ).negate().exp();
263
+ const lumW = centerLum.sub( sLum ).abs().div( sigmaL ).negate().exp();
264
+ // Clamp dot to [0,1] before pow — miss-ray normals decode to non-unit
265
+ // (-1,-1,-1) with dot 3, which would saturate pow to +inf → inf*0 = NaN.
266
+ const normW = pow( dot( centerNormal, sNormal ).clamp( 0.0, 1.0 ), phiNormal );
267
+ // Relative depth tolerance → scale-invariant across scene sizes.
268
+ const depW = centerDepth.sub( sDepth ).abs()
269
+ .div( max( centerDepth.mul( phiDepth ), float( 0.001 ) ) ).negate().exp();
145
270
 
146
271
  const bothHit = centerIsHit.mul( sampleIsHit );
147
272
  const bothMiss = centerIsHit.oneMinus().mul( sampleIsHit.oneMinus() );
148
273
  const sameKind = bothHit.add( bothMiss );
149
274
 
150
- // Clamp dot to [0,1] before pow — miss-ray normals decode to
151
- // non-unit (-1,-1,-1) with dot=3, which would saturate pow(.,phi)
152
- // to +inf and poison downstream via inf*0 = NaN.
153
- const cosTheta = dot( centerNormal, sNormal ).clamp( 0.0, 1.0 );
154
- const normW = pow( cosTheta, phiNormal );
155
- const depW = abs( centerDepth.sub( sDepth ) ).div( max( phiDepth, float( 0.001 ) ) ).negate().exp();
156
-
157
- // Geometric weights only meaningful for hit-vs-hit pairs.
275
+ // Geometric weights are only meaningful for hit-vs-hit pairs.
158
276
  const geomW = mix( float( 1.0 ), normW.mul( depW ), bothHit );
277
+ const w = float( kw ).mul( lumW ).mul( geomW ).mul( sameKind );
159
278
 
160
- const distWeight = float( 1.0 ).div( float( dist ).add( 0.5 ) );
161
- const w = lumW.mul( geomW ).mul( sameKind ).mul( distWeight );
162
-
163
- colorSum.addAssign( sColor.mul( w ) );
279
+ colorSum.addAssign( sLighting.mul( w ) );
164
280
  weightSum.addAssign( w );
165
281
 
166
282
  }
@@ -168,28 +284,22 @@ export class EdgeFilter extends RenderStage {
168
284
  }
169
285
 
170
286
  const filtered = colorSum.div( max( weightSum, float( 0.0001 ) ) );
171
- const finalColor = mix( center, filtered, effectiveStrength );
172
-
173
- // Firefly clamp on output luminance.
174
- const finalLum = dot( finalColor, REC709_LUMINANCE_COEFFICIENTS );
175
- const clampedColor = finalColor.toVar();
176
- If( finalLum.greaterThan( 10.0 ), () => {
177
287
 
178
- clampedColor.assign( finalColor.mul( float( 10.0 ).div( finalLum ) ) );
288
+ // Final pass: remodulate by albedo and blend against the raw color by
289
+ // filterStrength. Inner passes stay in demodulated space.
290
+ const isLast = isLastIterationU.equal( int( 1 ) );
291
+ const rawColor = textureLoad( colorTex, coord ).xyz;
292
+ const remodded = filtered.mul( demodAlbedo );
293
+ const finalModulated = mix( rawColor, remodded, filterStrength );
294
+ const output = isLast.select( finalModulated, filtered );
179
295
 
180
- } );
181
-
182
- textureStore(
183
- outputStorageTex,
184
- uvec2( uint( gx ), uint( gy ) ),
185
- vec4( clampedColor, 1.0 )
186
- ).toWriteOnly();
296
+ textureStore( writeStorageTex, uvec2( uint( gx ), uint( gy ) ), vec4( output, 1.0 ) ).toWriteOnly();
187
297
 
188
298
  } );
189
299
 
190
300
  } );
191
301
 
192
- this._computeNode = computeFn().compute(
302
+ return computeFn().compute(
193
303
  [ this._dispatchX, this._dispatchY, 1 ],
194
304
  [ WG_SIZE, WG_SIZE, 1 ]
195
305
  );
@@ -200,29 +310,31 @@ export class EdgeFilter extends RenderStage {
200
310
 
201
311
  if ( ! this.enabled ) return;
202
312
 
203
- const inputTex = context.getTexture( 'asvgf:output' )
204
- || context.getTexture( 'bilateralFiltering:output' )
205
- || context.getTexture( 'pathtracer:color' );
206
-
313
+ const colorTex = context.getTexture( 'pathtracer:color' );
207
314
  const ndTex = context.getTexture( 'pathtracer:normalDepth' );
315
+ // Fall back to geometric normalDepth if the mapped normal isn't published.
316
+ const snTex = context.getTexture( 'pathtracer:shadingNormal' ) || ndTex;
317
+ const albedoTex = context.getTexture( 'pathtracer:albedo' );
318
+ const varTex = context.getTexture( 'variance:output' );
208
319
 
209
- // Without the G-buffer there's no edge guidance pass input through
210
- // rather than producing a uniform blur.
211
- if ( ! inputTex || ! ndTex ) {
320
+ // The SVGF filter needs color + geometry + albedo + variance. Without the full
321
+ // set there's no edge/variance guidance — pass the input through rather than
322
+ // producing an unguided blur.
323
+ if ( ! colorTex || ! ndTex || ! albedoTex || ! varTex ) {
212
324
 
213
- if ( inputTex ) context.setTexture( 'edgeFiltering:output', inputTex );
325
+ if ( colorTex ) context.setTexture( 'edgeFiltering:output', colorTex );
214
326
  return;
215
327
 
216
328
  }
217
329
 
218
330
  if ( context.getState( 'interactionMode' ) ) {
219
331
 
220
- context.setTexture( 'edgeFiltering:output', inputTex );
332
+ context.setTexture( 'edgeFiltering:output', colorTex );
221
333
  return;
222
334
 
223
335
  }
224
336
 
225
- const img = inputTex.image;
337
+ const img = colorTex.image;
226
338
  if ( img && img.width > 0 && img.height > 0 ) {
227
339
 
228
340
  if ( img.width !== this.outputTarget.width ||
@@ -234,18 +346,56 @@ export class EdgeFilter extends RenderStage {
234
346
 
235
347
  }
236
348
 
237
- this._inputTexNode.value = inputTex;
349
+ this._colorTexNode.value = colorTex;
238
350
  this._ndTexNode.value = ndTex;
351
+ this._snTexNode.value = snTex;
352
+ this._albedoTexNode.value = albedoTex;
353
+ this._varTexNode.value = varTex;
354
+
355
+ // First-frame compile while _readTexNode still holds EmptyTexture — codegen
356
+ // then emits textureLoad with the level parameter the runtime requires for
357
+ // non-zero StorageTexture reads. The throwaway dispatches also initialise the
358
+ // ping-pong textures. _readTexNode must NOT be assigned before this.
359
+ if ( ! this._compiled ) {
360
+
361
+ this.renderer.compute( this._computeDemod );
362
+ this.renderer.compute( this._computeAtrousA );
363
+ this.renderer.compute( this._computeAtrousB );
364
+ this._compiled = true;
365
+
366
+ }
239
367
 
240
- this._iterations ++;
241
- this.iterationCount.value = this._iterations;
368
+ // 1. Demodulate → _demodTex.
369
+ this.renderer.compute( this._computeDemod );
242
370
 
243
- this.renderer.compute( this._computeNode );
371
+ // 2. À-trous iterations: step 2^i, ping-pong write direction. First pass reads
372
+ // _demodTex; the last pass remodulates + strength-blends.
373
+ let readTex = this._demodTex;
374
+ let writeNode = this._computeAtrousA;
375
+ let nextWriteNode = this._computeAtrousB;
376
+
377
+ for ( let i = 0; i < this.iterations; i ++ ) {
378
+
379
+ this.stepSizeU.value = 1 << i;
380
+ this.isLastIterationU.value = ( i === this.iterations - 1 ) ? 1 : 0;
381
+ this._readTexNode.value = readTex;
382
+
383
+ this.renderer.compute( writeNode );
384
+
385
+ readTex = ( writeNode === this._computeAtrousA )
386
+ ? this._storageTexA
387
+ : this._storageTexB;
388
+
389
+ const tmp = writeNode;
390
+ writeNode = nextWriteNode;
391
+ nextWriteNode = tmp;
392
+
393
+ }
244
394
 
245
- // Copy out of the over-allocated StorageTexture into the right-sized
246
- // RenderTarget; downstream stages can sample the latter.
395
+ // Copy the final result out of the over-allocated StorageTexture into the
396
+ // active-size RenderTarget; downstream stages UV-sample the latter.
247
397
  this._srcRegion.max.set( this.outputTarget.width, this.outputTarget.height );
248
- this.renderer.copyTextureToTexture( this._outputStorageTex, this.outputTarget.texture, this._srcRegion );
398
+ this.renderer.copyTextureToTexture( readTex, this.outputTarget.texture, this._srcRegion );
249
399
 
250
400
  context.setTexture( 'edgeFiltering:output', this.outputTarget.texture );
251
401
 
@@ -261,35 +411,35 @@ export class EdgeFilter extends RenderStage {
261
411
 
262
412
  if ( ! params ) return;
263
413
  if ( params.filterStrength !== undefined ) this.filterStrength.value = params.filterStrength;
264
- if ( params.strengthDecaySpeed !== undefined ) this.strengthDecaySpeed.value = params.strengthDecaySpeed;
265
- if ( params.edgeThreshold !== undefined ) this.edgeThreshold.value = params.edgeThreshold;
414
+ if ( params.phiLuminance !== undefined ) this.phiLuminance.value = params.phiLuminance;
266
415
  if ( params.phiNormal !== undefined ) this.phiNormal.value = params.phiNormal;
267
416
  if ( params.phiDepth !== undefined ) this.phiDepth.value = params.phiDepth;
417
+ if ( params.atrousIterations !== undefined ) this.iterations = params.atrousIterations;
268
418
 
269
419
  }
270
420
 
271
- // Free the 2048² StorageTexture when disabled; three.js re-creates it on the next dispatch
272
- // after re-enable, and reset() clears the iteration history. See ASVGF.releaseGPUMemory.
421
+ // Free the 2048² StorageTextures when disabled; three.js re-creates them on the next dispatch
422
+ // after re-enable (no temporal state to re-anchor). See ASVGF.releaseGPUMemory.
273
423
  releaseGPUMemory() {
274
424
 
275
- this._outputStorageTex?.dispose();
425
+ this._demodTex?.dispose();
426
+ this._storageTexA?.dispose();
427
+ this._storageTexB?.dispose();
276
428
  // Render-res RT texture (dispose .texture, not the RT — RT.dispose() doesn't free it here).
277
429
  this.context?.removeTexture( 'edgeFiltering:output' );
278
430
  this.outputTarget?.texture?.dispose();
279
- this.reset();
280
431
 
281
432
  }
282
433
 
283
434
  reset() {
284
435
 
285
- this._iterations = 0;
286
- this.iterationCount.value = 0;
436
+ // No per-frame or temporal state — variance guidance is owned by the Variance stage.
287
437
 
288
438
  }
289
439
 
290
440
  setSize( width, height ) {
291
441
 
292
- // StorageTexture stays at its max allocation (see constructor).
442
+ // StorageTextures stay at their max allocation (see constructor).
293
443
  this.outputTarget.setSize( width, height );
294
444
  this.outputTarget.texture.needsUpdate = true;
295
445
  this.resW.value = width;
@@ -297,17 +447,28 @@ export class EdgeFilter extends RenderStage {
297
447
 
298
448
  this._dispatchX = Math.ceil( width / 16 );
299
449
  this._dispatchY = Math.ceil( height / 16 );
300
- this._computeNode.dispatchSize = [ this._dispatchX, this._dispatchY, 1 ];
450
+ const size = [ this._dispatchX, this._dispatchY, 1 ];
451
+ this._computeDemod.dispatchSize = size;
452
+ this._computeAtrousA.dispatchSize = size;
453
+ this._computeAtrousB.dispatchSize = size;
301
454
 
302
455
  }
303
456
 
304
457
  dispose() {
305
458
 
306
- this._computeNode?.dispose();
307
- this._outputStorageTex?.dispose();
459
+ this._computeDemod?.dispose();
460
+ this._computeAtrousA?.dispose();
461
+ this._computeAtrousB?.dispose();
462
+ this._demodTex?.dispose();
463
+ this._storageTexA?.dispose();
464
+ this._storageTexB?.dispose();
308
465
  this.outputTarget?.dispose();
309
- this._inputTexNode?.dispose();
466
+ this._colorTexNode?.dispose();
310
467
  this._ndTexNode?.dispose();
468
+ this._snTexNode?.dispose();
469
+ this._albedoTexNode?.dispose();
470
+ this._varTexNode?.dispose();
471
+ this._readTexNode?.dispose();
311
472
 
312
473
  }
313
474
 
@@ -2,6 +2,7 @@ import {
2
2
  Fn,
3
3
  vec3,
4
4
  float,
5
+ dot,
5
6
  normalize,
6
7
  reflect,
7
8
  max,
@@ -10,10 +11,10 @@ import {
10
11
 
11
12
  import { struct } from './patches.js';
12
13
 
13
- import { DotProducts } from './Struct.js';
14
- import { PI, MIN_CLEARCOAT_ROUGHNESS, computeDotProducts } from './Common.js';
15
- import { DistributionGGX } from './MaterialProperties.js';
16
- import { ImportanceSampleGGX, ImportanceSampleCosine } from './MaterialSampling.js';
14
+ import { AnisoFrame, DotProducts } from './Struct.js';
15
+ import { PI, MIN_CLEARCOAT_ROUGHNESS, computeDotProductsAniso, anisoTangentFrame } from './Common.js';
16
+ import { DistributionGGX, computeAnisoAlphas, calculateVNDFPDFAniso } from './MaterialProperties.js';
17
+ import { ImportanceSampleGGX, ImportanceSampleCosine, sampleGGXVNDFAniso } from './MaterialSampling.js';
17
18
  import { evaluateLayeredBRDF } from './MaterialEvaluation.js';
18
19
  import { RandomValue } from './Random.js';
19
20
 
@@ -62,8 +63,20 @@ export const sampleClearcoat = Fn( ( [
62
63
 
63
64
  } ).ElseIf( rand.lessThan( clearcoatWeight.add( specularWeight ) ), () => {
64
65
 
65
- // Sample base specular
66
- H.assign( ImportanceSampleGGX( { N, roughness: baseRoughness, Xi: randomSample } ) );
66
+ // Sample base specular (anisotropic VNDF when anisotropy > 0)
67
+ If( material.anisotropy.greaterThan( 0.0 ), () => {
68
+
69
+ const f = AnisoFrame.wrap( anisoTangentFrame( N, material.anisotropyRotation ) );
70
+ const localV = vec3( dot( V, f.Ta ), dot( V, f.Ba ), dot( V, N ) );
71
+ const a = computeAnisoAlphas( material.roughness, material.anisotropy );
72
+ const localH = sampleGGXVNDFAniso( { V: localV, alphaX: a.x, alphaY: a.y, Xi: randomSample } );
73
+ H.assign( f.Ta.mul( localH.x ).add( f.Ba.mul( localH.y ) ).add( N.mul( localH.z ) ) );
74
+
75
+ } ).Else( () => {
76
+
77
+ H.assign( ImportanceSampleGGX( { N, roughness: baseRoughness, Xi: randomSample } ) );
78
+
79
+ } );
67
80
  L.assign( reflect( V.negate(), H ) );
68
81
 
69
82
  } ).Else( () => {
@@ -74,12 +87,23 @@ export const sampleClearcoat = Fn( ( [
74
87
 
75
88
  } );
76
89
 
77
- // Calculate dot products
78
- const dots = DotProducts.wrap( computeDotProducts( N, V, L ) );
90
+ // Calculate dot products (aniso-aware: also projects onto the anisotropy frame)
91
+ const dots = DotProducts.wrap( computeDotProductsAniso( N, V, L, material ) );
79
92
 
80
- // Calculate individual PDFs
93
+ // Calculate individual PDFs. Clearcoat layer is always isotropic; the base specular
94
+ // matches its sampler — VNDF-aniso when anisotropy>0, else the half-vector GGX pdf.
81
95
  const clearcoatPDF = DistributionGGX( dots.NoH, clearcoatRoughness ).mul( dots.NoH ).div( float( 4.0 ).mul( dots.VoH ) ).mul( clearcoatWeight );
82
- const specularPDF = DistributionGGX( dots.NoH, baseRoughness ).mul( dots.NoH ).div( float( 4.0 ).mul( dots.VoH ) ).mul( specularWeight );
96
+ const specularPDF = float( 0.0 ).toVar();
97
+ If( material.anisotropy.greaterThan( 0.0 ), () => {
98
+
99
+ const a = computeAnisoAlphas( material.roughness, material.anisotropy );
100
+ specularPDF.assign( calculateVNDFPDFAniso( a.x, a.y, dots.NoH, dots.ToH, dots.BoH, dots.NoV, dots.ToV, dots.BoV ).mul( specularWeight ) );
101
+
102
+ } ).Else( () => {
103
+
104
+ specularPDF.assign( DistributionGGX( dots.NoH, baseRoughness ).mul( dots.NoH ).div( float( 4.0 ).mul( dots.VoH ) ).mul( specularWeight ) );
105
+
106
+ } );
83
107
  const diffusePDF = dots.NoL.div( PI ).mul( diffuseWeight );
84
108
 
85
109
  // Combined PDF using MIS