Răsfoiți Sursa

Spatiotemporal Denoiser for SSR (#33843)

0beqz 1 săptămână în urmă
părinte
comite
64d604ba00

+ 1 - 0
examples/files.json

@@ -449,6 +449,7 @@
 		"webgpu_postprocessing_ssgi",
 		"webgpu_postprocessing_ssgi_ballpool",
 		"webgpu_postprocessing_ssr",
+		"webgpu_postprocessing_ssr_denoise",
 		"webgpu_postprocessing_sss",
 		"webgpu_postprocessing_traa",
 		"webgpu_postprocessing_transition",

+ 560 - 0
examples/jsm/tsl/display/ImportanceSampledEnvironment.js

@@ -0,0 +1,560 @@
+/**
+ * HDR environment importance sampling (CDF tables + MIS) for screen-space effects.
+ *
+ * CDF precomputation and the MIS env-miss estimator are adapted from
+ * [three-gpu-pathtracer](https://github.com/gkjohnson/three-gpu-pathtracer).
+ *
+ * @see {@link https://github.com/gkjohnson/three-gpu-pathtracer}
+ */
+
+import { If, dot, equirectUV, float, luminance, max, normalize, texture, uniform, vec2, vec4 } from 'three/tsl';
+import { ClampToEdgeWrapping, DataTexture, DataUtils, FloatType, HalfFloatType, LinearFilter, RedFormat, RepeatWrapping, Source, Vector2 } from 'three/webgpu';
+import { D_GTR, F_Schlick, GeometryTerm, SmithG, equirectDirPdf, misPowerHeuristic } from '../utils/SpecularHelpers.js';
+
+function colorToLuminance( r, g, b ) {
+
+	return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+
+}
+
+function binarySearchFindClosestIndexOf( array, targetValue, offset = 0, count = array.length ) {
+
+	let lower = offset;
+	let upper = offset + count - 1;
+
+	while ( lower < upper ) {
+
+		const mid = ( lower + upper ) >> 1;
+
+		if ( array[ mid ] < targetValue ) {
+
+			lower = mid + 1;
+
+		} else {
+
+			upper = mid;
+
+		}
+
+	}
+
+	return lower - offset;
+
+}
+
+function preprocessEnvMap( envMap ) {
+
+	const map = envMap.clone();
+	map.source = new Source( { ...map.image } );
+	const { width, height, data } = map.image;
+
+	let newData = data;
+
+	if ( map.type !== HalfFloatType ) {
+
+		newData = new Uint16Array( data.length );
+
+		let maxIntValue;
+		if ( data instanceof Int8Array || data instanceof Int16Array || data instanceof Int32Array ) {
+
+			maxIntValue = 2 ** ( 8 * data.BYTES_PER_ELEMENT - 1 ) - 1;
+
+		} else {
+
+			maxIntValue = 2 ** ( 8 * data.BYTES_PER_ELEMENT ) - 1;
+
+		}
+
+		for ( let i = 0, l = data.length; i < l; i ++ ) {
+
+			let v = data[ i ];
+
+			if ( map.type === HalfFloatType ) {
+
+				v = DataUtils.fromHalfFloat( data[ i ] );
+
+			}
+
+			if ( map.type !== FloatType && map.type !== HalfFloatType ) {
+
+				v /= maxIntValue;
+
+			}
+
+			newData[ i ] = DataUtils.toHalfFloat( v );
+
+		}
+
+		map.image.data = newData;
+		map.type = HalfFloatType;
+
+	}
+
+	if ( map.flipY ) {
+
+		const ogData = newData;
+		newData = newData.slice();
+
+		for ( let y = 0; y < height; y ++ ) {
+
+			for ( let x = 0; x < width; x ++ ) {
+
+				const newY = height - y - 1;
+				const ogIndex = 4 * ( y * width + x );
+				const newIndex = 4 * ( newY * width + x );
+
+				newData[ newIndex + 0 ] = ogData[ ogIndex + 0 ];
+				newData[ newIndex + 1 ] = ogData[ ogIndex + 1 ];
+				newData[ newIndex + 2 ] = ogData[ ogIndex + 2 ];
+				newData[ newIndex + 3 ] = ogData[ ogIndex + 3 ];
+
+			}
+
+		}
+
+		map.flipY = false;
+		map.image.data = newData;
+
+	}
+
+	return map;
+
+}
+
+/**
+ * Precomputes marginal and conditional CDF textures from an equirectangular HDR environment map
+ * for luminance importance sampling.
+ */
+class EnvMapCDFGenerator {
+
+	constructor() {
+
+		this.map = null;
+		this.marginalWeights = null;
+		this.conditionalWeights = null;
+		this.totalSum = 0;
+
+	}
+
+	updateFrom( hdr ) {
+
+		this.updateMapOnly( hdr );
+
+		const { width, height, data } = this.map.image;
+
+		const pdfConditional = new Float32Array( width * height );
+		const cdfConditional = new Float32Array( width * height );
+		const pdfMarginal = new Float32Array( height );
+		const cdfMarginal = new Float32Array( height );
+
+		let totalSumValue = 0.0;
+		let cumulativeWeightMarginal = 0.0;
+
+		for ( let y = 0; y < height; y ++ ) {
+
+			let cumulativeRowWeight = 0.0;
+
+			for ( let x = 0; x < width; x ++ ) {
+
+				const i = y * width + x;
+				const r = DataUtils.fromHalfFloat( data[ 4 * i + 0 ] );
+				const g = DataUtils.fromHalfFloat( data[ 4 * i + 1 ] );
+				const b = DataUtils.fromHalfFloat( data[ 4 * i + 2 ] );
+
+				const weight = colorToLuminance( r, g, b );
+				cumulativeRowWeight += weight;
+				totalSumValue += weight;
+
+				pdfConditional[ i ] = weight;
+				cdfConditional[ i ] = cumulativeRowWeight;
+
+			}
+
+			if ( cumulativeRowWeight !== 0 ) {
+
+				for ( let i = y * width, l = y * width + width; i < l; i ++ ) {
+
+					pdfConditional[ i ] /= cumulativeRowWeight;
+					cdfConditional[ i ] /= cumulativeRowWeight;
+
+				}
+
+			}
+
+			cumulativeWeightMarginal += cumulativeRowWeight;
+			pdfMarginal[ y ] = cumulativeRowWeight;
+			cdfMarginal[ y ] = cumulativeWeightMarginal;
+
+		}
+
+		if ( cumulativeWeightMarginal !== 0 ) {
+
+			for ( let i = 0, l = pdfMarginal.length; i < l; i ++ ) {
+
+				pdfMarginal[ i ] /= cumulativeWeightMarginal;
+				cdfMarginal[ i ] /= cumulativeWeightMarginal;
+
+			}
+
+		}
+
+		const marginalDataArray = new Uint16Array( height );
+		const conditionalDataArray = new Uint16Array( width * height );
+
+		for ( let i = 0; i < height; i ++ ) {
+
+			const dist = ( i + 1 ) / height;
+			const row = binarySearchFindClosestIndexOf( cdfMarginal, dist );
+			marginalDataArray[ i ] = DataUtils.toHalfFloat( ( row + 0.5 ) / height );
+
+		}
+
+		for ( let y = 0; y < height; y ++ ) {
+
+			for ( let x = 0; x < width; x ++ ) {
+
+				const i = y * width + x;
+				const dist = ( x + 1 ) / width;
+				const col = binarySearchFindClosestIndexOf( cdfConditional, dist, y * width, width );
+				conditionalDataArray[ i ] = DataUtils.toHalfFloat( ( col + 0.5 ) / width );
+
+			}
+
+		}
+
+		if ( this.marginalWeights ) {
+
+			this.marginalWeights.dispose();
+
+		}
+
+		if ( this.conditionalWeights ) {
+
+			this.conditionalWeights.dispose();
+
+		}
+
+		this.marginalWeights = new DataTexture( marginalDataArray, height, 1 );
+		this.marginalWeights.type = HalfFloatType;
+		this.marginalWeights.format = RedFormat;
+		this.marginalWeights.minFilter = LinearFilter;
+		this.marginalWeights.magFilter = LinearFilter;
+		this.marginalWeights.wrapS = ClampToEdgeWrapping;
+		this.marginalWeights.wrapT = ClampToEdgeWrapping;
+		this.marginalWeights.generateMipmaps = false;
+		this.marginalWeights.needsUpdate = true;
+
+		this.conditionalWeights = new DataTexture( conditionalDataArray, width, height );
+		this.conditionalWeights.type = HalfFloatType;
+		this.conditionalWeights.format = RedFormat;
+		this.conditionalWeights.minFilter = LinearFilter;
+		this.conditionalWeights.magFilter = LinearFilter;
+		this.conditionalWeights.wrapS = ClampToEdgeWrapping;
+		this.conditionalWeights.wrapT = ClampToEdgeWrapping;
+		this.conditionalWeights.generateMipmaps = false;
+		this.conditionalWeights.needsUpdate = true;
+
+		this.totalSum = totalSumValue;
+
+	}
+
+	updateMapOnly( hdr ) {
+
+		if ( this.map ) {
+
+			this.map.dispose();
+
+		}
+
+		const map = preprocessEnvMap( hdr );
+		map.wrapS = RepeatWrapping;
+		map.wrapT = ClampToEdgeWrapping;
+
+		this.map = map;
+		this.totalSum = 0;
+
+	}
+
+	dispose() {
+
+		if ( this.marginalWeights ) {
+
+			this.marginalWeights.dispose();
+			this.marginalWeights = null;
+
+		}
+
+		if ( this.conditionalWeights ) {
+
+			this.conditionalWeights.dispose();
+			this.conditionalWeights = null;
+
+		}
+
+		if ( this.map ) {
+
+			this.map.dispose();
+			this.map = null;
+
+		}
+
+	}
+
+}
+
+/**
+ * Manages a preprocessed HDR environment map (CDF textures, uniforms) and exposes
+ * TSL helpers for BRDF-direction lookups and MIS importance sampling.
+ *
+ * @see {@link https://github.com/gkjohnson/three-gpu-pathtracer}
+ */
+class ImportanceSampledEnvironment {
+
+	/**
+	 * @param {boolean} [importanceSampling=false] - When `true`, builds luminance CDF tables and enables MIS env sampling.
+	 */
+	constructor( importanceSampling = false ) {
+
+		this._importanceSampling = importanceSampling;
+		this._cdf = new EnvMapCDFGenerator();
+
+		this._totalSum = uniform( 0.0, 'float' );
+		this._size = uniform( new Vector2( 1, 1 ) );
+		this.intensity = uniform( 1.0, 'float' );
+
+		this._mapNode = null;
+		this._marginalNode = null;
+		this._conditionalNode = null;
+
+	}
+
+	/**
+	 * @param {import('three').Texture} hdr - Equirectangular HDR environment map.
+	 */
+	updateFrom( hdr ) {
+
+		if ( this._importanceSampling ) {
+
+			this._cdf.updateFrom( hdr );
+			this._totalSum.value = this._cdf.totalSum;
+
+		} else {
+
+			this._cdf.updateMapOnly( hdr );
+
+		}
+
+		this._size.value.set( this._cdf.map.image.width, this._cdf.map.image.height );
+
+		if ( this._mapNode === null ) {
+
+			this._mapNode = texture( this._cdf.map );
+
+			if ( this._importanceSampling ) {
+
+				this._marginalNode = texture( this._cdf.marginalWeights );
+				this._conditionalNode = texture( this._cdf.conditionalWeights );
+
+			}
+
+		} else {
+
+			this._mapNode.value = this._cdf.map;
+
+			if ( this._importanceSampling ) {
+
+				this._marginalNode.value = this._cdf.marginalWeights;
+				this._conditionalNode.value = this._cdf.conditionalWeights;
+
+			}
+
+		}
+
+	}
+
+	clear() {
+
+		this.dispose();
+		this._cdf = new EnvMapCDFGenerator();
+		this._mapNode = null;
+		this._marginalNode = null;
+		this._conditionalNode = null;
+		this._totalSum.value = 0;
+		this._size.value.set( 1, 1 );
+
+	}
+
+	/**
+	 * Simple environment lookup along the reflected direction (no MIS).
+	 *
+	 * @param {Object} params
+	 * @param {import('three/tsl').UniformNode<import('three').Matrix4>} params.cameraWorldMatrix
+	 * @param {import('three/tsl').Node<vec3>} params.viewReflectDir
+	 * @param {import('three/tsl').Node<float>} [params.sampleWeight] - Optional radiance scale (defaults to 1).
+	 * @return {import('three/tsl').Node<vec3>}
+	 */
+	sampleReflect( { cameraWorldMatrix, viewReflectDir, sampleWeight = float( 1 ) } ) {
+
+		const worldReflectDir = cameraWorldMatrix.mul( vec4( viewReflectDir, float( 0 ) ) ).xyz.normalize();
+		const envUV = equirectUV( worldReflectDir );
+
+		// Explicit LOD 0: the per-pixel reflected direction is discontinuous at the equirect pole/seam
+		// (atan is undefined at the poles), so derivative-driven mip selection collapses to the coarsest
+		// (near-average) mip there and produces a bright streak. Roughness is handled via direction sampling.
+		return texture( this._mapNode, envUV ).level( 0 ).rgb.mul( this.intensity ).mul( sampleWeight );
+
+	}
+
+	/**
+	 * Environment reflection for a screen-space miss using only the BRDF / reflected-ray direction.
+	 *
+	 * @param {Object} params
+	 * @param {import('three/tsl').UniformNode<import('three').Matrix4>} params.cameraWorldMatrix
+	 * @param {import('three/tsl').Node<vec3>} params.viewReflectDir - View-space GGX-sampled reflected ray.
+	 * @param {import('three/tsl').Node<vec3>} params.N - View-space shading normal.
+	 * @param {import('three/tsl').Node<vec3>} params.V - View-space direction to camera.
+	 * @param {import('three/tsl').Node<float>} params.alpha - GGX roughness (alpha).
+	 * @param {import('three/tsl').Node<vec3>} params.f0
+	 * @return {import('three/tsl').Node<vec3>}
+	 */
+	sampleEnvironmentBRDF( {
+		cameraWorldMatrix,
+		viewReflectDir,
+		N,
+		V,
+		alpha,
+		f0
+	} ) {
+
+		const worldNormal = cameraWorldMatrix.mul( vec4( N, 0 ) ).xyz.normalize().toVar();
+		const worldV = cameraWorldMatrix.mul( vec4( V, 0 ) ).xyz.normalize().toVar();
+		const NdotV = max( float( 0 ), dot( worldNormal, worldV ) ).toVar();
+
+		const L1 = cameraWorldMatrix.mul( vec4( viewReflectDir, float( 0 ) ) ).xyz.normalize().toVar();
+		// Explicit LOD 0: the equirect mapping is singular at the poles (atan undefined when the reflected
+		// ray points straight up/down, e.g. a flat floor under a top-down camera), so derivative-driven mip
+		// selection picks the coarsest, near-average mip and yields a bright streak. Sample full-res instead.
+		const brdfEnvColor = texture( this._mapNode, equirectUV( L1 ) ).level( 0 ).rgb;
+
+		const H1 = normalize( worldV.add( L1 ) ).toVar();
+		const NdotL1 = max( float( 0 ), dot( worldNormal, L1 ) ).toVar();
+		const VdotH1 = max( float( 0 ), dot( worldV, H1 ) ).toVar();
+
+		const W1 = F_Schlick( f0, VdotH1 ).mul( GeometryTerm( NdotL1, NdotV, alpha ) ).div( SmithG( NdotV, alpha ).max( float( 1e-4 ) ) );
+
+		return brdfEnvColor.mul( W1 ).mul( this.intensity );
+
+	}
+
+	/**
+	 * Environment reflection for a screen-space miss, estimated with multiple importance
+	 * sampling (MIS) between the BRDF / reflected-ray direction and the env-luminance CDF
+	 * direction. Both techniques use consistent solid-angle PDFs (`D·G1(N·V)/(4·N·V)`), so
+	 * the power heuristic is unbiased. Adapted from three-gpu-pathtracer.
+	 *
+	 * @see {@link https://github.com/gkjohnson/three-gpu-pathtracer}
+	 *
+	 * @param {Object} params
+	 * @param {import('three/tsl').UniformNode<import('three').Matrix4>} params.cameraWorldMatrix
+	 * @param {import('three/tsl').Node<vec3>} params.viewReflectDir - View-space GGX-sampled reflected ray.
+	 * @param {import('three/tsl').Node<vec3>} params.N - View-space shading normal.
+	 * @param {import('three/tsl').Node<vec3>} params.V - View-space direction to camera.
+	 * @param {import('three/tsl').Node<float>} params.alpha - GGX roughness (alpha).
+	 * @param {import('three/tsl').Node<vec3>} params.f0
+	 * @param {import('three/tsl').Node<vec4>} params.Xi2 - Second blue-noise sample (zw used for the CDF).
+	 * @return {import('three/tsl').Node<vec3>}
+	 */
+	sampleEnvironmentMIS( {
+		cameraWorldMatrix,
+		viewReflectDir,
+		N,
+		V,
+		alpha,
+		f0,
+		Xi2
+	} ) {
+
+		const mapNode = this._mapNode;
+		const marginalNode = this._marginalNode;
+		const conditionalNode = this._conditionalNode;
+		const totalSum = this._totalSum;
+		const envW = this._size.x;
+		const envH = this._size.y;
+		const envMapIntensity = this.intensity;
+
+		const worldNormal = cameraWorldMatrix.mul( vec4( N, 0 ) ).xyz.normalize().toVar();
+		const worldV = cameraWorldMatrix.mul( vec4( V, 0 ) ).xyz.normalize().toVar();
+		const NdotV = max( float( 0 ), dot( worldNormal, worldV ) ).toVar();
+
+		// MIS sample 1: the BRDF / reflected-ray direction
+		const L1 = cameraWorldMatrix.mul( vec4( viewReflectDir, float( 0 ) ) ).xyz.normalize().toVar();
+		const brdfEnvColor = texture( mapNode, equirectUV( L1 ) ).level( 0 ).rgb;
+
+		const H1 = normalize( worldV.add( L1 ) ).toVar();
+		const NdotL1 = max( float( 0 ), dot( worldNormal, L1 ) ).toVar();
+		const NdotH1 = max( float( 0 ), dot( worldNormal, H1 ) ).toVar();
+		const VdotH1 = max( float( 0 ), dot( worldV, H1 ) ).toVar();
+
+		// Solid-angle PDF of the reflected ray for the BRDF technique: D(H)·G1(N·V)/(4·N·V).
+		const pdfBrdf1 = D_GTR( alpha, NdotH1, float( 2 ) ).mul( SmithG( NdotV, alpha ) ).div( max( float( 1e-6 ), float( 4 ).mul( NdotV ) ) ).max( float( 1e-8 ) );
+		// Env-luminance CDF PDF evaluated at the same direction.
+		const pdfEnv1 = envW.mul( envH ).mul( luminance( brdfEnvColor ).div( totalSum ) ).mul( equirectDirPdf( L1 ) ).max( float( 1e-8 ) );
+		const w1 = misPowerHeuristic( pdfBrdf1, pdfEnv1 );
+
+		// Monte-Carlo weight f·cosθ/pdfBrdf1 = F·G1(N·L) (GGX D cancels analytically — stable at low
+		// roughness). G2 and the pdf's G1 must use the same alpha for the cancellation to hold.
+		const W1 = F_Schlick( f0, VdotH1 ).mul( GeometryTerm( NdotL1, NdotV, alpha ) ).div( SmithG( NdotV, alpha ).max( float( 1e-4 ) ) );
+		const result = brdfEnvColor.mul( W1 ).mul( w1 ).toVar();
+
+		// MIS sample 2: the env-luminance CDF direction
+		// Mitigates noise on high-dynamic-range environments (the CDF lands samples on bright regions
+		// the BRDF lobe rarely hits). Skipped for near-mirror lobes (alpha ≲ 0.01, i.e. roughness ≲ 0.1):
+		// a global CDF direction almost never lands inside such a tight specular lobe.
+		If( alpha.greaterThan( 0.01 ), () => {
+
+			const r_env = vec2( Xi2.z, Xi2.w );
+			const v_cdf = texture( marginalNode, vec2( r_env.x, float( 0 ) ) ).r;
+			const u_cdf = texture( conditionalNode, vec2( r_env.y, v_cdf ) ).r;
+			const isEnvUV = vec2( u_cdf, v_cdf );
+			const envDirWS = equirectUV( isEnvUV );
+
+			const envHalf = normalize( worldV.add( envDirWS ) );
+			const envNdotL = max( float( 0 ), dot( worldNormal, envDirWS ) );
+			const envNdotH = max( float( 0 ), dot( worldNormal, envHalf ) );
+			const envVdotH = max( float( 0 ), dot( worldV, envHalf ) );
+
+			If( envNdotL.greaterThan( 0.001 ), () => {
+
+				// GGX normal-distribution term, shared by the BRDF pdf and the specular BRDF
+				// (both evaluate D(envNdotH)) so the pow is computed once.
+				const D = D_GTR( alpha, envNdotH, float( 2 ) ).toVar();
+
+				const sampledColor = texture( mapNode, isEnvUV ).level( 0 ).rgb;
+				const pdfEnv2 = envW.mul( envH ).mul( luminance( sampledColor ).div( totalSum ) ).mul( equirectDirPdf( envDirWS ) ).max( float( 1e-8 ) );
+				// BRDF technique pdf at the env direction — same solid-angle form as pdfBrdf1 (no V·H).
+				const pdfBrdf2 = D.mul( SmithG( NdotV, alpha ) ).div( max( float( 1e-6 ), float( 4 ).mul( NdotV ) ) ).max( float( 1e-8 ) );
+				const w2 = misPowerHeuristic( pdfEnv2, pdfBrdf2 );
+
+				// Specular BRDF (without Fresnel): D·G2 / (4·N·L·N·V), reusing D. Same GGX alpha as the pdf.
+				const envBrdfSpec = D.mul( GeometryTerm( envNdotL, NdotV, alpha ) ).div( max( float( 1e-6 ), float( 4 ).mul( envNdotL ).mul( NdotV ) ) );
+				const envFresnelWeight = F_Schlick( f0, envVdotH ); // vec3 — chromatic metal tint
+
+				result.addAssign( sampledColor.mul( envBrdfSpec ).mul( envFresnelWeight ).mul( envNdotL ).div( pdfEnv2 ).mul( w2 ) );
+
+			} );
+
+		} );
+
+		return result.mul( envMapIntensity );
+
+	}
+
+	dispose() {
+
+		this._cdf.dispose();
+
+	}
+
+}
+
+export default ImportanceSampledEnvironment;

+ 912 - 0
examples/jsm/tsl/display/RecurrentDenoiseNode.js

@@ -0,0 +1,912 @@
+import { abs, atan, bool, convertToTexture, cos, cross, Discard, dot, EPSILON, exp, float, Fn, getScreenPosition, getViewPosition, If, int, log, Loop, luminance, mat2, max, mix, nodeObject, NodeUpdateType, normalize, passTexture, PI, property, reflect, sin, smoothstep, sqrt, tan, texture, uniform, unpackRGBToNormal, uv, vec2, vec3, vec4 } from 'three/tsl';
+import { HalfFloatType, MathUtils, Matrix4, NodeMaterial, QuadMesh, RendererUtils, RenderTarget, TempNode, Vector2 } from 'three/webgpu';
+import { bindAnalyticNoise } from '../utils/RNoise.js';
+import { ENV_RAY_LENGTH_THRESHOLD } from '../utils/SpecularHelpers.js';
+
+const _quadMesh = /*@__PURE__*/ new QuadMesh();
+const _size = /*@__PURE__*/ new Vector2();
+
+let _rendererState;
+
+const KERNEL_SAMPLES = 8;
+const NOISE_ROTATION_SEED = 83;
+const WORLD_RADIUS_SCALE = 0.1;
+
+const AO_EDGE_STOPPING_BIAS = 0.05;
+const AGGRESSIVITY_RADIUS_MIN = 0.001;
+const DIFFUSE_CHROMA_WEIGHT = 2.0;
+// Neighborhood luma coefficient-of-variation thresholds for gating the temporal inverse-luminance
+// (firefly) suppression: below MIN the region is treated as flicker-free, above MAX as noisy.
+const FLICKER_COV_GATE_MIN = 0.1;
+const FLICKER_COV_GATE_MAX = 2;
+
+/**
+ * Golden-angle Vogel disk offset.
+ *
+ * @tsl
+ */
+const vogelDisk = Fn( ( [ i, radius ] ) => {
+
+	const sampleCount = 8;
+	const theta = i.add( 0.5 ).mul( 2.399827721492203 );
+	const r = radius.mul( sqrt( i.add( 0.5 ).div( sampleCount ) ) );
+	return vec2( cos( theta ), sin( theta ) ).mul( r );
+
+} ).setLayout( {
+	name: 'vogelDisk',
+	type: 'vec2',
+	inputs: [
+		{ name: 'i', type: 'float' },
+		{ name: 'radius', type: 'float' }
+	]
+} );
+
+/**
+ * Chromatic color-similarity distance between two linear base colors (albedo).
+ *
+ * @tsl
+ */
+const diffuseColorDistance = Fn( ( [ a, b, compressLuma ] ) => {
+
+	const toYCoCg = ( c ) => vec3(
+		dot( c, vec3( 0.25, 0.5, 0.25 ) ),
+		c.r.sub( c.b ),
+		c.g.sub( c.r.add( c.b ).mul( 0.5 ) )
+	);
+
+	const ya = toYCoCg( a );
+	const yb = toYCoCg( b );
+
+	// `compressLuma` (0/1) range-compresses the luma term with log(1+L) so a fixed lumaPhi gives
+	// scale-invariant differences across the HDR range. 0 leaves luma linear (used for LDR albedo).
+	const compress = ( L ) => mix( L, log( L.add( 1 ) ), compressLuma );
+
+	const dLuma = abs( compress( ya.x ).sub( compress( yb.x ) ) );
+	const dChroma = vec2( ya.y.sub( yb.y ), ya.z.sub( yb.z ) ).length();
+
+	return dLuma.add( dChroma.mul( DIFFUSE_CHROMA_WEIGHT ) );
+
+} ).setLayout( {
+	name: 'diffuseColorDistance',
+	type: 'float',
+	inputs: [
+		{ name: 'a', type: 'vec3' },
+		{ name: 'b', type: 'vec3' },
+		{ name: 'compressLuma', type: 'float' }
+	]
+} );
+
+const _temporalWeight = Fn( ( [ x, strength ] ) => float( 1 ).div( x.pow( strength ) ) ).setLayout( {
+	name: 'temporalWeight',
+	type: 'float',
+	inputs: [
+		{ name: 'x', type: 'float' },
+		{ name: 'strength', type: 'float' }
+	]
+} );
+
+/**
+ * Temporal accumulation variance factor in `[0, 1]`. Higher values mean more history confidence.
+ *
+ * @tsl
+ */
+const getTemporalVarianceFactor = Fn( ( [ frameNum, strength ] ) => {
+
+	return _temporalWeight( frameNum, strength ).max( 0.05 );
+
+} ).setLayout( {
+	name: 'getTemporalVarianceFactor',
+	type: 'float',
+	inputs: [
+		{ name: 'frameNum', type: 'float' },
+		{ name: 'strength', type: 'float' }
+	]
+} );
+
+/**
+ * World-space frustum height at `viewZ`. Algorithm originally from REBLUR (NRD).
+ * `tanHalfFovY` is `tan( verticalFov / 2 )`, hoisted by the caller since it is loop-invariant.
+ *
+ * @tsl
+ */
+const computeFrustumSize = Fn( ( [ viewZ, tanHalfFovY ] ) => {
+
+	return float( 2 ).mul( viewZ ).mul( tanHalfFovY );
+
+} ).setLayout( {
+	name: 'computeFrustumSize',
+	type: 'float',
+	inputs: [
+		{ name: 'viewZ', type: 'float' },
+		{ name: 'tanHalfFovY', type: 'float' }
+	]
+} );
+
+/**
+ * Maps world-space SSR ray length to `[0, 1]`. Environment rays (`worldRayLength == 0`) map to `1`.
+ * Algorithm originally from REBLUR (NRD).
+ *
+ * @tsl
+ */
+const computeHitDistFactor = Fn( ( [ worldRayLength, viewZ, tanHalfFovY ] ) => {
+
+	const frustumSize = computeFrustumSize( viewZ, tanHalfFovY );
+	const factor = worldRayLength.div( frustumSize.max( 1e-6 ) ).clamp( 0, 1 );
+
+	return factor;
+
+} ).setLayout( {
+	name: 'computeHitDistFactor',
+	type: 'float',
+	inputs: [
+		{ name: 'worldRayLength', type: 'float' },
+		{ name: 'viewZ', type: 'float' },
+		{ name: 'tanHalfFovY', type: 'float' }
+	]
+} );
+
+/**
+ * Maps an AO factor for edge-stopping comparisons.
+ *
+ * @tsl
+ */
+const mapAo = Fn( ( [ aoVal ] ) => aoVal.pow( 0.1 ) );
+
+/**
+ * Specular dominant direction — smooth surfaces lean toward reflection, rough toward normal.
+ *
+ * @tsl
+ */
+const getSpecularDominantDirection = Fn( ( [ N, V, roughness ] ) => {
+
+	return normalize( mix( N, reflect( V.negate(), N ), roughness.oneMinus() ) );
+
+} ).setLayout( {
+	name: 'getSpecularDominantDirection',
+	type: 'vec3',
+	inputs: [
+		{ name: 'N', type: 'vec3' },
+		{ name: 'V', type: 'vec3' },
+		{ name: 'roughness', type: 'float' }
+	]
+} );
+
+/**
+ * GGX inverse-CDF: half-angle tangent enclosing `percent` of the specular lobe volume.
+ * `roughness` is perceptual (alpha = roughness²).
+ *
+ * @tsl
+ */
+const specularLobeTanHalfAngle = Fn( ( [ roughness, percent ] ) => {
+
+	const alpha = roughness.mul( roughness );
+	return alpha.mul( sqrt( percent.div( float( 1 ).sub( percent ).max( 1e-6 ) ) ) );
+
+} ).setLayout( {
+	name: 'specularLobeTanHalfAngle',
+	type: 'float',
+	inputs: [
+		{ name: 'roughness', type: 'float' },
+		{ name: 'percent', type: 'float' }
+	]
+} );
+
+const EXP_WEIGHT_SCALE = 4;
+const NORMAL_ENCODING_ERROR = 1.5 / 255;
+
+/**
+ * Loop-invariant part of the adaptive normal edge-stopping weight: the Gaussian falloff
+ * constant `2·EXP_WEIGHT_SCALE / lobeHalfAngle²`. `roughness`/`aggressivity`/`invNormalPhi`
+ * are constant across the kernel, so this is hoisted out of the tap loop and evaluated once
+ * per pixel. Lobe half-angle from REBLUR (NRD).
+ *
+ * @tsl
+ */
+const lobeNormalFalloff = Fn( ( [ roughness, aggressivity, invNormalPhi ] ) => {
+
+	const percent = mix( invNormalPhi.pow2(), float( 0 ), aggressivity.sqrt() ).clamp( 0.1, 0.99 );
+	const tanHalfAngle = specularLobeTanHalfAngle( roughness, percent );
+	const lobeHalfAngle = max( atan( tanHalfAngle ), float( NORMAL_ENCODING_ERROR ) );
+
+	const invHalfAngle = float( 1 ).div( lobeHalfAngle );
+	return invHalfAngle.mul( invHalfAngle ).mul( 2 * EXP_WEIGHT_SCALE );
+
+} ).setLayout( {
+	name: 'lobeNormalFalloff',
+	type: 'float',
+	inputs: [
+		{ name: 'roughness', type: 'float' },
+		{ name: 'aggressivity', type: 'float' },
+		{ name: 'invNormalPhi', type: 'float' }
+	]
+} );
+
+/**
+ * Adaptive lobe normal edge-stopping weight
+ *
+ * Evaluated entirely in cosine space: with `angle² ≈ 2(1 − cosθ)`, the original
+ * `exp( −SCALE·angle/halfAngle )` becomes a Gaussian `exp( falloff·(cosθ − 1) )`, so a
+ * single `exp` replaces the per-tap `acos`. Matches the original at the half-angle for
+ * narrow lobes and is slightly more permissive for wide (diffuse) ones.
+ *
+ * @tsl
+ */
+const lobeNormalWeight = Fn( ( [ viewNormal, nNormalV, lobeFalloff ] ) => {
+
+	const cosA = dot( viewNormal, nNormalV );
+
+	return exp( cosA.sub( 1 ).mul( lobeFalloff ) );
+
+} ).setLayout( {
+	name: 'lobeNormalWeight',
+	type: 'float',
+	inputs: [
+		{ name: 'viewNormal', type: 'vec3' },
+		{ name: 'nNormalV', type: 'vec3' },
+		{ name: 'lobeFalloff', type: 'float' }
+	]
+} );
+
+/**
+ * View-space plane distance between two surface points (edge-stopping geometry term).
+ *
+ * @tsl
+ */
+const planeDistance = Fn( ( [ position, nPosition, normal ] ) => {
+
+	return abs( dot( position.sub( nPosition ), normal ) );
+
+} ).setLayout( {
+	name: 'planeDistance',
+	type: 'float',
+	inputs: [
+		{ name: 'position', type: 'vec3' },
+		{ name: 'nPosition', type: 'vec3' },
+		{ name: 'normal', type: 'vec3' },
+	]
+} );
+
+/**
+ * Inverse-luminance temporal blend with optional adaptive trust (Karis-style).
+ *
+ * @tsl
+ */
+const karisTemporalBlend = Fn( ( [ denoisedRgb, denoisedRaw, a, flickerSuppression, adaptiveTrust, nbhdMeanLuma, nbhdStddevLuma ] ) => {
+
+	const localCoV = nbhdStddevLuma.div( nbhdMeanLuma.max( 1e-4 ) );
+	const trustSuppress = localCoV.mul( adaptiveTrust ).mul( a.oneMinus() ).clamp( 0, 0.9 );
+	const aTrust = a.mul( trustSuppress.oneMinus() );
+
+	// In flicker-free neighborhoods, back off the inverse-luminance weighting so valid bright highlights
+	// keep their energy. Scaled by adaptiveTrust so the default (0) path is unchanged.
+	const noisy = smoothstep( FLICKER_COV_GATE_MIN, FLICKER_COV_GATE_MAX, localCoV );
+	const effFlicker = flickerSuppression.mul( mix( adaptiveTrust.oneMinus(), float( 1 ), noisy ) );
+
+	const wHist = float( 1 ).sub( aTrust ).div( luminance( denoisedRgb ).mul( effFlicker ).mul( 10 ).add( 1 ) );
+	const wRaw = aTrust.div( luminance( denoisedRaw ).mul( effFlicker ).mul( 10 ).add( 1 ) );
+	return denoisedRgb.mul( wHist ).add( denoisedRaw.mul( wRaw ) ).div( wHist.add( wRaw ).max( EPSILON ) );
+
+} ).setLayout( {
+	name: 'karisTemporalBlend',
+	type: 'vec3',
+	inputs: [
+		{ name: 'denoisedRgb', type: 'vec3' },
+		{ name: 'denoisedRaw', type: 'vec3' },
+		{ name: 'a', type: 'float' },
+		{ name: 'flickerSuppression', type: 'float' },
+		{ name: 'adaptiveTrust', type: 'float' },
+		{ name: 'nbhdMeanLuma', type: 'float' },
+		{ name: 'nbhdStddevLuma', type: 'float' }
+	]
+} );
+
+const toTextureNode = ( value ) => {
+
+	if ( value === null ) return null;
+
+	if ( value.isTexture === true ) return texture( value );
+
+	return convertToTexture( value.getTextureNode?.() ?? value );
+
+};
+
+/**
+ * @typedef {'diffuse'|'specular'} DenoiseMode
+ */
+
+/**
+ * @typedef {'raylength'|'ao'|'none'} DenoiseAlphaSource
+ */
+
+/**
+ * @typedef {Object} RecurrentDenoiseNodeOptions
+ * @property {?Node<float>} [depth=null] - Scene depth buffer for view-space edge stopping.
+ * @property {?Node<vec3>} [normal=null] - View-space normals for geometric edge stopping.
+ * @property {?Node<vec4>} [metalRoughness=null] - Roughness/metalness G-buffer for specular edge stopping.
+ * @property {?Node<vec4>} [diffuse=null] - Scene base color (albedo) G-buffer for chromatic edge stopping.
+ * @property {?Node<vec4>} [raw=null] - Unfiltered input (e.g. raw SSR/SSGI) for secondary sampling and temporal blend.
+ * @property {DenoiseMode} [mode='diffuse'] - Denoising kernel type.
+ * @property {boolean} [accumulate=true] - When `true`, temporally blend the spatially-denoised result
+ * (Karis-style) and write frame weight to alpha for feedback loops. When `false`, only spatial filtering is applied.
+ */
+
+/**
+ * Post processing node for denoising temporally-accumulated screen-space effects
+ * such as SSGI (ambient occlusion / indirect diffuse) and SSR (specular reflections).
+ *
+ * The denoising kernel is selected at construction time via `mode`:
+ * `'diffuse'` (SSGI) or `'specular'` (SSR). The kernel uses a fixed 8-sample Vogel disk.
+ *
+ * @augments TempNode
+ * @three_import import { recurrentDenoise } from 'three/addons/tsl/display/RecurrentDenoiseNode.js';
+ */
+class RecurrentDenoiseNode extends TempNode {
+
+	static get type() {
+
+		return 'RecurrentDenoiseNode';
+
+	}
+
+	/**
+	 * @param {TextureNode} inputTexture - Temporally filtered input to denoise (e.g. TRAA output).
+	 * @param {Camera} camera
+	 * @param {RecurrentDenoiseNodeOptions} [options={}]
+	 */
+	constructor( inputTexture, camera, options = {} ) {
+
+		super( 'vec4' );
+
+		const {
+			depth = null,
+			normal = null,
+			metalRoughness = null,
+			diffuse = null,
+			raw = null,
+			mode = 'diffuse',
+			accumulate = true,
+		} = options;
+
+		this.isRecurrentDenoiseNode = true;
+		this.camera = camera;
+
+		/**
+		 * Denoising kernel type.
+		 *
+		 * @type {DenoiseMode}
+		 */
+		this.mode = mode;
+
+		/**
+		 * When `true`, apply temporal blending after spatial denoising. When `false`, output spatially
+		 * filtered colour only (alpha is passed through from the input temporal pass).
+		 *
+		 * @type {boolean}
+		 */
+		this.accumulate = accumulate;
+
+		this.textureNode = inputTexture;
+		this.depthNode = depth !== null ? nodeObject( depth ) : null;
+		this.normalNode = normal !== null ? nodeObject( normal ) : null;
+		this.rawNode = toTextureNode( raw );
+		this.roughnessMetalnessNode = metalRoughness !== null ? nodeObject( metalRoughness ) : null;
+		this.diffuseNode = diffuse !== null ? nodeObject( diffuse ) : null;
+
+		this._noiseIndex = uniform( 0 );
+
+		this.lumaPhi = uniform( 5 );
+		this.depthPhi = uniform( 5 );
+		this.normalPhi = uniform( 5 );
+		this.radius = uniform( 5 );
+		this.alphaPhi = uniform( 1 );
+		this.roughnessPhi = uniform( 100 );
+		this.diffusePhi = uniform( 100 );
+		this.adapt = uniform( 0.5 );
+		this.smoothDisocclusions = uniform( true, 'bool' );
+		this.strength = uniform( 0.25 );
+		this.maxFrames = uniform( 32 );
+
+		/**
+		 * Which channel of the raw texture drives alpha-based edge stopping.
+		 * `'raylength'` — alpha encodes SSR ray length; `'ao'` — alpha encodes AO factor;
+		 * `'none'` — skip alpha-based edge stopping.
+		 *
+		 * @type {DenoiseAlphaSource}
+		 * @default 'raylength'
+		 */
+		this.alphaSource = 'raylength';
+
+		this.flickerSuppression = uniform( 1 );
+		this.adaptiveTrust = uniform( 0 );
+
+		this.updateBeforeType = NodeUpdateType.FRAME;
+
+		this._resolution = uniform( new Vector2() );
+		this._fovY = uniform( MathUtils.degToRad( camera.fov ) );
+		this._cameraProjectionMatrixInverse = uniform( new Matrix4().copy( camera.projectionMatrixInverse ) );
+		this._cameraProjectionMatrix = uniform( new Matrix4().copy( camera.projectionMatrix ) );
+		this._viewMatrix = uniform( new Matrix4().copy( camera.matrixWorldInverse ) );
+
+		this._renderTarget = new RenderTarget( 1, 1, { depthBuffer: false, type: HalfFloatType } );
+		this._renderTarget.texture.name = 'RecurrentDenoiseNode.output';
+
+		this._material = new NodeMaterial();
+		this._material.name = 'RecurrentDenoise';
+
+		this._textureNode = passTexture( this, this._renderTarget.texture );
+
+	}
+
+	setSize( width, height ) {
+
+		if ( width === null || height === null ) return;
+
+		this._renderTarget.setSize( width, height );
+		this._resolution.value.set( width, height );
+
+	}
+
+	getTextureNode() {
+
+		return this._textureNode;
+
+	}
+
+	/**
+	 * Returns the internal output render target (e.g. for temporal reprojection/SSGI temporal feedback loops).
+	 *
+	 * @returns {RenderTarget}
+	 */
+	getRenderTarget() {
+
+		return this._renderTarget;
+
+	}
+
+	updateBefore( frame ) {
+
+		const { renderer } = frame;
+
+		const drawingBufferSize = renderer.getDrawingBufferSize( _size );
+		const width = drawingBufferSize.width;
+		const height = drawingBufferSize.height;
+
+		const needsRestart = this._renderTarget.width !== width || this._renderTarget.height !== height;
+		this.setSize( width, height );
+
+		this._cameraProjectionMatrix.value.copy( this.camera.projectionMatrix );
+		this._cameraProjectionMatrixInverse.value.copy( this.camera.projectionMatrixInverse );
+		this._viewMatrix.value.copy( this.camera.matrixWorldInverse );
+
+		if ( this.camera.isPerspectiveCamera ) {
+
+			this._fovY.value = MathUtils.degToRad( this.camera.fov );
+
+		}
+
+		if ( frame.frameId !== undefined ) this._noiseIndex.value = frame.frameId;
+
+		// Denoise renders via an internal _quadMesh, not through the RenderPipeline output graph.
+		// Upstream passes (e.g. TemporalReprojectNode) referenced by a PassTextureNode input are
+		// otherwise never scheduled, their updateBefore() would not run and this pass would sample
+		// a stale/empty render target.
+		if ( this.textureNode.isPassTextureNode === true ) frame.updateBeforeNode( this.textureNode.passNode );
+
+		_rendererState = RendererUtils.resetRendererState( renderer, _rendererState );
+
+		if ( needsRestart === true ) {
+
+			renderer.initRenderTarget( this._renderTarget );
+			renderer.setRenderTarget( this._renderTarget );
+			renderer.clear();
+			renderer.setRenderTarget( null );
+
+		}
+
+		renderer.setRenderTarget( this._renderTarget );
+		_quadMesh.material = this._material;
+		_quadMesh.name = 'RecurrentDenoise';
+		_quadMesh.render( renderer );
+		renderer.setRenderTarget( null );
+
+		RendererUtils.restoreRendererState( renderer, _rendererState );
+
+	}
+
+	setup( builder ) {
+
+		const sampleAnalyticNoise = bindAnalyticNoise( this._resolution, NOISE_ROTATION_SEED );
+
+		const noiseRotationMatrix = Fn( ( [ r ] ) => {
+
+			const angle = r.mul( 2 ).mul( PI );
+			return mat2( cos( angle ), sin( angle ).negate(), sin( angle ), cos( angle ) );
+
+		} );
+
+		const sampleTexture = ( uvCoord ) => texture( this.textureNode, uvCoord ).max( 0 );
+		const sampleRaw = ( uvCoord ) => this.rawNode?.sample( uvCoord )?.max( 0 ) ?? vec3( 0 ).max( 0 );
+		const sampleDepth = ( uvCoord ) => this.depthNode?.sample( uvCoord )?.x ?? float( 0.5 );
+		const sampleNormal = ( uvCoord ) => unpackRGBToNormal( this.normalNode?.sample( uvCoord )?.rgb ?? vec3( 0, 0, 1 ) );
+		const sampleRoughnessMetalness = ( uvCoord ) => this.roughnessMetalnessNode?.sample( uvCoord )?.rg ?? vec2( 0, 1 );
+		const sampleDiffuse = ( uvCoord ) => this.diffuseNode?.sample( uvCoord )?.rgb ?? vec3( 0 );
+
+		// Neighborhood luma moments for the adaptive-trust (firefly) gating of the temporal blend.
+		const getNeighborhoodStats = Fn( ( [ uvCoord, centerSample ] ) => {
+
+			const rlSum = float( 0 ).toVar();
+			const rlSumW = float( 0 ).toVar();
+			const meanLuma = float( 0 ).toVar();
+			const m2Luma = float( 0 ).toVar();
+			const lumaCount = float( 0 ).toVar();
+			const hasEnvRay = bool( false ).toVar();
+
+			// 4-tap cross (pre-sampled center + 4 axis neighbors) instead of a full 3×3 — about half the fetches.
+			// The center tap reuses the caller's already-sampled raw texel.
+			const accumulate = ( dx, dy, sample ) => {
+
+				const neighbor = sample !== undefined
+					? sample
+					: texture( this.rawNode, uvCoord.add( vec2( dx, dy ).div( this._resolution ) ) ).max( 0 ).toConst();
+
+				if ( this.alphaSource === 'raylength' ) {
+
+					const sampleRl = neighbor.a.toVar();
+					If( sampleRl.greaterThan( ENV_RAY_LENGTH_THRESHOLD ), () => {
+
+						sampleRl.assign( 0.25 );
+						hasEnvRay.assign( true );
+
+					} );
+					const w = float( 1 ).div( sampleRl.add( 0.001 ) );
+					rlSum.addAssign( sampleRl.mul( w ) );
+					rlSumW.addAssign( w );
+
+				}
+
+				If( this.adaptiveTrust.greaterThan( 0 ), () => {
+
+					const nLuma = luminance( neighbor.rgb );
+					lumaCount.addAssign( 1 );
+					const delta = nLuma.sub( meanLuma ).toConst();
+					meanLuma.addAssign( delta.div( lumaCount ) );
+					m2Luma.addAssign( delta.mul( nLuma.sub( meanLuma ) ) );
+
+				} );
+
+			};
+
+			accumulate( 0, 0, centerSample );
+			accumulate( - 1, 0 );
+			accumulate( 1, 0 );
+			accumulate( 0, - 1 );
+			accumulate( 0, 1 );
+
+			const avgRayLength = this.alphaSource === 'raylength' ? rlSum.div( rlSumW ) : float( 1 );
+			const stddevLuma = sqrt( m2Luma.div( lumaCount.max( 1 ) ) );
+
+			// vec3( avgRayLength, meanLuma, stddevLuma )
+			return vec4( avgRayLength, meanLuma, stddevLuma, hasEnvRay.toFloat() );
+
+		} ).setLayout( {
+			name: 'getNeighborhoodStats',
+			type: 'vec4',
+			inputs: [
+				{ name: 'uvCoord', type: 'vec2' },
+				{ name: 'centerSample', type: 'vec4' }
+			]
+		} );
+
+		const denoiseFn = Fn( ( [ uvCoord ] ) => {
+
+			const result = property( 'vec4' );
+
+			const depth = sampleDepth( uvCoord ).toConst();
+
+			const runDenoise = () => {
+
+				const viewNormal = sampleNormal( uvCoord ).toConst();
+				const worldNormal = viewNormal.transformDirection( this._viewMatrix ).toConst();
+				const texel = sampleTexture( uvCoord ).max( 0 ).toConst();
+
+				const viewPosition = getViewPosition( uvCoord, depth, this._cameraProjectionMatrixInverse ).toConst();
+				const roughnessMetalness = sampleRoughnessMetalness( uvCoord ).toConst();
+				const roughness = roughnessMetalness.g;
+				const metalness = roughnessMetalness.r;
+
+				const noiseTexel = sampleAnalyticNoise( uvCoord, this._noiseIndex );
+				const rotationMatrix = noiseRotationMatrix( noiseTexel.r );
+
+				const frameNum = float( 1 ).div( texel.a );
+				const varianceFactor = getTemporalVarianceFactor( frameNum, this.strength.oneMinus() );
+				const aggressivity = varianceFactor.oneMinus();
+
+				const raw = sampleRaw( uvCoord ).toConst();
+
+				const viewZ = abs( viewPosition.z );
+				const rl = float( 1 ).toVar();
+				const nbhdMeanLuma = float( 0 ).toVar();
+				const nbhdStddevLuma = float( 0 ).toVar();
+				const hasEnvRay = bool( false ).toVar();
+
+				if ( this.alphaSource === 'raylength' ) {
+
+					const stats = getNeighborhoodStats( uvCoord, raw );
+					rl.assign( stats.x );
+					nbhdMeanLuma.assign( stats.y );
+					nbhdStddevLuma.assign( stats.z );
+					hasEnvRay.assign( stats.w.greaterThan( 0.5 ) );
+
+				} else {
+
+					If( this.adaptiveTrust.greaterThan( 0 ), () => {
+
+						const stats = getNeighborhoodStats( uvCoord, raw );
+						nbhdMeanLuma.assign( stats.y );
+						nbhdStddevLuma.assign( stats.z );
+
+					} );
+
+				}
+
+				const tanHalfFovY = this.alphaSource === 'raylength' ? tan( this._fovY.mul( 0.5 ) ).toConst() : null;
+				const hitDistFactor = this.alphaSource === 'raylength'
+					? computeHitDistFactor( rl, viewZ, tanHalfFovY ).toConst()
+					: float( 1 );
+
+				const denoised = texel.rgb.toVar();
+				const totalWeight = float( 1 ).toVar();
+				const denoisedFrame = frameNum.toVar();
+				const totalFrameWeight = float( 1 ).toVar();
+
+				const denoisedRaw = raw.rgb.toVar();
+				const totalWeightRaw = float( 1 ).toVar();
+
+				If( raw.rgb.length().lessThan( 0.0001 ), () => {
+
+					denoisedRaw.assign( vec3( 0 ) );
+					totalWeightRaw.assign( 0 );
+
+				} );
+
+				const avgAo = this.alphaSource === 'ao' ? raw.a.toConst() : float( 1 );
+				const mappedAvgAo = this.alphaSource === 'ao' ? mapAo( avgAo ) : float( 0 );
+
+				const worldRadius = this.radius.mul( WORLD_RADIUS_SCALE ).toVar();
+
+				if ( this.mode === 'specular' ) {
+
+					worldRadius.mulAssign( rl.mul( viewPosition.z.abs() ) );
+					worldRadius.mulAssign( roughness.sqrt().max( 0.01 ) );
+
+				} else {
+
+					worldRadius.mulAssign( avgAo.pow( 2 ).mul( viewPosition.z.abs() ) );
+
+				}
+
+				worldRadius.mulAssign( mix( 1, AGGRESSIVITY_RADIUS_MIN, aggressivity ) );
+
+				const T = vec3( 0 ).toVar();
+				const B = vec3( 0 ).toVar();
+
+				if ( this.mode === 'specular' ) {
+
+					const V = normalize( viewPosition ).negate();
+					const D = getSpecularDominantDirection( viewNormal, V, roughness );
+					const R = reflect( D.negate(), viewNormal );
+					const Tv = normalize( cross( viewNormal, R ) );
+					const Bv = cross( R, Tv );
+					const viewAngle = abs( viewNormal.z ).acos().div( float( Math.PI * 0.5 ) ).clamp( 0, 1 );
+					const skewFactor = mix( 1.0, roughness, viewAngle );
+					T.assign( Tv.mul( skewFactor ) );
+					B.assign( Bv );
+
+				} else {
+
+					const up = vec3( 0, 0, 1 );
+					const Tv = cross( up, viewNormal ).normalize().toVar();
+					If( Tv.length().lessThan( EPSILON ), () => {
+
+						Tv.assign( cross( vec3( 0, 1, 0 ), viewNormal ).normalize() );
+
+					} );
+					T.assign( Tv );
+					B.assign( cross( viewNormal, Tv ).normalize() );
+
+				}
+
+				T.mulAssign( worldRadius );
+				B.mulAssign( worldRadius );
+
+				const centerDiffuse = sampleDiffuse( uvCoord ).toConst();
+				const radiusShrink = float( 1 ).toVar();
+
+				// Directional analog of radiusShrink: an accumulated tangent-space shift that skews
+				// subsequent taps toward directions that yielded high weight (related geometry).
+				const polarBias = vec2( 0 ).toVar();
+
+				const depthWeightScale = this.depthPhi.mul( 500 ).mul( viewNormal.z.abs() ).div( viewPosition.z.abs() );
+
+				// Lobe geometry depends only on per-pixel terms, so compute its falloff constant once here.
+				const lobeFalloff = lobeNormalFalloff( roughness, aggressivity, this.normalPhi.oneMinus() ).toConst();
+
+				Loop( { start: int( 0 ), end: int( KERNEL_SAMPLES ), type: 'int', condition: '<', name: 'i' }, ( { i } ) => {
+
+					const baseOffset = vogelDisk( float( i ), 1 ).toVar();
+					const sampleDir = baseOffset.normalize().toConst();
+
+					// Blend the tap direction toward the polar bias, then restore the Vogel radius and shrink.
+					const skewedDir = mix( sampleDir, polarBias.max( EPSILON ).normalize(), this.adapt.mul( aggressivity )
+						.mul( polarBias.dot( polarBias ).greaterThan( 0.001 ).select( 1, 0 ) ) );
+					const offset = rotationMatrix.mul( skewedDir.mul( baseOffset.length().mul( radiusShrink ) ) ).toVar();
+
+					// Exact per-sample view-space projection (both paths)
+					const sampleViewPos = viewPosition.add( B.mul( offset.x ).add( T.mul( offset.y ) ) );
+					const sampleUv = getScreenPosition( sampleViewPos, this._cameraProjectionMatrix ).toVar();
+					sampleUv.assign( sampleUv.abs().oneMinus().abs().oneMinus().clamp() );
+
+					const neighborColor = sampleTexture( sampleUv ).max( 0 ).toConst();
+
+					// When no raw texture is bound, sampleRaw falls back to the filtered texture at the same UV.
+					const rawNeighborColor = sampleRaw( sampleUv ).max( 0 ).toVar();
+					// if ( this.mode === 'diffuse' ) rawNeighborColor.rgb.assign( mix( neighborColor.rgb, rawNeighborColor.rgb, neighborColor.a ) );
+
+					const nDepth = sampleDepth( sampleUv );
+					const nViewPosition = getViewPosition( sampleUv, nDepth, this._cameraProjectionMatrixInverse ).toConst();
+					const nViewZ = abs( nViewPosition.z ).toConst();
+
+					const kernelDiff = float( 0 ).toVar();
+
+					// Luma edge stopping
+					kernelDiff.addAssign( luminance( rawNeighborColor.rgb ).sub( luminance( raw.rgb ) ).abs().mul( this.lumaPhi ).mul( 10 ) );
+
+					// Diffuse edge stopping (only relevant for specular mode)
+					if ( this.diffuseNode !== null ) {
+
+						kernelDiff.addAssign( ( diffuseColorDistance( centerDiffuse, sampleDiffuse( sampleUv ), float( 0 ) ).mul( this.diffusePhi ).mul( metalness ) ) );
+
+					}
+
+					// AO edge stopping
+					if ( this.alphaSource === 'ao' ) {
+
+						const neighborMappedAo = mapAo( rawNeighborColor.a );
+						// We multiply here with aggressivity as well, since early application of aoW yields noise
+						const aoW = mappedAvgAo.div( mappedAvgAo.add( neighborMappedAo ).add( AO_EDGE_STOPPING_BIAS ) ).mul( this.alphaPhi ).mul( aggressivity );
+
+						kernelDiff.addAssign( ( aoW ) );
+
+
+					} else if ( this.alphaSource === 'raylength' ) {
+
+						// Ray length edge stopping
+
+						const neighborHitDistFactor = computeHitDistFactor( rawNeighborColor.a, nViewZ, tanHalfFovY );
+						const hdfDiff = hitDistFactor.sub( neighborHitDistFactor ).abs();
+
+						const rayLengthFactor = hdfDiff.mul( this.alphaPhi ).div( viewPosition.z.abs() );
+
+						// Env rays are harder to compare so we accept if this sample is an env ray and there is an env ray in the neighborhood
+						kernelDiff.addAssign( rawNeighborColor.a.greaterThan( ENV_RAY_LENGTH_THRESHOLD ).and( hasEnvRay ).select( 1, rayLengthFactor ) );
+
+					}
+
+					// Roughness edge stopping
+					if ( this.mode === 'specular' ) kernelDiff.addAssign( ( abs( roughness.sub( sampleRoughnessMetalness( sampleUv ).g ) ).mul( this.roughnessPhi ) ) );
+
+					const nViewNormal = sampleNormal( sampleUv );
+					const nWorldNormal = nViewNormal.transformDirection( this._viewMatrix );
+					const distToPlane = planeDistance( viewPosition, nViewPosition, viewNormal );
+
+					// Geometric edge stopping (depth and normal)
+					const depthDiff = distToPlane.mul( depthWeightScale );
+					const normalW = lobeNormalWeight( worldNormal, nWorldNormal, lobeFalloff );
+
+					// Sum every negative-exponent edge-stopping term (kernel + depth/plane, plus the SSR hit-distance term)
+					const w = exp( kernelDiff.mul( aggressivity ).add( depthDiff ).negate() ).mul( normalW ).toVar();
+
+					// Feedback to shrink radius based on the weight
+					radiusShrink.assign( mix( radiusShrink, w, this.adapt ) );
+
+					// Polar feedback: skew subsequent taps toward high-weight directions (related geometry)
+					polarBias.assign( mix( polarBias, sampleDir.mul( w.sub( 0.5 ) ), 0.5 ) );
+
+					// to mitigate the effect of fireflies and high variance in recently disoccluded regions, we weigh by the inverse luminance for the first 5 frames
+					w.mulAssign( mix( float( 1 ).div( luminance( rawNeighborColor.rgb ).pow( 2 ).add( 0.01 ) ), 1, frameNum.div( 5 ).min( 1 ) ) );
+
+					denoisedRaw.addAssign( rawNeighborColor.rgb.mul( w ) );
+					totalWeightRaw.addAssign( w );
+
+					denoised.addAssign( neighborColor.rgb.mul( w ) );
+					totalWeight.addAssign( w );
+
+					// Denoising the alpha (accumulation speed), to get smoother disocclusion transitions
+					If( this.smoothDisocclusions, () => {
+
+						const neighborAWeight = neighborColor.a.greaterThan( texel.a ).select( w.mul( 0.33 ), 0 );
+						denoisedFrame.addAssign( float( 1 ).div( neighborColor.a ).mul( neighborAWeight ) );
+						totalFrameWeight.addAssign( neighborAWeight );
+
+					} );
+
+				} );
+
+				denoised.divAssign( totalWeight.max( EPSILON ) );
+				denoised.assign( denoised.max( EPSILON ) );
+				denoisedRaw.divAssign( totalWeightRaw.max( EPSILON ) );
+
+				if ( this.accumulate ) {
+
+					const computedFrame = denoisedFrame.div( totalFrameWeight.max( EPSILON ) );
+					const a = float( 1 ).div( computedFrame.max( EPSILON ) ).toConst();
+
+					if ( this.rawNode !== null ) {
+
+						const blended = karisTemporalBlend(
+							denoised,
+							denoisedRaw,
+							a,
+							this.flickerSuppression,
+							this.adaptiveTrust,
+							nbhdMeanLuma,
+							nbhdStddevLuma
+						);
+
+						result.assign( vec4( blended, a ) );
+
+					} else {
+
+						const finalDenoised = mix( denoised, denoisedRaw, a );
+						result.assign( vec4( finalDenoised, a ) );
+
+					}
+
+				} else {
+
+					result.assign( vec4( denoised, texel.a ) );
+
+				}
+
+			};
+
+			If( depth.greaterThanEqual( 1.0 ), () => {
+
+				Discard();
+
+			} ).Else( runDenoise );
+
+			return result;
+
+		} );
+
+		this._material.fragmentNode = denoiseFn( uv() ).context( builder.getSharedContext() );
+		this._material.needsUpdate = true;
+
+		return this._textureNode;
+
+	}
+
+	dispose() {
+
+		this._renderTarget.dispose();
+		this._material.dispose();
+
+	}
+
+}
+
+export default RecurrentDenoiseNode;
+
+/**
+ * @tsl
+ * @param {Node} inputTexture - Temporally filtered input to denoise (e.g. TRAA output).
+ * @param {Camera} camera
+ * @param {RecurrentDenoiseNodeOptions} [options={}]
+ * @returns {RecurrentDenoiseNode}
+ */
+export const recurrentDenoise = ( inputTexture, camera, options = {} ) => nodeObject( new RecurrentDenoiseNode(
+	toTextureNode( inputTexture ),
+	camera,
+	options
+) );

Fișier diff suprimat deoarece este prea mare
+ 808 - 127
examples/jsm/tsl/display/SSRNode.js


+ 1023 - 0
examples/jsm/tsl/display/TemporalReprojectNode.js

@@ -0,0 +1,1023 @@
+import { EPSILON, Fn, If, abs, convertToTexture, dFdx, dFdy, dot, exp, float, floor, fwidth, getViewPosition, ivec2, luminance, max, min, mix, nodeObject, normalize, passTexture, screenCoordinate, select, smoothstep, sqrt, struct, texture, textureLoad, uniform, unpackRGBToNormal, uv, vec2, vec3, vec4, velocity } from 'three/tsl';
+import { DepthTexture, HalfFloatType, Matrix4, NodeMaterial, NodeUpdateType, QuadMesh, RenderTarget, RendererUtils, TempNode, Vector2, Vector3 } from 'three/webgpu';
+import { ENV_RAY_LENGTH, ENV_RAY_LENGTH_THRESHOLD } from '../utils/SpecularHelpers.js';
+
+// Reprojection helpers
+
+/**
+ * Maps a resolve (screen) texel to the corresponding beauty-input texel when resolutions differ.
+ *
+ * @tsl
+ */
+const beautyTexelFromScreen = Fn( ( [ screenTexel, beautySize, resolveSize ] ) => {
+
+	return ivec2( floor( vec2( screenTexel ).mul( beautySize ).div( resolveSize ) ) );
+
+} ).setLayout( {
+	name: 'beautyTexelFromScreen',
+	type: 'ivec2',
+	inputs: [
+		{ name: 'screenTexel', type: 'ivec2' },
+		{ name: 'beautySize', type: 'vec2' },
+		{ name: 'resolveSize', type: 'vec2' }
+	]
+} );
+
+/**
+ * Projects a world-space position into previous-frame UV coordinates.
+ *
+ * @tsl
+ */
+const projectWorldToUV = Fn( ( [ worldPos, previousViewMatrix, previousProjectionMatrix ] ) => {
+
+	const resultUV = vec2( - 1 ).toVar();
+	const viewSpace = previousViewMatrix.mul( vec4( worldPos, 1.0 ) );
+	const clipSpace = previousProjectionMatrix.mul( viewSpace ).toVar();
+	const clipW = clipSpace.w.toVar();
+
+	If( abs( clipW ).greaterThan( float( 1e-5 ) ), () => {
+
+		const ndc = clipSpace.xyz.div( clipW );
+		resultUV.assign( ndc.xy.mul( 0.5 ).add( 0.5 ) );
+		resultUV.y.assign( resultUV.y.oneMinus() );
+
+	} );
+
+	return resultUV;
+
+} ).setLayout( {
+	name: 'projectWorldToUV',
+	type: 'vec2',
+	inputs: [
+		{ name: 'worldPos', type: 'vec3' },
+		{ name: 'previousViewMatrix', type: 'mat4' },
+		{ name: 'previousProjectionMatrix', type: 'mat4' }
+	]
+} );
+
+// YCoCg variance clipping
+
+/**
+ * @param {import('three/tsl').Node<vec3>} c
+ * @returns {import('three/tsl').Node<vec3>}
+ */
+const rgbToYCoCg = ( c ) => vec3(
+	dot( c, vec3( 0.25, 0.5, 0.25 ) ),
+	dot( c, vec3( 0.5, 0.0, - 0.5 ) ),
+	dot( c, vec3( - 0.25, 0.5, - 0.25 ) )
+);
+
+/**
+ * @param {import('three/tsl').Node<vec3>} c
+ * @returns {import('three/tsl').Node<vec3>}
+ */
+const ycocgToRGB = ( c ) => vec3(
+	c.x.add( c.y ).sub( c.z ),
+	c.x.add( c.z ),
+	c.x.sub( c.y ).sub( c.z )
+);
+
+const VARIANCE_CLIP_LUMA_SCALE = 10;
+
+/**
+ * Inverse-luminance compression for HDR variance clipping (Karis-style).
+ * Bright samples contribute less to neighbourhood moments so sun pixels do not
+ * inflate the YCoCg AABB and cause aggressive clipping flicker.
+ *
+ * @param {import('three/tsl').Node<vec3>} rgb
+ * @param {import('three/tsl').Node<float>} flickerSuppression
+ * @returns {import('three/tsl').Node<vec3>}
+ */
+const dampenForVarianceClip = ( rgb, flickerSuppression ) => {
+
+	const scale = luminance( rgb ).mul( flickerSuppression ).mul( VARIANCE_CLIP_LUMA_SCALE ).add( 1 );
+	return rgb.div( scale );
+
+};
+
+/**
+ * Clips the history sample to the neighbourhood AABB by projecting it toward the box centre.
+ * Reference: https://github.com/playdeadgames/temporal
+ *
+ * @tsl
+ */
+const clipToAABB = Fn( ( [ history, boxMin, boxMax ] ) => {
+
+	const pClip = boxMax.add( boxMin ).mul( 0.5 );
+	const eClip = boxMax.sub( boxMin ).mul( 0.5 ).add( 1e-7 );
+	const vClip = history.sub( pClip );
+	const vUnit = vClip.div( eClip );
+	const absUnit = vUnit.abs();
+	const maxUnit = max( absUnit.x, absUnit.y, absUnit.z );
+
+	return maxUnit.greaterThan( 1 ).select( pClip.add( vClip.div( maxUnit ) ), history );
+
+} ).setLayout( {
+	name: 'clipToAABB',
+	type: 'vec3',
+	inputs: [
+		{ name: 'history', type: 'vec3' },
+		{ name: 'boxMin', type: 'vec3' },
+		{ name: 'boxMax', type: 'vec3' }
+	]
+} );
+
+const neighborhoodStruct = struct( {
+	mean: 'vec3',
+	stdColor: 'vec3',
+	rayLength: 'float',
+	envProbability: 'float',
+	stdDevRayLength: 'float'
+} );
+
+/**
+ * Single 3×3 neighbourhood pass over the beauty buffer. One textureLoad per tap feeds both the
+ * YCoCg variance-clipping box (colour) and the SSR ray-length statistics (alpha), which previously
+ * required two separate 3×3 fetches of the same texture.
+ *
+ * Sampling is done on the beauty-texel grid (`beautyTexel + offset`), so the taps are distinct
+ * source texels even when the beauty buffer is lower resolution than the resolve pass (upscaling).
+ *
+ * @tsl
+ */
+const collectNeighborhood = Fn( ( [ beautyTexture, beautyTexel, inputColor, flickerSuppression ] ) => {
+
+	const offsets = [
+		[ - 1, - 1 ],
+		[ - 1, 1 ],
+		[ 1, - 1 ],
+		[ 1, 1 ],
+		[ 1, 0 ],
+		[ 0, - 1 ],
+		[ 0, 1 ],
+		[ - 1, 0 ],
+	];
+
+	// Colour moments (YCoCg) — centre reuses the already-fetched inputColor.
+	const center = rgbToYCoCg( dampenForVarianceClip( inputColor.rgb, flickerSuppression ) );
+	const moment1 = center.toVar();
+	const moment2 = center.pow2().toVar();
+
+	// Ray-length statistics (Welford) over screen-space hits only.
+	const rayLengthSum = float( 0 ).toVar();
+	const rayLengthCount = float( 0 ).toVar();
+	const meanRayLength = float( 0 ).toVar();
+	const m2RayLength = float( 0 ).toVar();
+
+	const accumulateRayLength = ( alpha ) => {
+
+		If( alpha.lessThan( ENV_RAY_LENGTH_THRESHOLD ), () => {
+
+			rayLengthSum.addAssign( alpha );
+			rayLengthCount.addAssign( 1 );
+
+			const delta = alpha.sub( meanRayLength ).toVar();
+			meanRayLength.addAssign( delta.div( rayLengthCount ) );
+			m2RayLength.addAssign( delta.mul( alpha.sub( meanRayLength ) ) );
+
+		} );
+
+	};
+
+	accumulateRayLength( inputColor.a );
+
+	for ( const [ x, y ] of offsets ) {
+
+		const neighbor = textureLoad( beautyTexture, beautyTexel.add( ivec2( x, y ) ) ).max( 0 ).toVar();
+
+		const c = rgbToYCoCg( dampenForVarianceClip( neighbor.rgb, flickerSuppression ) );
+		moment1.addAssign( c );
+		moment2.addAssign( c.pow2() );
+
+		accumulateRayLength( neighbor.a );
+
+	}
+
+	const N = float( offsets.length + 1 );
+	const mean = moment1.div( N );
+	const stdColor = moment2.div( N ).sub( mean.pow2() ).max( 0 ).sqrt();
+
+	// Continuous environment probability: fraction of the 3×3 neighbourhood that missed in screen space
+	// and fell back to env (0 = all hits, 1 = all env), for smooth reflection/environment transitions.
+	const envProbability = rayLengthCount.div( float( 9 ) ).oneMinus();
+	const rayLength = rayLengthCount.lessThan( 0.5 ).select( float( ENV_RAY_LENGTH ), rayLengthSum.div( max( rayLengthCount, float( 1e-4 ) ) ) );
+	const stdDevRayLength = sqrt( m2RayLength.div( max( rayLengthCount, float( 1.0 ) ) ) ).max( 1e-3 );
+
+	return neighborhoodStruct( mean, stdColor, rayLength, envProbability, stdDevRayLength );
+
+} );
+
+/**
+ * Variance clipping in YCoCg space (Salvi, GDC 2016). Uses the colour moments gathered by
+ * {@link collectNeighborhood}; `gamma` widens the AABB and is kept out of the gather so the
+ * neighbourhood pass stays independent of the per-pixel motion factor.
+ *
+ * @tsl
+ */
+const applyVarianceClipping = Fn( ( [ historyColor, mean, stdColor, gamma, flickerSuppression ] ) => {
+
+	const stddev = stdColor.mul( gamma );
+	const boxMin = mean.sub( stddev );
+	const boxMax = mean.add( stddev );
+
+	const historyRGB = historyColor.rgb.toVar();
+	const historyScale = luminance( historyRGB ).mul( flickerSuppression ).mul( VARIANCE_CLIP_LUMA_SCALE ).add( 1 );
+	const clipped = clipToAABB( rgbToYCoCg( historyRGB.div( historyScale ) ), boxMin, boxMax );
+
+	return ycocgToRGB( clipped ).mul( historyScale );
+
+} );
+
+// History sampling
+
+const bilinearTapStruct = struct( { color: 'vec4', weight: 'float', confidence: 'float' } );
+const historyResultStruct = struct( { color: 'vec4', tapConfidence: 'float', minConfidence: 'float' } );
+
+/**
+ * Single bilinear history tap with plane-distance and normal confidence.
+ *
+ * @tsl
+ */
+const sampleBilinearTap = Fn( ( [
+	historyTexture,
+	previousDepthNode,
+	previousNormalNode,
+	resolution,
+	previousProjectionMatrixInverse,
+	previousCameraWorldMatrix,
+	previousCameraViewMatrix,
+	tapCoord,
+	bilinearWeight,
+	worldPosition,
+	worldNormal
+] ) => {
+
+	const color = textureLoad( historyTexture, tapCoord ).max( 0 );
+	const reprojDepth = textureLoad( previousDepthNode, tapCoord ).r;
+	const reprojViewPos = getViewPosition( vec2( tapCoord ).add( 0.5 ).div( resolution ), reprojDepth, previousProjectionMatrixInverse );
+	const reprojWorldPos = previousCameraWorldMatrix.mul( vec4( reprojViewPos, 1.0 ) ).xyz;
+	const reprojWorldNorm = unpackRGBToNormal( textureLoad( previousNormalNode, tapCoord ).rgb ).transformDirection( previousCameraViewMatrix );
+
+	const planeDiff = abs( dot( reprojWorldPos.sub( worldPosition ), worldNormal ) ).toVar();
+	planeDiff.divAssign( abs( reprojViewPos.z ) );
+	const normalConfidence = smoothstep( 0.95, 0.999, reprojWorldNorm.dot( worldNormal ) );
+	const confidence = smoothstep( 0, 0.01, planeDiff ).oneMinus().mul( normalConfidence );
+	const weight = bilinearWeight.mul( confidence );
+
+	return bilinearTapStruct( color.mul( weight ), weight, confidence );
+
+} );
+
+/**
+ * @param {Object} ctx - Shared {@link sampleBilinearTap} inputs plus `reprojICoord`.
+ * @param {import('three/tsl').Node<ivec2>} tapOffset
+ * @param {import('three/tsl').Node<float>} bilinearWeight
+ */
+function bilinearHistoryTap( ctx, tapOffset, bilinearWeight ) {
+
+	return sampleBilinearTap(
+		ctx.historyTexture,
+		ctx.previousDepthNode,
+		ctx.previousNormalNode,
+		ctx.resolution,
+		ctx.previousProjectionMatrixInverse,
+		ctx.previousCameraWorldMatrix,
+		ctx.previousCameraViewMatrix,
+		ctx.reprojICoord.add( tapOffset ),
+		bilinearWeight,
+		ctx.worldPosition,
+		ctx.worldNormal
+	);
+
+}
+
+/**
+ * Geometrically-weighted 4-tap bilinear history sample.
+ *
+ * @tsl
+ */
+const sampleHistory4Tap = Fn( ( [
+	historyTexture,
+	previousDepthNode,
+	previousNormalNode,
+	resolution,
+	previousProjectionMatrixInverse,
+	previousCameraWorldMatrix,
+	previousCameraViewMatrix,
+	reprojUV,
+	worldPosition,
+	worldNormal,
+	inputColor
+] ) => {
+
+	const reprojPixelCoord = reprojUV.mul( resolution ).sub( 0.5 ).toVar();
+	const reprojICoord = ivec2( floor( reprojPixelCoord ) );
+	const fCoord = reprojPixelCoord.fract();
+
+	const fx = fCoord.x;
+	const fy = fCoord.y;
+	const f00 = float( 1 ).sub( fx ).mul( float( 1 ).sub( fy ) );
+	const f10 = fx.mul( float( 1 ).sub( fy ) );
+	const f01 = float( 1 ).sub( fx ).mul( fy );
+	const f11 = fx.mul( fy );
+
+	const tapCtx = {
+		historyTexture,
+		previousDepthNode,
+		previousNormalNode,
+		resolution,
+		previousProjectionMatrixInverse,
+		previousCameraWorldMatrix,
+		previousCameraViewMatrix,
+		reprojICoord,
+		worldPosition,
+		worldNormal
+	};
+
+	const tap00 = bilinearHistoryTap( tapCtx, ivec2( 0, 0 ), f00 );
+	const tap10 = bilinearHistoryTap( tapCtx, ivec2( 1, 0 ), f10 );
+	const tap01 = bilinearHistoryTap( tapCtx, ivec2( 0, 1 ), f01 );
+	const tap11 = bilinearHistoryTap( tapCtx, ivec2( 1, 1 ), f11 );
+
+	const colorSum = tap00.get( 'color' ).add( tap10.get( 'color' ) ).add( tap01.get( 'color' ) ).add( tap11.get( 'color' ) );
+	const weightSum = tap00.get( 'weight' ).add( tap10.get( 'weight' ) ).add( tap01.get( 'weight' ) ).add( tap11.get( 'weight' ) );
+	const maxConf = max( max( tap00.get( 'confidence' ), tap10.get( 'confidence' ) ), max( tap01.get( 'confidence' ), tap11.get( 'confidence' ) ) );
+	const minConf = min( min( tap00.get( 'confidence' ), tap10.get( 'confidence' ) ), min( tap01.get( 'confidence' ), tap11.get( 'confidence' ) ) );
+
+	return historyResultStruct(
+		select( weightSum.greaterThan( 0.01 ), colorSum.div( weightSum ), vec4( inputColor.rgb, float( 1 ) ) ),
+		maxConf,
+		minConf
+	);
+
+} );
+
+// Diffuse reprojection
+
+/**
+ * Reprojection-stretch confidence — detects history magnification (surface stretching).
+ *
+ * Differentiates the per-pixel history UV with hardware screen-space derivatives to form the
+ * reprojection Jacobian `J = ∂(historyPixel)/∂(screenPixel)`, then returns its **minimum
+ * singular value**, clamped to `[0,1]`.
+ *
+ * `σ_min < 1` means the most-stretched axis magnifies history — a few history pixels are smeared
+ * over many current pixels (e.g. a surface seen at grazing in the previous frame, face-on now), so
+ * history is undersampled and its confidence should be reduced. `σ_min ≥ 1` (history minified) is
+ * safe and clamps to 1. Using the minimum singular value rather than the Jacobian determinant
+ * catches anisotropic 1-D stretch that an area-only measure would smear out.
+ *
+ * Works for any reprojection (surface-velocity or parallax hit-point) since it differentiates the
+ * final history UV, so the same factor applies to both the diffuse and specular paths.
+ *
+ * @tsl
+ */
+const reprojectionStretchConfidence = Fn( ( [ historyUV, resolution ] ) => {
+
+	// Jacobian columns in pixels: how the history sample position moves per screen pixel.
+	const jx = dFdx( historyUV ).mul( resolution ).toVar();
+	const jy = dFdy( historyUV ).mul( resolution ).toVar();
+
+	// Singular values of the 2×2 J are sqrt( eigenvalues of JᵀJ ), with
+	// trace( JᵀJ ) = ‖J‖²_F and det( JᵀJ ) = det( J )².
+	const det = jx.x.mul( jy.y ).sub( jx.y.mul( jy.x ) );
+	const fro2 = dot( jx, jx ).add( dot( jy, jy ) );
+	const disc = fro2.mul( fro2 ).mul( 0.25 ).sub( det.mul( det ) ).max( 0 ).sqrt();
+	const sigMin = fro2.mul( 0.5 ).sub( disc ).max( 0 ).sqrt();
+
+	return sigMin.saturate();
+
+} );
+
+// Specular reprojection
+
+/**
+ * Parallax-corrected hit-point reprojection into previous-frame UVs.
+ *
+ * @tsl
+ */
+const reprojectHitPoint = Fn( ( [
+	rayOrig,
+	rayLength,
+	cameraWorldPosition,
+	previousViewMatrix,
+	previousProjectionMatrix
+] ) => {
+
+	const cameraRay = normalize( rayOrig.sub( cameraWorldPosition ) ).toVar();
+	const parallaxHitPoint = rayOrig.add( cameraRay.mul( rayLength ) );
+
+	return projectWorldToUV( parallaxHitPoint, previousViewMatrix, previousProjectionMatrix );
+
+} );
+
+/**
+ * Converts screen-space velocity (NDC derivative) to a UV reprojection offset.
+ *
+ * @tsl
+ */
+const velocityToUVOffset = Fn( ( [ velocity ] ) => {
+
+	return velocity.mul( vec2( 0.5, - 0.5 ) );
+
+} ).setLayout( {
+	name: 'velocityToUVOffset',
+	type: 'vec2',
+	inputs: [ { name: 'velocity', type: 'vec2' } ]
+} );
+
+/**
+ * Current and previous-frame camera matrices for temporal reprojection passes.
+ *
+ * @param {import('three').Camera} camera
+ */
+function bindTemporalCameraUniforms( camera ) {
+
+	const worldMatrix = uniform( new Matrix4().copy( camera.matrixWorld ) );
+	const viewMatrix = uniform( new Matrix4().copy( camera.matrixWorldInverse ) );
+	const projectionMatrix = uniform( new Matrix4().copy( camera.projectionMatrix ) );
+	const projectionMatrixInverse = uniform( new Matrix4().copy( camera.projectionMatrixInverse ) );
+	const worldPosition = uniform( new Vector3().copy( camera.position ) );
+
+	const previousWorldMatrix = uniform( new Matrix4().copy( camera.matrixWorld ) );
+	const previousViewMatrix = uniform( new Matrix4().copy( camera.matrixWorldInverse ) );
+	const previousProjectionMatrix = uniform( new Matrix4().copy( camera.projectionMatrix ) );
+	const previousProjectionMatrixInverse = uniform( new Matrix4().copy( camera.projectionMatrixInverse ) );
+
+	/**
+	 * @param {import('three').Camera} cam
+	 */
+	function updateFromCamera( cam ) {
+
+		previousWorldMatrix.value.copy( worldMatrix.value );
+		previousViewMatrix.value.copy( viewMatrix.value );
+		previousProjectionMatrix.value.copy( projectionMatrix.value );
+		previousProjectionMatrixInverse.value.copy( projectionMatrixInverse.value );
+
+		worldMatrix.value.copy( cam.matrixWorld );
+		viewMatrix.value.copy( cam.matrixWorldInverse );
+		projectionMatrix.value.copy( cam.projectionMatrix );
+		projectionMatrixInverse.value.copy( cam.projectionMatrixInverse );
+		worldPosition.value.copy( cam.position );
+
+	}
+
+	return {
+		worldMatrix,
+		viewMatrix,
+		projectionMatrix,
+		projectionMatrixInverse,
+		worldPosition,
+		previousWorldMatrix,
+		previousViewMatrix,
+		previousProjectionMatrix,
+		previousProjectionMatrixInverse,
+		updateFromCamera
+	};
+
+}
+
+const _quadMesh = /*@__PURE__*/ new QuadMesh();
+const _size = /*@__PURE__*/ new Vector2();
+
+let _rendererState;
+
+const DEFAULT_MAX_VELOCITY_LENGTH = 128;
+const VARIANCE_GAMMA_MIN = 0.5;
+const VARIANCE_GAMMA_MAX = 1;
+
+/**
+ * @typedef {'diffuse' | 'specular'} TemporalReprojectMode
+ */
+
+/**
+ * @typedef {Object} TemporalReprojectNodeOptions
+ * @property {TemporalReprojectMode} [mode='diffuse'] - `diffuse` for SSGI/scene colour; `specular` for SSR reflections.
+ * @property {boolean} [hitPointReprojection] - Parallax hit-point reprojection (specular mode only). Defaults to `true` in specular mode.
+ * @property {boolean} [accumulate=false] - When `true`, history is stored in this pass (classic temporal resolve). When `false`,
+ * use {@link TemporalReprojectNode#setHistoryTexture} to read history from another pass (e.g. denoise output).
+ */
+
+/**
+ * Temporal reprojection pass for denoising screen-space effects (SSGI, SSR, etc.).
+ *
+ * Both modes share geometrically-weighted 4-tap bilinear history sampling and YCoCg variance clipping.
+ * Surface velocity reprojection is always sampled first. Specular mode then blends in
+ * hit-point parallax history on top of that surface result.
+ * Diffuse mode applies velocity-field divergence to detect surface stretching.
+ *
+ * Unlike jitter-based TAA/TAAU, this node does not apply camera sub-pixel jitter — it only
+ * reprojects and accumulates history using motion vectors.
+ *
+ * References:
+ * - {@link https://alextardif.com/TAA.html}
+ * - {@link https://www.elopezr.com/temporal-aa-and-the-quest-for-the-holy-trail/}
+ *
+ * @augments TempNode
+ * @three_import import { temporalReproject } from 'three/addons/tsl/display/TemporalReprojectNode.js';
+ */
+class TemporalReprojectNode extends TempNode {
+
+	static get type() {
+
+		return 'TemporalReprojectNode';
+
+	}
+
+	/**
+	 * @param {import('three/tsl').TextureNode} beautyNode
+	 * @param {import('three/tsl').TextureNode} depthNode
+	 * @param {import('three/tsl').TextureNode} normalNode
+	 * @param {import('three/tsl').TextureNode} velocityNode
+	 * @param {import('three').Camera} camera
+	 * @param {TemporalReprojectNodeOptions} [options]
+	 */
+	constructor( beautyNode, depthNode, normalNode, velocityNode, camera, options = {} ) {
+
+		super( 'vec4' );
+
+		const {
+			mode = 'diffuse',
+			hitPointReprojection = mode === 'specular',
+			accumulate = false
+		} = options;
+
+		if ( mode !== 'specular' && mode !== 'diffuse' ) {
+
+			throw new Error( 'TemporalReprojectNode: `mode` must be `diffuse` or `specular`.' );
+
+		}
+
+		this.isTemporalReprojectNode = true;
+		this.updateBeforeType = NodeUpdateType.FRAME;
+
+		this.beautyNode = beautyNode;
+		this.depthNode = depthNode;
+		this.normalNode = normalNode;
+		this.velocityNode = velocityNode;
+		this.camera = camera;
+
+		/**
+		 * @type {TemporalReprojectMode}
+		 */
+		this.mode = mode;
+
+		/**
+		 * When `true`, resolve output is copied into the internal history buffer each frame.
+		 * When `false`, history is supplied externally via {@link TemporalReprojectNode#setHistoryTexture}.
+		 *
+		 * @type {boolean}
+		 */
+		this.accumulate = accumulate;
+
+		this.maxVelocityLength = DEFAULT_MAX_VELOCITY_LENGTH;
+
+		this._resolution = uniform( new Vector2() );
+
+		this._cameraUniforms = bindTemporalCameraUniforms( camera );
+
+		this.maxFrames = uniform( 32 );
+		this.hitPointReprojection = uniform( hitPointReprojection, 'bool' );
+		this.clampIntensity = uniform( 1 );
+		this.flickerSuppression = uniform( 1 );
+
+		this._historyRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, type: HalfFloatType, depthTexture: new DepthTexture() } );
+		this._historyRenderTarget.texture.name = 'TemporalReprojectNode.history';
+		this._historyTextureNode = texture( this._historyRenderTarget.texture );
+
+		this._resolveRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, type: HalfFloatType } );
+		this._resolveRenderTarget.texture.name = 'TemporalReprojectNode.resolve';
+
+		this._resolveMaterial = new NodeMaterial();
+		this._resolveMaterial.name = 'TemporalReproject.resolve';
+
+		this._seedMaterial = new NodeMaterial();
+		this._seedMaterial.name = 'TemporalReproject.seed';
+
+		this._textureNode = passTexture( this, this._resolveRenderTarget.texture );
+
+		this._originalProjectionMatrix = new Matrix4();
+
+		this._placeholderPreviousDepthTexture = new DepthTexture( 1, 1 );
+		this._previousDepthNode = texture( this._placeholderPreviousDepthTexture );
+		this._previousNormalTexture = normalNode.value.clone();
+		this._previousNormalNode = texture( this._previousNormalTexture );
+
+		this._needsPostProcessingSync = false;
+		this._externalHistoryTexture = null;
+
+		this._syncHistoryTextureBinding();
+
+	}
+
+	getTextureNode() {
+
+		return this._textureNode;
+
+	}
+
+	setSize( width, height ) {
+
+		if ( width === null || height === null ) return;
+
+		this._historyRenderTarget.setSize( width, height );
+		this._resolveRenderTarget.setSize( width, height );
+
+		this._resolution.value.set( width, height );
+
+	}
+
+	setViewOffset() {
+
+		this.camera.updateProjectionMatrix();
+		this._originalProjectionMatrix.copy( this.camera.projectionMatrix );
+		velocity.setProjectionMatrix( this._originalProjectionMatrix );
+
+	}
+
+	clearViewOffset() {
+
+		velocity.setProjectionMatrix( null );
+
+	}
+
+	updateBefore( frame ) {
+
+		const { renderer } = frame;
+
+		this._cameraUniforms.updateFromCamera( this.camera );
+
+		const drawingBufferSize = renderer.getDrawingBufferSize( _size );
+		const width = drawingBufferSize.width;
+		const height = drawingBufferSize.height;
+
+		if ( this._needsPostProcessingSync === true ) {
+
+			this.setViewOffset();
+			this._needsPostProcessingSync = false;
+
+		}
+
+		_rendererState = RendererUtils.resetRendererState( renderer, _rendererState );
+
+		const needsRestart = this._historyRenderTarget.width !== width || this._historyRenderTarget.height !== height;
+		this.setSize( width, height );
+
+		let historySwappedForRestart = false;
+
+		if ( needsRestart === true ) {
+
+			renderer.initRenderTarget( this._historyRenderTarget );
+			renderer.initRenderTarget( this._resolveRenderTarget );
+
+			this._previousNormalTexture.dispose();
+			this._previousNormalTexture = this.normalNode.value.clone();
+			this._previousNormalNode.value = this._previousNormalTexture;
+
+			// External history (e.g. denoise feedback) is stale at the old resolution — use
+			// freshly seeded internal history for this frame instead.
+			if ( this.accumulate === false && this._externalHistoryTexture !== null ) {
+
+				this._historyTextureNode.value = this._historyRenderTarget.texture;
+				historySwappedForRestart = true;
+
+			}
+
+			renderer.setRenderTarget( this._historyRenderTarget );
+			_quadMesh.material = this._seedMaterial;
+			_quadMesh.name = 'TemporalReproject.seed';
+			_quadMesh.render( renderer );
+			renderer.setRenderTarget( null );
+
+		}
+
+		renderer.setRenderTarget( this._resolveRenderTarget );
+
+		_quadMesh.material = this._resolveMaterial;
+		_quadMesh.name = 'TemporalReproject';
+		_quadMesh.render( renderer );
+		renderer.setRenderTarget( null );
+
+		if ( historySwappedForRestart === true ) {
+
+			this._syncHistoryTextureBinding();
+
+		} else if ( this.accumulate === true ) {
+
+			renderer.copyTextureToTexture( this._resolveRenderTarget.texture, this._historyRenderTarget.texture );
+
+		}
+
+		const currentDepth = this.depthNode.value;
+		const srcW = currentDepth.image !== null && currentDepth.image !== undefined ? currentDepth.image.width : 0;
+		const srcH = currentDepth.image !== null && currentDepth.image !== undefined ? currentDepth.image.height : 0;
+
+		if ( srcW > 0 && srcH > 0 ) {
+
+			renderer.copyTextureToTexture( currentDepth, this._historyRenderTarget.depthTexture );
+			renderer.copyTextureToTexture( this.normalNode.value, this._previousNormalTexture );
+
+			this._previousDepthNode.value = this._historyRenderTarget.depthTexture;
+
+		}
+
+		RendererUtils.restoreRendererState( renderer, _rendererState );
+
+	}
+
+	setup( builder ) {
+
+		const renderPipeline = builder.context.renderPipeline;
+
+		if ( renderPipeline ) {
+
+			this._needsPostProcessingSync = true;
+
+			renderPipeline.context.onBeforeRenderPipeline = () => {
+
+				this.setViewOffset();
+
+			};
+
+			renderPipeline.context.onAfterRenderPipeline = () => {
+
+				this.clearViewOffset();
+
+			};
+
+		}
+
+		this._resolveMaterial.fragmentNode = this._buildResolve( builder );
+		this._resolveMaterial.needsUpdate = true;
+
+		this._buildSeed( builder );
+
+		return this._textureNode;
+
+	}
+
+	_buildSeed( builder ) {
+
+		const seed = Fn( () => {
+
+			const screenTexel = ivec2( floor( screenCoordinate.xy.sub( 0.5 ) ) );
+			const beautySize = this.beautyNode.size();
+			const beautyTexel = beautyTexelFromScreen( screenTexel, beautySize, this._resolution );
+
+			return textureLoad( this.beautyNode, beautyTexel ).max( 0 );
+
+		} );
+
+		this._seedMaterial.fragmentNode = seed().context( builder.getSharedContext() );
+		this._seedMaterial.needsUpdate = true;
+
+	}
+
+	_buildResolve( builder ) {
+
+		const isSpecular = this.mode === 'specular';
+		const cameraUniforms = this._cameraUniforms;
+
+		const resolve = Fn( () => {
+
+			const uvNode = uv();
+
+			const screenTexel = ivec2( floor( screenCoordinate.xy.sub( 0.5 ) ) );
+			const depth = textureLoad( this.depthNode, screenTexel ).r.toVar();
+			depth.greaterThanEqual( 1.0 ).discard();
+
+			const beautySize = this.beautyNode.size();
+			const beautyTexel = beautyTexelFromScreen( screenTexel, beautySize, this._resolution );
+
+			const inputColor = textureLoad( this.beautyNode, beautyTexel ).max( 0 ).toVar();
+			const viewNormal = unpackRGBToNormal( textureLoad( this.normalNode, screenTexel ).rgb ).toVar();
+
+			// Shared 3×3 beauty fetch: feeds both the variance-clip box and the SSR ray-length stats.
+			const neighborhood = collectNeighborhood( this.beautyNode, beautyTexel, inputColor, this.flickerSuppression );
+			const worldNormal = viewNormal.transformDirection( cameraUniforms.viewMatrix ).toVar();
+
+			const viewPosition = getViewPosition( uvNode, depth, cameraUniforms.projectionMatrixInverse ).toVar();
+			const worldPosition = cameraUniforms.worldMatrix.mul( vec4( viewPosition, 1.0 ) ).xyz.toVar();
+
+			const sampleHistory = ( reprojUV ) => sampleHistory4Tap(
+				this._historyTextureNode,
+				this._previousDepthNode,
+				this._previousNormalNode,
+				this._resolution,
+				cameraUniforms.previousProjectionMatrixInverse,
+				cameraUniforms.previousWorldMatrix,
+				cameraUniforms.previousViewMatrix,
+				reprojUV,
+				worldPosition,
+				worldNormal,
+				inputColor.rgb
+			);
+
+			// Surface-velocity reprojection — the base history for both modes. `historyUV` is
+			// reused below for the stretch guard, so it is computed once here.
+			const velocityOff = velocityToUVOffset( textureLoad( this.velocityNode, screenTexel ).xy ).toVar();
+			const motionFactor = velocityOff.mul( this._resolution ).length().div( float( this.maxVelocityLength ) ).saturate();
+
+			const historyUV = uvNode.sub( velocityOff ).toVar();
+			const surf = sampleHistory( historyUV );
+
+			const historyColor = surf.get( 'color' ).toVar();
+			const totalConfidence = float( 1 ).toVar();
+			const historyTrust = float( 0 ).toVar();
+
+			// Specular: blend parallax hit-point history on top of the surface result. Returns the resolved
+			// color (rgb from the blend, alpha from the surface tap), its confidence, and the hit-vs-surface trust.
+			const resolveSpecularHistory = () => {
+
+				const surfValid = historyUV.x.greaterThanEqual( 0 ).and( historyUV.x.lessThanEqual( 1 ) )
+					.and( historyUV.y.greaterThanEqual( 0 ) ).and( historyUV.y.lessThanEqual( 1 ) );
+
+				const historyUV_hit = reprojectHitPoint(
+					worldPosition,
+					neighborhood.get( 'rayLength' ),
+					cameraUniforms.worldPosition,
+					cameraUniforms.previousViewMatrix,
+					cameraUniforms.previousProjectionMatrix
+				).toVar();
+
+				const hitValid = historyUV_hit.x.greaterThanEqual( 0 ).and( historyUV_hit.x.lessThanEqual( 1 ) )
+					.and( historyUV_hit.y.greaterThanEqual( 0 ) ).and( historyUV_hit.y.lessThanEqual( 1 ) )
+					.and( this.hitPointReprojection );
+
+				const hit = sampleHistory( historyUV_hit );
+
+				const hcHit = hit.get( 'color' ).rgb.max( 0 );
+				const hcSurf = surf.get( 'color' ).rgb.max( 0 );
+
+				const confHit = hitValid.select( hit.get( 'tapConfidence' ), float( 0 ) );
+				const confSurf = surfValid.select( surf.get( 'tapConfidence' ), float( 0 ) );
+				const minConfHit = hit.get( 'minConfidence' );
+
+				const reflectionEdgeFactor = neighborhood.get( 'stdDevRayLength' );
+				reflectionEdgeFactor.assign( reflectionEdgeFactor.mul( motionFactor.mul( 100 ).min( 1 ) ).mul( 3.5 ).min( 1 ).oneMinus() );
+
+				const curvatureFactor = fwidth( worldNormal.xyz ).length().mul( 50 ).clamp();
+
+				const envProbability = neighborhood.get( 'envProbability' );
+
+				const wHitRaw = minConfHit
+					.mul( reflectionEdgeFactor )
+					.mul( curvatureFactor.oneMinus() )
+					.mul( confHit ).toConst();
+
+				const wHit = wHitRaw.mul( envProbability.pow2().oneMinus() );
+				const wSurf = wHit.oneMinus().mul( confSurf );
+				const wSum = max( wHit.add( wSurf ), float( EPSILON ) );
+
+				const color = vec4(
+					hcHit.mul( wHit ).add( hcSurf.mul( wSurf ) ).div( wSum ),
+					surf.get( 'color' ).a
+				).toVar();
+				const confidence = confHit.mul( wHit ).add( confSurf.mul( wSurf ) ).div( wSum );
+
+				// Near-black blend means neither tap was usable — fall back to the current frame.
+				If( color.rgb.length().lessThan( EPSILON ), () => {
+
+					color.assign( vec4( inputColor.rgb, 1 ) );
+
+				} );
+
+				return { color, confidence, trust: wHitRaw }; // without env probability
+
+			};
+
+			if ( isSpecular ) {
+
+				const spec = resolveSpecularHistory();
+				historyColor.assign( spec.color );
+				totalConfidence.assign( spec.confidence );
+				historyTrust.assign( spec.trust );
+
+			}
+
+			const a = historyColor.a.max( EPSILON );
+
+			// Universal stretch guard: reduce confidence where a "small area" is projected over a "large area".
+			const stretchConfidence = reprojectionStretchConfidence( historyUV, this._resolution );
+			totalConfidence.mulAssign( stretchConfidence.pow( 2 ) );
+
+			const varianceGamma = mix( float( VARIANCE_GAMMA_MIN ), float( VARIANCE_GAMMA_MAX ), motionFactor.oneMinus().pow2() );
+
+			const clippedRGB = applyVarianceClipping(
+				historyColor,
+				neighborhood.get( 'mean' ),
+				neighborhood.get( 'stdColor' ),
+				varianceGamma,
+				this.flickerSuppression
+			).toVar();
+
+			const clampIntensity = this.clampIntensity.mul( max( motionFactor.mul( 10 ).min( 1 ), 0.25 ) ).mul(
+				float( 1 ).add( stretchConfidence.oneMinus().add( historyTrust.oneMinus() ).clamp() )
+			);
+			const originalHistoryColor = vec3( historyColor.rgb );
+			historyColor.rgb.assign( mix( historyColor.rgb, clippedRGB, clampIntensity ) );
+
+			totalConfidence.mulAssign( exp( originalHistoryColor.sub( clippedRGB ).length().mul( clampIntensity ).mul( 30 ).negate() ) );
+			totalConfidence.mulAssign( mix( float( 1 ), historyTrust.mul( 0.05 ).add( 0.95 ), motionFactor.mul( 100 ).clamp() ) );
+
+			If( totalConfidence.lessThan( EPSILON ), () => {
+
+				historyColor.assign( vec4( inputColor.rgb, 1 ) );
+
+			} );
+
+			const currentFrameCount = float( 1 ).div( a ).mul( totalConfidence ).add( 1 ).min( this.maxFrames ).toVar();
+
+			if ( isSpecular ) {
+
+				// A black current sample means no reflection was found this frame (a miss, not dark).
+				// Since no valid sample was found, decrement the frame count (as the next accumulating pass will increase it).
+				If( inputColor.rgb.length().lessThan( EPSILON ), () => {
+
+					currentFrameCount.assign( currentFrameCount.sub( 1 ).max( 1 ) );
+
+				} );
+
+			}
+
+			return vec4( historyColor.rgb, float( 1 ).div( currentFrameCount ) );
+
+		} );
+
+		return resolve().context( builder.getSharedContext() );
+
+	}
+
+	_syncHistoryTextureBinding() {
+
+		if ( this.accumulate === true || this._externalHistoryTexture === null ) {
+
+			this._historyTextureNode.value = this._historyRenderTarget.texture;
+
+		} else {
+
+			this._historyTextureNode.value = this._externalHistoryTexture;
+
+		}
+
+	}
+
+	/**
+	 * Supplies an external history source (e.g. a {@link RecurrentDenoiseNode} or its
+	 * texture). Only used when {@link TemporalReprojectNode#accumulate} is `false`.
+	 *
+	 * @param {?(Object|import('three').Texture)} source
+	 */
+	setHistoryTexture( source ) {
+
+		this._externalHistoryTexture = ( source && typeof source.getRenderTarget === 'function' )
+			? source.getRenderTarget().texture
+			: source;
+		this._syncHistoryTextureBinding();
+
+	}
+
+	dispose() {
+
+		this._previousNormalTexture.dispose();
+
+		if ( this._previousDepthNode.value !== this._historyRenderTarget.depthTexture ) {
+
+			this._previousDepthNode.value.dispose();
+
+		}
+
+		if ( this._placeholderPreviousDepthTexture !== this._historyRenderTarget.depthTexture ) {
+
+			this._placeholderPreviousDepthTexture.dispose();
+
+		}
+
+		this._historyRenderTarget.dispose();
+		this._resolveRenderTarget.dispose();
+		this._resolveMaterial.dispose();
+		this._seedMaterial.dispose();
+
+	}
+
+}
+
+export default TemporalReprojectNode;
+
+/**
+ * @param {import('three/tsl').TextureNode} beautyNode
+ * @param {import('three/tsl').TextureNode} depthNode
+ * @param {import('three/tsl').TextureNode} normalNode
+ * @param {import('three/tsl').TextureNode} velocityNode
+ * @param {import('three').Camera} camera
+ * @param {TemporalReprojectNodeOptions} [options]
+ * @returns {TemporalReprojectNode}
+ */
+export const temporalReproject = ( beautyNode, depthNode, normalNode, velocityNode, camera, options = {} ) => nodeObject( new TemporalReprojectNode(
+	convertToTexture( beautyNode ),
+	nodeObject( depthNode ),
+	nodeObject( normalNode ),
+	nodeObject( velocityNode ),
+	camera,
+	options
+) );

+ 51 - 0
examples/jsm/tsl/utils/RNoise.js

@@ -0,0 +1,51 @@
+import { float, Fn, fract, int, vec2, vec4 } from 'three/tsl';
+
+/**
+ * Returns a TSL function that samples texture-free analytic R² noise.
+ * Index 0 uses continuous screen pixels; other indices tile-shift with an R²
+ * sequence into a 64×64 period. Values are four independent R² dimensions
+ * hashed from the sample coordinates.
+ *
+ * @param {import('three/tsl').UniformNode<import('three').Vector2>} resolution
+ * @param {number} [seed=0] - Added to the coordinate hash so each pass gets an independent R² phase.
+ */
+export function bindAnalyticNoise( resolution, seed = 0 ) {
+
+	const seedOffset = int( seed );
+
+	const r4 = ( coords ) => {
+
+		const P = 1.32471795724474602596;
+
+		const t = coords.x.mul( 1 / P ).add( coords.y.mul( 1 / P ** 2 ) ).add( float( seed ) );
+
+		return vec4(
+			fract( t.mul( P ).mul( 1 / P ) ),
+			fract( t.mul( P * 2 ).mul( 1 / P ** 2 ) ),
+			fract( t.mul( P * 3 ).mul( 0.4198754210 ) ), // this is not 1 / P ** 3, however this magic constant gives better noise
+			fract( t.mul( P * 4 ).mul( 1 / P ** 3 ) )
+		);
+
+	};
+
+	return Fn( ( [ uvCoord, sampleIndex ] ) => {
+
+		const index = int( sampleIndex ).add( seedOffset );
+		const noise = vec4().toVar();
+
+		const tileSize = float( 32 );
+
+		const screenPixel = uvCoord.mul( resolution ).floor();
+		const offset = fract( vec2(
+			float( index ).mul( 0.7548776662 ),
+			float( index ).mul( 0.5698402910 )
+		) ).mul( tileSize ).floor();
+		const coords = screenPixel.add( offset ).mod( tileSize );
+
+		noise.assign( r4( coords ) );
+
+		return noise;
+
+	} );
+
+}

+ 325 - 0
examples/jsm/tsl/utils/SpecularHelpers.js

@@ -0,0 +1,325 @@
+import { Fn, If, PI, clamp, cos, cross, dot, equirectUV, float, log, max, mix, normalize, pow, reflect, sin, sqrt, struct, vec3 } from 'three/tsl';
+
+/**
+ * Specular / microfacet BRDF helpers: VNDF sampling, GTR distribution, Smith geometry,
+ * Fresnel, reflection importance sampling, parallax-corrected ray-length terms, and
+ * equirectangular environment sampling / MIS helpers.
+ * Pure TSL functions of their inputs (no scene/camera state).
+ */
+
+/**
+ * Sentinel ray length the SSR pass writes for environment misses (no screen-space hit), set far above
+ * any real hit distance so a single magnitude test separates misses from hits and survives `.max( 0 )`.
+ *
+ * @type {number}
+ */
+export const ENV_RAY_LENGTH = 1e4;
+
+/**
+ * Classification threshold for {@link ENV_RAY_LENGTH}: above this is an env miss, below a real hit.
+ * An order of magnitude under the sentinel, robust to fp16 storage and bilinear blending at borders.
+ *
+ * @type {number}
+ */
+export const ENV_RAY_LENGTH_THRESHOLD = 1e3;
+
+// Bounded-VNDF sampler (Eto & Tokuyoshi 2023; spherical-cap form, Dupuy & Benyoub 2023)
+const SampleGGXVNDF = Fn( ( [ V, ax, ay, r1, r2 ] ) => {
+
+	// Warp the view direction to the hemisphere ("standard") configuration.
+	const wiStd = normalize( vec3( ax.mul( V.x ), ay.mul( V.y ), V.z ) ).toVar();
+
+	// Isotropic bound on the spherical cap (Eto & Tokuyoshi eq. 5). alpha ∈ [0,1] here,
+	// so the sign term in `s` is always +1 and is dropped.
+	const a = ax.min( ay ).toVar();
+	const s = float( 1.0 ).add( V.xy.length() ).toVar();
+	const a2 = a.mul( a ).toVar();
+	const s2 = s.mul( s ).toVar();
+	const k = a2.oneMinus().mul( s2 ).div( s2.add( a2.mul( V.z ).mul( V.z ) ) ).toVar();
+
+	// Tighten the cap with the bound (upper hemisphere; N·V ≥ 0 in our usage).
+	const b = wiStd.z.mul( k ).toVar();
+
+	// Sample the (bounded) spherical cap.
+	const phi = float( 6.283185307179586 ).mul( r1 ).toVar(); // 2*pi
+	const z = r2.oneMinus().mul( float( 1.0 ).add( b ) ).sub( b ).toVar();
+	const sinTheta = sqrt( max( float( 0.0 ), float( 1.0 ).sub( z.mul( z ) ) ) ).toVar();
+	const c = vec3( sinTheta.mul( cos( phi ) ), sinTheta.mul( sin( phi ) ), z ).toVar();
+
+	// Microfacet normal in the standard config, then warp back to the ellipsoid (unstretch).
+	const wmStd = c.add( wiStd ).toVar();
+	const Ne = normalize( vec3(
+		ax.mul( wmStd.x ),
+		ay.mul( wmStd.y ),
+		max( float( 0.0 ), wmStd.z )
+	) ).toVar();
+
+	return Ne;
+
+}, {
+	name: 'SampleGGXVNDF',
+	type: 'vec3',
+	inputs: [
+		{ name: 'V', type: 'vec3' },
+		{ name: 'ax', type: 'float' },
+		{ name: 'ay', type: 'float' },
+		{ name: 'r1', type: 'float' },
+		{ name: 'r2', type: 'float' },
+	]
+} );
+
+// Generalized Trowbridge-Reitz (GTR). For GGX set k=2.
+// D_GTR(roughness, NoH, k) where roughness = α (not α²).
+export const D_GTR = Fn( ( [ roughness, NoH, k ] ) => {
+
+	// see: Filament - Normal distribution function (specular D) - 4.4.1
+	const a2 = roughness.mul( roughness ).toVar(); // α²
+	const NoH2 = NoH.mul( NoH ).toVar();
+	const base = NoH2.mul( a2.sub( float( 1.0 ) ) ).add( float( 1.0 ) ).toVar();
+	// a² / (π * base^k)
+	return a2.div( PI.mul( pow( base, k ) ) ).toVar(); // float
+
+} );
+
+// Smith G1 (Heitz): expects alpha (not squared); it squares internally
+export const SmithG = Fn( ( [ NDotX, alpha ] ) => {
+
+	// see: Filament - Geometric shadowing (specular G) - 4.4.2
+
+	const a2 = alpha.mul( alpha ).toVar(); // α²
+	const NDotX2 = NDotX.mul( NDotX ).toVar(); // (N·X)²
+	return float( 2.0 ).mul( NDotX ).div(
+		NDotX.add( sqrt(
+			a2.add( a2.oneMinus().mul( NDotX2 ) )
+		) )
+	);
+
+} );
+
+// Geometry term G = G1(N·L, α_G) * G1(N·V, α_G)  (α_G is NOT squared here)
+export const GeometryTerm = Fn( ( [ NoL, NoV, alphaG ] ) => {
+
+	const G1v = SmithG( NoV, alphaG ).toVar();
+	const G1l = SmithG( NoL, alphaG ).toVar();
+	return G1v.mul( G1l ).toVar();
+
+} );
+
+// Bounded VNDF direction PDF (reflection mapping), matching SampleGGXVNDF above.
+// p(L) = D_GTR(roughness, NoH, 2) / ( 2 * (k * N·V + t) )   (Eto & Tokuyoshi eq. 8)
+// with the isotropic cap bound k and t = ‖(α·V.xy, V.z)‖. Here 'roughness' is α, not α².
+const GGXVNDFPdf = Fn( ( [ NoH, NoV, roughness ] ) => {
+
+	const D = D_GTR( roughness, NoH, float( 2.0 ) ).toVar();
+	const a2 = roughness.mul( roughness ).toVar();
+	const sinV2 = max( float( 0.0 ), float( 1.0 ).sub( NoV.mul( NoV ) ) ).toVar(); // ‖V.xy‖²
+	const s = float( 1.0 ).add( sqrt( sinV2 ) ).toVar();
+	const s2 = s.mul( s ).toVar();
+	const k = float( 1.0 ).sub( a2 ).mul( s2 ).div( s2.add( a2.mul( NoV ).mul( NoV ) ) ).toVar();
+	const t = sqrt( a2.mul( sinV2 ).add( NoV.mul( NoV ) ) ).toVar();
+	return D.div( max( float( 1e-6 ), float( 2.0 ).mul( k.mul( NoV ).add( t ) ) ) ).toVar();
+
+} );
+
+/**
+ * Fresnel reflectance for the Schlick approximation.
+ */
+export const F_Schlick = Fn( ( [ f0, theta ] ) => {
+
+	const oneMinus = float( 1.0 ).sub( theta ).toVar();
+	const oneMinus2 = oneMinus.mul( oneMinus ).toVar();
+	const oneMinus5 = oneMinus2.mul( oneMinus2 ).mul( oneMinus ).toVar();
+	return f0.add( vec3( 1.0 ).sub( f0 ).mul( oneMinus5 ) ).toVar(); // vec3
+
+} );
+
+/**
+ * Specular dominant factor for parallax-corrected ray length.
+ * From REBLUR: A Hierarchical Recurrent Denoiser (NRD).
+ */
+export const getSpecularDominantFactor = Fn( ( [ NoV, roughness ] ) => {
+
+	const a = float( 0.298475 ).mul(
+		log( float( 39.4115 ).sub( float( 39.0029 ).mul( roughness ) ) )
+	);
+	const f = float( 1.0 ).sub( NoV ).pow( 10.8649 )
+		.mul( float( 1.0 ).sub( a ) )
+		.add( a );
+	return clamp( f );
+
+} ).setLayout( {
+	name: 'getSpecularDominantFactor',
+	type: 'float',
+	inputs: [
+		{ name: 'NoV', type: 'float' },
+		{ name: 'roughness', type: 'float' }
+	]
+} );
+
+/**
+ * Everything a single GGX reflection sample produces. `reflectDir` and `sampleWeight`
+ * drive the SSR ray-march and compositing; `pdf`, `NdotV`, `alpha` and `f0` are the GGX
+ * terms the env-miss MIS fallback needs so the caller never re-derives microfacet math.
+ */
+const ggxReflectionStruct = struct( {
+	reflectDir: 'vec3', // view-space reflected ray direction
+	sampleWeight: 'vec3', // chromatic weight (incl. Fresnel tint) to multiply gathered radiance by
+	pdf: 'float', // VNDF direction pdf (for MIS against the env CDF)
+	NdotV: 'float',
+	alpha: 'float', // GGX alpha (roughness²), clamped
+	f0: 'vec3' // Fresnel reflectance at normal incidence
+} );
+
+/**
+ * Importance-samples the GGX/VNDF specular lobe for one pixel and returns the reflected
+ * ray direction plus the Monte-Carlo weight to apply to the gathered radiance, along with
+ * the GGX terms the SSR env-miss MIS fallback needs.
+ *
+ * @param {Node<vec3>} N - View-space shading normal (normalized).
+ * @param {Node<vec3>} V - View-space surface→camera direction (normalized).
+ * @param {Node<float>} roughness - Perceptual roughness in `[0,1]`.
+ * @param {Node<float>} metalness - Metalness in `[0,1]`.
+ * @param {Node<vec3>} albedo - Surface base color; tints the metal Fresnel reflectance (`f0`).
+ * @param {Node<vec4>} Xi - Per-pixel random numbers; only `.xy` are used.
+ * @return {ggxReflectionStruct}
+ */
+export const ggxReflectionSample = Fn( ( [ N, V, roughness, metalness, albedo, Xi ] ) => {
+
+	// GGX alpha (use r^2, clamp to avoid degenerate)
+	const a = roughness.mul( roughness ).max( 0.001 ).toVar();
+	const ax = a.toVar();
+	const ay = a.toVar();
+
+	// TBN from view-space normal
+	const up = vec3( 0, 0, 1 );
+	let T = cross( up, N ).toVar();
+	T = T.normalize().toVar();
+	If( T.length().lessThan( 1e-3 ), () => {
+
+		T.assign( cross( vec3( 0, 1, 0 ), N ).normalize() );
+
+	} );
+	const B = cross( N, T ).normalize().toVar();
+
+	// transform V to LOCAL frame (N = +Z)
+	const Vlocal = vec3( dot( T, V ), dot( B, V ), dot( N, V ) ).toVar();
+
+	// VNDF sample **in local frame**
+	const Hlocal = SampleGGXVNDF( Vlocal, ax, ay, Xi.x, Xi.y ).toVar();
+	If( Hlocal.z.lessThan( 0 ), () => {
+
+		Hlocal.assign( Hlocal.negate() );
+
+	} );
+
+	// transform H back to VIEW space
+	const h = normalize( T.mul( Hlocal.x ).add( B.mul( Hlocal.y ) ).add( N.mul( Hlocal.z ) ) ).toVar();
+
+	// reflect with V (surface->camera) and H
+	const viewReflectDir = reflect( V.negate(), h ).normalize().toVar();
+
+	// BRDF/PDF evaluation for the sampled direction
+	// V: surface -> camera, L: reflected direction, N: normal, H: half-vector
+	const L = viewReflectDir.toVar();
+	const H = normalize( V.add( L ) ).toVar(); // ~h; recomputed for robustness
+
+	const NdotV = max( float( 0.0 ), dot( N, V ) ).toVar();
+	const NdotL = max( float( 0.0 ), dot( N, L ) ).toVar();
+	const NdotH = max( float( 0.0 ), dot( N, H ) ).toVar();
+	const VdotH = max( float( 0.0 ), dot( V, H ) ).toVar();
+
+	const f0 = mix( vec3( 0.04 ), albedo, metalness ).toVar();
+	// Chromatic Fresnel reflectance: for metals f0 = albedo, so the reflection is tinted and desaturates
+	// toward white at grazing angles. Kept as vec3 so colored metals reflect with the correct chroma.
+	const fresnelWeight = F_Schlick( f0, VdotH ).toVar(); // vec3
+
+	// Bounded-VNDF direction pdf — still needed for the env-miss MIS path.
+	const pdf = GGXVNDFPdf( NdotH, NdotV, ax ).toVar();
+
+	// Numerically stable importance weight: brdf·NdotL/pdf ≡ fresnel·G2·(k·NdotV + t)/(2·NdotV), which
+	// cancels the GGX D analytically. Evaluating D explicitly is catastrophic at low roughness
+	// (D → 3e5 at α = 0.001 wrecks f32 precision); the cancelled form stays stable down to a mirror.
+	// (k·NdotV + t) is the bounded-cap normalization; k shrinks the cap to drop below-horizon samples.
+	const a2 = ax.mul( ax ).toVar();
+	const sinV2 = NdotV.mul( NdotV ).oneMinus().max( 0.0 ).toVar(); // ‖V.xy‖²
+	const sB = float( 1.0 ).add( sqrt( sinV2 ) ).toVar();
+	const s2B = sB.mul( sB ).toVar();
+	const kB = a2.oneMinus().mul( s2B ).div( s2B.add( a2.mul( NdotV ).mul( NdotV ) ) ).toVar();
+	const tB = sqrt( a2.mul( sinV2 ).add( NdotV.mul( NdotV ) ) ).toVar();
+	const glossyWeight = fresnelWeight
+		.mul( GeometryTerm( NdotL, NdotV, ax ) )
+		.mul( kB.mul( NdotV ).add( tB ) )
+		.div( float( 2.0 ).mul( NdotV ).max( 1e-4 ) ).toVar();
+
+	return ggxReflectionStruct( viewReflectDir, glossyWeight, pdf, NdotV, ax, f0 );
+
+} );
+
+// Equirectangular environment sampling
+
+/**
+ * Equirectangular direction / UV / PDF helpers and MIS weighting shared by environment sampling code.
+ * Env-miss MIS integration lives in {@link ImportanceSampledEnvironment}.
+ *
+ * Equirectangular parameterization helpers used with CDF importance sampling are adapted from
+ * [three-gpu-pathtracer](https://github.com/gkjohnson/three-gpu-pathtracer).
+ *
+ * @see {@link https://github.com/gkjohnson/three-gpu-pathtracer}
+ */
+
+// uv -> direction (equirectangular)
+export const equirectUvToDir = Fn( ( [ uvIn ] ) => {
+
+	const phi = uvIn.x.mul( Math.PI * 2 ).sub( Math.PI );
+	const lat = uvIn.y.sub( 0.5 ).mul( Math.PI );
+	const cosLat = cos( lat );
+	return normalize( vec3(
+		cosLat.mul( cos( phi ) ),
+		sin( lat ),
+		cosLat.mul( sin( phi ) )
+	) );
+
+} ).setLayout( {
+	name: 'equirectUvToDir',
+	type: 'vec3',
+	inputs: [ { name: 'uv', type: 'vec2' } ]
+} );
+
+// Solid-angle PDF of a direction under equirectangular parameterization.
+export const equirectDirPdf = Fn( ( [ direction ] ) => {
+
+	const uvDir = equirectUV( direction );
+	const sinTheta = sin( uvDir.y.mul( Math.PI ) );
+	return sinTheta.abs().lessThan( float( 1e-6 ) ).select(
+		float( 0 ),
+		float( 1 ).div( float( 2 * Math.PI * Math.PI ).mul( sinTheta ) )
+	);
+
+} ).setLayout( {
+	name: 'equirectDirPdf',
+	type: 'float',
+	inputs: [ { name: 'direction', type: 'vec3' } ]
+} );
+
+/**
+ * MIS power heuristic with β = 2: `pdfA² / (pdfA² + pdfB²)`.
+ * Weights the contribution of the strategy that produced `pdfA` against the other strategy.
+ *
+ * @see Eric Veach, *Optimally Combining Sampling Techniques for Monte Carlo Rendering*
+ * @tsl
+ */
+export const misPowerHeuristic = Fn( ( [ pdfA, pdfB ] ) => {
+
+	const pdfASq = pdfA.mul( pdfA );
+	const pdfBSq = pdfB.mul( pdfB );
+	return pdfASq.div( pdfASq.add( pdfBSq ) );
+
+} ).setLayout( {
+	name: 'misPowerHeuristic',
+	type: 'float',
+	inputs: [
+		{ name: 'pdfA', type: 'float' },
+		{ name: 'pdfB', type: 'float' }
+	]
+} );
+

BIN
examples/screenshots/webgpu_postprocessing_ssr_denoise.jpg


+ 15 - 6
examples/webgpu_postprocessing_ssr.html

@@ -58,8 +58,9 @@
 			quality: 0.5,
 			blurQuality: 1,
 			maxDistance: 1,
-			opacity: 1,
+			intensity: 1,
 			thickness: 0.03,
+			binaryRefine: false,
 			roughness: 1,
 			enabled: true
 		};
@@ -179,7 +180,10 @@
 
 			//
 
-			ssrPass = ssr( scenePassColor, scenePassDepth, sceneNormal, scenePassMetalRough.r, scenePassMetalRough.g ).toInspector( 'SSR' );
+			ssrPass = ssr( scenePassColor, scenePassDepth, sceneNormal, {
+				metalnessNode: scenePassMetalRough.r,
+				roughnessNode: scenePassMetalRough.g
+			} ).toInspector( 'SSR' );
 
 			// blend SSR over beauty (SSR outputs premultiplied color, so use additive blending)
 
@@ -202,8 +206,9 @@
 			ssrFolder.add( params, 'quality', 0, 1 ).onChange( updateParameters );
 			ssrFolder.add( params, 'blurQuality', 1, 3, 1 ).onChange( updateParameters );
 			ssrFolder.add( params, 'maxDistance', 0, 1 ).onChange( updateParameters );
-			ssrFolder.add( params, 'opacity', 0, 1 ).onChange( updateParameters );
+			ssrFolder.add( params, 'intensity', 0, 1 ).onChange( updateParameters );
 			ssrFolder.add( params, 'thickness', 0, 0.05 ).onChange( updateParameters );
+			ssrFolder.add( params, 'binaryRefine' ).name( 'binary refine' ).onChange( updateParameters );
 			ssrFolder.add( params, 'enabled' ).onChange( () => {
 
 				if ( params.enabled === true ) {
@@ -241,10 +246,14 @@
 		function updateParameters() {
 
 			ssrPass.quality.value = params.quality;
-			ssrPass.blurQuality.value = params.blurQuality;
+			// blurQuality is a build-time constant: assigning it recompiles the blur material
+			// (the setter no-ops when the value is unchanged).
+			ssrPass.blurQuality = params.blurQuality;
 			ssrPass.maxDistance.value = params.maxDistance;
-			ssrPass.opacity.value = params.opacity;
+			ssrPass.intensity.value = params.intensity;
 			ssrPass.thickness.value = params.thickness;
+			// build-time constant: assigning it recompiles the SSR material (setter no-ops if unchanged)
+			ssrPass.binaryRefine = params.binaryRefine;
 
 		}
 
@@ -262,7 +271,7 @@
 			controls.update();
 
 			renderPipeline.render();
-		
+
 		}
 
 	</script>

+ 574 - 0
examples/webgpu_postprocessing_ssr_denoise.html

@@ -0,0 +1,574 @@
+<!DOCTYPE html>
+<html lang="en">
+
+	<head>
+		<title>three.js webgpu - postprocessing - Screen Space Reflections (SSR) + denoise</title>
+		<meta charset="utf-8">
+		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+		<meta property="og:title" content="three.js webgpu - postprocessing - Screen Space Reflections (SSR) + denoise">
+		<meta property="og:type" content="website">
+		<meta property="og:url" content="https://threejs.org/examples/webgpu_postprocessing_ssr_denoise.html">
+		<meta property="og:image" content="https://threejs.org/examples/screenshots/webgpu_postprocessing_ssr_denoise.jpg">
+		<link type="text/css" rel="stylesheet" href="example.css">
+		<style>
+			#compare-hint {
+				position: fixed;
+				bottom: 20px;
+				left: 50%;
+				transform: translateX(-50%);
+				z-index: 1001;
+				color: #e0e0e0;
+				font: 400 13px 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+				text-shadow: 1px 1px 5px rgba(0, 0, 0, 0.7);
+				pointer-events: none;
+				opacity: 0.85;
+				white-space: nowrap;
+			}
+		</style>
+	</head>
+
+<body>
+
+	<div id="compare-hint" hidden>Hold Shift and move the mouse to scrub the comparison</div>
+
+	<div id="info">
+		<a href="https://threejs.org/" target="_blank" rel="noopener" class="logo-link"></a>
+
+		<div class="title-wrapper">
+			<a href="https://threejs.org/" target="_blank" rel="noopener">three.js</a><span>SSR + Denoising</span>
+		</div>
+
+		<small>
+			Screen Space Reflections with Spatiotemporal Denoising by <a href="https://x.com/0beqz" target="_blank" rel="noopener">0beqz</a>.<br />
+			Dungeon - Low Poly Game Level Challenge by
+			<a href="https://sketchfab.com/warkarma" target="_blank" rel="noopener">Warkarma</a>.<br />
+		</small>
+	</div>
+
+	<script type="importmap">
+		{
+			"imports": {
+				"three": "../build/three.webgpu.js",
+				"three/webgpu": "../build/three.webgpu.js",
+				"three/tsl": "../build/three.tsl.js",
+				"three/addons/": "./jsm/"
+			}
+		}
+	</script>
+
+	<script type="module">
+
+		import * as THREE from 'three/webgpu';
+		import { pass, mrt, output, normalView, materialMetalness, materialRoughness, screenUV, sample, packNormalToRGB, unpackRGBToNormal, vec2, velocity, diffuseColor, vec3, vec4, uniform, mix, step, abs, float, renderOutput, saturation } from 'three/tsl';
+		import { ssr } from 'three/addons/tsl/display/SSRNode.js';
+		import { temporalReproject } from 'three/addons/tsl/display/TemporalReprojectNode.js';
+		import { recurrentDenoise } from 'three/addons/tsl/display/RecurrentDenoiseNode.js';
+		import { sharpen } from 'three/addons/tsl/display/SharpenNode.js';
+		import { traa } from 'three/addons/tsl/display/TRAANode.js';
+
+		import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+		import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+		import { Inspector } from 'three/addons/inspector/Inspector.js';
+		import { HDRLoader } from 'three/addons/loaders/HDRLoader.js';
+
+		// Disable env map specular (radiance and clearcoat radiance) for every PBR material
+		// in this scene, since SSR provides the specular reflections instead. Diffuse env
+		// (irradiance) is left untouched. Equivalent to zeroing EnvironmentNode contributions,
+		// but scoped to this example rather than modifying the core library.
+		const _indirectSpecular = THREE.PhysicalLightingModel.prototype.indirectSpecular;
+		THREE.PhysicalLightingModel.prototype.indirectSpecular = function ( builder ) {
+
+			builder.context.radiance = vec3( 0 );
+
+			if ( this.clearcoatRadiance ) {
+
+				this.clearcoatRadiance.assign( vec3( 0 ) );
+
+			}
+
+			_indirectSpecular.call( this, builder );
+
+		};
+
+		const OUTPUT_COMPARE_DENOISE = 8;
+		const OUTPUT_COMPARE_SSR = 9;
+
+		const GRADED_OUTPUTS = new Set( [ 0, 1, 3, 4, OUTPUT_COMPARE_DENOISE, OUTPUT_COMPARE_SSR ] );
+
+		const compareHint = document.getElementById( 'compare-hint' );
+
+		function isCompareMode( output ) {
+
+			return output === OUTPUT_COMPARE_DENOISE || output === OUTPUT_COMPARE_SSR;
+
+		}
+
+		function updateCompareHint( output ) {
+
+			compareHint.hidden = ! isCompareMode( output );
+
+		}
+
+		const compareSplit = uniform( 0.5 );
+		const compareResolution = uniform( new THREE.Vector2( window.innerWidth, window.innerHeight ) );
+
+		function buildCompareNode( noisy, denoised ) {
+
+			const compareColor = mix( noisy, denoised, step( compareSplit, screenUV.x ) );
+			const lineWidth = float( 1 ).div( compareResolution.x );
+			const onLine = float( 1 ).sub( step( lineWidth, abs( screenUV.x.sub( compareSplit ) ) ) );
+
+			return mix( compareColor, vec4( 1 ), onLine );
+
+		}
+
+		const params = {
+			output: 0,
+			roughness: 0.3,
+
+			ssr: {
+				resolutionScale: 1,
+				quality: 0.25,
+				mirrorBias: 0.5,
+				maxDistance: 0.4,
+				intensity: 1,
+				thickness: 0.1,
+				maxLuminance: 35,
+				binaryRefine: false,
+				stepExponent: 3,
+				envImportanceSampling: false,
+				screenEdgeFade: 0.2,
+				screenEdgeFadeBlack: false, // for indoor scenes, set to true
+				environmentIntensity: 3.14, // not too sure why exactly, but multiplying by ~PI makes the env map reflections match Blender more
+			},
+
+			temporalReproject: {
+				maxFrames: 16,
+				clampIntensity: 0.25,
+				flickerSuppression: 1,
+				hitPointReprojection: true,
+			},
+
+			denoise: {
+				enabled: true,
+				lumaPhi: 0.75,
+				depthPhi: 20,
+				normalPhi: 0.3,
+				roughnessPhi: 100,
+				radius: 1.5,
+				alphaPhi: 5,
+				strength: 0.725,
+				adapt: 0.5,
+				smoothDisocclusions: true,
+				flickerSuppression: 1,
+				adaptiveTrust: 1
+			},
+
+			post: {
+				grading: { toneMapping: 'AgX', exposure: 1.57, gamma: 0.89, contrast: 1.31, saturation: 1 },
+			},
+		};
+
+		let camera, scene, model, renderer, postProcessing, ssrNode, temporalReprojectNode, denoiseNode;
+		let controls;
+		let scenePassNode, combinedOutputNode, scenePassDepth, scenePassVelocity;
+		let gammaUniform, contrastUniform, saturationUniform;
+
+		init();
+
+		async function init() {
+
+			camera = new THREE.PerspectiveCamera( 35, window.innerWidth / window.innerHeight, 0.1, 8 );
+
+			camera.position.set( 0.9210513838983053, 0.16195074025403253, 0.431687274895316 );
+
+			scene = new THREE.Scene();
+
+			const loader = new GLTFLoader();
+			loader.load( 'models/gltf/dungeon_warkarma.glb', function ( gltf ) {
+
+				gltf.scene.scale.multiplyScalar( 0.1 );
+
+				gltf.scene.traverse( function ( object ) {
+
+					if ( ! object.material ) return;
+
+					object.castShadow = true;
+					object.receiveShadow = true;
+					object.material.roughness = 0.3;
+					object.material.normalMap = null;
+
+				} );
+
+				model = gltf.scene;
+				scene.add( model );
+
+			} );
+
+			renderer = new THREE.WebGPURenderer();
+			renderer.inspector = new Inspector();
+			renderer.setSize( window.innerWidth, window.innerHeight );
+			renderer.toneMapping = THREE.AgXToneMapping;
+			renderer.toneMappingExposure = params.post.grading.exposure;
+			renderer.shadowMap.enabled = true;
+			document.body.appendChild( renderer.domElement );
+
+			const hdrLoader = new HDRLoader().setPath( 'textures/equirectangular/' );
+			const hdrTexture = await hdrLoader.loadAsync( 'quarry_01_1k.hdr' );
+			hdrTexture.mapping = THREE.EquirectangularReflectionMapping;
+			scene.background = hdrTexture;
+			scene.environment = hdrTexture;
+			hdrTexture.generateMipmaps = true;
+			hdrTexture.needsUpdate = true;
+
+			const directionalLight = new THREE.DirectionalLight( '#ffffff', 20 );
+			directionalLight.position.set( - 10.9, 2.2, 10.75 );
+			directionalLight.castShadow = true;
+			directionalLight.shadow.autoUpdate = false;
+			directionalLight.shadow.needsUpdate = true;
+			directionalLight.shadow.mapSize.width = 4096;
+			directionalLight.shadow.mapSize.height = 4096;
+			directionalLight.shadow.camera.left = - 1.75;
+			directionalLight.shadow.camera.right = 1.75;
+			directionalLight.shadow.camera.top = 1.75;
+			directionalLight.shadow.camera.bottom = - 1.75;
+			directionalLight.shadow.camera.near = 0.1;
+			directionalLight.shadow.camera.far = 50;
+			directionalLight.shadow.bias = - 0.0005;
+			scene.add( directionalLight );
+
+			await renderer.init();
+
+			scene.environmentIntensity = 1;
+
+			postProcessing = new THREE.RenderPipeline( renderer );
+
+			const scenePass = pass( scene, camera );
+			scenePassNode = scenePass;
+			scenePass.setMRT( mrt( {
+				output: output,
+				// Store base color (albedo) in RGB + metalness in alpha. The albedo is needed for
+				// metal Fresnel f0; do NOT bake in the (1-metalness) diffuse attenuation here, as
+				// that zeroes metals and destroys their specular tint (f0 would become 0 → black).
+				diffuseColor: vec4( diffuseColor.rgb, materialMetalness ),
+				// Pack roughness into normal alpha channel to save MRT bandwidth
+				normal: vec4( packNormalToRGB( normalView ).rgb, materialRoughness ),
+				velocity: velocity
+			} ) );
+
+			const scenePassColor = scenePass.getTextureNode( 'output' ).toInspector( 'Color' );
+			const scenePassNormal = scenePass.getTextureNode( 'normal' ).toInspector( 'Normal' );
+			scenePassDepth = scenePass.getTextureNode( 'depth' ).toInspector( 'Depth', () => {
+
+				return scenePass.getLinearDepthNode();
+
+			} );
+			scenePassVelocity = scenePass.getTextureNode( 'velocity' ).toInspector( 'Velocity' );
+			const scenePassDiffuseColor = scenePass.getTextureNode( 'diffuseColor' ).toInspector( 'Diffuse Color' );
+
+			const normalTexture = scenePass.getTexture( 'normal' );
+			normalTexture.type = THREE.UnsignedByteType;
+
+			const diffuseTexture = scenePass.getTexture( 'diffuseColor' );
+			diffuseTexture.type = THREE.UnsignedByteType;
+
+			const sceneNormal = sample( ( uv ) => unpackRGBToNormal( scenePassNormal.sample( uv ).rgb ) );
+
+			// metalness in diffuseColor.a, roughness in normal.a (no separate metalrough MRT)
+			const scenePassMetalRough = sample( ( uv ) => vec2(
+				scenePassDiffuseColor.sample( uv ).a,
+				scenePassNormal.sample( uv ).a
+			) ).toInspector( 'Metalness/Roughness' );
+
+			ssrNode = ssr( scenePassColor, scenePassDepth, sceneNormal, {
+				stochastic: true,
+				diffuseNode: scenePassDiffuseColor,
+				metalnessNode: scenePassDiffuseColor.a,
+				roughnessNode: scenePassNormal.a,
+				environmentNode: hdrTexture,
+				envImportanceSampling: params.ssr.envImportanceSampling,
+				binaryRefine: params.ssr.binaryRefine
+			} );
+			ssrNode.setEnvMap( hdrTexture );
+			ssrNode.toInspector( 'SSR' );
+
+			temporalReprojectNode = temporalReproject( ssrNode, scenePassDepth, scenePassNormal, scenePassVelocity, camera, {
+				mode: 'specular',
+				accumulate: false
+			} );
+			temporalReprojectNode.toInspector( 'Temporal Reproject' );
+
+			denoiseNode = recurrentDenoise( temporalReprojectNode, camera, {
+				depth: scenePassDepth,
+				normal: scenePassNormal,
+				raw: ssrNode,
+				metalRoughness: scenePassMetalRough,
+				mode: 'specular',
+				accumulate: true,
+			} );
+			denoiseNode.alphaSource = 'raylength'; // SSR alpha channel contains ray length
+			denoiseNode.toInspector( 'Denoise' );
+			// feed the denoised result + velocity back into SSR for multi-bounce reflections
+			ssrNode.setHistory( denoiseNode, scenePassVelocity );
+			temporalReprojectNode.setHistoryTexture( denoiseNode );
+
+			const denoisePassBlend = vec4( denoiseNode.rgb, ssrNode.a.greaterThan( 0 ).toVar() );
+
+			gammaUniform = uniform( params.post.grading.gamma );
+			contrastUniform = uniform( params.post.grading.contrast );
+			saturationUniform = uniform( params.post.grading.saturation );
+
+			const litColor = scenePassColor.rgb.add( denoisePassBlend.rgb );
+
+			const outputNode = vec4( litColor, 1 );
+			outputNode.toInspector( 'Combined SSR' );
+			combinedOutputNode = outputNode;
+
+			postProcessing.outputNode = applyPostProcessing( combinedOutputNode );
+			postProcessing.outputColorTransform = false;
+
+			controls = new OrbitControls( camera, renderer.domElement );
+			controls.enableDamping = true;
+			controls.update();
+
+			// Initial camera transform
+			camera.position.set( 1.259878548682251, 0.5391287340899181, - 0.27217301481427114 );
+			camera.rotation.set( - 0.3158233106804791, 0.26820684188431526, 0.08637696823742165 );
+			controls.target.set( 1.0258536154689288, 0.2746440590977971, - 1.0815876858987743 );
+
+			window.addEventListener( 'resize', onWindowResize );
+			renderer.domElement.addEventListener( 'pointermove', onComparePointerMove );
+
+			applyParams();
+			applyPost();
+
+			const outputTypes = {
+				Combined: 0,
+				'Denoised SSR': 4,
+				'Compare (Denoise)': OUTPUT_COMPARE_DENOISE,
+				'Compare (Reflections)': OUTPUT_COMPARE_SSR,
+				'SSR (Raw)': 1,
+				'Ray Length': 7,
+				'Accumulation Speed (Alpha)': 6
+			};
+
+			const ssrGui = renderer.inspector.createParameters( 'SSR settings' );
+			ssrGui.add( params, 'output', outputTypes ).onChange( updateOutputNode );
+			ssrGui.add( params.ssr, 'quality', 0, 1 ).name( 'quality' ).onChange( applyParams );
+			ssrGui.add( params.ssr, 'mirrorBias', 0, 1 ).name( 'mirror bias' ).onChange( applyParams );
+			ssrGui.add( params.ssr, 'stepExponent', 1, 4, 0.5 ).name( 'step exponent' ).onChange( applyParams );
+			ssrGui.add( params.ssr, 'binaryRefine' ).name( 'binary refine' ).onChange( applyParams );
+			ssrGui.add( params.ssr, 'maxDistance', 0, 5 ).name( 'max distance' ).onChange( applyParams );
+			ssrGui.add( params.ssr, 'intensity', 0, 4 ).name( 'intensity' ).onChange( applyParams );
+			ssrGui.add( params.ssr, 'thickness', 0, 0.25 ).name( 'thickness' ).onChange( applyParams );
+			ssrGui.add( params.ssr, 'environmentIntensity', 0, 10 ).name( 'env intensity' ).onChange( applyParams );
+			ssrGui.add( params, 'roughness', 0, 1 ).onChange( ( value ) => {
+
+				scene.traverse( ( object ) => {
+
+					if ( object.material ) object.material.roughness = value;
+
+				} );
+
+			} );
+
+			const denoiseGui = renderer.inspector.createParameters( 'Denoise settings' );
+			denoiseGui.add( params.denoise, 'enabled' ).name( 'enabled' ).onChange( applyParams );
+			denoiseGui.add( params.denoise, 'lumaPhi', 0, 3 ).name( 'luma phi' ).onChange( applyParams );
+			denoiseGui.add( params.denoise, 'depthPhi', 0, 50 ).name( 'depth phi' ).onChange( applyParams );
+			denoiseGui.add( params.denoise, 'normalPhi', 0.01, 1, 0.01 ).name( 'normal phi' ).onChange( applyParams );
+
+			// We have uniform roughness in the scene, so we don't need to adjust the roughness phi
+			// denoiseGui.add( params.denoise, 'roughnessPhi', 0, 500 ).name( 'roughness phi' ).onChange( applyParams );
+
+			denoiseGui.add( params.denoise, 'alphaPhi', 0, 15 ).name( 'ray length phi' ).onChange( applyParams );
+			denoiseGui.add( params.denoise, 'radius', 0, 3 ).name( 'radius' ).onChange( applyParams );
+			denoiseGui.add( params.denoise, 'strength', 0.5, 0.95 ).name( 'strength' ).onChange( applyParams );
+			denoiseGui.add( params.denoise, 'adapt', 0, 1 ).name( 'adapt' ).onChange( applyParams );
+
+			// Extra options that are not used in this example
+			// denoiseGui.add( params.denoise, 'smoothDisocclusions' ).name( 'smooth disocclusions' ).onChange( applyParams );
+			// denoiseGui.add( params.denoise, 'flickerSuppression', 0, 1 ).name( 'flicker suppression' ).onChange( applyParams );
+			// denoiseGui.add( params.denoise, 'adaptiveTrust', 0, 1 ).name( 'adaptive trust' ).onChange( applyParams );
+
+			const temporalReprojectGui = renderer.inspector.createParameters( 'Temporal Reproject settings' );
+			temporalReprojectGui.add( params.temporalReproject, 'maxFrames', 1, 128, 1 ).name( 'max frames' ).onChange( applyParams );
+			temporalReprojectGui.add( params.temporalReproject, 'clampIntensity', 0, 1 ).name( 'clamp intensity' ).onChange( applyParams );
+			temporalReprojectGui.add( params.temporalReproject, 'flickerSuppression', 0, 1 ).name( 'flicker suppression' ).onChange( applyParams );
+			temporalReprojectGui.add( params.temporalReproject, 'hitPointReprojection' ).name( 'hit point reprojection' ).onChange( applyParams );
+			temporalReprojectGui.close();
+
+			// Concise UI for Directional Light controls
+			const lightGui = renderer.inspector.createParameters( 'Light' ).close();
+			[ 'x', 'y', 'z' ].forEach( axis => {
+
+				lightGui.add( directionalLight.position, axis, - 30, 30 ).name( axis.toUpperCase() )
+					.onChange( () => directionalLight.shadow.needsUpdate = true );
+
+			} );
+			lightGui.add( directionalLight, 'intensity', 0, 50 ).name( 'Intensity' );
+
+			updateOutputNode();
+			renderer.setAnimationLoop( animate );
+
+		}
+
+		function applyParams() {
+
+			if ( ! ssrNode ) return;
+
+			ssrNode.resolutionScale = params.ssr.resolutionScale;
+			ssrNode.quality.value = params.ssr.quality;
+			ssrNode.mirrorBias.value = params.ssr.mirrorBias;
+			// stepExponent / screenEdgeFadeBlack / binaryRefine are build-time constants: assigning
+			// them recompiles the SSR material (the setters no-op when the value is unchanged).
+			ssrNode.stepExponent = params.ssr.stepExponent;
+			ssrNode.binaryRefine = params.ssr.binaryRefine;
+			ssrNode.maxDistance.value = params.ssr.maxDistance;
+			ssrNode.intensity.value = params.ssr.intensity;
+			ssrNode.thickness.value = params.ssr.thickness;
+			ssrNode.maxLuminance.value = params.ssr.maxLuminance;
+			ssrNode.screenEdgeFade.value = params.ssr.screenEdgeFade;
+			ssrNode.screenEdgeFadeBlack = params.ssr.screenEdgeFadeBlack;
+			ssrNode.environmentIntensity.value = params.ssr.environmentIntensity;
+
+			if ( temporalReprojectNode ) {
+
+				temporalReprojectNode.maxFrames.value = params.temporalReproject.maxFrames;
+				temporalReprojectNode.clampIntensity.value = params.temporalReproject.clampIntensity;
+				temporalReprojectNode.flickerSuppression.value = params.temporalReproject.flickerSuppression;
+				temporalReprojectNode.hitPointReprojection.value = params.temporalReproject.hitPointReprojection;
+
+			}
+
+			if ( denoiseNode ) {
+
+				denoiseNode.lumaPhi.value = params.denoise.lumaPhi;
+				denoiseNode.depthPhi.value = params.denoise.depthPhi;
+				denoiseNode.normalPhi.value = params.denoise.normalPhi;
+				denoiseNode.roughnessPhi.value = params.denoise.roughnessPhi;
+				denoiseNode.radius.value = params.denoise.enabled ? params.denoise.radius : 0;
+				denoiseNode.alphaPhi.value = params.denoise.alphaPhi;
+				denoiseNode.strength.value = params.denoise.strength;
+				denoiseNode.adapt.value = params.denoise.adapt;
+				denoiseNode.smoothDisocclusions.value = params.denoise.smoothDisocclusions;
+				denoiseNode.flickerSuppression.value = params.denoise.flickerSuppression;
+				denoiseNode.adaptiveTrust.value = params.denoise.adaptiveTrust;
+
+	}
+
+		}
+
+		function applyGrading( source ) {
+
+			let rgb = source.rgb;
+
+			rgb = renderOutput( vec4( rgb, 1 ), THREE.AgXToneMapping, THREE.SRGBColorSpace ).rgb;
+			rgb = rgb.sub( 0.5 ).mul( contrastUniform ).add( 0.5 );
+			rgb = saturation( rgb, saturationUniform );
+			rgb = rgb.max( 0.0 ).pow( float( 1 ).div( gammaUniform ) );
+
+			return vec4( rgb, 1 );
+
+		}
+
+		function applyPostProcessing( source ) {
+
+			return sharpen( traa( applyGrading( source ), scenePassDepth, scenePassVelocity, camera ), 0 );
+
+		}
+
+		function applyPost() {
+
+			const g = params.post.grading;
+			gammaUniform.value = g.gamma;
+			contrastUniform.value = g.contrast;
+			saturationUniform.value = g.saturation;
+
+			renderer.toneMapping = THREE.AgXToneMapping;
+			renderer.toneMappingExposure = g.exposure;
+
+		}
+
+		function updateOutputNode() {
+
+			if ( ! postProcessing ) return;
+
+			let node;
+
+			switch ( params.output ) {
+
+				case 0: node = applyPostProcessing( combinedOutputNode ); break; // Combined
+				case 1: node = applyPostProcessing( vec4( ssrNode.rgb, 1 ) ); break; // SSR (Raw)
+				case 4: node = applyPostProcessing( vec4( denoiseNode.rgb, 1 ) ); break; // Denoised SSR
+				case 6: node = vec4( denoiseNode.aaa, 1 ); break; // Samples (Alpha)
+				case 7: node = vec4( ssrNode.aaa, 1 ); break; // Ray Length (Raw)
+				case OUTPUT_COMPARE_DENOISE: { // Compare (denoised | noisy)
+
+					const noisySSR = applyPostProcessing( vec4( ssrNode.rgb, 1 ) );
+					const denoisedSSR = applyPostProcessing( vec4( denoiseNode.rgb, 1 ) );
+					node = buildCompareNode( denoisedSSR, noisySSR );
+					break;
+
+				}
+
+				case OUTPUT_COMPARE_SSR: { // Compare (combined SSR vs. scene only)
+
+					node = buildCompareNode( applyPostProcessing( combinedOutputNode ), applyPostProcessing( scenePassNode ) );
+					break;
+
+				}
+
+	}
+
+			if ( ! isCompareMode( params.output ) ) controls.enabled = true;
+
+			updateCompareHint( params.output );
+
+			postProcessing.outputColorTransform = ! GRADED_OUTPUTS.has( params.output );
+			postProcessing.outputNode = node;
+			postProcessing.needsUpdate = true;
+
+		}
+
+		function onWindowResize() {
+
+			camera.aspect = window.innerWidth / window.innerHeight;
+			camera.updateProjectionMatrix();
+
+			renderer.setSize( window.innerWidth, window.innerHeight );
+			compareResolution.value.set( window.innerWidth, window.innerHeight );
+
+		}
+
+		function onComparePointerMove( event ) {
+
+			if ( ! isCompareMode( params.output ) ) return;
+
+			if ( event.shiftKey ) {
+
+				controls.enabled = false;
+
+				const rect = renderer.domElement.getBoundingClientRect();
+				compareSplit.value = Math.max( 0, Math.min( 1, ( event.clientX - rect.left ) / rect.width ) );
+
+			} else {
+
+				controls.enabled = true;
+
+			}
+
+		}
+
+		function animate() {
+
+			controls.update();
+
+			postProcessing.render();
+
+		}
+
+	</script>
+</body>
+
+</html>

+ 1 - 0
test/e2e/puppeteer.js

@@ -57,6 +57,7 @@ const exceptionList = [
 	'webgpu_materials_matcap',
 	'webgpu_morphtargets_face',
 	'webgpu_shadowmap_progressive',
+	'webgpu_postprocessing_ssr_denoise',
 
 	// Video hangs the CI?
 	'css3d_youtube',

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff

粤ICP备19079148号