Przeglądaj źródła

SVGFNode: Add variance-guided filtering.

Luminance moments are accumulated alongside the signal and drive both
the à-trous luminance edge-stop and the temporal gradient, which is now
measured in units of the local standard deviation. Includes small
cleanups and tunes the ball pool example against a path traced
reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mr.doob 3 dni temu
rodzic
commit
fb4d5bf4f5

+ 12 - 13
examples/jsm/tsl/display/SSGINode.js

@@ -164,7 +164,7 @@ class SSGINode extends TempNode {
 		 * Makes the sample distance in screen space instead of world-space (helps having more detail up close).
 		 *
 		 * @type {UniformNode<bool>}
-		 * @default false
+		 * @default true
 		 */
 		this.useScreenSpaceSampling = uniform( true, 'bool' );
 
@@ -205,12 +205,11 @@ class SSGINode extends TempNode {
 
 		/**
 		 * Whether to use temporal filtering or not. Setting this property to
-		 * `true` requires the usage of `TRAANode`. This will help to reduce noise
-		 * although it introduces typical TAA artifacts like ghosting and temporal
-		 * instabilities.
+		 * `true` requires a temporal resolve like `TRAANode` or `SVGFNode` in the
+		 * pipeline, which converges the varying sampling pattern to a stable result.
 		 *
-		 * If setting this property to `false`, a manual denoise via `DenoiseNode`
-		 * is required.
+		 * If setting this property to `false`, the sampling pattern is static and
+		 * a spatial denoise via `DenoiseNode` is required instead.
 		 *
 		 * @type {boolean}
 		 * @default true
@@ -231,7 +230,7 @@ class SSGINode extends TempNode {
 		 * Used to compute the effective step radius when viewSpaceSampling is `false`.
 		 *
 		 * @private
-		 * @type {UniformNode<vec2>}
+		 * @type {UniformNode<float>}
 		 */
 		this._halfProjScale = uniform( 1 );
 
@@ -501,8 +500,8 @@ class SSGINode extends TempNode {
 			const THICKNESS = this.thickness.toConst();
 			const BACKFACE_LIGHTING = this.backfaceLighting.toConst();
 
-			const uvDirection = directionIsRight.equal( true ).select( vec2( 1, - 1 ), vec2( - 1, 1 ) ); // Port note: Because of different uv conventions, uv-y has a different sign
-			const samplingDirection = directionIsRight.equal( true ).select( 1, - 1 );
+			const uvDirection = directionIsRight.select( vec2( 1, - 1 ), vec2( - 1, 1 ) ); // Port note: Because of different uv conventions, uv-y has a different sign
+			const samplingDirection = directionIsRight.select( 1, - 1 );
 
 			const color = vec3( 0 );
 
@@ -520,13 +519,13 @@ class SSGINode extends TempNode {
 
 				const sampleViewPosition = getViewPosition( sampleUV, sampleDepth( sampleUV ) ).toConst();
 				const pixelToSample = sampleViewPosition.sub( viewPosition ).normalize().toConst();
-				const linearThicknessMultiplier = this.useLinearThickness.equal( true ).select( sampleViewPosition.z.negate().div( this._cameraFar ).clamp().mul( 100 ), float( 1 ) );
+				const linearThicknessMultiplier = this.useLinearThickness.select( sampleViewPosition.z.negate().div( this._cameraFar ).clamp().mul( 100 ), float( 1 ) );
 				const pixelToSampleBackface = normalize( sampleViewPosition.sub( linearThicknessMultiplier.mul( viewDir ).mul( THICKNESS ) ).sub( viewPosition ) );
 
 				let frontBackHorizon = vec2( dot( pixelToSample, viewDir ), dot( pixelToSampleBackface, viewDir ) );
 				frontBackHorizon = GTAOFastAcos( clamp( frontBackHorizon, - 1, 1 ) );
 				frontBackHorizon = clamp( div( mul( samplingDirection, frontBackHorizon.negate() ).sub( n.sub( HALF_PI ) ), PI ) ); // Port note: subtract half pi instead of adding it
-				frontBackHorizon = directionIsRight.equal( true ).select( frontBackHorizon.yx, frontBackHorizon.xy ); // Front/Back get inverted depending on angle
+				frontBackHorizon = directionIsRight.select( frontBackHorizon.yx, frontBackHorizon.xy ); // Front/Back get inverted depending on angle
 
 				// inline ComputeOccludedBitfield() for easier debugging
 
@@ -574,7 +573,7 @@ class SSGINode extends TempNode {
 
 			} );
 
-			return vec3( color );
+			return color;
 
 		} );
 
@@ -624,7 +623,7 @@ class SSGINode extends TempNode {
 
 			const stepRadius = float( 0 );
 
-			If( this.useScreenSpaceSampling.equal( true ), () => {
+			If( this.useScreenSpaceSampling, () => {
 
 				stepRadius.assign( RADIUS.mul( this._resolution.x.div( 2 ) ).div( float( 16 ) ) ); // SSRT3 has a bug where stepRadius is divided by STEP_COUNT twice; fix here
 

+ 201 - 94
examples/jsm/tsl/display/SVGFNode.js

@@ -1,5 +1,5 @@
 import { RenderTarget, Vector2, TempNode, QuadMesh, NodeMaterial, RendererUtils, HalfFloatType, NearestFilter, DepthTexture, FloatType } from 'three/webgpu';
-import { Fn, float, vec2, vec4, uv, uniform, texture, passTexture, convertToTexture, luminance, dot, max, abs, pow, mix, If, ivec2, getViewPosition, getNormalFromDepth, NodeUpdateType } from 'three/tsl';
+import { Fn, If, float, vec2, vec4, uv, uniform, texture, passTexture, convertToTexture, luminance, dot, min, max, abs, pow, mix, outputStruct, property, sqrt, ivec2, perspectiveDepthToViewZ, getNormalFromDepth, NodeUpdateType } from 'three/tsl';
 
 const _quadMesh = /*@__PURE__*/ new QuadMesh();
 const _size = /*@__PURE__*/ new Vector2();
@@ -10,6 +10,16 @@ let _rendererState;
 // keeps each level cheap, while the increasing per-level step still covers a wide area across levels.
 const _kernel = [ 1 / 4, 1 / 2, 1 / 4 ];
 
+// deadzone of the temporal gradient, in units of the local standard deviation: sampling jitter
+// moves the neighborhood mean by about one deviation per frame ( the jitter is spatially
+// coherent ) while a real lighting change moves it by many
+const _gradientDeadzone = 3;
+
+// fixed accumulation weight of the luminance moments, decoupled from the adaptive alpha: if the
+// moments followed it, a fully rejected history would collapse the variance to zero, which in
+// turn pins the gradient and the alpha at their maximum with no way to recover
+const _momentsAlpha = 0.2;
+
 /**
  * Post processing node that denoises a noisy screen-space signal (such as the raw output of
  * {@link SSGINode}) using a spatiotemporal filter in the spirit of SVGF (Spatiotemporal
@@ -18,20 +28,20 @@ const _kernel = [ 1 / 4, 1 / 2, 1 / 4 ];
  * The pipeline is:
  * - **Temporal accumulation**: the current frame is reprojected against history using the
  *   velocity buffer and blended, with a depth-based disocclusion test that resets history where
- *   the reprojection is invalid. This is an alternative to denoising via {@link TRAANode}.
- * - **Adaptive temporal alpha** (A-SVGF-style): a temporal gradient raises the accumulation weight
- *   towards the current frame where the signal changed, rejecting stale history to limit ghosting
- *   under motion. The gradient is derived here from the current-vs-history difference rather than
- *   from re-shaded forward-projected samples as in the original A-SVGF.
- * - **Edge-avoiding à-trous**: a multi-level wavelet filter (increasing step size per level)
- *   spatially denoises the accumulated signal while preserving edges via depth, normal and
- *   luminance edge-stopping functions.
+ *   the reprojection is invalid. Luminance moments are accumulated alongside the signal, giving
+ *   a per-pixel variance estimate of the incoming noise.
+ * - **Adaptive temporal alpha**: a temporal gradient measured in units of the local standard
+ *   deviation raises the accumulation weight towards the current frame where the signal changed,
+ *   rejecting stale history to limit ghosting. Expressing the gradient in deviation units
+ *   separates sampling jitter (about one deviation by construction) from real lighting change
+ *   (many deviations).
+ * - **Variance-guided à-trous**: a multi-level edge-avoiding wavelet filter (increasing step size
+ *   per level) spatially denoises the accumulated signal. The luminance edge-stop scales with the
+ *   local deviation, so noisy regions are smoothed aggressively while converged regions keep
+ *   their edges; the variance estimate is filtered along with the signal.
  * - **Feedback**: the first à-trous level is fed back as the color history for the next frame,
  *   which keeps the temporal signal denoised without over-blurring (the SVGF feedback trick).
  *
- * It does not include SVGF's variance-guided luminance weighting (per-pixel variance from temporal
- * moments driving the à-trous luminance edge-stop); the luminance weight uses a fixed `lumaPhi`.
- *
  * References:
  * - {@link https://cg.ivd.kit.edu/publications/2017/svgf/svgf_preprint.pdf} (SVGF, Schied et al.)
  * - {@link https://cg.ivd.kit.edu/english/atf.php} (A-SVGF adaptive temporal filtering, Schied et al.)
@@ -124,11 +134,11 @@ class SVGFNode extends TempNode {
 		this.temporalAlpha = uniform( 0.1 );
 
 		/**
-		 * Strength of the adaptive temporal alpha (A-SVGF-style anti-ghosting). The temporal gradient
-		 * (how much the signal changed vs reprojected history) is scaled by this value to raise the
-		 * accumulation weight towards the current frame where change is detected, rejecting stale
-		 * history under motion. `0` disables it (fixed `temporalAlpha`); higher reduces ghosting at
-		 * the cost of more noise on moving regions.
+		 * Strength of the adaptive temporal alpha (anti-ghosting). The temporal gradient — how much
+		 * the signal changed versus the reprojected history, in units of the local standard
+		 * deviation — is scaled by this value to raise the accumulation weight towards the current
+		 * frame, rejecting stale history where the lighting changed. `0` disables it (fixed
+		 * `temporalAlpha`); higher reduces ghosting at the cost of more noise on changing regions.
 		 *
 		 * @type {UniformNode<float>}
 		 * @default 4
@@ -160,7 +170,9 @@ class SVGFNode extends TempNode {
 		this.normalPhi = uniform( 128 );
 
 		/**
-		 * Luminance edge-stopping strength of the à-trous filter.
+		 * Luminance edge-stopping strength of the à-trous filter, in units of the local standard
+		 * deviation. Differences below `lumaPhi` deviations are smoothed; larger differences are
+		 * treated as edges and preserved.
 		 *
 		 * @type {UniformNode<float>}
 		 * @default 4
@@ -204,28 +216,35 @@ class SVGFNode extends TempNode {
 		 */
 		this._cameraProjectionMatrixInverse = uniform( camera.projectionMatrixInverse );
 
-		// render targets
+		/**
+		 * The camera's near and far values.
+		 *
+		 * @private
+		 * @type {UniformNode<vec2>}
+		 */
+		this._cameraNearFar = uniform( new Vector2() );
+
+		// render targets. The signal targets carry two attachments: the filtered signal, and
+		// the luminance moments with the variance derived from them
 
-		const rtOptions = { depthBuffer: false, type: HalfFloatType };
+		const rtOptions = { depthBuffer: false, type: HalfFloatType, count: 2 };
 
 		/**
-		 * Holds the previous frame's filtered result (the color history fed back each frame). Its
-		 * depth texture stores the previous frame's depth for the disocclusion test.
+		 * Holds the previous frame's filtered result and luminance moments (the history fed back
+		 * each frame). Its depth texture stores the previous frame's depth for the disocclusion test.
 		 *
 		 * @private
 		 * @type {RenderTarget}
 		 */
 		this._historyRenderTarget = new RenderTarget( 1, 1, { ...rtOptions, depthTexture: new DepthTexture() } );
-		this._historyRenderTarget.texture.name = 'SVGF.history';
 
 		/**
-		 * Holds the temporally accumulated signal before spatial filtering.
+		 * Holds the temporally accumulated signal and moments before spatial filtering.
 		 *
 		 * @private
 		 * @type {RenderTarget}
 		 */
 		this._temporalRenderTarget = new RenderTarget( 1, 1, rtOptions );
-		this._temporalRenderTarget.texture.name = 'SVGF.temporal';
 
 		/**
 		 * Ping-pong targets for the à-trous iterations.
@@ -234,8 +253,6 @@ class SVGFNode extends TempNode {
 		 * @type {Array<RenderTarget>}
 		 */
 		this._atrousRenderTargets = [ new RenderTarget( 1, 1, rtOptions ), new RenderTarget( 1, 1, rtOptions ) ];
-		this._atrousRenderTargets[ 0 ].texture.name = 'SVGF.atrous0';
-		this._atrousRenderTargets[ 1 ].texture.name = 'SVGF.atrous1';
 
 		/**
 		 * Holds the final filtered result.
@@ -244,7 +261,6 @@ class SVGFNode extends TempNode {
 		 * @type {RenderTarget}
 		 */
 		this._resolveRenderTarget = new RenderTarget( 1, 1, rtOptions );
-		this._resolveRenderTarget.texture.name = 'SVGF.resolve';
 
 		/**
 		 * Holds a packed geometry buffer ( view-space normal in rgb, linear view-space Z in a )
@@ -253,16 +269,31 @@ class SVGFNode extends TempNode {
 		 * @private
 		 * @type {RenderTarget}
 		 */
-		this._geometryRenderTarget = new RenderTarget( 1, 1, rtOptions );
+		this._geometryRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, type: HalfFloatType } );
 		this._geometryRenderTarget.texture.name = 'SVGF.geometry';
 
-		// the resolve target keeps linear filtering so the result upsamples smoothly when the
+		this._historyRenderTarget.textures[ 0 ].name = 'SVGF.history';
+		this._historyRenderTarget.textures[ 1 ].name = 'SVGF.history.moments';
+		this._temporalRenderTarget.textures[ 0 ].name = 'SVGF.temporal';
+		this._temporalRenderTarget.textures[ 1 ].name = 'SVGF.temporal.moments';
+		this._atrousRenderTargets[ 0 ].textures[ 0 ].name = 'SVGF.atrous0';
+		this._atrousRenderTargets[ 0 ].textures[ 1 ].name = 'SVGF.atrous0.moments';
+		this._atrousRenderTargets[ 1 ].textures[ 0 ].name = 'SVGF.atrous1';
+		this._atrousRenderTargets[ 1 ].textures[ 1 ].name = 'SVGF.atrous1.moments';
+		this._resolveRenderTarget.textures[ 0 ].name = 'SVGF.resolve';
+		this._resolveRenderTarget.textures[ 1 ].name = 'SVGF.resolve.moments';
+
+		// the resolve output keeps linear filtering so the result upsamples smoothly when the
 		// effect runs at a lower resolution than the output
 
 		for ( const rt of [ this._historyRenderTarget, this._temporalRenderTarget, this._geometryRenderTarget, ...this._atrousRenderTargets ] ) {
 
-			rt.texture.minFilter = NearestFilter;
-			rt.texture.magFilter = NearestFilter;
+			for ( const tex of rt.textures ) {
+
+				tex.minFilter = NearestFilter;
+				tex.magFilter = NearestFilter;
+
+			}
 
 		}
 
@@ -303,15 +334,31 @@ class SVGFNode extends TempNode {
 		 * @private
 		 * @type {TextureNode}
 		 */
-		this._historyNode = texture( this._historyRenderTarget.texture );
+		this._historyNode = texture( this._historyRenderTarget.textures[ 0 ] );
 
 		/**
-		 * Texture node holding the current à-trous input (swapped per iteration).
+		 * Texture node holding the history luminance moments.
 		 *
 		 * @private
 		 * @type {TextureNode}
 		 */
-		this._atrousInputNode = texture( this._temporalRenderTarget.texture );
+		this._historyMomentsNode = texture( this._historyRenderTarget.textures[ 1 ] );
+
+		/**
+		 * Texture node holding the current à-trous color input (swapped per iteration).
+		 *
+		 * @private
+		 * @type {TextureNode}
+		 */
+		this._atrousInputNode = texture( this._temporalRenderTarget.textures[ 0 ] );
+
+		/**
+		 * Texture node holding the current à-trous variance input (swapped per iteration).
+		 *
+		 * @private
+		 * @type {TextureNode}
+		 */
+		this._atrousVarianceNode = texture( this._temporalRenderTarget.textures[ 1 ] );
 
 		/**
 		 * Texture node holding the packed geometry buffer ( view normal + linear view Z ).
@@ -327,7 +374,7 @@ class SVGFNode extends TempNode {
 		 * @private
 		 * @type {PassTextureNode}
 		 */
-		this._textureNode = passTexture( this, this._resolveRenderTarget.texture );
+		this._textureNode = passTexture( this, this._resolveRenderTarget.textures[ 0 ] );
 
 	}
 
@@ -371,6 +418,7 @@ class SVGFNode extends TempNode {
 		const { renderer } = frame;
 
 		this._cameraProjectionMatrixInverse.value.copy( this.camera.projectionMatrixInverse );
+		this._cameraNearFar.value.set( this.camera.near, this.camera.far );
 
 		// keep the effect in sync with the dimensions of the beauty texture
 
@@ -390,7 +438,7 @@ class SVGFNode extends TempNode {
 			// not fade in from black
 
 			renderer.initRenderTarget( this._historyRenderTarget );
-			renderer.copyTextureToTexture( beautyTexture, this._historyRenderTarget.texture );
+			renderer.copyTextureToTexture( beautyTexture, this._historyRenderTarget.textures[ 0 ] );
 
 		}
 
@@ -408,10 +456,14 @@ class SVGFNode extends TempNode {
 		_quadMesh.name = 'SVGF.temporal';
 		_quadMesh.render( renderer );
 
+		// the integrated moments become next frame's moments history
+
+		renderer.copyTextureToTexture( this._temporalRenderTarget.textures[ 1 ], this._historyRenderTarget.textures[ 1 ] );
+
 		// edge-avoiding à-trous, ping-pong between targets, last level into the resolve target
 
 		const iterations = this.atrousIterations;
-		let inputTexture = this._temporalRenderTarget.texture;
+		let inputTarget = this._temporalRenderTarget;
 
 		_quadMesh.material = this._atrousMaterial;
 		_quadMesh.name = 'SVGF.atrous';
@@ -421,7 +473,8 @@ class SVGFNode extends TempNode {
 			const target = ( i === iterations - 1 ) ? this._resolveRenderTarget : this._atrousRenderTargets[ i % 2 ];
 
 			this._stepSize.value = 1 << i; // 1, 2, 4, 8, 16, ...
-			this._atrousInputNode.value = inputTexture;
+			this._atrousInputNode.value = inputTarget.textures[ 0 ];
+			this._atrousVarianceNode.value = inputTarget.textures[ 1 ];
 
 			renderer.setRenderTarget( target );
 			_quadMesh.render( renderer );
@@ -430,11 +483,11 @@ class SVGFNode extends TempNode {
 
 			if ( i === 0 ) {
 
-				renderer.copyTextureToTexture( target.texture, this._historyRenderTarget.texture );
+				renderer.copyTextureToTexture( target.textures[ 0 ], this._historyRenderTarget.textures[ 0 ] );
 
 			}
 
-			inputTexture = target.texture;
+			inputTarget = target;
 
 		}
 
@@ -472,7 +525,14 @@ class SVGFNode extends TempNode {
 		const sampleDepth = ( uvNode ) => this.depthNode.sample( uvNode ).r;
 		const sampleNormal = ( uvNode ) => ( this.normalNode !== null ) ? this.normalNode.sample( uvNode ).rgb.normalize() : getNormalFromDepth( uvNode, this.depthNode.value, this._cameraProjectionMatrixInverse );
 
+		const sharedContext = builder.getSharedContext();
+
 		// --- temporal accumulation pass ---
+		// outputs the accumulated signal and the integrated luminance moments
+		// ( μ1, μ2 ) with the variance derived from them
+
+		const temporalColor = property( 'vec4' );
+		const temporalMoments = property( 'vec4' );
 
 		const temporal = Fn( () => {
 
@@ -482,7 +542,8 @@ class SVGFNode extends TempNode {
 			const current = this.beautyNode.sample( uvNode ).toVar();
 			const depth = sampleDepth( uvNode ).toVar();
 
-			const result = vec4( current ).toVar();
+			temporalColor.assign( current );
+			temporalMoments.assign( vec4( 0.0 ) );
 
 			If( depth.lessThan( 1.0 ), () => {
 
@@ -495,76 +556,90 @@ class SVGFNode extends TempNode {
 
 				// depth-based disocclusion test ( compare linear view-space Z )
 
-				const currentZ = getViewPosition( uvNode, depth, this._cameraProjectionMatrixInverse ).z;
-				const previousZ = getViewPosition( historyUV, this._previousDepthNode.sample( historyUV ).r, this._cameraProjectionMatrixInverse ).z;
+				const { x: near, y: far } = this._cameraNearFar;
+				const currentZ = perspectiveDepthToViewZ( depth, near, far );
+				const previousZ = perspectiveDepthToViewZ( this._previousDepthNode.sample( historyUV ).r, near, far );
 				const validDepth = abs( currentZ.sub( previousZ ) ).lessThan( abs( currentZ ).mul( this.depthRejection ) );
 
 				const validHistory = inBounds.and( validDepth );
 
-				const history = this._historyNode.sample( historyUV );
+				const history = this._historyNode.sample( historyUV ).toVar();
+				const historyMoments = this._historyMomentsNode.sample( historyUV ).rg.toVar();
+
+				// 3×3 spatial luminance moments of the incoming frame: used by the firefly clamp, as
+				// the change estimate for the temporal gradient and as variance fallback on disocclusion
 
-				// A-SVGF-style adaptive temporal alpha. The temporal gradient measures how much the
-				// signal changed versus the reprojected ( denoised ) history. Because the input is
-				// noisy, the current luminance is averaged over a 3×3 neighborhood and a small noise
-				// floor is subtracted, so only real change ( motion, disocclusion, lighting ) raises
-				// alpha towards 1 and rejects stale history. A true A-SVGF gradient would instead
-				// re-shade forward-projected samples with the same random sequence.
+				const currentLuma = luminance( current.rgb ).toVar();
 
-				const blurredLuma = float( 0 ).toVar();
+				const blurredLuma = float( currentLuma ).toVar();
+				const blurredLuma2 = currentLuma.mul( currentLuma ).toVar();
 
 				for ( let y = - 1; y <= 1; y ++ ) {
 
 					for ( let x = - 1; x <= 1; x ++ ) {
 
-						blurredLuma.addAssign( luminance( this.beautyNode.sample( uvNode.add( vec2( x, y ).mul( this._invSize ) ) ).rgb ) );
+						if ( x === 0 && y === 0 ) continue; // the center tap is already in currentLuma
+
+						const tapLuma = luminance( this.beautyNode.sample( uvNode.add( vec2( x, y ).mul( this._invSize ) ) ).rgb );
+
+						blurredLuma.addAssign( tapLuma );
+						blurredLuma2.addAssign( tapLuma.mul( tapLuma ) );
 
 					}
 
 				}
 
 				blurredLuma.mulAssign( 1 / 9 );
+				blurredLuma2.mulAssign( 1 / 9 );
 
 				// suppress fireflies: clamp the sample against its neighborhood mean so isolated
 				// bright outliers cannot blink in and out of the accumulated result
 
-				const currentLuma = luminance( current.rgb ).toVar();
 				const maxLuma = blurredLuma.mul( this.fireflyFactor );
 
 				current.rgb.mulAssign( currentLuma.greaterThan( maxLuma ).select( maxLuma.div( currentLuma ), float( 1.0 ) ) );
 
+				// temporal gradient in units of the local standard deviation
+
+				const historyVariance = max( historyMoments.y.sub( historyMoments.x.mul( historyMoments.x ) ), 0.0 );
+				const deviation = sqrt( historyVariance.add( 1e-4 ) );
 				const historyLuma = luminance( history.rgb );
+				const gradient = abs( blurredLuma.sub( historyLuma ) ).div( deviation );
 
-				// the luminance floor in the denominator keeps the relative gradient from amplifying
-				// sub-noise changes in dark regions, where it would otherwise keep rejecting history
-				// and let the raw noise blink through
+				const adaptiveAlpha = max( this.temporalAlpha, gradient.sub( _gradientDeadzone ).mul( this.antiGhosting ).clamp() );
 
-				const gradient = abs( blurredLuma.sub( historyLuma ) ).div( max( blurredLuma, historyLuma ).add( 0.25 ) );
+				const alpha = validHistory.select( adaptiveAlpha, float( 1.0 ) );
 
-				// the gradient compares a single jittered frame against the accumulated history, so it
-				// cannot distinguish sampling jitter from real lighting change. The deadzone must stay
-				// above the per-frame deviation the jitter produces, otherwise the accumulation tracks
-				// the jitter cycle instead of averaging it and the result never settles
+				temporalColor.assign( mix( history, current, alpha ) );
 
-				const adaptiveAlpha = max( this.temporalAlpha, gradient.sub( 0.35 ).mul( this.antiGhosting ).clamp() );
+				// the luminance moments are accumulated with the same reprojection; on disocclusion
+				// the spatial moments take over as an immediate estimate
 
-				const alpha = validHistory.select( adaptiveAlpha, float( 1.0 ) );
+				const clampedLuma = min( currentLuma, maxLuma ); // the firefly clamp limits the luminance to maxLuma
+				const currentMoments = vec2( clampedLuma, clampedLuma.mul( clampedLuma ) );
+				const integratedMoments = validHistory.select( mix( historyMoments, currentMoments, _momentsAlpha ), vec2( blurredLuma, blurredLuma2 ) ).toVar();
+				const variance = max( integratedMoments.y.sub( integratedMoments.x.mul( integratedMoments.x ) ), 0.0 );
 
-				result.assign( mix( history, current, alpha ) );
+				temporalMoments.assign( vec4( integratedMoments, variance, 0.0 ) );
 
 			} );
 
-			return result;
+			return vec4( 0 ); // temporary solution until TSL does not complain anymore
 
 		} );
 
+		this._temporalMaterial.colorNode = temporal().context( sharedContext );
+		this._temporalMaterial.outputNode = outputStruct( temporalColor, temporalMoments );
+		this._temporalMaterial.needsUpdate = true;
+
 		// --- geometry prepare pass ( view normal + linear view Z, packed once per frame ) ---
 
 		const prepare = Fn( () => {
 
 			const uvNode = uv();
-			const depth = this.depthNode.sample( uvNode ).r;
+			const depth = sampleDepth( uvNode );
 			const normal = sampleNormal( uvNode );
-			const viewZ = getViewPosition( uvNode, depth, this._cameraProjectionMatrixInverse ).z;
+			const viewZ = perspectiveDepthToViewZ( depth, this._cameraNearFar.x, this._cameraNearFar.y );
 
 			// valid view Z is negative; store a positive sentinel for background so the filter skips it
 
@@ -572,71 +647,103 @@ class SVGFNode extends TempNode {
 
 		} );
 
-		// --- edge-avoiding à-trous pass ---
+		this._geometryMaterial.fragmentNode = prepare().context( sharedContext );
+		this._geometryMaterial.needsUpdate = true;
+
+		// --- variance-guided à-trous pass ---
+
+		const atrousColor = property( 'vec4' );
+		const atrousMoments = property( 'vec4' );
 
 		const atrous = Fn( () => {
 
 			const uvNode = uv();
 
 			const centerColor = this._atrousInputNode.sample( uvNode ).toVar();
+			const centerVariance = this._atrousVarianceNode.sample( uvNode ).b.toVar();
 			const centerGeometry = this._geometryNode.sample( uvNode ).toVar();
 			const centerZ = centerGeometry.w.toVar();
 
-			const result = vec4( centerColor ).toVar();
+			atrousColor.assign( centerColor );
+			atrousMoments.assign( vec4( 0.0, 0.0, centerVariance, 0.0 ) );
 
 			If( centerZ.lessThan( 0.0 ), () => { // valid geometry only
 
 				const centerNormal = centerGeometry.xyz.toVar();
 				const centerLuma = luminance( centerColor.rgb ).toVar();
 
-				const sum = vec4( centerColor ).toVar(); // center tap has weight 1
-				const totalWeight = float( 1.0 ).toVar();
-
 				const step = this._invSize.mul( this._stepSize );
 
+				// gather the neighborhood once; the taps drive both the variance prefilter and the filter itself
+
+				const taps = [];
+
 				for ( let y = - 1; y <= 1; y ++ ) {
 
 					for ( let x = - 1; x <= 1; x ++ ) {
 
 						if ( x === 0 && y === 0 ) continue;
 
-						const kernelWeight = _kernel[ x + 1 ] * _kernel[ y + 1 ];
-
 						const sampleUV = uvNode.add( vec2( x, y ).mul( step ) ).toVar();
 
-						const sampleColor = this._atrousInputNode.sample( sampleUV );
-						const sampleGeometry = this._geometryNode.sample( sampleUV );
+						taps.push( {
+							kernelWeight: _kernel[ x + 1 ] * _kernel[ y + 1 ],
+							color: this._atrousInputNode.sample( sampleUV ).toVar(),
+							geometry: this._geometryNode.sample( sampleUV ).toVar(),
+							variance: this._atrousVarianceNode.sample( sampleUV ).b.toVar()
+						} );
 
-						// edge-stopping weights ( normal, linear depth, luminance )
+					}
 
-						const normalWeight = pow( max( dot( centerNormal, sampleGeometry.xyz ), 0.0 ), this.normalPhi );
-						const depthWeight = max( float( 1.0 ).sub( abs( centerZ.sub( sampleGeometry.w ) ).div( this.depthPhi ) ), 0.0 );
-						const lumaWeight = max( float( 1.0 ).sub( abs( luminance( sampleColor.rgb ).sub( centerLuma ) ).div( this.lumaPhi ) ), 0.0 );
+				}
 
-						const weight = float( kernelWeight ).mul( normalWeight ).mul( depthWeight ).mul( lumaWeight );
+				// the variance estimate is itself noisy: a kernel-prefiltered variance stabilizes the
+				// luminance edge-stop
 
-						sum.addAssign( sampleColor.mul( weight ) );
-						totalWeight.addAssign( weight );
+				const prefilteredVariance = centerVariance.mul( _kernel[ 1 ] * _kernel[ 1 ] ).toVar();
 
-					}
+				for ( const tap of taps ) {
+
+					prefilteredVariance.addAssign( tap.variance.mul( tap.kernelWeight ) );
 
 				}
 
-				result.assign( sum.div( totalWeight ) );
+				// luminance differences are weighted in units of the local deviation: noisy regions
+				// get smoothed aggressively while converged regions keep their edges
 
-			} );
+				const lumaScale = sqrt( prefilteredVariance ).mul( this.lumaPhi ).add( 1e-3 ).toVar();
 
-			return result;
+				const sum = centerColor.toVar(); // center tap has weight 1
+				const totalWeight = float( 1.0 ).toVar();
+				const varianceSum = centerVariance.toVar();
 
-		} );
+				for ( const tap of taps ) {
 
-		this._geometryMaterial.fragmentNode = prepare().context( builder.getSharedContext() );
-		this._geometryMaterial.needsUpdate = true;
+					// edge-stopping weights ( normal, linear depth, luminance )
 
-		this._temporalMaterial.fragmentNode = temporal().context( builder.getSharedContext() );
-		this._temporalMaterial.needsUpdate = true;
+					const normalWeight = pow( max( dot( centerNormal, tap.geometry.xyz ), 0.0 ), this.normalPhi );
+					const depthWeight = max( float( 1.0 ).sub( abs( centerZ.sub( tap.geometry.w ) ).div( this.depthPhi ) ), 0.0 );
+					const lumaWeight = max( float( 1.0 ).sub( abs( luminance( tap.color.rgb ).sub( centerLuma ) ).div( lumaScale ) ), 0.0 );
+
+					const weight = float( tap.kernelWeight ).mul( normalWeight ).mul( depthWeight ).mul( lumaWeight ).toVar();
+
+					sum.addAssign( tap.color.mul( weight ) );
+					totalWeight.addAssign( weight );
+					varianceSum.addAssign( tap.variance.mul( weight.mul( weight ) ) );
+
+				}
+
+				atrousColor.assign( sum.div( totalWeight ) );
+				atrousMoments.assign( vec4( 0.0, 0.0, varianceSum.div( totalWeight.mul( totalWeight ) ), 0.0 ) );
+
+			} );
+
+			return vec4( 0 ); // temporary solution until TSL does not complain anymore
+
+		} );
 
-		this._atrousMaterial.fragmentNode = atrous().context( builder.getSharedContext() );
+		this._atrousMaterial.colorNode = atrous().context( sharedContext );
+		this._atrousMaterial.outputNode = outputStruct( atrousColor, atrousMoments );
 		this._atrousMaterial.needsUpdate = true;
 
 		return this._textureNode;

+ 5 - 11
examples/jsm/tsl/math/BlueNoise.js

@@ -1,4 +1,4 @@
-import { DataTexture, RedFormat, RGFormat, RGBAFormat, UnsignedByteType, RepeatWrapping, TempNode } from 'three/webgpu';
+import { DataTexture, MathUtils, RedFormat, RGFormat, RGBAFormat, UnsignedByteType, RepeatWrapping, TempNode } from 'three/webgpu';
 import { texture, screenCoordinate, uniform, fract, float, vec2, vec4 } from 'three/tsl';
 
 /**
@@ -63,7 +63,7 @@ class BlueNoiseGenerator {
 		this.majorityPointsRatio = 0.1;
 
 		/**
-		 * Seed for the internal LCG, for reproducible output.
+		 * Seed of the random initial pattern, for reproducible output.
 		 *
 		 * @type {number}
 		 * @default 1
@@ -105,14 +105,8 @@ class BlueNoiseGenerator {
 		const binaryPattern = new Uint8Array( total );
 		const energy = new Float32Array( total );
 
-		// Linear-congruential PRNG, seeded for reproducibility.
-		let rngState = ( this.seed | 0 ) || 1;
-		const random = () => {
-
-			rngState = ( Math.imul( rngState, 1664525 ) + 1013904223 ) | 0;
-			return ( rngState >>> 0 ) / 0x100000000;
-
-		};
+		MathUtils.seededRandom( this.seed );
+		const random = () => MathUtils.seededRandom();
 
 		// Place `targetOnes` 1s at random positions via Fisher–Yates.
 		const targetOnes = Math.max( 1, Math.floor( total * this.majorityPointsRatio ) );
@@ -447,4 +441,4 @@ export default BlueNoiseNode;
  */
 export const blueNoise = ( channels, size ) => new BlueNoiseNode( channels, size );
 
-export { BlueNoiseGenerator, BlueNoiseNode };
+export { BlueNoiseGenerator };

+ 2 - 0
examples/webgpu_postprocessing_ssgi_ballpool.html

@@ -136,6 +136,8 @@
 				const giPass = ssgi( scenePassColor, scenePassDepth, sceneNormal, camera );
 				giPass.sliceCount.value = 2;
 				giPass.stepCount.value = 8;
+				giPass.giIntensity.value = 18;
+				giPass.aoIntensity.value = 0.55;
 				giPass.noiseNode = blueNoise( 2 );
 
 				// svgf denoise

粤ICP备19079148号