RecurrentDenoiseNode.js 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912
  1. 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';
  2. import { HalfFloatType, MathUtils, Matrix4, NodeMaterial, QuadMesh, RendererUtils, RenderTarget, TempNode, Vector2 } from 'three/webgpu';
  3. import { bindAnalyticNoise } from '../utils/RNoise.js';
  4. import { ENV_RAY_LENGTH_THRESHOLD } from '../utils/SpecularHelpers.js';
  5. const _quadMesh = /*@__PURE__*/ new QuadMesh();
  6. const _size = /*@__PURE__*/ new Vector2();
  7. let _rendererState;
  8. const KERNEL_SAMPLES = 8;
  9. const NOISE_ROTATION_SEED = 83;
  10. const WORLD_RADIUS_SCALE = 0.1;
  11. const AO_EDGE_STOPPING_BIAS = 0.05;
  12. const AGGRESSIVITY_RADIUS_MIN = 0.001;
  13. const DIFFUSE_CHROMA_WEIGHT = 2.0;
  14. // Neighborhood luma coefficient-of-variation thresholds for gating the temporal inverse-luminance
  15. // (firefly) suppression: below MIN the region is treated as flicker-free, above MAX as noisy.
  16. const FLICKER_COV_GATE_MIN = 0.1;
  17. const FLICKER_COV_GATE_MAX = 2;
  18. /**
  19. * Golden-angle Vogel disk offset.
  20. *
  21. * @tsl
  22. */
  23. const vogelDisk = Fn( ( [ i, radius ] ) => {
  24. const sampleCount = 8;
  25. const theta = i.add( 0.5 ).mul( 2.399827721492203 );
  26. const r = radius.mul( sqrt( i.add( 0.5 ).div( sampleCount ) ) );
  27. return vec2( cos( theta ), sin( theta ) ).mul( r );
  28. } ).setLayout( {
  29. name: 'vogelDisk',
  30. type: 'vec2',
  31. inputs: [
  32. { name: 'i', type: 'float' },
  33. { name: 'radius', type: 'float' }
  34. ]
  35. } );
  36. /**
  37. * Chromatic color-similarity distance between two linear base colors (albedo).
  38. *
  39. * @tsl
  40. */
  41. const diffuseColorDistance = Fn( ( [ a, b, compressLuma ] ) => {
  42. const toYCoCg = ( c ) => vec3(
  43. dot( c, vec3( 0.25, 0.5, 0.25 ) ),
  44. c.r.sub( c.b ),
  45. c.g.sub( c.r.add( c.b ).mul( 0.5 ) )
  46. );
  47. const ya = toYCoCg( a );
  48. const yb = toYCoCg( b );
  49. // `compressLuma` (0/1) range-compresses the luma term with log(1+L) so a fixed lumaPhi gives
  50. // scale-invariant differences across the HDR range. 0 leaves luma linear (used for LDR albedo).
  51. const compress = ( L ) => mix( L, log( L.add( 1 ) ), compressLuma );
  52. const dLuma = abs( compress( ya.x ).sub( compress( yb.x ) ) );
  53. const dChroma = vec2( ya.y.sub( yb.y ), ya.z.sub( yb.z ) ).length();
  54. return dLuma.add( dChroma.mul( DIFFUSE_CHROMA_WEIGHT ) );
  55. } ).setLayout( {
  56. name: 'diffuseColorDistance',
  57. type: 'float',
  58. inputs: [
  59. { name: 'a', type: 'vec3' },
  60. { name: 'b', type: 'vec3' },
  61. { name: 'compressLuma', type: 'float' }
  62. ]
  63. } );
  64. const _temporalWeight = Fn( ( [ x, strength ] ) => float( 1 ).div( x.pow( strength ) ) ).setLayout( {
  65. name: 'temporalWeight',
  66. type: 'float',
  67. inputs: [
  68. { name: 'x', type: 'float' },
  69. { name: 'strength', type: 'float' }
  70. ]
  71. } );
  72. /**
  73. * Temporal accumulation variance factor in `[0, 1]`. Higher values mean more history confidence.
  74. *
  75. * @tsl
  76. */
  77. const getTemporalVarianceFactor = Fn( ( [ frameNum, strength ] ) => {
  78. return _temporalWeight( frameNum, strength ).max( 0.05 );
  79. } ).setLayout( {
  80. name: 'getTemporalVarianceFactor',
  81. type: 'float',
  82. inputs: [
  83. { name: 'frameNum', type: 'float' },
  84. { name: 'strength', type: 'float' }
  85. ]
  86. } );
  87. /**
  88. * World-space frustum height at `viewZ`. Algorithm originally from REBLUR (NRD).
  89. * `tanHalfFovY` is `tan( verticalFov / 2 )`, hoisted by the caller since it is loop-invariant.
  90. *
  91. * @tsl
  92. */
  93. const computeFrustumSize = Fn( ( [ viewZ, tanHalfFovY ] ) => {
  94. return float( 2 ).mul( viewZ ).mul( tanHalfFovY );
  95. } ).setLayout( {
  96. name: 'computeFrustumSize',
  97. type: 'float',
  98. inputs: [
  99. { name: 'viewZ', type: 'float' },
  100. { name: 'tanHalfFovY', type: 'float' }
  101. ]
  102. } );
  103. /**
  104. * Maps world-space SSR ray length to `[0, 1]`. Environment rays (`worldRayLength == 0`) map to `1`.
  105. * Algorithm originally from REBLUR (NRD).
  106. *
  107. * @tsl
  108. */
  109. const computeHitDistFactor = Fn( ( [ worldRayLength, viewZ, tanHalfFovY ] ) => {
  110. const frustumSize = computeFrustumSize( viewZ, tanHalfFovY );
  111. const factor = worldRayLength.div( frustumSize.max( 1e-6 ) ).clamp( 0, 1 );
  112. return factor;
  113. } ).setLayout( {
  114. name: 'computeHitDistFactor',
  115. type: 'float',
  116. inputs: [
  117. { name: 'worldRayLength', type: 'float' },
  118. { name: 'viewZ', type: 'float' },
  119. { name: 'tanHalfFovY', type: 'float' }
  120. ]
  121. } );
  122. /**
  123. * Maps an AO factor for edge-stopping comparisons.
  124. *
  125. * @tsl
  126. */
  127. const mapAo = Fn( ( [ aoVal ] ) => aoVal.pow( 0.1 ) );
  128. /**
  129. * Specular dominant direction — smooth surfaces lean toward reflection, rough toward normal.
  130. *
  131. * @tsl
  132. */
  133. const getSpecularDominantDirection = Fn( ( [ N, V, roughness ] ) => {
  134. return normalize( mix( N, reflect( V.negate(), N ), roughness.oneMinus() ) );
  135. } ).setLayout( {
  136. name: 'getSpecularDominantDirection',
  137. type: 'vec3',
  138. inputs: [
  139. { name: 'N', type: 'vec3' },
  140. { name: 'V', type: 'vec3' },
  141. { name: 'roughness', type: 'float' }
  142. ]
  143. } );
  144. /**
  145. * GGX inverse-CDF: half-angle tangent enclosing `percent` of the specular lobe volume.
  146. * `roughness` is perceptual (alpha = roughness²).
  147. *
  148. * @tsl
  149. */
  150. const specularLobeTanHalfAngle = Fn( ( [ roughness, percent ] ) => {
  151. const alpha = roughness.mul( roughness );
  152. return alpha.mul( sqrt( percent.div( float( 1 ).sub( percent ).max( 1e-6 ) ) ) );
  153. } ).setLayout( {
  154. name: 'specularLobeTanHalfAngle',
  155. type: 'float',
  156. inputs: [
  157. { name: 'roughness', type: 'float' },
  158. { name: 'percent', type: 'float' }
  159. ]
  160. } );
  161. const EXP_WEIGHT_SCALE = 4;
  162. const NORMAL_ENCODING_ERROR = 1.5 / 255;
  163. /**
  164. * Loop-invariant part of the adaptive normal edge-stopping weight: the Gaussian falloff
  165. * constant `2·EXP_WEIGHT_SCALE / lobeHalfAngle²`. `roughness`/`aggressivity`/`invNormalPhi`
  166. * are constant across the kernel, so this is hoisted out of the tap loop and evaluated once
  167. * per pixel. Lobe half-angle from REBLUR (NRD).
  168. *
  169. * @tsl
  170. */
  171. const lobeNormalFalloff = Fn( ( [ roughness, aggressivity, invNormalPhi ] ) => {
  172. const percent = mix( invNormalPhi.pow2(), float( 0 ), aggressivity.sqrt() ).clamp( 0.1, 0.99 );
  173. const tanHalfAngle = specularLobeTanHalfAngle( roughness, percent );
  174. const lobeHalfAngle = max( atan( tanHalfAngle ), float( NORMAL_ENCODING_ERROR ) );
  175. const invHalfAngle = float( 1 ).div( lobeHalfAngle );
  176. return invHalfAngle.mul( invHalfAngle ).mul( 2 * EXP_WEIGHT_SCALE );
  177. } ).setLayout( {
  178. name: 'lobeNormalFalloff',
  179. type: 'float',
  180. inputs: [
  181. { name: 'roughness', type: 'float' },
  182. { name: 'aggressivity', type: 'float' },
  183. { name: 'invNormalPhi', type: 'float' }
  184. ]
  185. } );
  186. /**
  187. * Adaptive lobe normal edge-stopping weight
  188. *
  189. * Evaluated entirely in cosine space: with `angle² ≈ 2(1 − cosθ)`, the original
  190. * `exp( −SCALE·angle/halfAngle )` becomes a Gaussian `exp( falloff·(cosθ − 1) )`, so a
  191. * single `exp` replaces the per-tap `acos`. Matches the original at the half-angle for
  192. * narrow lobes and is slightly more permissive for wide (diffuse) ones.
  193. *
  194. * @tsl
  195. */
  196. const lobeNormalWeight = Fn( ( [ viewNormal, nNormalV, lobeFalloff ] ) => {
  197. const cosA = dot( viewNormal, nNormalV );
  198. return exp( cosA.sub( 1 ).mul( lobeFalloff ) );
  199. } ).setLayout( {
  200. name: 'lobeNormalWeight',
  201. type: 'float',
  202. inputs: [
  203. { name: 'viewNormal', type: 'vec3' },
  204. { name: 'nNormalV', type: 'vec3' },
  205. { name: 'lobeFalloff', type: 'float' }
  206. ]
  207. } );
  208. /**
  209. * View-space plane distance between two surface points (edge-stopping geometry term).
  210. *
  211. * @tsl
  212. */
  213. const planeDistance = Fn( ( [ position, nPosition, normal ] ) => {
  214. return abs( dot( position.sub( nPosition ), normal ) );
  215. } ).setLayout( {
  216. name: 'planeDistance',
  217. type: 'float',
  218. inputs: [
  219. { name: 'position', type: 'vec3' },
  220. { name: 'nPosition', type: 'vec3' },
  221. { name: 'normal', type: 'vec3' },
  222. ]
  223. } );
  224. /**
  225. * Inverse-luminance temporal blend with optional adaptive trust (Karis-style).
  226. *
  227. * @tsl
  228. */
  229. const karisTemporalBlend = Fn( ( [ denoisedRgb, denoisedRaw, a, flickerSuppression, adaptiveTrust, nbhdMeanLuma, nbhdStddevLuma ] ) => {
  230. const localCoV = nbhdStddevLuma.div( nbhdMeanLuma.max( 1e-4 ) );
  231. const trustSuppress = localCoV.mul( adaptiveTrust ).mul( a.oneMinus() ).clamp( 0, 0.9 );
  232. const aTrust = a.mul( trustSuppress.oneMinus() );
  233. // In flicker-free neighborhoods, back off the inverse-luminance weighting so valid bright highlights
  234. // keep their energy. Scaled by adaptiveTrust so the default (0) path is unchanged.
  235. const noisy = smoothstep( FLICKER_COV_GATE_MIN, FLICKER_COV_GATE_MAX, localCoV );
  236. const effFlicker = flickerSuppression.mul( mix( adaptiveTrust.oneMinus(), float( 1 ), noisy ) );
  237. const wHist = float( 1 ).sub( aTrust ).div( luminance( denoisedRgb ).mul( effFlicker ).mul( 10 ).add( 1 ) );
  238. const wRaw = aTrust.div( luminance( denoisedRaw ).mul( effFlicker ).mul( 10 ).add( 1 ) );
  239. return denoisedRgb.mul( wHist ).add( denoisedRaw.mul( wRaw ) ).div( wHist.add( wRaw ).max( EPSILON ) );
  240. } ).setLayout( {
  241. name: 'karisTemporalBlend',
  242. type: 'vec3',
  243. inputs: [
  244. { name: 'denoisedRgb', type: 'vec3' },
  245. { name: 'denoisedRaw', type: 'vec3' },
  246. { name: 'a', type: 'float' },
  247. { name: 'flickerSuppression', type: 'float' },
  248. { name: 'adaptiveTrust', type: 'float' },
  249. { name: 'nbhdMeanLuma', type: 'float' },
  250. { name: 'nbhdStddevLuma', type: 'float' }
  251. ]
  252. } );
  253. const toTextureNode = ( value ) => {
  254. if ( value === null ) return null;
  255. if ( value.isTexture === true ) return texture( value );
  256. return convertToTexture( value.getTextureNode?.() ?? value );
  257. };
  258. /**
  259. * @typedef {'diffuse'|'specular'} DenoiseMode
  260. */
  261. /**
  262. * @typedef {'raylength'|'ao'|'none'} DenoiseAlphaSource
  263. */
  264. /**
  265. * @typedef {Object} RecurrentDenoiseNodeOptions
  266. * @property {?Node<float>} [depth=null] - Scene depth buffer for view-space edge stopping.
  267. * @property {?Node<vec3>} [normal=null] - View-space normals for geometric edge stopping.
  268. * @property {?Node<vec4>} [metalRoughness=null] - Roughness/metalness G-buffer for specular edge stopping.
  269. * @property {?Node<vec4>} [diffuse=null] - Scene base color (albedo) G-buffer for chromatic edge stopping.
  270. * @property {?Node<vec4>} [raw=null] - Unfiltered input (e.g. raw SSR/SSGI) for secondary sampling and temporal blend.
  271. * @property {DenoiseMode} [mode='diffuse'] - Denoising kernel type.
  272. * @property {boolean} [accumulate=true] - When `true`, temporally blend the spatially-denoised result
  273. * (Karis-style) and write frame weight to alpha for feedback loops. When `false`, only spatial filtering is applied.
  274. */
  275. /**
  276. * Post processing node for denoising temporally-accumulated screen-space effects
  277. * such as SSGI (ambient occlusion / indirect diffuse) and SSR (specular reflections).
  278. *
  279. * The denoising kernel is selected at construction time via `mode`:
  280. * `'diffuse'` (SSGI) or `'specular'` (SSR). The kernel uses a fixed 8-sample Vogel disk.
  281. *
  282. * @augments TempNode
  283. * @three_import import { recurrentDenoise } from 'three/addons/tsl/display/RecurrentDenoiseNode.js';
  284. */
  285. class RecurrentDenoiseNode extends TempNode {
  286. static get type() {
  287. return 'RecurrentDenoiseNode';
  288. }
  289. /**
  290. * @param {TextureNode} inputTexture - Temporally filtered input to denoise (e.g. TRAA output).
  291. * @param {Camera} camera
  292. * @param {RecurrentDenoiseNodeOptions} [options={}]
  293. */
  294. constructor( inputTexture, camera, options = {} ) {
  295. super( 'vec4' );
  296. const {
  297. depth = null,
  298. normal = null,
  299. metalRoughness = null,
  300. diffuse = null,
  301. raw = null,
  302. mode = 'diffuse',
  303. accumulate = true,
  304. } = options;
  305. this.isRecurrentDenoiseNode = true;
  306. this.camera = camera;
  307. /**
  308. * Denoising kernel type.
  309. *
  310. * @type {DenoiseMode}
  311. */
  312. this.mode = mode;
  313. /**
  314. * When `true`, apply temporal blending after spatial denoising. When `false`, output spatially
  315. * filtered colour only (alpha is passed through from the input temporal pass).
  316. *
  317. * @type {boolean}
  318. */
  319. this.accumulate = accumulate;
  320. this.textureNode = inputTexture;
  321. this.depthNode = depth !== null ? nodeObject( depth ) : null;
  322. this.normalNode = normal !== null ? nodeObject( normal ) : null;
  323. this.rawNode = toTextureNode( raw );
  324. this.roughnessMetalnessNode = metalRoughness !== null ? nodeObject( metalRoughness ) : null;
  325. this.diffuseNode = diffuse !== null ? nodeObject( diffuse ) : null;
  326. this._noiseIndex = uniform( 0 );
  327. this.lumaPhi = uniform( 5 );
  328. this.depthPhi = uniform( 5 );
  329. this.normalPhi = uniform( 5 );
  330. this.radius = uniform( 5 );
  331. this.alphaPhi = uniform( 1 );
  332. this.roughnessPhi = uniform( 100 );
  333. this.diffusePhi = uniform( 100 );
  334. this.adapt = uniform( 0.5 );
  335. this.smoothDisocclusions = uniform( true, 'bool' );
  336. this.strength = uniform( 0.25 );
  337. this.maxFrames = uniform( 32 );
  338. /**
  339. * Which channel of the raw texture drives alpha-based edge stopping.
  340. * `'raylength'` — alpha encodes SSR ray length; `'ao'` — alpha encodes AO factor;
  341. * `'none'` — skip alpha-based edge stopping.
  342. *
  343. * @type {DenoiseAlphaSource}
  344. * @default 'raylength'
  345. */
  346. this.alphaSource = 'raylength';
  347. this.flickerSuppression = uniform( 1 );
  348. this.adaptiveTrust = uniform( 0 );
  349. this.updateBeforeType = NodeUpdateType.FRAME;
  350. this._resolution = uniform( new Vector2() );
  351. this._fovY = uniform( MathUtils.degToRad( camera.fov ) );
  352. this._cameraProjectionMatrixInverse = uniform( new Matrix4().copy( camera.projectionMatrixInverse ) );
  353. this._cameraProjectionMatrix = uniform( new Matrix4().copy( camera.projectionMatrix ) );
  354. this._viewMatrix = uniform( new Matrix4().copy( camera.matrixWorldInverse ) );
  355. this._renderTarget = new RenderTarget( 1, 1, { depthBuffer: false, type: HalfFloatType } );
  356. this._renderTarget.texture.name = 'RecurrentDenoiseNode.output';
  357. this._material = new NodeMaterial();
  358. this._material.name = 'RecurrentDenoise';
  359. this._textureNode = passTexture( this, this._renderTarget.texture );
  360. }
  361. setSize( width, height ) {
  362. if ( width === null || height === null ) return;
  363. this._renderTarget.setSize( width, height );
  364. this._resolution.value.set( width, height );
  365. }
  366. getTextureNode() {
  367. return this._textureNode;
  368. }
  369. /**
  370. * Returns the internal output render target (e.g. for temporal reprojection/SSGI temporal feedback loops).
  371. *
  372. * @returns {RenderTarget}
  373. */
  374. getRenderTarget() {
  375. return this._renderTarget;
  376. }
  377. updateBefore( frame ) {
  378. const { renderer } = frame;
  379. const drawingBufferSize = renderer.getDrawingBufferSize( _size );
  380. const width = drawingBufferSize.width;
  381. const height = drawingBufferSize.height;
  382. const needsRestart = this._renderTarget.width !== width || this._renderTarget.height !== height;
  383. this.setSize( width, height );
  384. this._cameraProjectionMatrix.value.copy( this.camera.projectionMatrix );
  385. this._cameraProjectionMatrixInverse.value.copy( this.camera.projectionMatrixInverse );
  386. this._viewMatrix.value.copy( this.camera.matrixWorldInverse );
  387. if ( this.camera.isPerspectiveCamera ) {
  388. this._fovY.value = MathUtils.degToRad( this.camera.fov );
  389. }
  390. if ( frame.frameId !== undefined ) this._noiseIndex.value = frame.frameId;
  391. // Denoise renders via an internal _quadMesh, not through the RenderPipeline output graph.
  392. // Upstream passes (e.g. TemporalReprojectNode) referenced by a PassTextureNode input are
  393. // otherwise never scheduled, their updateBefore() would not run and this pass would sample
  394. // a stale/empty render target.
  395. if ( this.textureNode.isPassTextureNode === true ) frame.updateBeforeNode( this.textureNode.passNode );
  396. _rendererState = RendererUtils.resetRendererState( renderer, _rendererState );
  397. if ( needsRestart === true ) {
  398. renderer.initRenderTarget( this._renderTarget );
  399. renderer.setRenderTarget( this._renderTarget );
  400. renderer.clear();
  401. renderer.setRenderTarget( null );
  402. }
  403. renderer.setRenderTarget( this._renderTarget );
  404. _quadMesh.material = this._material;
  405. _quadMesh.name = 'RecurrentDenoise';
  406. _quadMesh.render( renderer );
  407. renderer.setRenderTarget( null );
  408. RendererUtils.restoreRendererState( renderer, _rendererState );
  409. }
  410. setup( builder ) {
  411. const sampleAnalyticNoise = bindAnalyticNoise( this._resolution, NOISE_ROTATION_SEED );
  412. const noiseRotationMatrix = Fn( ( [ r ] ) => {
  413. const angle = r.mul( 2 ).mul( PI );
  414. return mat2( cos( angle ), sin( angle ).negate(), sin( angle ), cos( angle ) );
  415. } );
  416. const sampleTexture = ( uvCoord ) => texture( this.textureNode, uvCoord ).max( 0 );
  417. const sampleRaw = ( uvCoord ) => this.rawNode?.sample( uvCoord )?.max( 0 ) ?? vec3( 0 ).max( 0 );
  418. const sampleDepth = ( uvCoord ) => this.depthNode?.sample( uvCoord )?.x ?? float( 0.5 );
  419. const sampleNormal = ( uvCoord ) => unpackRGBToNormal( this.normalNode?.sample( uvCoord )?.rgb ?? vec3( 0, 0, 1 ) );
  420. const sampleRoughnessMetalness = ( uvCoord ) => this.roughnessMetalnessNode?.sample( uvCoord )?.rg ?? vec2( 0, 1 );
  421. const sampleDiffuse = ( uvCoord ) => this.diffuseNode?.sample( uvCoord )?.rgb ?? vec3( 0 );
  422. // Neighborhood luma moments for the adaptive-trust (firefly) gating of the temporal blend.
  423. const getNeighborhoodStats = Fn( ( [ uvCoord, centerSample ] ) => {
  424. const rlSum = float( 0 ).toVar();
  425. const rlSumW = float( 0 ).toVar();
  426. const meanLuma = float( 0 ).toVar();
  427. const m2Luma = float( 0 ).toVar();
  428. const lumaCount = float( 0 ).toVar();
  429. const hasEnvRay = bool( false ).toVar();
  430. // 4-tap cross (pre-sampled center + 4 axis neighbors) instead of a full 3×3 — about half the fetches.
  431. // The center tap reuses the caller's already-sampled raw texel.
  432. const accumulate = ( dx, dy, sample ) => {
  433. const neighbor = sample !== undefined
  434. ? sample
  435. : texture( this.rawNode, uvCoord.add( vec2( dx, dy ).div( this._resolution ) ) ).max( 0 ).toConst();
  436. if ( this.alphaSource === 'raylength' ) {
  437. const sampleRl = neighbor.a.toVar();
  438. If( sampleRl.greaterThan( ENV_RAY_LENGTH_THRESHOLD ), () => {
  439. sampleRl.assign( 0.25 );
  440. hasEnvRay.assign( true );
  441. } );
  442. const w = float( 1 ).div( sampleRl.add( 0.001 ) );
  443. rlSum.addAssign( sampleRl.mul( w ) );
  444. rlSumW.addAssign( w );
  445. }
  446. If( this.adaptiveTrust.greaterThan( 0 ), () => {
  447. const nLuma = luminance( neighbor.rgb );
  448. lumaCount.addAssign( 1 );
  449. const delta = nLuma.sub( meanLuma ).toConst();
  450. meanLuma.addAssign( delta.div( lumaCount ) );
  451. m2Luma.addAssign( delta.mul( nLuma.sub( meanLuma ) ) );
  452. } );
  453. };
  454. accumulate( 0, 0, centerSample );
  455. accumulate( - 1, 0 );
  456. accumulate( 1, 0 );
  457. accumulate( 0, - 1 );
  458. accumulate( 0, 1 );
  459. const avgRayLength = this.alphaSource === 'raylength' ? rlSum.div( rlSumW ) : float( 1 );
  460. const stddevLuma = sqrt( m2Luma.div( lumaCount.max( 1 ) ) );
  461. // vec3( avgRayLength, meanLuma, stddevLuma )
  462. return vec4( avgRayLength, meanLuma, stddevLuma, hasEnvRay.toFloat() );
  463. } ).setLayout( {
  464. name: 'getNeighborhoodStats',
  465. type: 'vec4',
  466. inputs: [
  467. { name: 'uvCoord', type: 'vec2' },
  468. { name: 'centerSample', type: 'vec4' }
  469. ]
  470. } );
  471. const denoiseFn = Fn( ( [ uvCoord ] ) => {
  472. const result = property( 'vec4' );
  473. const depth = sampleDepth( uvCoord ).toConst();
  474. const runDenoise = () => {
  475. const viewNormal = sampleNormal( uvCoord ).toConst();
  476. const worldNormal = viewNormal.transformDirection( this._viewMatrix ).toConst();
  477. const texel = sampleTexture( uvCoord ).max( 0 ).toConst();
  478. const viewPosition = getViewPosition( uvCoord, depth, this._cameraProjectionMatrixInverse ).toConst();
  479. const roughnessMetalness = sampleRoughnessMetalness( uvCoord ).toConst();
  480. const roughness = roughnessMetalness.g;
  481. const metalness = roughnessMetalness.r;
  482. const noiseTexel = sampleAnalyticNoise( uvCoord, this._noiseIndex );
  483. const rotationMatrix = noiseRotationMatrix( noiseTexel.r );
  484. const frameNum = float( 1 ).div( texel.a );
  485. const varianceFactor = getTemporalVarianceFactor( frameNum, this.strength.oneMinus() );
  486. const aggressivity = varianceFactor.oneMinus();
  487. const raw = sampleRaw( uvCoord ).toConst();
  488. const viewZ = abs( viewPosition.z );
  489. const rl = float( 1 ).toVar();
  490. const nbhdMeanLuma = float( 0 ).toVar();
  491. const nbhdStddevLuma = float( 0 ).toVar();
  492. const hasEnvRay = bool( false ).toVar();
  493. if ( this.alphaSource === 'raylength' ) {
  494. const stats = getNeighborhoodStats( uvCoord, raw );
  495. rl.assign( stats.x );
  496. nbhdMeanLuma.assign( stats.y );
  497. nbhdStddevLuma.assign( stats.z );
  498. hasEnvRay.assign( stats.w.greaterThan( 0.5 ) );
  499. } else {
  500. If( this.adaptiveTrust.greaterThan( 0 ), () => {
  501. const stats = getNeighborhoodStats( uvCoord, raw );
  502. nbhdMeanLuma.assign( stats.y );
  503. nbhdStddevLuma.assign( stats.z );
  504. } );
  505. }
  506. const tanHalfFovY = this.alphaSource === 'raylength' ? tan( this._fovY.mul( 0.5 ) ).toConst() : null;
  507. const hitDistFactor = this.alphaSource === 'raylength'
  508. ? computeHitDistFactor( rl, viewZ, tanHalfFovY ).toConst()
  509. : float( 1 );
  510. const denoised = texel.rgb.toVar();
  511. const totalWeight = float( 1 ).toVar();
  512. const denoisedFrame = frameNum.toVar();
  513. const totalFrameWeight = float( 1 ).toVar();
  514. const denoisedRaw = raw.rgb.toVar();
  515. const totalWeightRaw = float( 1 ).toVar();
  516. If( raw.rgb.length().lessThan( 0.0001 ), () => {
  517. denoisedRaw.assign( vec3( 0 ) );
  518. totalWeightRaw.assign( 0 );
  519. } );
  520. const avgAo = this.alphaSource === 'ao' ? raw.a.toConst() : float( 1 );
  521. const mappedAvgAo = this.alphaSource === 'ao' ? mapAo( avgAo ) : float( 0 );
  522. const worldRadius = this.radius.mul( WORLD_RADIUS_SCALE ).toVar();
  523. if ( this.mode === 'specular' ) {
  524. worldRadius.mulAssign( rl.mul( viewPosition.z.abs() ) );
  525. worldRadius.mulAssign( roughness.sqrt().max( 0.01 ) );
  526. } else {
  527. worldRadius.mulAssign( avgAo.pow( 2 ).mul( viewPosition.z.abs() ) );
  528. }
  529. worldRadius.mulAssign( mix( 1, AGGRESSIVITY_RADIUS_MIN, aggressivity ) );
  530. const T = vec3( 0 ).toVar();
  531. const B = vec3( 0 ).toVar();
  532. if ( this.mode === 'specular' ) {
  533. const V = normalize( viewPosition ).negate();
  534. const D = getSpecularDominantDirection( viewNormal, V, roughness );
  535. const R = reflect( D.negate(), viewNormal );
  536. const Tv = normalize( cross( viewNormal, R ) );
  537. const Bv = cross( R, Tv );
  538. const viewAngle = abs( viewNormal.z ).acos().div( float( Math.PI * 0.5 ) ).clamp( 0, 1 );
  539. const skewFactor = mix( 1.0, roughness, viewAngle );
  540. T.assign( Tv.mul( skewFactor ) );
  541. B.assign( Bv );
  542. } else {
  543. const up = vec3( 0, 0, 1 );
  544. const Tv = cross( up, viewNormal ).normalize().toVar();
  545. If( Tv.length().lessThan( EPSILON ), () => {
  546. Tv.assign( cross( vec3( 0, 1, 0 ), viewNormal ).normalize() );
  547. } );
  548. T.assign( Tv );
  549. B.assign( cross( viewNormal, Tv ).normalize() );
  550. }
  551. T.mulAssign( worldRadius );
  552. B.mulAssign( worldRadius );
  553. const centerDiffuse = sampleDiffuse( uvCoord ).toConst();
  554. const radiusShrink = float( 1 ).toVar();
  555. // Directional analog of radiusShrink: an accumulated tangent-space shift that skews
  556. // subsequent taps toward directions that yielded high weight (related geometry).
  557. const polarBias = vec2( 0 ).toVar();
  558. const depthWeightScale = this.depthPhi.mul( 500 ).mul( viewNormal.z.abs() ).div( viewPosition.z.abs() );
  559. // Lobe geometry depends only on per-pixel terms, so compute its falloff constant once here.
  560. const lobeFalloff = lobeNormalFalloff( roughness, aggressivity, this.normalPhi.oneMinus() ).toConst();
  561. Loop( { start: int( 0 ), end: int( KERNEL_SAMPLES ), type: 'int', condition: '<', name: 'i' }, ( { i } ) => {
  562. const baseOffset = vogelDisk( float( i ), 1 ).toVar();
  563. const sampleDir = baseOffset.normalize().toConst();
  564. // Blend the tap direction toward the polar bias, then restore the Vogel radius and shrink.
  565. const skewedDir = mix( sampleDir, polarBias.max( EPSILON ).normalize(), this.adapt.mul( aggressivity )
  566. .mul( polarBias.dot( polarBias ).greaterThan( 0.001 ).select( 1, 0 ) ) );
  567. const offset = rotationMatrix.mul( skewedDir.mul( baseOffset.length().mul( radiusShrink ) ) ).toVar();
  568. // Exact per-sample view-space projection (both paths)
  569. const sampleViewPos = viewPosition.add( B.mul( offset.x ).add( T.mul( offset.y ) ) );
  570. const sampleUv = getScreenPosition( sampleViewPos, this._cameraProjectionMatrix ).toVar();
  571. sampleUv.assign( sampleUv.abs().oneMinus().abs().oneMinus().clamp() );
  572. const neighborColor = sampleTexture( sampleUv ).max( 0 ).toConst();
  573. // When no raw texture is bound, sampleRaw falls back to the filtered texture at the same UV.
  574. const rawNeighborColor = sampleRaw( sampleUv ).max( 0 ).toVar();
  575. // if ( this.mode === 'diffuse' ) rawNeighborColor.rgb.assign( mix( neighborColor.rgb, rawNeighborColor.rgb, neighborColor.a ) );
  576. const nDepth = sampleDepth( sampleUv );
  577. const nViewPosition = getViewPosition( sampleUv, nDepth, this._cameraProjectionMatrixInverse ).toConst();
  578. const nViewZ = abs( nViewPosition.z ).toConst();
  579. const kernelDiff = float( 0 ).toVar();
  580. // Luma edge stopping
  581. kernelDiff.addAssign( luminance( rawNeighborColor.rgb ).sub( luminance( raw.rgb ) ).abs().mul( this.lumaPhi ).mul( 10 ) );
  582. // Diffuse edge stopping (only relevant for specular mode)
  583. if ( this.diffuseNode !== null ) {
  584. kernelDiff.addAssign( ( diffuseColorDistance( centerDiffuse, sampleDiffuse( sampleUv ), float( 0 ) ).mul( this.diffusePhi ).mul( metalness ) ) );
  585. }
  586. // AO edge stopping
  587. if ( this.alphaSource === 'ao' ) {
  588. const neighborMappedAo = mapAo( rawNeighborColor.a );
  589. // We multiply here with aggressivity as well, since early application of aoW yields noise
  590. const aoW = mappedAvgAo.div( mappedAvgAo.add( neighborMappedAo ).add( AO_EDGE_STOPPING_BIAS ) ).mul( this.alphaPhi ).mul( aggressivity );
  591. kernelDiff.addAssign( ( aoW ) );
  592. } else if ( this.alphaSource === 'raylength' ) {
  593. // Ray length edge stopping
  594. const neighborHitDistFactor = computeHitDistFactor( rawNeighborColor.a, nViewZ, tanHalfFovY );
  595. const hdfDiff = hitDistFactor.sub( neighborHitDistFactor ).abs();
  596. const rayLengthFactor = hdfDiff.mul( this.alphaPhi ).div( viewPosition.z.abs() );
  597. // 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
  598. kernelDiff.addAssign( rawNeighborColor.a.greaterThan( ENV_RAY_LENGTH_THRESHOLD ).and( hasEnvRay ).select( 1, rayLengthFactor ) );
  599. }
  600. // Roughness edge stopping
  601. if ( this.mode === 'specular' ) kernelDiff.addAssign( ( abs( roughness.sub( sampleRoughnessMetalness( sampleUv ).g ) ).mul( this.roughnessPhi ) ) );
  602. const nViewNormal = sampleNormal( sampleUv );
  603. const nWorldNormal = nViewNormal.transformDirection( this._viewMatrix );
  604. const distToPlane = planeDistance( viewPosition, nViewPosition, viewNormal );
  605. // Geometric edge stopping (depth and normal)
  606. const depthDiff = distToPlane.mul( depthWeightScale );
  607. const normalW = lobeNormalWeight( worldNormal, nWorldNormal, lobeFalloff );
  608. // Sum every negative-exponent edge-stopping term (kernel + depth/plane, plus the SSR hit-distance term)
  609. const w = exp( kernelDiff.mul( aggressivity ).add( depthDiff ).negate() ).mul( normalW ).toVar();
  610. // Feedback to shrink radius based on the weight
  611. radiusShrink.assign( mix( radiusShrink, w, this.adapt ) );
  612. // Polar feedback: skew subsequent taps toward high-weight directions (related geometry)
  613. polarBias.assign( mix( polarBias, sampleDir.mul( w.sub( 0.5 ) ), 0.5 ) );
  614. // to mitigate the effect of fireflies and high variance in recently disoccluded regions, we weigh by the inverse luminance for the first 5 frames
  615. w.mulAssign( mix( float( 1 ).div( luminance( rawNeighborColor.rgb ).pow( 2 ).add( 0.01 ) ), 1, frameNum.div( 5 ).min( 1 ) ) );
  616. denoisedRaw.addAssign( rawNeighborColor.rgb.mul( w ) );
  617. totalWeightRaw.addAssign( w );
  618. denoised.addAssign( neighborColor.rgb.mul( w ) );
  619. totalWeight.addAssign( w );
  620. // Denoising the alpha (accumulation speed), to get smoother disocclusion transitions
  621. If( this.smoothDisocclusions, () => {
  622. const neighborAWeight = neighborColor.a.greaterThan( texel.a ).select( w.mul( 0.33 ), 0 );
  623. denoisedFrame.addAssign( float( 1 ).div( neighborColor.a ).mul( neighborAWeight ) );
  624. totalFrameWeight.addAssign( neighborAWeight );
  625. } );
  626. } );
  627. denoised.divAssign( totalWeight.max( EPSILON ) );
  628. denoised.assign( denoised.max( EPSILON ) );
  629. denoisedRaw.divAssign( totalWeightRaw.max( EPSILON ) );
  630. if ( this.accumulate ) {
  631. const computedFrame = denoisedFrame.div( totalFrameWeight.max( EPSILON ) );
  632. const a = float( 1 ).div( computedFrame.max( EPSILON ) ).toConst();
  633. if ( this.rawNode !== null ) {
  634. const blended = karisTemporalBlend(
  635. denoised,
  636. denoisedRaw,
  637. a,
  638. this.flickerSuppression,
  639. this.adaptiveTrust,
  640. nbhdMeanLuma,
  641. nbhdStddevLuma
  642. );
  643. result.assign( vec4( blended, a ) );
  644. } else {
  645. const finalDenoised = mix( denoised, denoisedRaw, a );
  646. result.assign( vec4( finalDenoised, a ) );
  647. }
  648. } else {
  649. result.assign( vec4( denoised, texel.a ) );
  650. }
  651. };
  652. If( depth.greaterThanEqual( 1.0 ), () => {
  653. Discard();
  654. } ).Else( runDenoise );
  655. return result;
  656. } );
  657. this._material.fragmentNode = denoiseFn( uv() ).context( builder.getSharedContext() );
  658. this._material.needsUpdate = true;
  659. return this._textureNode;
  660. }
  661. dispose() {
  662. this._renderTarget.dispose();
  663. this._material.dispose();
  664. }
  665. }
  666. export default RecurrentDenoiseNode;
  667. /**
  668. * @tsl
  669. * @param {Node} inputTexture - Temporally filtered input to denoise (e.g. TRAA output).
  670. * @param {Camera} camera
  671. * @param {RecurrentDenoiseNodeOptions} [options={}]
  672. * @returns {RecurrentDenoiseNode}
  673. */
  674. export const recurrentDenoise = ( inputTexture, camera, options = {} ) => nodeObject( new RecurrentDenoiseNode(
  675. toTextureNode( inputTexture ),
  676. camera,
  677. options
  678. ) );
粤ICP备19079148号