diff --git a/examples/files.json b/examples/files.json index 1f571ae8bd4688..0dd8f20f16cb70 100644 --- a/examples/files.json +++ b/examples/files.json @@ -422,6 +422,7 @@ "webgpu_postprocessing_ssaa", "webgpu_postprocessing_ssgi", "webgpu_postprocessing_ssr", + "webgpu_postprocessing_sss", "webgpu_postprocessing_traa", "webgpu_postprocessing_transition", "webgpu_postprocessing", diff --git a/examples/jsm/tsl/display/SSSNode.js b/examples/jsm/tsl/display/SSSNode.js new file mode 100644 index 00000000000000..7cc7c9ed324131 --- /dev/null +++ b/examples/jsm/tsl/display/SSSNode.js @@ -0,0 +1,502 @@ +import { RedFormat, RenderTarget, Vector2, RendererUtils, QuadMesh, TempNode, NodeMaterial, NodeUpdateType, UnsignedByteType } from 'three/webgpu'; +import { reference, viewZToPerspectiveDepth, logarithmicDepthToViewZ, getScreenPosition, getViewPosition, float, Break, Loop, int, max, abs, If, dot, screenCoordinate, nodeObject, Fn, passTexture, uv, uniform, perspectiveDepthToViewZ, orthographicDepthToViewZ, vec2, lightPosition, lightTargetPosition, fract, rand, mix } from 'three/tsl'; + +const _quadMesh = /*@__PURE__*/ new QuadMesh(); +const _size = /*@__PURE__*/ new Vector2(); + +const _spatialOffsets = [ 0, 0.5, 0.25, 0.75 ]; + +let _rendererState; + +/** + * Post processing node for applying Screen-Space Shadows (SSS) to a scene. + * + * Screen-Space Shadows (also known as Contact Shadows) should ideally be used to complement + * traditional shadow maps. They are best suited for rendering detailed shadows of smaller + * objects at a closer scale like intricate shadowing on highly detailed models. In other words: + * Use Shadow Maps for the foundation and Screen-Space Shadows for the details. + * + * The shadows produced by this implementation might have too hard edges for certain use cases. + * Use a box, gaussian or hash blur to soften the edges before doing the composite with the + * beauty pass. Code example: + * + * ```js + * const sssPass = sss( scenePassDepth, camera, mainLight ); + * + * const sssBlur = boxBlur( sssPass.r, { size: 2, separation: 1 } ); // optional blur + * ``` + * + * Limitations: + * + * - Ideally the maximum shadow length should not exceed `1` meter. Otherwise the effect gets + * computationally very expensive since more samples during the ray marching process are evaluated. + * You can mitigate this issue by reducing the `quality` paramter. + * - The effect can only be used with a single directional light, the main light of your scene. + * This main light usually represents the sun or daylight. + * - Like other Screen-Space techniques SSS can only honor objects in the shadowing computation that + * are currently visible within the camera's view. + * + * References: + * - {@link https://panoskarabelas.com/posts/screen_space_shadows/}. + * - {@link https://www.bendstudio.com/blog/inside-bend-screen-space-shadows/}. + * + * @augments TempNode + * @three_import import { sss } from 'three/addons/tsl/display/SSSNode.js'; + */ +class SSSNode extends TempNode { + + static get type() { + + return 'SSSNode'; + + } + + /** + * Constructs a new SSS 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} mainLight - The main directional light of the scene. + */ + constructor( depthNode, camera, mainLight ) { + + super( 'float' ); + + /** + * A node that represents the beauty pass's depth. + * + * @type {TextureNode} + */ + this.depthNode = depthNode; + + /** + * Maximum shadow length in world units. Longer shadows result in more computational + * overhead. + * + * @type {UniformNode} + * @default 0.1 + */ + this.maxDistance = uniform( 0.1, 'float' ); + + /** + * Depth testing thickness. + * + * @type {UniformNode} + * @default 0.01 + */ + this.thickness = uniform( 0.01, 'float' ); + + /** + * Shadow intensity. Must be in the range `[0, 1]`. + * + * @type {UniformNode} + * @default 0.5 + */ + this.shadowIntensity = uniform( 0.5, 'float' ); + + /** + * This parameter controls how detailed the raymarching process works. + * The value ranges is `[0,1]` where `1` means best quality (the maximum number + * of raymarching iterations/samples) and `0` means no samples at all. + * + * A quality of `0.5` is usually sufficient for most use cases. Try to keep + * this parameter as low as possible. Larger values result in noticeable more + * overhead. + * + * @type {UniformNode} + * @default 0.5 + */ + this.quality = uniform( 0.5 ); + + /** + * The resolution scale. Valid values are in the range + * `[0,1]`. `1` means best quality but also results in + * more computational overhead. Setting to `0.5` means + * the effect is computed in half-resolution. + * + * @type {number} + * @default 1 + */ + this.resolutionScale = 1; + + /** + * Whether to use temporal filtering or not. Setting this property to + * `true` requires the usage of `TRAANode`. This will help to reduce noice + * although it introduces typical TAA artifacts like ghosting and temporal + * instabilities. + * + * @type {boolean} + * @default false + */ + this.useTemporalFiltering = false; + + /** + * 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 view matrix of the scene's camera. + * + * @private + * @type {UniformNode} + */ + this._cameraViewMatrix = uniform( camera.matrixWorldInverse ); + + /** + * 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 resolution of the pass. + * + * @private + * @type {UniformNode} + */ + this._resolution = uniform( new Vector2() ); + + /** + * Temporal offset added to the initial ray step. + * + * @type {UniformNode} + */ + this._temporalOffset = uniform( 0 ); + + /** + * The frame ID use when temporal filtering is enabled. + * + * @type {UniformNode} + */ + this._frameId = uniform( 0 ); + + /** + * A reference to the scene's main light. + * + * @private + * @type {DirectionalLight} + */ + this._mainLight = mainLight; + + /** + * The camera the scene is rendered with. + * + * @private + * @type {Camera} + */ + this._camera = camera; + + /** + * The render target the SSS is rendered into. + * + * @private + * @type {RenderTarget} + */ + this._sssRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, format: RedFormat, type: UnsignedByteType } ); + this._sssRenderTarget.texture.name = 'SSS'; + + /** + * The material that is used to render the effect. + * + * @private + * @type {NodeMaterial} + */ + this._material = new NodeMaterial(); + this._material.name = 'SSS'; + + /** + * The result of the effect is represented as a separate texture node. + * + * @private + * @type {PassTextureNode} + */ + this._textureNode = passTexture( this, this._sssRenderTarget.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._resolution.value.set( width, height ); + this._sssRenderTarget.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 ); + + // update temporal uniforms + + if ( this.useTemporalFiltering === true ) { + + const frameId = frame.frameId; + + this._temporalOffset.value = _spatialOffsets[ frameId % 4 ]; + this._frameId = frame.frameId; + + } else { + + this._temporalOffset.value = 0; + this._frameId = 0; + + } + + // + + _quadMesh.material = this._material; + _quadMesh.name = 'SSS'; + + // clear + + renderer.setClearColor( 0xffffff, 1 ); + + // sss + + renderer.setRenderTarget( this._sssRenderTarget ); + _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 getViewZ = Fn( ( [ depth ] ) => { + + let viewZNode; + + if ( this._camera.isPerspectiveCamera ) { + + viewZNode = perspectiveDepthToViewZ( depth, this._cameraNear, this._cameraFar ); + + } else { + + viewZNode = orthographicDepthToViewZ( depth, this._cameraNear, this._cameraFar ); + + } + + return viewZNode; + + } ); + + 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; + + }; + + // Interleaved gradient function from Jimenez 2014 http://goo.gl/eomGso + + const gradientNoise = Fn( ( [ position ] ) => { + + return fract( float( 52.9829189 ).mul( fract( dot( position, vec2( 0.06711056, 0.00583715 ) ) ) ) ); + + } ).setLayout( { + name: 'gradientNoise', + type: 'float', + inputs: [ + { name: 'position', type: 'vec2' } + ] + } ); + + const sss = Fn( () => { + + const depth = sampleDepth( uvNode ).toVar(); + depth.greaterThanEqual( 1.0 ).discard(); + + // compute ray position and direction (in view-space) + + const rayStartPosition = getViewPosition( uvNode, depth, this._cameraProjectionMatrixInverse ).toVar( 'rayStartPosition' ); + const rayDirection = this._cameraViewMatrix.transformDirection( lightPosition( this._mainLight ).sub( lightTargetPosition( this._mainLight ) ) ).toConst( 'rayDirection' ); + const rayEndPosition = rayStartPosition.add( rayDirection.mul( this.maxDistance ) ).toConst( 'rayEndPosition' ); + + // d0 and d1 are the start and maximum points of the ray in screen space + const d0 = screenCoordinate.xy.toVar(); + const d1 = getScreenPosition( rayEndPosition, this._cameraProjectionMatrix ).mul( this._resolution ).toVar(); + + // below variables are used to control the raymarching process + + // total length of the ray + const totalLen = d1.sub( d0 ).length().toVar(); + + // offset in x and y direction + const xLen = d1.x.sub( d0.x ).toVar(); + const yLen = d1.y.sub( d0.y ).toVar(); + + // determine the larger delta + // The larger difference will help to determine how much to travel in the X and Y direction each iteration and + // how many iterations are needed to travel the entire ray + const totalStep = int( max( abs( xLen ), abs( yLen ) ).mul( this.quality.clamp() ) ).toConst(); + + // step sizes in the x and y directions + const xSpan = xLen.div( totalStep ).toVar(); + const ySpan = yLen.div( totalStep ).toVar(); + + // compute noise based ray offset + const noise = gradientNoise( screenCoordinate ); + const offset = fract( noise.add( this._temporalOffset ) ).add( rand( uvNode.add( this._frameId ) ) ).toConst( 'offset' ); + + const occlusion = float( 0 ).toVar(); + + Loop( totalStep, ( { i } ) => { + + // advance on the ray by computing a new position in screen coordinates + const xy = vec2( d0.x.add( xSpan.mul( float( i ).add( offset ) ) ), d0.y.add( ySpan.mul( float( i ).add( offset ) ) ) ).toVar(); + + // stop processing if the new position lies outside of the screen + If( xy.x.lessThan( 0 ).or( xy.x.greaterThan( this._resolution.x ) ).or( xy.y.lessThan( 0 ) ).or( xy.y.greaterThan( this._resolution.y ) ), () => { + + Break(); + + } ); + + // compute new uv, depth and viewZ for the next fragment + + const uvNode = xy.div( this._resolution ); + const fragmentDepth = sampleDepth( uvNode ).toConst(); + const fragmentViewZ = getViewZ( fragmentDepth ).toConst( 'fragmentViewZ' ); + + const s = xy.sub( d0 ).length().div( totalLen ).toVar(); + const rayPosition = mix( rayStartPosition, rayEndPosition, s ); + + const depthDelta = rayPosition.z.sub( fragmentViewZ ).negate(); // Port note: viewZ values are negative in three + + // check if the camera can't "see" the ray (ray depth must be larger than the camera depth, so positive depth_delta) + + If( depthDelta.greaterThan( 0 ).and( depthDelta.lessThan( this.thickness ) ), () => { + + // mark as occluded + + occlusion.assign( this.shadowIntensity ); + + Break(); + + } ); + + + } ); + + return occlusion.oneMinus(); + + } ); + + this._material.fragmentNode = sss().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._sssRenderTarget.dispose(); + + this._material.dispose(); + + } + +} + +export default SSSNode; + +/** + * TSL function for creating a SSS 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} mainLight - The main directional light of the scene. + * @returns {SSSNode} + */ +export const sss = ( depthNode, camera, mainLight ) => nodeObject( new SSSNode( depthNode, camera, mainLight ) ); diff --git a/examples/models/gltf/nemetona.glb b/examples/models/gltf/nemetona.glb new file mode 100644 index 00000000000000..a0e7211cf843b4 Binary files /dev/null and b/examples/models/gltf/nemetona.glb differ diff --git a/examples/screenshots/webgpu_postprocessing_sss.jpg b/examples/screenshots/webgpu_postprocessing_sss.jpg new file mode 100644 index 00000000000000..4348146b52a053 Binary files /dev/null and b/examples/screenshots/webgpu_postprocessing_sss.jpg differ diff --git a/examples/webgpu_postprocessing_sss.html b/examples/webgpu_postprocessing_sss.html new file mode 100644 index 00000000000000..7077d03da9cdf8 --- /dev/null +++ b/examples/webgpu_postprocessing_sss.html @@ -0,0 +1,232 @@ + + + + three.js webgpu - SSS + + + + + + +
+ + +
+ three.jsSSS +
+ + + Screen-Space Shadows (SSS) combined with Shadow Maps.
+ Nemetona_NatureBeauty by + JOJObrush is licensed under Creative Commons Attribution.
+
+
+ + + + + + \ No newline at end of file diff --git a/test/e2e/puppeteer.js b/test/e2e/puppeteer.js index 1cf9ffc3f39153..42121748780212 100644 --- a/test/e2e/puppeteer.js +++ b/test/e2e/puppeteer.js @@ -186,6 +186,7 @@ const exceptionList = [ 'webgpu_postprocessing_afterimage', 'webgpu_postprocessing_ca', 'webgpu_postprocessing_ssgi', + 'webgpu_postprocessing_sss', 'webgpu_xr_native_layers', 'webgpu_volume_caustics', 'webgpu_volume_lighting',