Kaynağa Gözat

Add cheap denoise pass

Marco Fugaro 1 ay önce
ebeveyn
işleme
377f9aab1b

+ 135 - 4
examples/jsm/tsl/display/GTAONode.js

@@ -1,5 +1,5 @@
 import { RenderTarget, Vector2, TempNode, QuadMesh, NodeMaterial, RendererUtils, RedFormat, FloatType, NearestFilter } from 'three/webgpu';
-import { reference, logarithmicDepthToViewZ, viewZToPerspectiveDepth, getNormalFromDepth, getScreenPosition, getViewPosition, nodeObject, Fn, float, NodeUpdateType, uv, uniform, Loop, vec2, vec3, vec4, int, dot, max, min, pow, abs, If, textureSize, sin, cos, PI, texture, passTexture, mat3, add, normalize, cross, mix, acos, clamp } from 'three/tsl';
+import { reference, logarithmicDepthToViewZ, viewZToPerspectiveDepth, perspectiveDepthToViewZ, getNormalFromDepth, getScreenPosition, getViewPosition, nodeObject, Fn, float, NodeUpdateType, uv, uniform, Loop, vec2, vec3, vec4, int, dot, max, min, pow, abs, exp, If, textureSize, sin, cos, PI, texture, passTexture, mat3, add, normalize, cross, mix, acos, clamp } from 'three/tsl';
 import { generateBlueNoiseTexture } from '../../math/BlueNoise.js';
 
 const _quadMesh = /*@__PURE__*/ new QuadMesh();
@@ -97,6 +97,20 @@ class GTAONode extends TempNode {
 		this._aoRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, format: RedFormat } );
 		this._aoRenderTarget.texture.name = 'GTAONode.AO';
 
+		/**
+		 * Render target the denoise pass writes into; the public output texture
+		 * always reads from this target. When {@link GTAONode#denoise} is `false`
+		 * the AO pass writes directly here and the denoise pass is skipped, so
+		 * consumers see the same texture binding regardless of the toggle.
+		 *
+		 * @private
+		 * @type {RenderTarget}
+		 */
+		this._denoiseRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, format: RedFormat } );
+		this._denoiseRenderTarget.texture.name = 'GTAONode.Denoise';
+		this._denoiseRenderTarget.texture.minFilter = NearestFilter;
+		this._denoiseRenderTarget.texture.magFilter = NearestFilter;
+
 		// Half-res depth target, populated by the downsample pass when
 		// resolutionScale < 1. Depth uses Float32 because perspective depth values
 		// cluster near 1.0 where Float16 quantizes horizon angles into visible rings.
@@ -186,6 +200,23 @@ class GTAONode extends TempNode {
 		 */
 		this.useTemporalFiltering = false;
 
+		/**
+		 * Controls the 3×3 cross-bilateral filter applied to the raw AO output.
+		 * Each tap is weighted by (spatial Gaussian) × (view-Z similarity) ×
+		 * (normal angle similarity), so the filter smooths horizon-scan noise
+		 * within a surface but rejects neighbors across silhouettes.
+		 *
+		 * Unlike `resolutionScale`, this filter runs at any resolution and is
+		 * gated purely by this flag.
+		 *
+		 * Cross-bilateral reconstruction as described in the Activision GTAO
+		 * paper, Section 5.5 (and slides 94–96 of the accompanying deck).
+		 *
+		 * @type {boolean}
+		 * @default false
+		 */
+		this.denoise = false;
+
 		/**
 		 * Blue-noise texture sampled for the slice rotation and step jitter.
 		 * Generated by Ulichney's void-and-cluster method (see
@@ -250,13 +281,19 @@ class GTAONode extends TempNode {
 		this._depthDownsampleMaterial = new NodeMaterial();
 		this._depthDownsampleMaterial.name = 'GTAO.DepthDownsample';
 
+		this._denoiseMaterial = new NodeMaterial();
+		this._denoiseMaterial.name = 'GTAO.Denoise';
+
 		/**
 		 * The result of the effect is represented as a separate texture node.
+		 * Points at the denoise render target — when the denoise pass is
+		 * disabled the AO pass writes there directly, so this binding is fixed
+		 * regardless of the toggle.
 		 *
 		 * @private
 		 * @type {PassTextureNode}
 		 */
-		this._textureNode = passTexture( this, this._aoRenderTarget.texture );
+		this._textureNode = passTexture( this, this._denoiseRenderTarget.texture );
 
 	}
 
@@ -318,6 +355,7 @@ class GTAONode extends TempNode {
 
 		this.resolution.value.set( lowW, lowH );
 		this._aoRenderTarget.setSize( lowW, lowH );
+		this._denoiseRenderTarget.setSize( lowW, lowH );
 
 		// Only resize the half-res RT when we'll actually use it. Shrinking it
 		// here at scale=1 while the renderer holds cached bindings from a previous
@@ -377,13 +415,28 @@ class GTAONode extends TempNode {
 
 		}
 
-		// ao
+		// AO horizon search.
+		const aoTarget = this.denoise ? this._aoRenderTarget : this._denoiseRenderTarget;
 
 		_quadMesh.material = this._material;
 		_quadMesh.name = 'AO';
-		renderer.setRenderTarget( this._aoRenderTarget );
+		renderer.setRenderTarget( aoTarget );
 		_quadMesh.render( renderer );
 
+		// Cross-bilateral denoise of the raw AO. Reads _aoRenderTarget, writes
+		// _denoiseRenderTarget at the same resolution. Depth + normal edge-aware
+		// weights smooth horizon-scan noise within a surface without bleeding
+		// across silhouettes.
+
+		if ( this.denoise ) {
+
+			_quadMesh.material = this._denoiseMaterial;
+			_quadMesh.name = 'GTAO.Denoise';
+			renderer.setRenderTarget( this._denoiseRenderTarget );
+			_quadMesh.render( renderer );
+
+		}
+
 		// restore
 
 		RendererUtils.restoreRendererState( renderer, _rendererState );
@@ -583,6 +636,82 @@ class GTAONode extends TempNode {
 		this._material.fragmentNode = ao().context( builder.getSharedContext() );
 		this._material.needsUpdate = true;
 
+		// ─── Cross-bilateral denoise ───────────────────────────────────────────
+		//
+		// 3×3 spatial filter over the raw AO. Each tap is weighted by
+		// (spatial Gaussian) × (view-Z similarity) × (normal angle similarity).
+		// The center pixel is included with full weight (1×1×1). Sky neighbors
+		// (depth ≥ 1.0) are naturally rejected by the depth weight because
+		// their view-Z sits at −far.
+		//
+		// Operates at the AO target's resolution and reads the same depth /
+		// normal sources the AO pass uses (full-res when resolutionScale = 1,
+		// the half-res RTs otherwise), so the depth / normal at each tap stays
+		// aligned with the AO texel it produced. The material is always built so
+		// the `denoise` flag can be toggled at runtime without a recompile.
+		//
+		// Cross-bilateral reconstruction as described in the Activision GTAO
+		// paper, Section 5.5, and slides 94–96 of the accompanying deck
+		// (https://www.activision.com/cdn/research/s2016_pbs_activision_occlusion.pptx),
+		// which present the spatial bilateral filter and its depth/normal edge guards.
+
+		const aoTextureNode = texture( this._aoRenderTarget.texture );
+
+		const denoise = Fn( () => {
+
+			const centerDepth = sampleDepth( uvNode ).toVar();
+
+			// Sky pixels short-circuit. The render target clears to white (set in
+			// updateBefore via setClearColor( 0xffffff, 1 )), so discarding here
+			// leaves the sky reading as visibility = 1 (no occlusion).
+			centerDepth.greaterThanEqual( 1.0 ).discard();
+
+			const centerViewZ = perspectiveDepthToViewZ( centerDepth, this._cameraNear, this._cameraFar ).toVar();
+			const centerNormal = sampleNormal( uvNode ).toVar();
+			const texelSize = vec2( 1 ).div( this.resolution ).toVar();
+
+			const totalAO = float( 0 ).toVar();
+			const totalW = float( 0 ).toVar();
+
+			for ( let dy = - 1; dy <= 1; dy ++ ) {
+
+				for ( let dx = - 1; dx <= 1; dx ++ ) {
+
+					const sUv = uvNode.add( vec2( dx, dy ).mul( texelSize ) );
+					const sDepth = sampleDepth( sUv ).toVar();
+					const sAO = aoTextureNode.sample( sUv ).r;
+					const sViewZ = perspectiveDepthToViewZ( sDepth, this._cameraNear, this._cameraFar );
+					const sNormal = sampleNormal( sUv );
+
+					// Spatial Gaussian, σ ≈ 1 texel. dx / dy are loop-unrolled
+					// compile-time constants, so this folds to a literal float.
+					const spatialW = float( Math.exp( - 0.5 * ( dx * dx + dy * dy ) ) );
+
+					// View-Z difference falloff — rejects neighbors more than
+					// ~0.5 view-space units away at the chosen sharpness.
+					const depthDiff = abs( centerViewZ.sub( sViewZ ) );
+					const depthW = exp( depthDiff.negate().mul( 2.0 ) );
+
+					// Normal angle similarity. pow( ·, 8 ) sharpens the cutoff
+					// so a ~30° normal difference roughly halves the weight.
+					const normalW = pow( max( dot( centerNormal, sNormal ), 0 ), float( 8 ) );
+
+					const w = spatialW.mul( depthW ).mul( normalW );
+
+					totalAO.addAssign( sAO.mul( w ) );
+					totalW.addAssign( w );
+
+				}
+
+			}
+
+			return vec4( totalAO.div( max( totalW, float( 0.0001 ) ) ), 0, 0, 1 );
+
+		} );
+
+		this._denoiseMaterial.fragmentNode = denoise().context( builder.getSharedContext() );
+		this._denoiseMaterial.needsUpdate = true;
+
 		// At scale=1 the downsample passes are skipped, so building their fragment
 		// nodes is dead work. Recompile when crossing the boundary picks the right path.
 		if ( ! useHalfRes ) {
@@ -631,9 +760,11 @@ class GTAONode extends TempNode {
 
 		this._aoRenderTarget.dispose();
 		this._depthHalfRT.dispose();
+		this._denoiseRenderTarget.dispose();
 
 		this._material.dispose();
 		this._depthDownsampleMaterial.dispose();
+		this._denoiseMaterial.dispose();
 
 	}
 

+ 2 - 0
examples/webgpu_postprocessing_ao.html

@@ -140,6 +140,7 @@
 				aoPass = ao( prePassDepth, prePassNormal, camera ).toInspector( 'GTAO', ( inspectNode ) => inspectNode.r );
 				aoPass.resolutionScale = 0.5; // running AO in half resolution is often sufficient
 				aoPass.useTemporalFiltering = true;
+				aoPass.denoise = true;
 
 				const aoPassOutput = aoPass.getTextureNode();
 
@@ -501,6 +502,7 @@
 				gui.add( params, 'scale', 0.01, 1 ).onChange( updateParameters );
 				gui.add( params, 'thickness', 0.01, 2 ).onChange( updateParameters );
 				gui.add( aoPass, 'useTemporalFiltering' ).name( 'temporal filtering' );
+				gui.add( aoPass, 'denoise' ).name( 'denoise' );
 				gui.add( transparentMesh, 'visible' ).name( 'show transparent mesh' );
 				gui.add( params, 'transparentOpacity', 0, 1, 0.01 ).name( 'transparent opacity' ).onChange( updateParameters );
 				gui.add( params, 'aoOnly' ).onChange( ( value ) => {

粤ICP备19079148号