import { RenderTarget, Vector2, TempNode, QuadMesh, NodeMaterial, RendererUtils, RedFormat } from 'three/webgpu'; import { reference, logarithmicDepthToViewZ, viewZToPerspectiveDepth, getViewPosition, getScreenPositionFromClip, vogelDiskSample, interleavedGradientNoise, nodeObject, Fn, float, NodeUpdateType, uv, uniform, Loop, vec4, int, dot, max, clamp, length, screenCoordinate, PI2, texture, passTexture, context } from 'three/tsl'; import { depthAwareBlur } from './depthAwareBlur.js'; const _quadMesh = /*@__PURE__*/ new QuadMesh(); const _size = /*@__PURE__*/ new Vector2(); let _rendererState; /** * Post processing node for a fast, screen-space ambient occlusion (SSAO). * * It point-samples a per-pixel rotated Vogel disk and estimates obscurance with a single depth tap * per sample, trading the ground-truth accuracy of {@link GTAONode}'s horizon ray-marching for * lower cost. A built-in separable, depth-aware blur denoises the result so it can be used without * temporal accumulation. * ```js * const scenePass = pass( scene, camera ); * scenePass.setMRT( mrt( { output, normal: normalView } ) ); * const scenePassColor = scenePass.getTextureNode( 'output' ); * const scenePassDepth = scenePass.getTextureNode( 'depth' ); * const scenePassNormal = scenePass.getTextureNode( 'normal' ); * * const aoPass = ssao( scenePassDepth, scenePassNormal, camera ); * * renderPipeline.outputNode = scenePassColor.mul( aoPass.r ); * ``` * * @augments TempNode * @three_import import { ssao } from 'three/addons/tsl/display/SSAONode.js'; */ class SSAONode extends TempNode { static get type() { return 'SSAONode'; } /** * Constructs a new SSAO node. * * @param {Node} depthNode - A node that represents the scene's depth. * @param {Node} normalNode - A node that represents the scene's normals. * @param {Camera} camera - The camera the scene is rendered with. */ constructor( depthNode, normalNode, camera ) { super( 'float' ); /** * A node that represents the scene's depth. * * @type {Node} */ this.depthNode = depthNode; /** * A node that represents the scene's normals. * * @type {Node} */ this.normalNode = normalNode; /** * The resolution scale. The effect renders at a fraction of the drawing buffer * for extra speed; `0.5` is a good default for a low-frequency signal like AO. * * @type {number} * @default 0.5 */ this.resolutionScale = 0.5; /** * The `updateBeforeType` is set to `NodeUpdateType.FRAME` since the node renders * its effect once per frame in `updateBefore()`. * * @type {string} * @default 'frame' */ this.updateBeforeType = NodeUpdateType.FRAME; /** * The world-space radius the occlusion is gathered within. * * @type {UniformNode} */ this.radius = uniform( 0.5 ); /** * The strength of the occlusion. * * @type {UniformNode} */ this.intensity = uniform( 1 ); /** * An angle bias that suppresses self-occlusion on near-flat surfaces. * * @type {UniformNode} */ this.bias = uniform( 0.025 ); /** * How many samples are used to estimate the occlusion. A higher value * results in a smoother result at a higher runtime cost. * * @type {UniformNode} */ this.samples = uniform( 16 ); /** * Whether the depth-aware blur that denoises the raw AO is applied or not. * * @type {boolean} * @default true */ this.blurEnabled = true; /** * How strongly the blur rejects samples across depth discontinuities, * relative to the AO radius. A higher value keeps edges crisper. * * @type {UniformNode} */ this.blurSharpness = uniform( 2 ); /** * The resolution of the effect. Set from the drawing buffer size and `resolutionScale`. * * @type {Vector2} */ this.resolution = new Vector2(); /** * The render target the raw ambient occlusion is rendered into. Also holds the * final result after the separable blur has ping-ponged back into it. * * @private * @type {RenderTarget} */ this._aoRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, format: RedFormat } ); this._aoRenderTarget.texture.name = 'SSAONode.AO'; /** * The render target the intermediate (horizontally blurred) result is rendered into. * * @private * @type {RenderTarget} */ this._blurRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, format: RedFormat } ); this._blurRenderTarget.texture.name = 'SSAONode.Blur'; /** * Represents the projection matrix of the scene's camera. * * @private * @type {UniformNode} */ this._cameraProjectionMatrix = uniform( camera.projectionMatrix ); /** * Represents the inverse projection matrix of the scene's camera. * * @private * @type {UniformNode} */ this._cameraProjectionMatrixInverse = uniform( camera.projectionMatrixInverse ); /** * Represents the near value of the scene's camera. * * @private * @type {ReferenceNode} */ this._cameraNear = reference( 'near', 'float', camera ); /** * Represents the far value of the scene's camera. * * @private * @type {ReferenceNode} */ this._cameraFar = reference( 'far', 'float', camera ); /** * The camera the scene is rendered with. * * @private * @type {Camera} */ this._camera = camera; /** * The input texture the blur material reads from. Swapped between the two render * targets to run the horizontal and vertical passes with a single material. * * @private * @type {TextureNode} */ this._blurInput = texture( this._aoRenderTarget.texture ); /** * The blur direction (one texel along x or y). * * @private * @type {UniformNode} */ this._blurDirection = uniform( new Vector2() ); /** * The material that computes the raw ambient occlusion. * * @private * @type {NodeMaterial} */ this._aoMaterial = new NodeMaterial(); this._aoMaterial.name = 'SSAO.AO'; /** * The material that applies the separable, depth-aware blur. * * @private * @type {NodeMaterial} */ this._blurMaterial = new NodeMaterial(); this._blurMaterial.name = 'SSAO.Blur'; /** * The result of the effect is represented as a separate texture node. * * @private * @type {PassTextureNode} */ this._textureNode = passTexture( this, this._aoRenderTarget.texture ); } /** * Returns the result of the effect as a texture node. * * @return {PassTextureNode} A texture node that represents the result of the effect. */ getTextureNode() { return this._textureNode; } /** * Sets the size of the effect. * * @param {number} width - The width of the effect. * @param {number} height - The height of the effect. */ setSize( width, height ) { width = Math.max( 1, Math.round( this.resolutionScale * width ) ); height = Math.max( 1, Math.round( this.resolutionScale * height ) ); this.resolution.set( width, height ); this._aoRenderTarget.setSize( width, height ); this._blurRenderTarget.setSize( width, height ); } /** * This method is used to render the effect once per frame. * * @param {NodeFrame} frame - The current node frame. */ updateBefore( frame ) { const { renderer } = frame; _rendererState = RendererUtils.resetRendererState( renderer, _rendererState ); const size = renderer.getDrawingBufferSize( _size ); this.setSize( size.width, size.height ); renderer.setClearColor( 0xffffff, 1 ); // ambient occlusion _quadMesh.material = this._aoMaterial; _quadMesh.name = 'SSAO.AO'; renderer.setRenderTarget( this._aoRenderTarget ); _quadMesh.render( renderer ); // separable, depth-aware blur: horizontal ( AO -> blur ) then vertical ( blur -> AO ) if ( this.blurEnabled === true ) { _quadMesh.material = this._blurMaterial; _quadMesh.name = 'SSAO.Blur'; this._blurInput.value = this._aoRenderTarget.texture; this._blurDirection.value.set( 1 / this.resolution.x, 0 ); renderer.setRenderTarget( this._blurRenderTarget ); _quadMesh.render( renderer ); this._blurInput.value = this._blurRenderTarget.texture; this._blurDirection.value.set( 0, 1 / this.resolution.y ); renderer.setRenderTarget( this._aoRenderTarget ); _quadMesh.render( renderer ); } RendererUtils.restoreRendererState( renderer, _rendererState ); } /** * This method is used to setup the effect's TSL code. * * @param {NodeBuilder} builder - The current node builder. * @return {PassTextureNode} */ setup( builder ) { const uvNode = uv(); const sampleDepth = ( uv ) => { const depth = this.depthNode.sample( uv ).r; if ( builder.renderer.logarithmicDepthBuffer === true ) { const viewZ = logarithmicDepthToViewZ( depth, this._cameraNear, this._cameraFar ); return viewZToPerspectiveDepth( viewZ, this._cameraNear, this._cameraFar ); } return depth; }; // ambient occlusion const ao = Fn( () => { const depth = sampleDepth( uvNode ).toVar(); depth.greaterThanEqual( 1.0 ).discard(); const viewPosition = getViewPosition( uvNode, depth, this._cameraProjectionMatrixInverse ).toVar(); const viewNormal = this.normalNode.sample( uvNode ).rgb.normalize().toVar(); // a low-discrepancy Vogel disk rotated per-pixel decorrelates the samples spatially, // so a small blur is enough to hide the sampling pattern const phi = interleavedGradientNoise( screenCoordinate ).mul( PI2 ).toVar(); const samples = this.samples; // the fragment's clip position is loop-invariant; projection is linear, so each // sample only has to project its (view-plane) offset and add it const clipPosition = this._cameraProjectionMatrix.mul( vec4( viewPosition, 1.0 ) ).toVar(); const occlusion = float( 0 ).toVar(); Loop( { start: int( 0 ), end: samples, type: 'int', condition: '<' }, ( { i } ) => { // disk offset in the view-aligned plane at the fragment's depth, then reprojected const offset = vogelDiskSample( i, samples, phi ).mul( this.radius ); const clipOffset = this._cameraProjectionMatrix.mul( vec4( offset, 0.0, 0.0 ) ); const sampleUv = getScreenPositionFromClip( clipPosition.add( clipOffset ) ); const sampleViewPosition = getViewPosition( sampleUv, sampleDepth( sampleUv ), this._cameraProjectionMatrixInverse ); // normalized obscurance: only occluders above the tangent plane count, faded by distance const v = sampleViewPosition.sub( viewPosition ).toVar(); const dist = length( v ).toVar(); const cosAngle = dot( v, viewNormal ).div( max( dist, float( 0.0001 ) ) ); const falloff = this.radius.div( this.radius.add( dist ) ); occlusion.addAssign( max( cosAngle.sub( this.bias ), 0.0 ).mul( falloff ) ); } ); return clamp( occlusion.div( samples ).mul( this.intensity ).oneMinus(), 0.0, 1.0 ); } ); const sharedContext = context( builder.getSharedContext() ); // compute ambient occlusion this._aoMaterial.contextNode = sharedContext; this._aoMaterial.fragmentNode = ao(); this._aoMaterial.needsUpdate = true; // separable, depth-aware blur ( run horizontally then vertically via `_blurDirection` ) this._blurMaterial.contextNode = sharedContext; this._blurMaterial.fragmentNode = depthAwareBlur( this._blurInput, this.depthNode, this._blurDirection, this._camera, this.blurSharpness, this.radius ); this._blurMaterial.needsUpdate = true; return this._textureNode; } /** * Frees internal resources. This method should be called * when the effect is no longer required. */ dispose() { this._aoRenderTarget.dispose(); this._blurRenderTarget.dispose(); this._aoMaterial.dispose(); this._blurMaterial.dispose(); } } export default SSAONode; /** * TSL function for creating a fast screen-space ambient occlusion (SSAO) effect. * * @tsl * @function * @param {Node} depthNode - A node that represents the scene's depth. * @param {Node} normalNode - A node that represents the scene's normals. * @param {Camera} camera - The camera the scene is rendered with. * @returns {SSAONode} */ export const ssao = ( depthNode, normalNode, camera ) => new SSAONode( nodeObject( depthNode ), nodeObject( normalNode ), camera );