three
Version:
JavaScript 3D library
913 lines (678 loc) • 30.1 kB
JavaScript
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
) );