rayzee 5.7.0 → 5.8.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": "5.7.0",
3
+ "version": "5.8.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",
@@ -90,8 +90,8 @@ export const ENGINE_DEFAULTS = {
90
90
  directionalLightPosition: [ 1, 1, 1 ],
91
91
  directionalLightAngle: 0.0,
92
92
 
93
- pixelEdgeSharpness: 0.75,
94
- edgeSharpenSpeed: 0.05,
93
+ filterStrength: 0.75,
94
+ strengthDecaySpeed: 0.05,
95
95
  edgeThreshold: 1.0,
96
96
 
97
97
  enableOIDN: false,
@@ -115,7 +115,7 @@ export const ENGINE_DEFAULTS = {
115
115
  debugVisScale: 100,
116
116
 
117
117
  // Denoising strategy
118
- denoiserStrategy: 'edgeaware',
118
+ denoiserStrategy: 'none',
119
119
 
120
120
  enableASVGF: false,
121
121
  asvgfTemporalAlpha: 0.1,
@@ -1,4 +1,4 @@
1
- import { WebGPURenderer, RectAreaLightNode } from 'three/webgpu';
1
+ import { WebGPURenderer, RectAreaLightNode, LinearSRGBColorSpace, SRGBColorSpace } from 'three/webgpu';
2
2
  import { texture as _tslTexture, cubeTexture as _tslCubeTexture } from 'three/tsl';
3
3
  import {
4
4
  ACESFilmicToneMapping, Scene, EventDispatcher, TimestampQuery
@@ -16,7 +16,7 @@ import { AdaptiveSampling } from './Stages/AdaptiveSampling.js';
16
16
  import { EdgeFilter } from './Stages/EdgeFilter.js';
17
17
  import { AutoExposure } from './Stages/AutoExposure.js';
18
18
  import { SSRC } from './Stages/SSRC.js';
19
- import { Display } from './Stages/Display.js';
19
+ import { Compositor } from './Stages/Compositor.js';
20
20
  import { RenderPipeline } from './Pipeline/RenderPipeline.js';
21
21
  import { CompletionTracker } from './Pipeline/CompletionTracker.js';
22
22
  import { ENGINE_DEFAULTS as DEFAULT_STATE, FINAL_RENDER_CONFIG, PREVIEW_RENDER_CONFIG } from './EngineDefaults.js';
@@ -286,7 +286,7 @@ export class PathTracerApp extends EventDispatcher {
286
286
  if ( this._needsDisplayRefresh ) {
287
287
 
288
288
  this._needsDisplayRefresh = false;
289
- this.stages.display.render( this.pipeline.context );
289
+ this.stages.compositor.render( this.pipeline.context );
290
290
  this._renderHelperOverlay();
291
291
 
292
292
  }
@@ -775,7 +775,7 @@ export class PathTracerApp extends EventDispatcher {
775
775
  // Apply all settings to stages in one shot
776
776
  timer.start( 'Apply settings' );
777
777
  this.settings.applyAll();
778
- this.stages.display.setTransparentBackground( this.settings.get( 'transparentBackground' ) );
778
+ this.stages.compositor.setTransparentBackground( this.settings.get( 'transparentBackground' ) );
779
779
  timer.end( 'Apply settings' );
780
780
 
781
781
  timer.print();
@@ -1006,10 +1006,10 @@ export class PathTracerApp extends EventDispatcher {
1006
1006
 
1007
1007
  if ( usePostProcess ) return dm.denoiserCanvas;
1008
1008
 
1009
- // Re-render display stage so the WebGPU canvas has valid content
1010
- if ( this.stages.display && this.pipeline?.context ) {
1009
+ // Re-render compositor stage so the WebGPU canvas has valid content
1010
+ if ( this.stages.compositor && this.pipeline?.context ) {
1011
1011
 
1012
- this.stages.display.render( this.pipeline.context );
1012
+ this.stages.compositor.render( this.pipeline.context );
1013
1013
 
1014
1014
  }
1015
1015
 
@@ -1208,6 +1208,8 @@ export class PathTracerApp extends EventDispatcher {
1208
1208
 
1209
1209
  RectAreaLightNode.setLTC( RectAreaLightTexturesLib.init() );
1210
1210
 
1211
+ this.renderer.workingColorSpace = SRGBColorSpace;
1212
+ this.renderer.outputColorSpace = SRGBColorSpace;
1211
1213
  this.renderer.toneMapping = ACESFilmicToneMapping;
1212
1214
  this.renderer.toneMappingExposure = 1.0;
1213
1215
  this.renderer.setPixelRatio( 1.0 );
@@ -1261,7 +1263,7 @@ export class PathTracerApp extends EventDispatcher {
1261
1263
  this.pipeline.addStage( this.stages.adaptiveSampling );
1262
1264
  this.pipeline.addStage( this.stages.edgeFilter );
1263
1265
  this.pipeline.addStage( this.stages.autoExposure );
1264
- this.pipeline.addStage( this.stages.display );
1266
+ this.pipeline.addStage( this.stages.compositor );
1265
1267
 
1266
1268
  const initRenderW = this.canvas.clientWidth || 1;
1267
1269
  const initRenderH = this.canvas.clientHeight || 1;
@@ -1367,10 +1369,13 @@ export class PathTracerApp extends EventDispatcher {
1367
1369
  // Bind settings to pipeline stages
1368
1370
  this.settings.bind( {
1369
1371
  stages: this.stages,
1372
+ renderer: this.renderer,
1370
1373
  resetCallback: () => this.reset(),
1371
1374
  reconcileCompletion: () => this._reconcileCompletion(),
1372
1375
  } );
1373
1376
 
1377
+ this.renderer.toneMappingExposure = this.settings.get( 'exposure' ) ?? 1.0;
1378
+
1374
1379
  // Resize handling
1375
1380
  this.onResize();
1376
1381
  this.resizeHandler = () => this.onResize();
@@ -1474,8 +1479,7 @@ export class PathTracerApp extends EventDispatcher {
1474
1479
  this.stages.edgeFilter = new EdgeFilter( this.renderer, { enabled: false } );
1475
1480
  this.stages.autoExposure = new AutoExposure( this.renderer, { enabled: DEFAULT_STATE.autoExposure ?? false } );
1476
1481
 
1477
- this.stages.display = new Display( this.renderer, {
1478
- exposure: ( DEFAULT_STATE.autoExposure ) ? 1.0 : ( this.settings.get( 'exposure' ) ?? 1.0 ),
1482
+ this.stages.compositor = new Compositor( this.renderer, {
1479
1483
  saturation: this.settings.get( 'saturation' ) ?? DEFAULT_STATE.saturation,
1480
1484
  } );
1481
1485
 
@@ -1497,7 +1501,7 @@ export class PathTracerApp extends EventDispatcher {
1497
1501
  edgeFilter: this.stages.edgeFilter,
1498
1502
  ssrc: this.stages.ssrc,
1499
1503
  autoExposure: this.stages.autoExposure,
1500
- display: this.stages.display,
1504
+ compositor: this.stages.compositor,
1501
1505
  },
1502
1506
  pipeline: this.pipeline,
1503
1507
  getExposure: () => this.settings.get( 'exposure' ) ?? 1.0,
@@ -104,16 +104,16 @@ export class RenderSettings extends EventDispatcher {
104
104
  * Wires internal references. Called by PathTracerApp after init().
105
105
  *
106
106
  * @param {Object} params
107
- * @param {Object} params.stages - Pipeline stages { pathTracer, display, autoExposure, ... }
107
+ * @param {Object} params.stages - Pipeline stages { pathTracer, compositor, autoExposure, ... }
108
108
  * @param {Function} params.resetCallback - Called to reset accumulation
109
109
  * @param {Function} [params.reconcileCompletion] - Called when completion limits change
110
110
  */
111
- bind( { stages, resetCallback, reconcileCompletion } ) {
111
+ bind( { stages, renderer, resetCallback, reconcileCompletion } ) {
112
112
 
113
113
  this._pathTracer = stages.pathTracer;
114
114
  this._resetCallback = resetCallback;
115
115
  this._delegates = {};
116
- this._handlers = this._buildHandlers( stages, reconcileCompletion );
116
+ this._handlers = this._buildHandlers( stages, renderer, reconcileCompletion );
117
117
 
118
118
  }
119
119
 
@@ -121,22 +121,24 @@ export class RenderSettings extends EventDispatcher {
121
121
  * Builds handler functions for multi-stage settings that can't
122
122
  * be routed with a simple uniform forward.
123
123
  */
124
- _buildHandlers( stages, reconcileCompletion ) {
124
+ _buildHandlers( stages, renderer, reconcileCompletion ) {
125
125
 
126
126
  return {
127
127
 
128
128
  handleTransparentBackground: ( value ) => {
129
129
 
130
130
  stages.pathTracer?.setUniform( 'transparentBackground', value );
131
- stages.display?.setTransparentBackground( value );
131
+ stages.compositor?.setTransparentBackground( value );
132
132
 
133
133
  },
134
134
 
135
135
  handleExposure: ( value ) => {
136
136
 
137
- if ( ! stages.autoExposure?.enabled ) {
137
+ // Three.js applies toneMappingExposure inside the tone-mapping branch,
138
+ // so this has no effect when renderer.toneMapping === NoToneMapping.
139
+ if ( ! stages.autoExposure?.enabled && renderer ) {
138
140
 
139
- stages.display?.setExposure( value );
141
+ renderer.toneMappingExposure = value;
140
142
 
141
143
  }
142
144
 
@@ -144,7 +146,7 @@ export class RenderSettings extends EventDispatcher {
144
146
 
145
147
  handleSaturation: ( value ) => {
146
148
 
147
- stages.display?.setSaturation( value );
149
+ stages.compositor?.setSaturation( value );
148
150
 
149
151
  },
150
152
 
@@ -0,0 +1,101 @@
1
+ import { vec4, vec3, uv, uniform, select, dot, mix } from 'three/tsl';
2
+ import { MeshBasicNodeMaterial, QuadMesh, TextureNode } from 'three/webgpu';
3
+ import { NoBlending } from 'three';
4
+ import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
5
+ import { REC709_LUMINANCE_COEFFICIENTS } from '../TSL/Common.js';
6
+
7
+ /**
8
+ * Compositor — Terminal pipeline stage.
9
+ *
10
+ * Selects the latest upstream texture via a priority fallback chain, applies
11
+ * a saturation grade, sets alpha, and hands the linear HDR result to the
12
+ * renderer's output pass (tone mapping + sRGB gamma happen there).
13
+ *
14
+ * Exposure is not applied here — `renderer.toneMappingExposure` owns it,
15
+ * and Three.js applies it inside the tone-mapping branch of the output pass
16
+ * (so it has no effect when `renderer.toneMapping === NoToneMapping`).
17
+ */
18
+ export class Compositor extends RenderStage {
19
+
20
+ constructor( renderer, options = {} ) {
21
+
22
+ super( 'Compositor', {
23
+ ...options,
24
+ executionMode: StageExecutionMode.ALWAYS
25
+ } );
26
+
27
+ this.renderer = renderer;
28
+
29
+ // 1.0 = neutral; >1 boosts to compensate for ACES/AgX desaturation.
30
+ this.saturation = uniform( options.saturation ?? 1.0 );
31
+
32
+ this._transparentBackground = uniform( 0, 'int' );
33
+
34
+ // TextureNode reused across frames — only `.value` mutates, shader doesn't recompile.
35
+ this._sourceTexNode = new TextureNode();
36
+
37
+ const texSample = this._sourceTexNode.sample( uv() );
38
+
39
+ const luma = dot( texSample.xyz, REC709_LUMINANCE_COEFFICIENTS );
40
+ const gradedColor = mix( vec3( luma ), texSample.xyz, this.saturation );
41
+
42
+ const outputAlpha = select( this._transparentBackground, texSample.w, 1.0 );
43
+
44
+ this.compositorMaterial = new MeshBasicNodeMaterial();
45
+ this.compositorMaterial.colorNode = vec4( gradedColor, outputAlpha );
46
+ this.compositorMaterial.blending = NoBlending;
47
+
48
+ this.compositorQuad = new QuadMesh( this.compositorMaterial );
49
+
50
+ }
51
+
52
+ /**
53
+ * Later stages in the chain take priority; `pathtracer:color` is the
54
+ * baseline fallback that is always present.
55
+ */
56
+ _resolveSourceTexture( context ) {
57
+
58
+ return context.getTexture( 'bloom:output' )
59
+ || context.getTexture( 'edgeFiltering:output' )
60
+ || context.getTexture( 'asvgf:output' )
61
+ || context.getTexture( 'ssrc:output' )
62
+ || context.getTexture( 'pathtracer:color' );
63
+
64
+ }
65
+
66
+ render( context ) {
67
+
68
+ if ( ! this.enabled ) return;
69
+
70
+ const sourceTexture = this._resolveSourceTexture( context );
71
+ if ( ! sourceTexture ) return;
72
+
73
+ this._sourceTexNode.value = sourceTexture;
74
+
75
+ this.renderer.setRenderTarget( null );
76
+ this.compositorQuad.render( this.renderer );
77
+
78
+ }
79
+
80
+ setSaturation( value ) {
81
+
82
+ this.saturation.value = value;
83
+
84
+ }
85
+
86
+ setTransparentBackground( enabled ) {
87
+
88
+ this._transparentBackground.value = enabled ? 1 : 0;
89
+
90
+ }
91
+
92
+ dispose() {
93
+
94
+ this._sourceTexNode?.dispose();
95
+ this.compositorMaterial?.dispose();
96
+ // QuadMesh extends Mesh — no dispose method; material already released above.
97
+ this.compositorQuad = null;
98
+
99
+ }
100
+
101
+ }
@@ -1,27 +1,24 @@
1
- import { Fn, vec3, vec4, float, int, uint, ivec2, uvec2, uniform,
2
- If, dot, max, abs, mix,
1
+ import { Fn, vec4, float, int, uint, ivec2, uvec2, uniform,
2
+ If, dot, max, abs, mix, pow, step,
3
3
  textureLoad, textureStore, localId, workgroupId } from 'three/tsl';
4
4
  import { RenderTarget, TextureNode, StorageTexture } from 'three/webgpu';
5
5
  import { HalfFloatType, RGBAFormat, NearestFilter, Box2, Vector2 } from 'three';
6
6
  import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
7
+ import { REC709_LUMINANCE_COEFFICIENTS } from '../TSL/Common.js';
7
8
 
8
9
  /**
9
- * WebGPU Edge-Aware Filtering Stage (Compute Shader)
10
+ * WebGPU Edge-Aware Filtering Stage (Compute Shader).
10
11
  *
11
- * Edge-preserving temporal filtering with progressive edge sharpening.
12
- * Uses a large directional sampling kernel for edge-guided smoothing,
13
- * then a smaller kernel for edge-detail refinement.
12
+ * Geometry-guided bilateral filter (8-dir × 2-dist kernel). Edge weights
13
+ * combine luminance, surface normal, and ray distance see Dammertz 2010
14
+ * "Edge-Avoiding À-Trous" for the structure. Strength decays over iterations
15
+ * so the filter is strongest on early frames and fades as accumulation
16
+ * converges. Single-pass; no temporal reuse.
14
17
  *
15
- * Features:
16
- * - Scene-adaptive: aggressive filtering for dynamic, progressive for static
17
- * - Edge sharpening increases over iterations
18
- * - Fast path during interaction mode (direct copy, no filtering cost)
19
- * - Firefly reduction for high-luminance outliers
20
- *
21
- * Execution: PER_CYCLE
22
- *
23
- * Textures published: edgeFiltering:output
24
- * Textures read: asvgf:output (fallback: pathtracer:color)
18
+ * Reads: pathtracer:color (or asvgf:output / bilateralFiltering:output)
19
+ * and pathtracer:normalDepth
20
+ * Publishes: edgeFiltering:output
21
+ * Mode: PER_CYCLE
25
22
  */
26
23
  export class EdgeFilter extends RenderStage {
27
24
 
@@ -34,25 +31,25 @@ export class EdgeFilter extends RenderStage {
34
31
 
35
32
  this.renderer = renderer;
36
33
 
37
- // Parameters
38
- this.pixelEdgeSharpness = uniform( options.pixelEdgeSharpness ?? 0.75 );
39
- this.edgeSharpenSpeed = uniform( options.edgeSharpenSpeed ?? 0.05 );
34
+ // filterStrength: 0 = passthrough, 1 = fully filtered.
35
+ // strengthDecaySpeed: per-iteration falloff toward passthrough.
36
+ // edgeThreshold: luminance σ. phiNormal: dot(n,n) exponent. phiDepth: depth σ.
37
+ this.filterStrength = uniform( options.filterStrength ?? 0.75 );
38
+ this.strengthDecaySpeed = uniform( options.strengthDecaySpeed ?? 0.05 );
40
39
  this.edgeThreshold = uniform( options.edgeThreshold ?? 1.0 );
40
+ this.phiNormal = uniform( options.phiNormal ?? 128.0 );
41
+ this.phiDepth = uniform( options.phiDepth ?? 1.0 );
41
42
  this.iterationCount = uniform( 0.0 );
42
43
  this.resW = uniform( options.width || 1 );
43
44
  this.resH = uniform( options.height || 1 );
44
45
 
45
- // Internal counter
46
46
  this._iterations = 0;
47
47
 
48
- // Input texture node
49
48
  this._inputTexNode = new TextureNode();
49
+ this._ndTexNode = new TextureNode();
50
50
 
51
- // Output StorageTexture (compute writes here)
52
- // Pre-allocated at max size NEVER resize/dispose after this.
53
- // Kept as a defensive pattern: bug #32969 (setSize bind-group staleness)
54
- // was fixed in r184 (PR #33028), but #33061 (TSL compute pipeline
55
- // re-compile returns zeros) is still open.
51
+ // Pre-allocate StorageTexture at max — defensive against three.js #33061
52
+ // (TSL compute pipeline re-compile returns zeros after resize).
56
53
  const MAX_STORAGE_SIZE = 2048;
57
54
  const w = options.width || 1;
58
55
  const h = options.height || 1;
@@ -63,10 +60,8 @@ export class EdgeFilter extends RenderStage {
63
60
  this._outputStorageTex.minFilter = NearestFilter;
64
61
  this._outputStorageTex.magFilter = NearestFilter;
65
62
 
66
- // Reusable Box2 for srcRegion in copyTextureToTexture
67
63
  this._srcRegion = new Box2( new Vector2( 0, 0 ), new Vector2( 0, 0 ) );
68
64
 
69
- // Output RenderTarget (readable copy for downstream stages)
70
65
  this.outputTarget = new RenderTarget( w, h, {
71
66
  type: HalfFloatType,
72
67
  format: RGBAFormat,
@@ -76,7 +71,6 @@ export class EdgeFilter extends RenderStage {
76
71
  stencilBuffer: false
77
72
  } );
78
73
 
79
- // Dispatch dimensions
80
74
  this._dispatchX = Math.ceil( w / 16 );
81
75
  this._dispatchY = Math.ceil( h / 16 );
82
76
 
@@ -87,10 +81,13 @@ export class EdgeFilter extends RenderStage {
87
81
  _buildCompute() {
88
82
 
89
83
  const inputTex = this._inputTexNode;
84
+ const ndTex = this._ndTexNode;
90
85
  const outputStorageTex = this._outputStorageTex;
91
- const sharpness = this.pixelEdgeSharpness;
92
- const sharpenSpeed = this.edgeSharpenSpeed;
86
+ const filterStrength = this.filterStrength;
87
+ const decaySpeed = this.strengthDecaySpeed;
93
88
  const threshold = this.edgeThreshold;
89
+ const phiNormal = this.phiNormal;
90
+ const phiDepth = this.phiDepth;
94
91
  const iterCount = this.iterationCount;
95
92
  const resW = this.resW;
96
93
  const resH = this.resH;
@@ -104,41 +101,64 @@ export class EdgeFilter extends RenderStage {
104
101
 
105
102
  If( gx.lessThan( int( resW ) ).and( gy.lessThan( int( resH ) ) ), () => {
106
103
 
107
- const center = textureLoad( inputTex, ivec2( gx, gy ) ).xyz;
108
- const centerLum = dot( center, vec3( 0.2126, 0.7152, 0.0722 ) );
104
+ const coord = ivec2( gx, gy );
105
+ const center = textureLoad( inputTex, coord ).xyz;
106
+ const centerLum = dot( center, REC709_LUMINANCE_COEFFICIENTS );
109
107
 
110
- // Progressive edge sharpening factor
111
- const edgeFactor = sharpness.add( iterCount.mul( sharpenSpeed ) ).clamp( 0.0, 0.95 );
108
+ // NormalDepth writes (0,0,0, 1e6) 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.
111
+ const MISS_THRESHOLD = 1e5;
112
+ const centerND = textureLoad( ndTex, coord );
113
+ const centerNormal = centerND.xyz.mul( 2.0 ).sub( 1.0 );
114
+ const centerDepth = centerND.w;
115
+ const centerIsHit = step( float( MISS_THRESHOLD ), centerDepth ).oneMinus();
116
+
117
+ const effectiveStrength = filterStrength.sub( iterCount.mul( decaySpeed ) ).clamp( 0.0, 1.0 );
112
118
 
113
- // Sample 8-direction cross pattern for edge-aware filtering
114
- // 8 directions x 2 distances + centre
115
119
  const colorSum = center.toVar();
116
120
  const weightSum = float( 1.0 ).toVar();
117
121
 
118
- // Directions: right, up, left, down, and diagonals
119
122
  const dirs = [
120
123
  [ 1, 0 ], [ 0, 1 ], [ - 1, 0 ], [ 0, - 1 ],
121
124
  [ 1, 1 ], [ - 1, 1 ], [ - 1, - 1 ], [ 1, - 1 ],
122
125
  ];
123
126
 
124
- // Sample along each direction at distances 1, 2
125
127
  for ( const [ dx, dy ] of dirs ) {
126
128
 
127
129
  for ( const dist of [ 1, 2 ] ) {
128
130
 
129
131
  const sx = gx.add( dx * dist ).clamp( int( 0 ), int( resW ).sub( 1 ) );
130
132
  const sy = gy.add( dy * dist ).clamp( int( 0 ), int( resH ).sub( 1 ) );
131
- const sColor = textureLoad( inputTex, ivec2( sx, sy ) ).xyz;
132
- const sLum = dot( sColor, vec3( 0.2126, 0.7152, 0.0722 ) );
133
+ const sCoord = ivec2( sx, sy );
134
+
135
+ const sColor = textureLoad( inputTex, sCoord ).xyz;
136
+ const sLum = dot( sColor, REC709_LUMINANCE_COEFFICIENTS );
137
+
138
+ const sND = textureLoad( ndTex, sCoord );
139
+ const sNormal = sND.xyz.mul( 2.0 ).sub( 1.0 );
140
+ const sDepth = sND.w;
141
+ const sampleIsHit = step( float( MISS_THRESHOLD ), sDepth ).oneMinus();
142
+
143
+ const lumW = abs( centerLum.sub( sLum ) ).div( max( threshold, float( 0.001 ) ) ).negate().exp();
144
+
145
+ const bothHit = centerIsHit.mul( sampleIsHit );
146
+ const bothMiss = centerIsHit.oneMinus().mul( sampleIsHit.oneMinus() );
147
+ const sameKind = bothHit.add( bothMiss );
133
148
 
134
- // Luminance-based edge weight
135
- const lumDiff = abs( centerLum.sub( sLum ) );
136
- const edgeWeight = lumDiff.div( max( threshold, float( 0.001 ) ) ).negate().exp();
149
+ // Clamp dot to [0,1] before pow — miss-ray normals decode to
150
+ // non-unit (-1,-1,-1) with dot=3, which would saturate pow(.,phi)
151
+ // to +inf and poison downstream via inf*0 = NaN.
152
+ const cosTheta = dot( centerNormal, sNormal ).clamp( 0.0, 1.0 );
153
+ const normW = pow( cosTheta, phiNormal );
154
+ const depW = abs( centerDepth.sub( sDepth ) ).div( max( phiDepth, float( 0.001 ) ) ).negate().exp();
155
+
156
+ // Geometric weights only meaningful for hit-vs-hit pairs.
157
+ const geomW = mix( float( 1.0 ), normW.mul( depW ), bothHit );
137
158
 
138
- // Distance falloff
139
159
  const distWeight = float( 1.0 ).div( float( dist ).add( 0.5 ) );
160
+ const w = lumW.mul( geomW ).mul( sameKind ).mul( distWeight );
140
161
 
141
- const w = edgeWeight.mul( distWeight );
142
162
  colorSum.addAssign( sColor.mul( w ) );
143
163
  weightSum.addAssign( w );
144
164
 
@@ -147,12 +167,10 @@ export class EdgeFilter extends RenderStage {
147
167
  }
148
168
 
149
169
  const filtered = colorSum.div( max( weightSum, float( 0.0001 ) ) );
170
+ const finalColor = mix( center, filtered, effectiveStrength );
150
171
 
151
- // Blend between filtered and original based on edge sharpening factor
152
- const finalColor = mix( filtered, center, edgeFactor );
153
-
154
- // Firefly suppression for very high luminance
155
- const finalLum = dot( finalColor, vec3( 0.2126, 0.7152, 0.0722 ) );
172
+ // Firefly clamp on output luminance.
173
+ const finalLum = dot( finalColor, REC709_LUMINANCE_COEFFICIENTS );
156
174
  const clampedColor = finalColor.toVar();
157
175
  If( finalLum.greaterThan( 10.0 ), () => {
158
176
 
@@ -181,23 +199,28 @@ export class EdgeFilter extends RenderStage {
181
199
 
182
200
  if ( ! this.enabled ) return;
183
201
 
184
- // Resolve input with fallback chain
185
202
  const inputTex = context.getTexture( 'asvgf:output' )
186
203
  || context.getTexture( 'bilateralFiltering:output' )
187
204
  || context.getTexture( 'pathtracer:color' );
188
205
 
189
- if ( ! inputTex ) return;
206
+ const ndTex = context.getTexture( 'pathtracer:normalDepth' );
207
+
208
+ // Without the G-buffer there's no edge guidance — pass input through
209
+ // rather than producing a uniform blur.
210
+ if ( ! inputTex || ! ndTex ) {
211
+
212
+ if ( inputTex ) context.setTexture( 'edgeFiltering:output', inputTex );
213
+ return;
214
+
215
+ }
190
216
 
191
- // Fast path during interaction mode — direct copy
192
- const interactionMode = context.getState( 'interactionMode' );
193
- if ( interactionMode ) {
217
+ if ( context.getState( 'interactionMode' ) ) {
194
218
 
195
219
  context.setTexture( 'edgeFiltering:output', inputTex );
196
220
  return;
197
221
 
198
222
  }
199
223
 
200
- // Auto-size
201
224
  const img = inputTex.image;
202
225
  if ( img && img.width > 0 && img.height > 0 ) {
203
226
 
@@ -210,23 +233,20 @@ export class EdgeFilter extends RenderStage {
210
233
 
211
234
  }
212
235
 
213
- // Update input texture
214
236
  this._inputTexNode.value = inputTex;
237
+ this._ndTexNode.value = ndTex;
215
238
 
216
- // Update iteration count for progressive sharpening
217
239
  this._iterations ++;
218
240
  this.iterationCount.value = this._iterations;
219
241
 
220
- // Dispatch compute
221
242
  this.renderer.compute( this._computeNode );
222
243
 
223
- // Copy StorageTexture RenderTarget for downstream readability
224
- // Use Box2 srcRegion since StorageTexture is pre-allocated at max size
244
+ // Copy out of the over-allocated StorageTexture into the right-sized
245
+ // RenderTarget; downstream stages can sample the latter.
225
246
  this._srcRegion.min.set( 0, 0 );
226
247
  this._srcRegion.max.set( this.outputTarget.width, this.outputTarget.height );
227
248
  this.renderer.copyTextureToTexture( this._outputStorageTex, this.outputTarget.texture, this._srcRegion );
228
249
 
229
- // Publish RenderTarget texture (NOT StorageTexture)
230
250
  context.setTexture( 'edgeFiltering:output', this.outputTarget.texture );
231
251
 
232
252
  }
@@ -240,9 +260,11 @@ export class EdgeFilter extends RenderStage {
240
260
  updateUniforms( params ) {
241
261
 
242
262
  if ( ! params ) return;
243
- if ( params.pixelEdgeSharpness !== undefined ) this.pixelEdgeSharpness.value = params.pixelEdgeSharpness;
244
- if ( params.edgeSharpenSpeed !== undefined ) this.edgeSharpenSpeed.value = params.edgeSharpenSpeed;
263
+ if ( params.filterStrength !== undefined ) this.filterStrength.value = params.filterStrength;
264
+ if ( params.strengthDecaySpeed !== undefined ) this.strengthDecaySpeed.value = params.strengthDecaySpeed;
245
265
  if ( params.edgeThreshold !== undefined ) this.edgeThreshold.value = params.edgeThreshold;
266
+ if ( params.phiNormal !== undefined ) this.phiNormal.value = params.phiNormal;
267
+ if ( params.phiDepth !== undefined ) this.phiDepth.value = params.phiDepth;
246
268
 
247
269
  }
248
270
 
@@ -255,15 +277,12 @@ export class EdgeFilter extends RenderStage {
255
277
 
256
278
  setSize( width, height ) {
257
279
 
258
- // Only resize the RenderTarget — StorageTexture stays at max allocation
259
- // (see constructor note: pre-allocation is a defensive pattern, retained
260
- // after r184 fixed #32969, because #33061 is still open.)
280
+ // StorageTexture stays at its max allocation (see constructor).
261
281
  this.outputTarget.setSize( width, height );
262
282
  this.outputTarget.texture.needsUpdate = true;
263
283
  this.resW.value = width;
264
284
  this.resH.value = height;
265
285
 
266
- // Update dispatch dimensions
267
286
  this._dispatchX = Math.ceil( width / 16 );
268
287
  this._dispatchY = Math.ceil( height / 16 );
269
288
  this._computeNode.dispatchSize = [ this._dispatchX, this._dispatchY, 1 ];
@@ -276,6 +295,7 @@ export class EdgeFilter extends RenderStage {
276
295
  this._outputStorageTex?.dispose();
277
296
  this.outputTarget?.dispose();
278
297
  this._inputTexNode?.dispose();
298
+ this._ndTexNode?.dispose();
279
299
 
280
300
  }
281
301