three
Version:
JavaScript 3D library
625 lines (429 loc) • 16.6 kB
JavaScript
import { Frustum, Matrix4, RenderTarget, Vector2, RendererUtils, QuadMesh, TempNode, NodeMaterial, NodeUpdateType, Vector3, Plane, WebGPUCoordinateSystem } from 'three/webgpu';
import { cubeTexture, clamp, viewZToPerspectiveDepth, logarithmicDepthToViewZ, float, Loop, max, Fn, passTexture, uv, dot, uniformArray, If, getViewPosition, uniform, vec4, add, interleavedGradientNoise, screenCoordinate, round, mul, uint, mix, exp, vec3, distance, pow, reference, lightPosition, vec2, bool, texture, perspectiveDepthToViewZ, lightShadowMatrix } from 'three/tsl';
const _quadMesh = /*@__PURE__*/ new QuadMesh();
const _size = /*@__PURE__*/ new Vector2();
const _DIRECTIONS = [
new Vector3( 1, 0, 0 ),
new Vector3( - 1, 0, 0 ),
new Vector3( 0, 1, 0 ),
new Vector3( 0, - 1, 0 ),
new Vector3( 0, 0, 1 ),
new Vector3( 0, 0, - 1 ),
];
const _PLANES = _DIRECTIONS.map( () => new Plane() );
const _SCRATCH_VECTOR = new Vector3();
const _SCRATCH_MAT4 = new Matrix4();
const _SCRATCH_FRUSTUM = new Frustum();
let _rendererState;
/**
* Post-Processing node for apply Screen-space raymarched godrays to a scene.
*
* After the godrays have been computed, it's recommened to apply a Bilateral
* Blur to the result to mitigate raymarching and noise artifacts.
*
* The composite with the scene pass is ideally done with `depthAwareBlend()`,
* which mitigates aliasing and light leaking.
*
* ```js
* const godraysPass = godrays( scenePassDepth, camera, light );
*
* const blurPass = bilateralBlur( godraysPassColor ); // optional blur
*
* const outputBlurred = depthAwareBlend( scenePassColor, blurPassColor, scenePassDepth, camera, { blendColor, edgeRadius, edgeStrength } ); // composite
* ```
*
* Limitations:
*
* - Only point and directional lights are currently supported.
* - The effect requires a full shadow setup. Meaning shadows must be enabled in the renderer,
* 3D objects must cast and receive shadows and the main light must cast shadows.
*
* Reference: This Node is a part of [three-good-godrays](https://github.com/Ameobea/three-good-godrays).
*
* @augments TempNode
* @three_import import { godrays } from 'three/addons/tsl/display/GodraysNode.js';
*/
class GodraysNode extends TempNode {
static get type() {
return 'GodraysNode';
}
/**
* Constructs a new Godrays node.
*
* @param {TextureNode} depthNode - A texture node that represents the scene's depth.
* @param {Camera} camera - The camera the scene is rendered with.
* @param {(DirectionalLight|PointLight)} light - The light the godrays are rendered for.
*/
constructor( depthNode, camera, light ) {
super( 'vec4' );
/**
* A node that represents the beauty pass's depth.
*
* @type {TextureNode}
*/
this.depthNode = depthNode;
/**
* The number of raymarching steps
*
* @type {UniformNode<uint>}
* @default 60
*/
this.raymarchSteps = uniform( uint( 60 ) );
/**
* The rate of accumulation for the godrays. Higher values roughly equate to more humid air/denser fog.
*
* @type {UniformNode<float>}
* @default 0.7
*/
this.density = uniform( float( 0.7 ) );
/**
* The maximum density of the godrays. Limits the maximum brightness of the godrays.
*
* @type {UniformNode<float>}
* @default 0.5
*/
this.maxDensity = uniform( float( 0.5 ) );
/**
* Higher values decrease the accumulation of godrays the further away they are from the light source.
*
* @type {UniformNode<float>}
* @default 2
*/
this.distanceAttenuation = uniform( float( 2 ) );
/**
* The resolution scale.
*
* @type {number}
*/
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;
// private uniforms
/**
* Represents the world matrix of the scene's camera.
*
* @private
* @type {UniformNode<mat4>}
*/
this._cameraMatrixWorld = uniform( camera.matrixWorld );
/**
* Represents the inverse projection matrix of the scene's camera.
*
* @private
* @type {UniformNode<mat4>}
*/
this._cameraProjectionMatrixInverse = uniform( camera.projectionMatrixInverse );
/**
* Represents the inverse projection matrix of the scene's camera.
*
* @private
* @type {UniformNode<mat4>}
*/
this._premultipliedLightCameraMatrix = uniform( new Matrix4() );
/**
* Represents the world position of the scene's camera.
*
* @private
* @type {UniformNode<mat4>}
*/
this._cameraPosition = uniform( new Vector3() );
/**
* 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 );
/**
* The near value of the shadow camera.
*
* @private
* @type {ReferenceNode<float>}
*/
this._shadowCameraNear = reference( 'near', 'float', light.shadow.camera );
/**
* The far value of the shadow camera.
*
* @private
* @type {ReferenceNode<float>}
*/
this._shadowCameraFar = reference( 'far', 'float', light.shadow.camera );
this._fNormals = uniformArray( _DIRECTIONS.map( () => new Vector3() ) );
this._fConstants = uniformArray( _DIRECTIONS.map( () => 0 ) );
/**
* The light the godrays are rendered for.
*
* @private
* @type {(DirectionalLight|PointLight)}
*/
this._light = light;
/**
* The camera the scene is rendered with.
*
* @private
* @type {Camera}
*/
this._camera = camera;
/**
* The render target the godrays are rendered into.
*
* @private
* @type {RenderTarget}
*/
this._godraysRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false } );
this._godraysRenderTarget.texture.name = 'Godrays';
/**
* The material that is used to render the effect.
*
* @private
* @type {NodeMaterial}
*/
this._material = new NodeMaterial();
this._material.name = 'Godrays';
/**
* The result of the effect is represented as a separate texture node.
*
* @private
* @type {PassTextureNode}
*/
this._textureNode = passTexture( this, this._godraysRenderTarget.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._godraysRenderTarget.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 );
//
_quadMesh.material = this._material;
_quadMesh.name = 'Godrays';
this._updateLightParams();
this._cameraPosition.value.setFromMatrixPosition( this._camera.matrixWorld );
// clear
renderer.setClearColor( 0xffffff, 1 );
// godrays
renderer.setRenderTarget( this._godraysRenderTarget );
_quadMesh.render( renderer );
// restore
RendererUtils.restoreRendererState( renderer, _rendererState );
}
_updateLightParams() {
const light = this._light;
const shadowCamera = light.shadow.camera;
this._premultipliedLightCameraMatrix.value.multiplyMatrices( shadowCamera.projectionMatrix, shadowCamera.matrixWorldInverse );
if ( light.isPointLight ) {
for ( let i = 0; i < _DIRECTIONS.length; i ++ ) {
const direction = _DIRECTIONS[ i ];
const plane = _PLANES[ i ];
_SCRATCH_VECTOR.copy( light.position );
_SCRATCH_VECTOR.addScaledVector( direction, shadowCamera.far );
plane.setFromNormalAndCoplanarPoint( direction, _SCRATCH_VECTOR );
this._fNormals.array[ i ].copy( plane.normal );
this._fConstants.array[ i ] = plane.constant;
}
} else if ( light.isDirectionalLight ) {
_SCRATCH_MAT4.multiplyMatrices( shadowCamera.projectionMatrix, shadowCamera.matrixWorldInverse );
_SCRATCH_FRUSTUM.setFromProjectionMatrix( _SCRATCH_MAT4 );
for ( let i = 0; i < 6; i ++ ) {
const plane = _SCRATCH_FRUSTUM.planes[ i ];
this._fNormals.array[ i ].copy( plane.normal ).multiplyScalar( - 1 );
this._fConstants.array[ i ] = plane.constant * - 1;
}
}
}
/**
* This method is used to setup the effect's TSL code.
*
* @param {NodeBuilder} builder - The current node builder.
* @return {PassTextureNode}
*/
setup( builder ) {
const { renderer } = builder;
const uvNode = uv();
const lightPos = lightPosition( this._light );
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;
};
const sdPlane = ( p, n, h ) => {
return dot( p, n ).add( h );
};
const intersectRayPlane = ( rayOrigin, rayDirection, planeNormal, planeDistance ) => {
const denom = dot( planeNormal, rayDirection );
return sdPlane( rayOrigin, planeNormal, planeDistance ).div( denom ).negate();
};
const computeShadowCoord = ( worldPos ) => {
const shadowPosition = lightShadowMatrix( this._light ).mul( worldPos );
const shadowCoord = shadowPosition.xyz.div( shadowPosition.w );
let coordZ = shadowCoord.z;
if ( renderer.coordinateSystem === WebGPUCoordinateSystem ) {
coordZ = coordZ.mul( 2 ).sub( 1 ); // WebGPU: Conversion [ 0, 1 ] to [ - 1, 1 ]
}
return vec3( shadowCoord.x, shadowCoord.y.oneMinus(), coordZ );
};
const inShadow = ( worldPos ) => {
if ( this._light.isPointLight ) {
const lightToPos = worldPos.sub( lightPos ).toConst();
const shadowPositionAbs = lightToPos.abs().toConst();
const viewZ = shadowPositionAbs.x.max( shadowPositionAbs.y ).max( shadowPositionAbs.z ).negate();
const depth = viewZToPerspectiveDepth( viewZ, this._shadowCameraNear, this._shadowCameraFar );
const result = cubeTexture( this._light.shadow.map.depthTexture, lightToPos ).compare( depth ).r;
return vec2( result.oneMinus().add( 0.005 ), viewZ.negate() );
} else if ( this._light.isDirectionalLight ) {
const shadowCoord = computeShadowCoord( worldPos ).toConst();
const frustumTest = shadowCoord.x.greaterThanEqual( 0 )
.and( shadowCoord.x.lessThanEqual( 1 ) )
.and( shadowCoord.y.greaterThanEqual( 0 ) )
.and( shadowCoord.y.lessThanEqual( 1 ) )
.and( shadowCoord.z.greaterThanEqual( 0 ) )
.and( shadowCoord.z.lessThanEqual( 1 ) );
const output = vec2( 1, 0 );
If( frustumTest.equal( true ), () => {
const result = texture( this._light.shadow.map.depthTexture, shadowCoord.xy ).compare( shadowCoord.z ).r;
const viewZ = perspectiveDepthToViewZ( shadowCoord.z, this._shadowCameraNear, this._shadowCameraFar );
output.assign( vec2( result.oneMinus(), viewZ.negate() ) );
} );
return output;
} else {
throw new Error( 'GodraysNode: Unsupported light type.' );
}
};
const godrays = Fn( () => {
const output = vec4( 0, 0, 0, 1 ).toVar();
const isEarlyOut = bool( false );
const depth = sampleDepth( uvNode ).toConst();
const viewPosition = getViewPosition( uvNode, depth, this._cameraProjectionMatrixInverse ).toConst();
const worldPosition = this._cameraMatrixWorld.mul( viewPosition );
const inBoxDist = float( - 10000.0 ).toVar();
Loop( 6, ( { i } ) => {
inBoxDist.assign( max( inBoxDist, sdPlane( this._cameraPosition, this._fNormals.element( i ), this._fConstants.element( i ) ) ) );
} );
const startPosition = this._cameraPosition.toVar();
If( inBoxDist.lessThan( 0 ), () => {
// If the ray target is outside the shadow box, move it to the nearest
// point on the box to avoid marching through unlit space
Loop( 6, ( { i } ) => {
If( sdPlane( worldPosition, this._fNormals.element( i ), this._fConstants.element( i ) ).greaterThan( 0 ), () => {
const direction = worldPosition.sub( this._cameraPosition ).toConst();
const t = intersectRayPlane( this._cameraPosition, direction, this._fNormals.element( i ), this._fConstants.element( i ) );
worldPosition.assign( this._cameraPosition.add( t.mul( direction ) ) );
} );
} );
} ).Else( () => {
// Find the first point where the ray intersects the shadow box (startPos)
const direction = worldPosition.sub( this._cameraPosition ).toConst();
const minT = float( 10000 ).toVar();
Loop( 6, ( { i } ) => {
const t = intersectRayPlane( this._cameraPosition, direction, this._fNormals.element( i ), this._fConstants.element( i ) );
If( t.lessThan( minT ).and( t.greaterThan( 0 ) ), () => {
minT.assign( t );
} );
} );
If( minT.equal( 10000 ), () => {
isEarlyOut.assign( true );
} ).Else( () => {
startPosition.assign( this._cameraPosition.add( minT.add( 0.001 ).mul( direction ) ) );
// If the ray target is outside the shadow box, move it to the nearest
// point on the box to avoid marching through unlit space
const endInBoxDist = float( - 10000 ).toVar();
Loop( 6, ( { i } ) => {
endInBoxDist.assign( max( endInBoxDist, sdPlane( worldPosition, this._fNormals.element( i ), this._fConstants.element( i ) ) ) );
} );
If( endInBoxDist.greaterThanEqual( 0 ), () => {
const minT = float( 10000 ).toVar();
Loop( 6, ( { i } ) => {
If( sdPlane( worldPosition, this._fNormals.element( i ), this._fConstants.element( i ) ).greaterThan( 0 ), () => {
const t = intersectRayPlane( startPosition, direction, this._fNormals.element( i ), this._fConstants.element( i ) );
If( t.lessThan( minT ).and( t.greaterThan( 0 ) ), () => {
minT.assign( t );
} );
} );
} );
If( minT.lessThan( worldPosition.distance( startPosition ) ), () => {
worldPosition.assign( startPosition.add( minT.mul( direction ) ) );
} );
} );
} );
} );
If( isEarlyOut.equal( false ), () => {
const illum = float( 0 ).toVar();
const noise = interleavedGradientNoise( screenCoordinate ).toConst();
const samplesFloat = round( add( this.raymarchSteps, mul( this.raymarchSteps.div( 8 ).add( 2 ), noise ) ) ).toConst();
const samples = uint( samplesFloat ).toConst();
Loop( samples, ( { i } ) => {
const samplePos = mix( startPosition, worldPosition, float( i ).div( samplesFloat ) ).toConst();
const shadowInfo = inShadow( samplePos );
const shadowAmount = shadowInfo.x.oneMinus().toConst();
illum.addAssign( shadowAmount.mul( distance( startPosition, worldPosition ).mul( this.density.div( 100 ) ) ).mul( pow( shadowInfo.y.div( this._shadowCameraFar ).oneMinus(), this.distanceAttenuation ) ) );
} );
illum.divAssign( samplesFloat );
output.assign( vec4( vec3( clamp( exp( illum.negate() ).oneMinus(), 0, this.maxDensity ) ), depth ) );
} );
return output;
} );
this._material.fragmentNode = godrays().context( builder.getSharedContext() );
this._material.needsUpdate = true;
return this._textureNode;
}
/**
* Frees internal resources. This method should be called
* when the effect is no longer required.
*/
dispose() {
this._godraysRenderTarget.dispose();
this._material.dispose();
}
}
export default GodraysNode;
/**
* TSL function for creating a Godrays effect.
*
* @tsl
* @function
* @param {TextureNode} depthNode - A texture node that represents the scene's depth.
* @param {Camera} camera - The camera the scene is rendered with.
* @param {(DirectionalLight|PointLight)} light - The light the godrays are rendered for.
* @returns {GodraysNode}
*/
export const godrays = ( depthNode, camera, light ) => new GodraysNode( depthNode, camera, light );