| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912 |
- 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
- ) );
|