three
Version:
JavaScript 3D library
720 lines (544 loc) • 21.2 kB
JavaScript
import { DataTexture, RenderTarget, RepeatWrapping, Vector2, Vector3, TempNode, QuadMesh, NodeMaterial, RendererUtils, RedFormat } from 'three/webgpu';
import { reference, logarithmicDepthToViewZ, viewZToPerspectiveDepth, getNormalFromDepth, getViewPosition, getScreenPositionFromClip, nodeObject, Fn, float, NodeUpdateType, uv, uniform, Loop, vec2, vec3, vec4, int, dot, max, min, pow, abs, If, textureSize, sin, cos, PI, texture, passTexture, mat3, normalize, cross, mix, acos, clamp, interleavedGradientNoise, screenCoordinate, rand, context } from 'three/tsl';
const _quadMesh = /*@__PURE__*/ new QuadMesh();
const _size = /*@__PURE__*/ new Vector2();
// From Activision GTAO paper: https://www.activision.com/cdn/research/s2016_pbs_activision_occlusion.pptx
const _temporalRotations = [ 60, 300, 180, 240, 120, 0 ];
const _spatialOffsets = [ 0, 0.5, 0.25, 0.75 ];
let _rendererState;
/**
* Post processing node for applying Ground Truth Ambient Occlusion (GTAO) to a scene.
* ```js
* const renderPipeline = new THREE.RenderPipeline( renderer );
*
* // pre-pass for normals and depth
*
* const prePass = pass( scene, camera );
* prePass.setMRT( mrt( {
* output: normalView
* } ) );
*
* const prePassNormal = prePass.getTextureNode();
* const prePassDepth = prePass.getTextureNode( 'depth' );
*
* // scene pass
*
* const scenePass = pass( scene, camera );
*
* // ao
*
* const aoPass = ao( prePassDepth, prePassNormal, camera );
* const aoPassOutput = aoPass.getTextureNode();
*
* // apply the ambient occlusion to the scene
*
* scenePass.contextNode = builtinAOContext( aoPassOutput.sample( screenUV ).r );
*
* renderPipeline.outputNode = scenePass;
* ```
*
* 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).
*
* @augments TempNode
* @three_import import { ao } from 'three/addons/tsl/display/GTAONode.js';
*/
class GTAONode extends TempNode {
static get type() {
return 'GTAONode';
}
/**
* Constructs a new GTAO node.
*
* @param {Node<float>} depthNode - A node that represents the scene's depth.
* @param {?Node<vec3>} 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<float>}
*/
this.depthNode = depthNode;
/**
* A node that represents the scene's normals. If no normals are passed to the
* constructor (because MRT is not available), normals can be automatically
* reconstructed from depth values in the shader.
*
* @type {?Node<vec3>}
*/
this.normalNode = normalNode;
/**
* The resolution scale. By default the effect is rendered in full resolution
* for best quality but a value of `0.5` should be sufficient for most scenes.
*
* @type {number}
* @default 1
*/
this.resolutionScale = 1;
/**
* 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 render target the ambient occlusion is rendered into.
*
* @private
* @type {RenderTarget}
*/
this._aoRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, format: RedFormat } );
this._aoRenderTarget.texture.name = 'GTAONode.AO';
// uniforms
/**
* The radius of the ambient occlusion.
*
* @type {UniformNode<float>}
*/
this.radius = uniform( 0.25 );
/**
* The thickness of the ambient occlusion.
*
* @type {UniformNode<float>}
*/
this.thickness = uniform( 1 );
/**
* @deprecated since r186. The new distance model "Quadratic Ray Stepping"
* does not need it anymore.
*
* @type {UniformNode<float>}
*/
this.distanceExponent = uniform( 1 );
/**
* @deprecated since r186. The new distance model "Quadratic Ray Stepping"
* does not need it anymore.
*
* @type {UniformNode<float>}
*/
this.distanceFallOff = uniform( 1 );
/**
* The scale of the ambient occlusion.
*
* @type {UniformNode<float>}
*/
this.scale = uniform( 1 );
/**
* How many samples are used to compute the AO.
* A higher value results in better quality but also
* in a more expensive runtime behavior.
*
* Note: Changing this member triggers a shader recompilation.
*
* @type {UniformNode<float>}
*/
this.samples = uniform( 16 );
/**
* Whether to use temporal filtering or not. Setting this property to
* `true` requires the usage of `TRAANode`. This will help to reduce noise
* although it introduces typical TAA artifacts like ghosting and temporal
* instabilities.
*
* If setting this property to `false`, a manual denoise via `DenoiseNode`
* might be required.
*
* @type {boolean}
* @default false
*/
this.useTemporalFiltering = false;
/**
* The resolution of the effect. Can be scaled via `resolutionScale`.
*
* @private
* @type {UniformNode<vec2>}
*/
this._resolution = uniform( new Vector2() );
/**
* The internal noise texture used by the AO.
*
* @private
* @type {DataTexture}
*/
this._noiseTexture = generateMagicSquareNoise();
/**
* The node represents the internal noise texture used by the AO.
*
* @private
* @type {TextureNode}
*/
this._noiseNode = texture( this._noiseTexture );
/**
* Represents the projection matrix of the scene's camera.
*
* @private
* @type {UniformNode<mat4>}
*/
this._cameraProjectionMatrix = uniform( camera.projectionMatrix );
/**
* Represents the inverse projection matrix of the scene's camera.
*
* @private
* @type {UniformNode<mat4>}
*/
this._cameraProjectionMatrixInverse = uniform( camera.projectionMatrixInverse );
/**
* Represents the near value of the scene's camera.
*
* @private
* @type {ReferenceNode<float>}
*/
this._cameraNear = reference( 'near', 'float', camera );
/**
* Represents the far value of the scene's camera.
*
* @private
* @type {ReferenceNode<float>}
*/
this._cameraFar = reference( 'far', 'float', camera );
/**
* Temporal direction that influences the rotation angle for each slice.
*
* @private
* @type {UniformNode<float>}
*/
this._temporalDirection = uniform( 0 );
/**
* Temporal offset added to the initial ray step.
*
* @private
* @type {UniformNode<float>}
*/
this._temporalOffset = uniform( 0 );
/**
* Resolution scale uniform.
*
* @private
* @type {UniformNode<float>}
*/
this._resolutionScale = uniform( 0 );
/**
* The TSL function that computes the AO. Required for rebuild.
*
* @private
* @type {?Function}
* @default null
*/
this._ao = null;
/**
* The sample count currently baked into the shader.
*
* @private
* @type {number}
*/
this._currentSamples = - 1;
/**
* The shared builder context. Required for rebuild.
*
* @private
* @type {?Object}
* @default null
*/
this._sharedContext = null;
/**
* The material that is used to render the effect.
*
* @private
* @type {NodeMaterial}
*/
this._material = new NodeMaterial();
this._material.name = 'GTAO';
/**
* 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.round( this.resolutionScale * width );
height = Math.round( this.resolutionScale * height );
this._resolutionScale.value = this.resolutionScale;
this._resolution.value.set( width, height );
this._aoRenderTarget.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 );
// update temporal uniforms
if ( this.useTemporalFiltering === true ) {
const frameId = frame.frameId;
this._temporalDirection.value = _temporalRotations[ frameId % 6 ] / 360;
this._temporalOffset.value = _spatialOffsets[ frameId % 4 ];
} else {
this._temporalDirection.value = 0;
this._temporalOffset.value = 1;
}
// rebuild the material if the sample count has changed
if ( this.samples.value !== this._currentSamples ) {
this._currentSamples = this.samples.value;
this._material.contextNode = context( this._sharedContext );
this._material.fragmentNode = this._ao();
this._material.needsUpdate = true;
}
//
const size = renderer.getDrawingBufferSize( _size );
this.setSize( size.width, size.height );
_quadMesh.material = this._material;
_quadMesh.name = 'AO';
// clear
renderer.setClearColor( 0xffffff, 1 );
// ao
renderer.setRenderTarget( this._aoRenderTarget );
_quadMesh.render( renderer );
// restore
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 linearizeDepth = ( depth ) => {
if ( builder.renderer.logarithmicDepthBuffer === true ) {
const viewZ = logarithmicDepthToViewZ( depth, this._cameraNear, this._cameraFar );
return viewZToPerspectiveDepth( viewZ, this._cameraNear, this._cameraFar );
}
return depth;
};
const sampleDepth = ( uv ) => linearizeDepth( this.depthNode.sample( uv ).r );
const sampleCenterDepth = ( uv ) => {
// Sidestep the nearest-rounding during depth access for the unjittered center pixel to avoid banding
const g = this.depthNode.gather().sample( uv );
const depth = min( min( g.x, g.y ), min( g.z, g.w ) );
return linearizeDepth( depth );
};
const sampleNoise = ( uv ) => this._noiseNode.sample( uv );
const sampleNormal = ( uv ) => ( this.normalNode !== null ) ? this.normalNode.sample( uv ).rgb.normalize() : getNormalFromDepth( uv, this.depthNode.value, this._cameraProjectionMatrixInverse );
this._ao = Fn( () => {
const depth = this._resolutionScale.lessThan( 1 ).select( sampleCenterDepth( uvNode ), sampleDepth( uvNode ) ).toConst();
depth.greaterThanEqual( 1.0 ).discard();
const viewPosition = getViewPosition( uvNode, depth, this._cameraProjectionMatrixInverse ).toConst();
const viewNormal = sampleNormal( uvNode ).toConst();
const radius = this.radius;
const invRadius = radius.reciprocal().toConst();
const viewDir = normalize( viewPosition.xyz.negate() ).toConst();
const clipPosition = this._cameraProjectionMatrix.mul( vec4( viewPosition, 1.0 ) ).toConst();
const noiseResolution = textureSize( this._noiseNode, 0 );
let noiseUv = vec2( uvNode.x, uvNode.y.oneMinus() );
noiseUv = noiseUv.mul( this._resolution.div( noiseResolution ) );
const noiseTexel = sampleNoise( noiseUv );
const randomVec = noiseTexel.xyz.mul( 2.0 ).sub( 1.0 );
const tangent = vec3( randomVec.xy, 0.0 ).normalize();
const bitangent = vec3( tangent.y.mul( - 1.0 ), tangent.x, 0.0 );
const kernelMatrix = mat3( tangent, bitangent, vec3( 0.0, 0.0, 1.0 ) );
// The sample count is baked into the shader so loop unrolling works
const SAMPLES = this.samples.value;
const DIRECTIONS = SAMPLES < 30 ? 3 : 5;
const STEPS = Math.ceil( SAMPLES / DIRECTIONS );
const invSteps = 1 / STEPS;
const ao = float( 0 ).toVar();
// Each iteration analyzes one vertical "slice" of the 3D space around the fragment.
// Per-step phase jitter for spatio-temporal decorrelation.
const noiseJitterIdx = this._temporalDirection.mul( 0.02 );
const stepJitter = interleavedGradientNoise( screenCoordinate.add( this._temporalOffset ) ).add( rand( uvNode.add( noiseJitterIdx ).mul( 2 ).sub( 1 ) ) );
Loop( { start: int( 0 ), end: int( DIRECTIONS ), type: 'int', condition: '<' }, ( { i } ) => {
const angle = float( i ).div( DIRECTIONS ).mul( PI ).add( this._temporalDirection ).toConst();
const sampleDir = kernelMatrix.mul( vec3( cos( angle ), sin( angle ), 0 ) ).toConst();
const clipDirRadius = this._cameraProjectionMatrix.mul( vec4( sampleDir, 0.0 ) ).mul( radius ).toConst();
const sliceBitangent = normalize( cross( sampleDir, viewDir ) ).toConst();
const sliceTangent = cross( sliceBitangent, viewDir ).toConst();
// Project the view normal onto the slice plane (remove component along sliceBitangent).
// The unnormalized length is the foreshortening weight applied at slice integration.
// (Activision GTAO paper, Section 3.2 "Per-pixel sampling".)
const projNRaw = viewNormal.sub( sliceBitangent.mul( dot( viewNormal, sliceBitangent ) ) ).toConst();
const projNLen = projNRaw.length().toConst();
const projN = projNRaw.div( max( projNLen, float( 0.0001 ) ) ).toConst();
// γ — angle of projN within the slice plane, signed by the tangent direction.
const nSin = dot( projN, sliceTangent ).toConst();
const nCos = clamp( dot( projN, viewDir ), 0, 1 ).toConst();
const signNSin = nSin.greaterThanEqual( 0 ).select( float( 1 ), float( - 1 ) );
const angleN = signNSin.mul( acos( nCos ) ).toConst();
const tangentToNormalInSlice = cross( projN, sliceBitangent ).toConst();
const cosHorizon = dot( viewDir, tangentToNormalInSlice ).toConst();
const cosHorizons = vec2( cosHorizon, cosHorizon.negate() ).toVar();
// For each slice, the inner loop performs ray marching to find the horizons.
Loop( { end: int( STEPS ), type: 'int', name: 'j', condition: '<' }, ( { j } ) => {
// Quadratic step distribution ( sampleDist = t² ) concentrates samples in the
// near-field. (Blender's Eevee adaptation)
const t = float( j ).add( 1.0 ).add( stepJitter ).mul( invSteps ).toConst();
const sampleDist = t.mul( t );
const clipOffset = clipDirRadius.mul( sampleDist ).toConst();
// The loop marches in two opposite directions (x and y) along the slice's line to find the horizon on both sides.
// x
const sampleScreenPositionX = getScreenPositionFromClip( clipPosition.add( clipOffset ) ).toConst();
const sampleDepthX = sampleDepth( sampleScreenPositionX ).toConst();
const sampleSceneViewPositionX = getViewPosition( sampleScreenPositionX, sampleDepthX, this._cameraProjectionMatrixInverse ).toConst();
const viewDeltaX = sampleSceneViewPositionX.sub( viewPosition ).toConst();
const lenX = viewDeltaX.length().toConst();
// Manual normalize guards against zero-length delta.
const sHX = dot( viewDir, viewDeltaX ).div( max( lenX, float( 0.0001 ) ) );
// Sphere falloff: ( dist / radius )² fades the sample's horizon contribution
// back toward the prior horizon as it approaches the radius boundary.
// (squared variant of the paper's near-field attenuation;
// Activision GTAO paper, Section 4.3 "Bounding the sampling area")
const distFacX = min( lenX.mul( invRadius ), 1 );
const distFacSqX = distFacX.mul( distFacX );
If( abs( viewDeltaX.z ).lessThan( this.thickness ), () => {
cosHorizons.x.assign( mix( max( cosHorizons.x, sHX ), cosHorizons.x, distFacSqX ) );
} );
// y
const sampleScreenPositionY = getScreenPositionFromClip( clipPosition.sub( clipOffset ) ).toConst();
const sampleDepthY = sampleDepth( sampleScreenPositionY ).toConst();
const sampleSceneViewPositionY = getViewPosition( sampleScreenPositionY, sampleDepthY, this._cameraProjectionMatrixInverse ).toConst();
const viewDeltaY = sampleSceneViewPositionY.sub( viewPosition ).toConst();
const lenY = viewDeltaY.length().toConst();
const sHY = dot( viewDir, viewDeltaY ).div( max( lenY, float( 0.0001 ) ) );
const distFacY = min( lenY.mul( invRadius ), 1 );
const distFacSqY = distFacY.mul( distFacY );
If( abs( viewDeltaY.z ).lessThan( this.thickness ), () => {
cosHorizons.y.assign( mix( max( cosHorizons.y, sHY ), cosHorizons.y, distFacSqY ) );
} );
} );
// Cosine-weighted inner integral, closed-form (Activision GTAO paper, Eq. 7).
// Per horizon h_i: term_i = −cos( 2 h_i − γ ) + cos( γ ) + 2 h_i sin( γ )
// The 0.25 factor is ½ (integral normalization) × ½ (averaging the two horizons).
//
// In this slice setup `sliceTangent = cross( sliceBitangent, viewDir )` works out
// opposite to `sampleDir`, so the +sampleDir samples (cosHorizons.x) live on the
// −T side of the slice and −sampleDir samples (cosHorizons.y) on the +T side.
// γ is signed by +T (sliceTangent), so hPos must read from cosHorizons.y.
const hPos = acos( cosHorizons.y ).toConst();
const hNeg = acos( cosHorizons.x ).negate().toConst();
const termPos = cos( hPos.mul( 2 ).sub( angleN ) ).negate().add( nCos ).add( hPos.mul( 2 ).mul( nSin ) );
const termNeg = cos( hNeg.mul( 2 ).sub( angleN ) ).negate().add( nCos ).add( hNeg.mul( 2 ).mul( nSin ) );
const a = termPos.add( termNeg ).mul( 0.25 );
// |projN| is the foreshortening weight from the per-slice normal projection.
ao.addAssign( projNLen.mul( a ) );
} );
ao.assign( clamp( ao.div( DIRECTIONS ), 0, 1 ) );
ao.assign( pow( ao, this.scale ) );
return ao;
} );
this._sharedContext = builder.getSharedContext();
this._currentSamples = this.samples.value;
this._material.contextNode = context( builder.getSharedContext() );
this._material.fragmentNode = this._ao();
this._material.needsUpdate = true;
//
return this._textureNode;
}
/**
* Frees internal resources. This method should be called
* when the effect is no longer required.
*/
dispose() {
super.dispose();
this._aoRenderTarget.dispose();
this._noiseTexture.dispose();
this._material.dispose();
}
}
export default GTAONode;
/**
* Generates the AO's noise texture for the given size.
*
* @param {number} [size=5] - The noise size.
* @return {DataTexture} The generated noise texture.
*/
function generateMagicSquareNoise( size = 5 ) {
const noiseSize = Math.floor( size ) % 2 === 0 ? Math.floor( size ) + 1 : Math.floor( size );
const magicSquare = generateMagicSquare( noiseSize );
const noiseSquareSize = magicSquare.length;
const data = new Uint8Array( noiseSquareSize * 4 );
for ( let inx = 0; inx < noiseSquareSize; ++ inx ) {
const iAng = magicSquare[ inx ];
const angle = ( 2 * Math.PI * iAng ) / noiseSquareSize;
const randomVec = new Vector3(
Math.cos( angle ),
Math.sin( angle ),
0
).normalize();
data[ inx * 4 ] = ( randomVec.x * 0.5 + 0.5 ) * 255;
data[ inx * 4 + 1 ] = ( randomVec.y * 0.5 + 0.5 ) * 255;
data[ inx * 4 + 2 ] = 127;
data[ inx * 4 + 3 ] = 255;
}
const noiseTexture = new DataTexture( data, noiseSize, noiseSize );
noiseTexture.wrapS = RepeatWrapping;
noiseTexture.wrapT = RepeatWrapping;
noiseTexture.needsUpdate = true;
return noiseTexture;
}
/**
* Computes an array of magic square values required to generate the noise texture.
*
* @param {number} size - The noise size.
* @return {Array<number>} The magic square values.
*/
function generateMagicSquare( size ) {
const noiseSize = Math.floor( size ) % 2 === 0 ? Math.floor( size ) + 1 : Math.floor( size );
const noiseSquareSize = noiseSize * noiseSize;
const magicSquare = Array( noiseSquareSize ).fill( 0 );
let i = Math.floor( noiseSize / 2 );
let j = noiseSize - 1;
for ( let num = 1; num <= noiseSquareSize; ) {
if ( i === - 1 && j === noiseSize ) {
j = noiseSize - 2;
i = 0;
} else {
if ( j === noiseSize ) {
j = 0;
}
if ( i < 0 ) {
i = noiseSize - 1;
}
}
if ( magicSquare[ i * noiseSize + j ] !== 0 ) {
j -= 2;
i ++;
continue;
} else {
magicSquare[ i * noiseSize + j ] = num ++;
}
j ++;
i --;
}
return magicSquare;
}
/**
* TSL function for creating a Ground Truth Ambient Occlusion (GTAO) effect.
*
* @tsl
* @function
* @param {Node<float>} depthNode - A node that represents the scene's depth.
* @param {?Node<vec3>} normalNode - A node that represents the scene's normals.
* @param {Camera} camera - The camera the scene is rendered with.
* @returns {GTAONode}
*/
export const ao = ( depthNode, normalNode, camera ) => new GTAONode( nodeObject( depthNode ), nodeObject( normalNode ), camera );