GTAONode.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768
  1. import { RenderTarget, Vector2, TempNode, QuadMesh, NodeMaterial, RendererUtils, RedFormat, LinearFilter } from 'three/webgpu';
  2. import { reference, logarithmicDepthToViewZ, viewZToPerspectiveDepth, getNormalFromDepth, getViewPosition, nodeObject, Fn, float, NodeUpdateType, uv, uniform, Loop, vec2, vec3, vec4, int, dot, max, min, pow, abs, If, textureSize, sin, cos, PI, texture, passTexture, mat3, add, normalize, cross, mix, acos, clamp, fract, interleavedGradientNoise, screenCoordinate } from 'three/tsl';
  3. const _quadMesh = /*@__PURE__*/ new QuadMesh();
  4. const _size = /*@__PURE__*/ new Vector2();
  5. // R2 low-discrepancy sequence constants — the 2D generalization of the golden
  6. // ratio. Each frame the blue-noise samples are scrolled by `frameId ×` these, so
  7. // successive frames form a maximally-spread quasirandom sequence for the temporal
  8. // accumulator while every frame keeps its blue-noise spectrum. Using two distinct
  9. // irrationals (rather than the same golden ratio on both channels) keeps the
  10. // rotation/step-jitter pair jointly well distributed in 2D instead of marching
  11. // along one diagonal. (Roberts 2018, "The Unreasonable Effectiveness of
  12. // Quasirandom Sequences"; Wolfe 2017, "Animating Noise For Integration Over Time".)
  13. const _r2GoldenX = 0.7548776662466927; // 1 / plastic-number
  14. const _r2GoldenY = 0.5698402909980532; // 1 / plastic-number²
  15. let _rendererState;
  16. /**
  17. * Post processing node for applying Ground Truth Ambient Occlusion (GTAO) to a scene.
  18. * ```js
  19. * const renderPipeline = new THREE.RenderPipeline( renderer );
  20. *
  21. * const scenePass = pass( scene, camera );
  22. * scenePass.setMRT( mrt( {
  23. * output: output,
  24. * normal: normalView
  25. * } ) );
  26. *
  27. * const scenePassColor = scenePass.getTextureNode( 'output' );
  28. * const scenePassNormal = scenePass.getTextureNode( 'normal' );
  29. * const scenePassDepth = scenePass.getTextureNode( 'depth' );
  30. *
  31. * const aoPass = ao( scenePassDepth, scenePassNormal, camera );
  32. * const aoPassOutput = aoPass.getTextureNode();
  33. *
  34. * renderPipeline.outputNode = scenePassColor.mul( vec4( vec3( aoPassOutput.r ), 1 ) );
  35. * ```
  36. *
  37. * Reference: [Practical Real-Time Strategies for Accurate Indirect Occlusion](https://www.activision.com/cdn/research/Practical_Real_Time_Strategies_for_Accurate_Indirect_Occlusion_NEW%20VERSION_COLOR.pdf).
  38. *
  39. * @augments TempNode
  40. * @three_import import { ao } from 'three/addons/tsl/display/GTAONode.js';
  41. */
  42. class GTAONode extends TempNode {
  43. static get type() {
  44. return 'GTAONode';
  45. }
  46. /**
  47. * Constructs a new GTAO node.
  48. *
  49. * @param {Node<float>} depthNode - A node that represents the scene's depth.
  50. * @param {?Node<vec3>} normalNode - A node that represents the scene's normals.
  51. * @param {Camera} camera - The camera the scene is rendered with.
  52. * @param {?Node<vec2>} velocityNode - A node that represents the scene's velocity.
  53. * Required to enable the temporal accumulation pass (see {@link GTAONode#temporalAccumulation}); omit to disable it.
  54. */
  55. constructor( depthNode, normalNode, camera, velocityNode = null ) {
  56. super( 'float' );
  57. /**
  58. * A node that represents the scene's depth.
  59. *
  60. * @type {Node<float>}
  61. */
  62. this.depthNode = depthNode;
  63. /**
  64. * A node that represents the scene's normals. If no normals are passed to the
  65. * constructor (because MRT is not available), normals can be automatically
  66. * reconstructed from depth values in the shader.
  67. *
  68. * @type {?Node<vec3>}
  69. */
  70. this.normalNode = normalNode;
  71. /**
  72. * A node that represents the scene's velocity. Used by the temporal
  73. * accumulation pass to reproject the previous resolved AO. `null` disables
  74. * temporal accumulation (the pass degrades to a passthrough).
  75. *
  76. * @private
  77. * @type {?Node<vec2>}
  78. */
  79. this._velocityNode = velocityNode;
  80. /**
  81. * Render resolution as a fraction of the screen size (`1` = full, `0.5` = half).
  82. *
  83. * @type {number}
  84. * @default 1
  85. */
  86. this.resolutionScale = 1;
  87. /**
  88. * The `updateBeforeType` is set to `NodeUpdateType.FRAME` since the node renders
  89. * its effect once per frame in `updateBefore()`.
  90. *
  91. * @type {string}
  92. * @default 'frame'
  93. */
  94. this.updateBeforeType = NodeUpdateType.FRAME;
  95. /**
  96. * The render target the ambient occlusion is rendered into.
  97. *
  98. * @private
  99. * @type {RenderTarget}
  100. */
  101. this._aoRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, format: RedFormat } );
  102. this._aoRenderTarget.texture.name = 'GTAONode.AO';
  103. // History + resolve targets for the temporal accumulation pass. LinearFilter
  104. // so the reprojected history fetch (fractional UV) is bilinear, and so the
  105. // output upsamples bilinearly to full-res instead of blocky nearest texels.
  106. this._historyRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, format: RedFormat } );
  107. this._historyRenderTarget.texture.name = 'GTAONode.History';
  108. this._historyRenderTarget.texture.minFilter = LinearFilter;
  109. this._historyRenderTarget.texture.magFilter = LinearFilter;
  110. this._accumulationRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, format: RedFormat } );
  111. this._accumulationRenderTarget.texture.name = 'GTAONode.Accumulation';
  112. this._accumulationRenderTarget.texture.minFilter = LinearFilter;
  113. this._accumulationRenderTarget.texture.magFilter = LinearFilter;
  114. // uniforms
  115. /**
  116. * The radius of the ambient occlusion.
  117. *
  118. * @type {UniformNode<float>}
  119. */
  120. this.radius = uniform( 0.25 );
  121. /**
  122. * The resolution of the effect. Can be scaled via
  123. * `resolutionScale`.
  124. *
  125. * @type {UniformNode<vec2>}
  126. */
  127. this.resolution = uniform( new Vector2() );
  128. /**
  129. * The thickness of the ambient occlusion.
  130. *
  131. * @type {UniformNode<float>}
  132. */
  133. this.thickness = uniform( 1 );
  134. /**
  135. * @deprecated Since the switch to quadratic ray stepping with sphere falloff,
  136. * step distribution is fixed at `t²` and this uniform has no effect. Kept for
  137. * backward compatibility and will be removed in a future release.
  138. *
  139. * @type {UniformNode<float>}
  140. */
  141. this.distanceExponent = uniform( 1 );
  142. /**
  143. * @deprecated Replaced by the sphere falloff `mix( max( h, sH ), h, (dist/radius)² )`,
  144. * which has no tunable parameter. Kept for backward compatibility and will be
  145. * removed in a future release.
  146. *
  147. * @type {UniformNode<float>}
  148. */
  149. this.distanceFallOff = uniform( 1 );
  150. /**
  151. * The scale of the ambient occlusion.
  152. *
  153. * @type {UniformNode<float>}
  154. */
  155. this.scale = uniform( 1 );
  156. /**
  157. * How many samples are used to compute the AO.
  158. * A higher value results in better quality but also
  159. * in a more expensive runtime behavior.
  160. *
  161. * @type {UniformNode<float>}
  162. */
  163. this.samples = uniform( 16 );
  164. /**
  165. * Whether to vary the sampling pattern per frame. When `true`, the blue-noise
  166. * samples are scrolled along the R2 quasirandom sequence each frame, so every
  167. * frame is a fresh, decorrelated realization for
  168. * {@link GTAONode#temporalAccumulation} (and/or an external `TRAANode`) to
  169. * average. When `false` the pattern is static.
  170. *
  171. * @type {boolean}
  172. * @default true
  173. */
  174. this.jitter = true;
  175. /**
  176. * Whether to temporally accumulate the AO: each frame is blended with the
  177. * previous resolved frame reprojected through the velocity buffer, with stale
  178. * history (disocclusions / edges) rejected by clamping to the current frame's
  179. * local AO min/max. Pair with {@link GTAONode#jitter} so the
  180. * pattern varies per frame — otherwise there's nothing to average.
  181. *
  182. * When `false`, the AO pass writes the output directly and no accumulation
  183. * pass runs. Requires a velocity node to be supplied to the constructor.
  184. *
  185. * @type {boolean}
  186. */
  187. this.temporalAccumulation = false;
  188. /**
  189. * Current-frame blend weight for temporal accumulation when the reprojected
  190. * history is valid (range `(0, 1]`). Lower = smoother but slower to react,
  191. * higher = more responsive but noisier. `0.1` accumulates over ~10 frames.
  192. *
  193. * @type {UniformNode<float>}
  194. */
  195. this.temporalAccumulationAlpha = uniform( 0.1 );
  196. /**
  197. * Backing field for {@link GTAONode#noiseNode}.
  198. *
  199. * @private
  200. * @type {?TextureNode}
  201. */
  202. this._noiseNode = null;
  203. /**
  204. * Represents the projection matrix of the scene's camera.
  205. *
  206. * @private
  207. * @type {UniformNode<mat4>}
  208. */
  209. this._cameraProjectionMatrix = uniform( camera.projectionMatrix );
  210. /**
  211. * Represents the inverse projection matrix of the scene's camera.
  212. *
  213. * @private
  214. * @type {UniformNode<mat4>}
  215. */
  216. this._cameraProjectionMatrixInverse = uniform( camera.projectionMatrixInverse );
  217. /**
  218. * Represents the near value of the scene's camera.
  219. *
  220. * @private
  221. * @type {ReferenceNode<float>}
  222. */
  223. this._cameraNear = reference( 'near', 'float', camera );
  224. /**
  225. * Represents the far value of the scene's camera.
  226. *
  227. * @private
  228. * @type {ReferenceNode<float>}
  229. */
  230. this._cameraFar = reference( 'far', 'float', camera );
  231. /**
  232. * Per-frame golden-ratio scroll added to the blue-noise samples (R channel →
  233. * slice rotation, G channel → step jitter) while {@link GTAONode#jitter}
  234. * is enabled. Advancing along the R2 quasirandom sequence each frame gives the
  235. * temporal accumulation pass a fresh, maximally-decorrelated realization while
  236. * preserving each frame's blue-noise spectrum. Zero when temporal filtering is
  237. * off (the noise is then static). Computed on the CPU to stay precise at large
  238. * frame counts.
  239. *
  240. * @private
  241. * @type {UniformNode<vec2>}
  242. */
  243. this._jitterOffset = uniform( new Vector2() );
  244. /**
  245. * The material that is used to render the effect.
  246. *
  247. * @private
  248. * @type {NodeMaterial}
  249. */
  250. this._material = new NodeMaterial();
  251. this._material.name = 'GTAO';
  252. this._accumulationMaterial = new NodeMaterial();
  253. this._accumulationMaterial.name = 'GTAO.Accumulation';
  254. /**
  255. * The result of the effect is represented as a separate texture node.
  256. * Points at the resolve target: the accumulation pass writes it when temporal
  257. * accumulation is on, otherwise the AO pass writes it directly. The binding is
  258. * fixed either way, so the `temporalAccumulation` toggle needs no recompile.
  259. *
  260. * @private
  261. * @type {PassTextureNode}
  262. */
  263. this._textureNode = passTexture( this, this._accumulationRenderTarget.texture );
  264. }
  265. /**
  266. * Optional tileable noise texture driving the slice rotation (channel R) and
  267. * per-step phase jitter (channel G). When `null` (default), the AO uses cheap
  268. * per-pixel interleaved gradient noise — no texture fetch, no CPU generation.
  269. * Assign a two-channel blue-noise texture for a higher-quality spectrum when a
  270. * temporal resolve (TRAA / accumulation) is available. Switching the source at
  271. * runtime triggers a shader recompile.
  272. *
  273. * @type {?TextureNode}
  274. * @default null
  275. */
  276. get noiseNode() {
  277. return this._noiseNode;
  278. }
  279. set noiseNode( value ) {
  280. if ( value === this._noiseNode ) return;
  281. this._noiseNode = value;
  282. // The noise source is resolved at build time inside the AO fragment, so the
  283. // fragment must be rebuilt (not just recompiled) to switch between the
  284. // texture path and interleaved gradient noise.
  285. if ( this._rebuildAOMaterial !== undefined ) this._rebuildAOMaterial();
  286. }
  287. /**
  288. * Returns the result of the effect as a texture node.
  289. *
  290. * @return {PassTextureNode} A texture node that represents the result of the effect.
  291. */
  292. getTextureNode() {
  293. return this._textureNode;
  294. }
  295. /**
  296. * Sets the size of the effect.
  297. *
  298. * @param {number} width - The width of the effect.
  299. * @param {number} height - The height of the effect.
  300. */
  301. setSize( width, height ) {
  302. const lowW = Math.max( 1, Math.round( this.resolutionScale * width ) );
  303. const lowH = Math.max( 1, Math.round( this.resolutionScale * height ) );
  304. this.resolution.value.set( lowW, lowH );
  305. this._aoRenderTarget.setSize( lowW, lowH );
  306. this._historyRenderTarget.setSize( lowW, lowH );
  307. this._accumulationRenderTarget.setSize( lowW, lowH );
  308. }
  309. /**
  310. * This method is used to render the effect once per frame.
  311. *
  312. * @param {NodeFrame} frame - The current node frame.
  313. */
  314. updateBefore( frame ) {
  315. const { renderer } = frame;
  316. _rendererState = RendererUtils.resetRendererState( renderer, _rendererState );
  317. // update temporal uniforms
  318. if ( this.jitter === true ) {
  319. const frameId = frame.frameId;
  320. // Scroll the blue noise along the R2 sequence. Computed here in double
  321. // precision and reduced mod 1, so it stays exact for any frameId (a
  322. // shader-side `frameId × irrational` would lose its fraction at high
  323. // frame counts).
  324. this._jitterOffset.value.set(
  325. ( frameId * _r2GoldenX ) % 1,
  326. ( frameId * _r2GoldenY ) % 1
  327. );
  328. } else {
  329. this._jitterOffset.value.set( 0, 0 );
  330. }
  331. //
  332. const size = renderer.getDrawingBufferSize( _size );
  333. this.setSize( size.width, size.height );
  334. // clear
  335. renderer.setClearColor( 0xffffff, 1 );
  336. // AO horizon search. With temporal accumulation on, the AO is written to its
  337. // own target and the accumulation pass resolves it into the output; with it
  338. // off, the AO is written straight to the output target and the accumulation
  339. // pass (and its history copy) is skipped.
  340. const aoTarget = this.temporalAccumulation ? this._aoRenderTarget : this._accumulationRenderTarget;
  341. _quadMesh.material = this._material;
  342. _quadMesh.name = 'AO';
  343. renderer.setRenderTarget( aoTarget );
  344. _quadMesh.render( renderer );
  345. // Temporal accumulation: reproject the previous resolved AO through the
  346. // velocity buffer and blend it with the current raw AO, writing the resolve
  347. // target (the node's output), then copy it into the history target for the
  348. // next frame.
  349. if ( this.temporalAccumulation ) {
  350. if ( this._velocityNode === null && ! this._warnedNoVelocity ) {
  351. console.warn( 'GTAONode: temporalAccumulation requires a velocityNode passed to the constructor.' );
  352. this._warnedNoVelocity = true;
  353. }
  354. _quadMesh.material = this._accumulationMaterial;
  355. _quadMesh.name = 'GTAO.Accumulation';
  356. renderer.setRenderTarget( this._accumulationRenderTarget );
  357. _quadMesh.render( renderer );
  358. renderer.copyTextureToTexture( this._accumulationRenderTarget.texture, this._historyRenderTarget.texture );
  359. }
  360. // restore
  361. RendererUtils.restoreRendererState( renderer, _rendererState );
  362. }
  363. /**
  364. * This method is used to setup the effect's TSL code.
  365. *
  366. * @param {NodeBuilder} builder - The current node builder.
  367. * @return {PassTextureNode}
  368. */
  369. setup( builder ) {
  370. const uvNode = uv();
  371. const normalSourceNode = this.normalNode;
  372. const fallbackDepthForNormal = this.depthNode.value;
  373. // Bilinear depth sampling blends values across silhouette edges, producing
  374. // phantom mid-depth values that corrupt the horizon search and cause visible
  375. // banding at low AO radius. textureGather returns the same 2×2 footprint
  376. // unblended, so we can take the min (nearest surface wins) without a separate
  377. // downsample pass.
  378. const sampleDepth = ( uv ) => {
  379. const g = this.depthNode.gather().sample( uv );
  380. const depth = min( min( g.x, g.y ), min( g.z, g.w ) );
  381. if ( builder.renderer.logarithmicDepthBuffer === true ) {
  382. const viewZ = logarithmicDepthToViewZ( depth, this._cameraNear, this._cameraFar );
  383. return viewZToPerspectiveDepth( viewZ, this._cameraNear, this._cameraFar );
  384. }
  385. return depth;
  386. };
  387. const sampleNormal = ( uv ) => ( normalSourceNode !== null )
  388. ? normalSourceNode.sample( uv ).rgb.normalize()
  389. : getNormalFromDepth( uv, fallbackDepthForNormal, this._cameraProjectionMatrixInverse );
  390. const ao = Fn( () => {
  391. const depth = sampleDepth( uvNode ).toVar();
  392. depth.greaterThanEqual( 1.0 ).discard();
  393. const viewPosition = getViewPosition( uvNode, depth, this._cameraProjectionMatrixInverse ).toVar();
  394. const viewNormal = sampleNormal( uvNode ).toVar();
  395. const radiusToUse = this.radius;
  396. // Per-pixel sampling noise. `noise1` → slice rotation, `noise2` → per-step
  397. // phase jitter. Both are scrolled per frame by the R2 jitter offset (zero
  398. // when `jitter` is off) so each frame is a decorrelated realization for the
  399. // temporal resolve. Default: cheap interleaved gradient noise (pure ALU, no
  400. // fetch). When a noise texture is injected, sample its R/G channels instead.
  401. let noise1, noise2;
  402. if ( this.noiseNode !== null ) {
  403. const noiseUv = uvNode.mul( this.resolution.div( textureSize( this.noiseNode, 0 ) ) );
  404. const noiseSample = this.noiseNode.sample( noiseUv );
  405. noise1 = fract( noiseSample.r.add( this._jitterOffset.x ) );
  406. noise2 = fract( noiseSample.g.add( this._jitterOffset.y ) );
  407. } else {
  408. noise1 = fract( interleavedGradientNoise( screenCoordinate ).add( this._jitterOffset.x ) );
  409. noise2 = fract( interleavedGradientNoise( screenCoordinate.add( vec2( 97.0, 71.0 ) ) ).add( this._jitterOffset.y ) );
  410. }
  411. // Random tangent direction from noise1, used to rotate the per-slice azimuth.
  412. const tangentAngle = noise1.mul( PI ).mul( 2.0 );
  413. const tangent = vec3( cos( tangentAngle ), sin( tangentAngle ), 0.0 );
  414. const bitangent = vec3( tangent.y.mul( - 1.0 ), tangent.x, 0.0 );
  415. const kernelMatrix = mat3( tangent, bitangent, vec3( 0.0, 0.0, 1.0 ) );
  416. const DIRECTIONS = this.samples.lessThan( 30 ).select( 3, 5 ).toVar();
  417. const STEPS = add( this.samples, DIRECTIONS.sub( 1 ) ).div( DIRECTIONS ).toVar();
  418. const ao = float( 0 ).toVar();
  419. // Each iteration analyzes one vertical "slice" of the 3D space around the fragment.
  420. // Per-step phase jitter for spatio-temporal decorrelation.
  421. // (Activision GTAO slides 86, 92–93 "Noise Distribution".)
  422. const stepJitter = noise2.toVar();
  423. // Loop-invariant (constant per pixel): view direction and the clip-space ray
  424. // origin. Projecting the slice direction once per slice (clipStep, below) turns
  425. // each ray-march step into a clip-space multiply-add instead of a full matrix
  426. // transform per tap.
  427. const viewDir = normalize( viewPosition.xyz.negate() ).toVar();
  428. const clipCenter = this._cameraProjectionMatrix.mul( vec4( viewPosition.xyz, 1.0 ) ).toVar();
  429. Loop( { start: int( 0 ), end: DIRECTIONS, type: 'int', condition: '<' }, ( { i } ) => {
  430. const angle = float( i ).div( float( DIRECTIONS ) ).mul( PI ).toVar();
  431. const sampleDir = vec3( cos( angle ), sin( angle ), 0 ).toVar();
  432. sampleDir.assign( normalize( kernelMatrix.mul( sampleDir ) ) );
  433. // Clip-space step direction for this slice (see clipCenter above).
  434. const clipStep = this._cameraProjectionMatrix.mul( vec4( sampleDir, 0.0 ) ).toVar();
  435. const sliceBitangent = normalize( cross( sampleDir, viewDir ) ).toVar();
  436. const sliceTangent = cross( sliceBitangent, viewDir ).toVar();
  437. // Project the view normal onto the slice plane (remove component along sliceBitangent).
  438. // The unnormalized length is the foreshortening weight applied at slice integration.
  439. // (Activision GTAO paper, Section 3.2 "Per-pixel sampling".)
  440. const projNRaw = viewNormal.sub( sliceBitangent.mul( dot( viewNormal, sliceBitangent ) ) ).toVar();
  441. const projNLen = projNRaw.length().toVar();
  442. const projN = projNRaw.div( max( projNLen, float( 0.0001 ) ) ).toVar();
  443. // γ — angle of projN within the slice plane, signed by the tangent direction.
  444. const nSin = dot( projN, sliceTangent ).toVar();
  445. const nCos = clamp( dot( projN, viewDir ), 0, 1 ).toVar();
  446. const signNSin = nSin.greaterThanEqual( 0 ).select( float( 1 ), float( - 1 ) );
  447. const angleN = signNSin.mul( acos( nCos ) ).toVar();
  448. const tangentToNormalInSlice = cross( projN, sliceBitangent ).toVar();
  449. const cosHorizons = vec2( dot( viewDir, tangentToNormalInSlice ), dot( viewDir, tangentToNormalInSlice.negate() ) ).toVar();
  450. // For each slice, the inner loop performs ray marching to find the horizons.
  451. Loop( { end: STEPS, type: 'int', name: 'j', condition: '<' }, ( { j } ) => {
  452. // Quadratic step distribution ( sampleDist = t² ) concentrates samples in the
  453. // near-field. (Blender's Eevee adaptation)
  454. const t = float( j ).add( 1.0 ).add( stepJitter ).div( STEPS ).toVar();
  455. const sampleDist = t.mul( t );
  456. const offsetScale = radiusToUse.mul( sampleDist ).toVar();
  457. // The loop marches in two opposite directions (x and y) along the slice's line to find the horizon on both sides.
  458. // x — clip-space ray step ( = getScreenPosition( viewPosition + sampleDir·offsetScale ) )
  459. const clipX = clipCenter.add( clipStep.mul( offsetScale ) ).toVar();
  460. const sampleUvX = clipX.xy.div( clipX.w ).mul( 0.5 ).add( 0.5 ).toVar();
  461. const sampleScreenPositionX = vec2( sampleUvX.x, sampleUvX.y.oneMinus() ).toVar();
  462. const sampleDepthX = sampleDepth( sampleScreenPositionX ).toVar();
  463. const sampleSceneViewPositionX = getViewPosition( sampleScreenPositionX, sampleDepthX, this._cameraProjectionMatrixInverse ).toVar();
  464. const viewDeltaX = sampleSceneViewPositionX.sub( viewPosition ).toVar();
  465. const lenX = viewDeltaX.length().toVar();
  466. // Manual normalize guards against zero-length delta.
  467. const sHX = dot( viewDir, viewDeltaX.div( max( lenX, float( 0.0001 ) ) ) );
  468. // Sphere falloff: ( dist / radius )² fades the sample's horizon contribution
  469. // back toward the prior horizon as it approaches the radius boundary.
  470. // (squared variant of the paper's near-field attenuation;
  471. // Activision GTAO paper, Section 4.3 "Bounding the sampling area")
  472. const distFacX = clamp( lenX.div( radiusToUse ), 0, 1 );
  473. const distFacSqX = distFacX.mul( distFacX );
  474. If( abs( viewDeltaX.z ).lessThan( this.thickness ), () => {
  475. cosHorizons.x.assign( mix( max( cosHorizons.x, sHX ), cosHorizons.x, distFacSqX ) );
  476. } );
  477. // y — opposite clip-space ray step (reuses clipStep)
  478. const clipY = clipCenter.sub( clipStep.mul( offsetScale ) ).toVar();
  479. const sampleUvY = clipY.xy.div( clipY.w ).mul( 0.5 ).add( 0.5 ).toVar();
  480. const sampleScreenPositionY = vec2( sampleUvY.x, sampleUvY.y.oneMinus() ).toVar();
  481. const sampleDepthY = sampleDepth( sampleScreenPositionY ).toVar();
  482. const sampleSceneViewPositionY = getViewPosition( sampleScreenPositionY, sampleDepthY, this._cameraProjectionMatrixInverse ).toVar();
  483. const viewDeltaY = sampleSceneViewPositionY.sub( viewPosition ).toVar();
  484. const lenY = viewDeltaY.length().toVar();
  485. const sHY = dot( viewDir, viewDeltaY.div( max( lenY, float( 0.0001 ) ) ) );
  486. const distFacY = clamp( lenY.div( radiusToUse ), 0, 1 );
  487. const distFacSqY = distFacY.mul( distFacY );
  488. If( abs( viewDeltaY.z ).lessThan( this.thickness ), () => {
  489. cosHorizons.y.assign( mix( max( cosHorizons.y, sHY ), cosHorizons.y, distFacSqY ) );
  490. } );
  491. } );
  492. // Cosine-weighted inner integral, closed-form (Activision GTAO paper, Eq. 7).
  493. // Per horizon h_i: term_i = −cos( 2 h_i − γ ) + cos( γ ) + 2 h_i sin( γ )
  494. // The 0.25 factor is ½ (integral normalization) × ½ (averaging the two horizons).
  495. //
  496. // In this slice setup `sliceTangent = cross( sliceBitangent, viewDir )` works out
  497. // opposite to `sampleDir`, so the +sampleDir samples (cosHorizons.x) live on the
  498. // −T side of the slice and −sampleDir samples (cosHorizons.y) on the +T side.
  499. // γ is signed by +T (sliceTangent), so hPos must read from cosHorizons.y.
  500. const hPos = acos( cosHorizons.y ).toVar();
  501. const hNeg = acos( cosHorizons.x ).negate().toVar();
  502. const termPos = cos( hPos.mul( 2 ).sub( angleN ) ).negate().add( nCos ).add( hPos.mul( 2 ).mul( nSin ) );
  503. const termNeg = cos( hNeg.mul( 2 ).sub( angleN ) ).negate().add( nCos ).add( hNeg.mul( 2 ).mul( nSin ) );
  504. const a = termPos.add( termNeg ).mul( 0.25 );
  505. // |projN| is the foreshortening weight from the per-slice normal projection.
  506. ao.addAssign( projNLen.mul( a ) );
  507. } );
  508. ao.assign( clamp( ao.div( DIRECTIONS ), 0, 1 ) );
  509. ao.assign( pow( ao, this.scale ) );
  510. return ao;
  511. } );
  512. // The IGN ↔ noise-texture choice is resolved at build time inside `ao()`, so
  513. // switching `noiseNode` at runtime has to rebuild this fragment, not just
  514. // recompile the existing one.
  515. this._rebuildAOMaterial = () => {
  516. this._material.fragmentNode = ao().context( builder.getSharedContext() );
  517. this._material.needsUpdate = true;
  518. };
  519. this._rebuildAOMaterial();
  520. // ─── Temporal accumulation ─────────────────────────────────────────────
  521. //
  522. // Velocity-reprojected accumulation of the raw AO. Each frame blends the
  523. // current AO with the previous resolved frame sampled at the reprojected UV;
  524. // the reprojected history is clamped to the current frame's local 3×3 AO
  525. // min/max so stale values at disocclusions / edges are rejected rather than
  526. // ghosting. Averaging the per-frame realizations (fresh each frame when
  527. // `jitter` scrolls the blue noise) drives the blue-noise
  528. // variance toward zero. Reprojection convention matches `TRAANode`:
  529. // offsetUV = velocity.xy * ( 0.5, -0.5 ), historyUV = uv - offsetUV.
  530. const aoTextureNode = texture( this._aoRenderTarget.texture );
  531. const historyTextureNode = texture( this._historyRenderTarget.texture );
  532. const temporal = Fn( () => {
  533. // Sky pixels short-circuit. Single sample is sufficient here — the
  534. // gather + min used by sampleDepth() is only needed in the horizon
  535. // search where silhouette blending would corrupt the result.
  536. this.depthNode.sample( uvNode ).greaterThanEqual( 1.0 ).discard();
  537. const current = aoTextureNode.sample( uvNode ).r.toVar();
  538. // Local AO min/max over a 3×3 neighborhood — the clamp window for history.
  539. const texelSize = vec2( 1 ).div( this.resolution ).toVar();
  540. const aoMin = current.toVar();
  541. const aoMax = current.toVar();
  542. for ( let dy = - 1; dy <= 1; dy ++ ) {
  543. for ( let dx = - 1; dx <= 1; dx ++ ) {
  544. if ( dx === 0 && dy === 0 ) continue;
  545. const s = aoTextureNode.sample( uvNode.add( vec2( dx, dy ).mul( texelSize ) ) ).r;
  546. aoMin.assign( min( aoMin, s ) );
  547. aoMax.assign( max( aoMax, s ) );
  548. }
  549. }
  550. // Reproject through velocity (NDC → UV with Y flip). Without a velocity
  551. // node, assume zero motion (the pass is forced to passthrough anyway).
  552. const offsetUV = ( this._velocityNode !== null )
  553. ? this._velocityNode.sample( uvNode ).xy.mul( vec2( 0.5, - 0.5 ) )
  554. : vec2( 0 );
  555. const historyUV = uvNode.sub( offsetUV ).toVar();
  556. // Clamp reprojected history into the current local AO range (ghost reject).
  557. const history = clamp( historyTextureNode.sample( historyUV ).r, aoMin, aoMax );
  558. // History only valid where the reprojected UV stays on screen.
  559. const validUV = historyUV.greaterThanEqual( 0 ).all().and( historyUV.lessThanEqual( 1 ).all() );
  560. // Current-frame weight: temporalAccumulationAlpha while accumulating, 1
  561. // (pure current) when the reprojected history is off-screen.
  562. const alpha = validUV.select( this.temporalAccumulationAlpha, float( 1 ) );
  563. return vec4( mix( history, current, alpha ), 0, 0, 1 );
  564. } );
  565. this._accumulationMaterial.fragmentNode = temporal().context( builder.getSharedContext() );
  566. this._accumulationMaterial.needsUpdate = true;
  567. return this._textureNode;
  568. }
  569. /**
  570. * Frees internal resources. This method should be called
  571. * when the effect is no longer required.
  572. */
  573. dispose() {
  574. this._aoRenderTarget.dispose();
  575. this._historyRenderTarget.dispose();
  576. this._accumulationRenderTarget.dispose();
  577. this._material.dispose();
  578. this._accumulationMaterial.dispose();
  579. }
  580. }
  581. export default GTAONode;
  582. /**
  583. * TSL function for creating a Ground Truth Ambient Occlusion (GTAO) effect.
  584. *
  585. * @tsl
  586. * @function
  587. * @param {Node<float>} depthNode - A node that represents the scene's depth.
  588. * @param {?Node<vec3>} normalNode - A node that represents the scene's normals.
  589. * @param {Camera} camera - The camera the scene is rendered with.
  590. * @param {?Node<vec2>} velocityNode - A node that represents the scene's velocity.
  591. * Required to enable temporal accumulation of the AO; omit to disable it.
  592. * @returns {GTAONode}
  593. */
  594. export const ao = ( depthNode, normalNode, camera, velocityNode = null ) => new GTAONode( nodeObject( depthNode ), nodeObject( normalNode ), camera, velocityNode !== null ? nodeObject( velocityNode ) : null );
粤ICP备19079148号