diff --git a/.gitignore b/.gitignore index 409ea01fecfc28..74a8799be70c97 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ .claude/ .DS_Store .idea/ -.jshintrc .project .puppeteer_profile/ .vs/ diff --git a/examples/files.json b/examples/files.json index a68d269d829064..e96404303b094a 100644 --- a/examples/files.json +++ b/examples/files.json @@ -416,6 +416,7 @@ "webgpu_postprocessing_dof", "webgpu_postprocessing_dof_basic", "webgpu_postprocessing_fxaa", + "webgpu_postprocessing_godrays", "webgpu_postprocessing_lensflare", "webgpu_postprocessing_masking", "webgpu_postprocessing_ca", diff --git a/examples/jsm/tsl/display/BilateralBlurNode.js b/examples/jsm/tsl/display/BilateralBlurNode.js new file mode 100644 index 00000000000000..aa0a9fc72c6e6e --- /dev/null +++ b/examples/jsm/tsl/display/BilateralBlurNode.js @@ -0,0 +1,364 @@ +import { RenderTarget, Vector2, NodeMaterial, RendererUtils, QuadMesh, TempNode, NodeUpdateType } from 'three/webgpu'; +import { nodeObject, Fn, float, uv, uniform, convertToTexture, vec2, vec4, passTexture, luminance, abs, exp, max } from 'three/tsl'; + +const _quadMesh = /*@__PURE__*/ new QuadMesh(); + +let _rendererState; + +/** + * Post processing node for creating a bilateral blur effect. + * + * Bilateral blur smooths an image while preserving sharp edges. Unlike a + * standard Gaussian blur which blurs everything equally, bilateral blur + * analyzes the intensity/color of neighboring pixels. If a neighbor is too + * different from the center pixel (indicating an edge), it is excluded + * from the blurring process. + * + * Reference: {@link https://en.wikipedia.org/wiki/Bilateral_filter} + * + * @augments TempNode + * @three_import import { bilateralBlur } from 'three/addons/tsl/display/BilateralBlurNode.js'; + */ +class BilateralBlurNode extends TempNode { + + static get type() { + + return 'BilateralBlurNode'; + + } + + /** + * Constructs a new bilateral blur node. + * + * @param {TextureNode} textureNode - The texture node that represents the input of the effect. + * @param {Node} directionNode - Defines the direction and radius of the blur. + * @param {number} sigma - Controls the spatial kernel of the blur filter. Higher values mean a wider blur radius. + * @param {number} sigmaColor - Controls the intensity kernel. Higher values allow more color difference to be blurred together. + */ + constructor( textureNode, directionNode = null, sigma = 4, sigmaColor = 0.1 ) { + + super( 'vec4' ); + + /** + * The texture node that represents the input of the effect. + * + * @type {TextureNode} + */ + this.textureNode = textureNode; + + /** + * Defines the direction and radius of the blur. + * + * @type {Node} + */ + this.directionNode = directionNode; + + /** + * Controls the spatial kernel of the blur filter. Higher values mean a wider blur radius. + * + * @type {number} + */ + this.sigma = sigma; + + /** + * Controls the color/intensity kernel. Higher values allow more color difference + * to be blurred together. Lower values preserve edges more strictly. + * + * @type {number} + */ + this.sigmaColor = sigmaColor; + + /** + * A uniform node holding the inverse resolution value. + * + * @private + * @type {UniformNode} + */ + this._invSize = uniform( new Vector2() ); + + /** + * Bilateral blur is applied in two passes (horizontal, vertical). + * This node controls the direction of each pass. + * + * @private + * @type {UniformNode} + */ + this._passDirection = uniform( new Vector2() ); + + /** + * The render target used for the horizontal pass. + * + * @private + * @type {RenderTarget} + */ + this._horizontalRT = new RenderTarget( 1, 1, { depthBuffer: false } ); + this._horizontalRT.texture.name = 'BilateralBlurNode.horizontal'; + + /** + * The render target used for the vertical pass. + * + * @private + * @type {RenderTarget} + */ + this._verticalRT = new RenderTarget( 1, 1, { depthBuffer: false } ); + this._verticalRT.texture.name = 'BilateralBlurNode.vertical'; + + /** + * The result of the effect is represented as a separate texture node. + * + * @private + * @type {PassTextureNode} + */ + this._textureNode = passTexture( this, this._verticalRT.texture ); + this._textureNode.uvNode = textureNode.uvNode; + + /** + * 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 resolution scale. + * + * @type {number} + * @default 1 + */ + this.resolutionScale = 1; + + } + + /** + * 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( Math.round( width * this.resolutionScale ), 1 ); + height = Math.max( Math.round( height * this.resolutionScale ), 1 ); + + this._invSize.value.set( 1 / width, 1 / height ); + this._horizontalRT.setSize( width, height ); + this._verticalRT.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 textureNode = this.textureNode; + const map = textureNode.value; + + const currentTexture = textureNode.value; + + _quadMesh.material = this._material; + + this.setSize( map.image.width, map.image.height ); + + const textureType = map.type; + + this._horizontalRT.texture.type = textureType; + this._verticalRT.texture.type = textureType; + + // horizontal + + renderer.setRenderTarget( this._horizontalRT ); + + this._passDirection.value.set( 1, 0 ); + + _quadMesh.name = 'Bilateral Blur [ Horizontal Pass ]'; + _quadMesh.render( renderer ); + + // vertical + + textureNode.value = this._horizontalRT.texture; + renderer.setRenderTarget( this._verticalRT ); + + this._passDirection.value.set( 0, 1 ); + + _quadMesh.name = 'Bilateral Blur [ Vertical Pass ]'; + _quadMesh.render( renderer ); + + // restore + + textureNode.value = currentTexture; + + RendererUtils.restoreRendererState( renderer, _rendererState ); + + } + + /** + * 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; + + } + + /** + * This method is used to setup the effect's TSL code. + * + * @param {NodeBuilder} builder - The current node builder. + * @return {PassTextureNode} + */ + setup( builder ) { + + const textureNode = this.textureNode; + + // + + const uvNode = uv(); + const directionNode = vec2( this.directionNode || 1 ); + + const sampleTexture = ( uv ) => textureNode.sample( uv ); + + const blur = Fn( () => { + + const kernelSize = this.sigma * 2 + 3; + const spatialCoefficients = this._getSpatialCoefficients( kernelSize ); + + const invSize = this._invSize; + const direction = directionNode.mul( this._passDirection ); + + // Sample center pixel + const centerColor = sampleTexture( uvNode ).toVar(); + const centerLuminance = luminance( centerColor.rgb ).toVar(); + + // Accumulate weighted samples + const weightSum = float( spatialCoefficients[ 0 ] ).toVar(); + const colorSum = vec4( centerColor.mul( spatialCoefficients[ 0 ] ) ).toVar(); + + // Precompute color sigma factor: -0.5 / (sigmaColor^2) + const colorSigmaFactor = float( - 0.5 ).div( float( this.sigmaColor * this.sigmaColor ) ).toConst(); + + for ( let i = 1; i < kernelSize; i ++ ) { + + const x = float( i ); + const spatialWeight = float( spatialCoefficients[ i ] ); + + const uvOffset = vec2( direction.mul( invSize.mul( x ) ) ).toVar(); + + // Sample in both directions (+/-) + const sampleUv1 = uvNode.add( uvOffset ); + const sampleUv2 = uvNode.sub( uvOffset ); + + const sample1 = sampleTexture( sampleUv1 ); + const sample2 = sampleTexture( sampleUv2 ); + + // Compute luminance difference for edge detection + const lum1 = luminance( sample1.rgb ); + const lum2 = luminance( sample2.rgb ); + + const diff1 = abs( lum1.sub( centerLuminance ) ); + const diff2 = abs( lum2.sub( centerLuminance ) ); + + // Compute color-based weights using Gaussian function + const colorWeight1 = exp( diff1.mul( diff1 ).mul( colorSigmaFactor ) ).toVar(); + const colorWeight2 = exp( diff2.mul( diff2 ).mul( colorSigmaFactor ) ).toVar(); + + // Combined bilateral weight = spatial weight * color weight + const bilateralWeight1 = spatialWeight.mul( colorWeight1 ); + const bilateralWeight2 = spatialWeight.mul( colorWeight2 ); + + colorSum.addAssign( sample1.mul( bilateralWeight1 ) ); + colorSum.addAssign( sample2.mul( bilateralWeight2 ) ); + + weightSum.addAssign( bilateralWeight1 ); + weightSum.addAssign( bilateralWeight2 ); + + } + + // Normalize by the total weight + return colorSum.div( max( weightSum, 0.0001 ) ); + + } ); + + // + + const material = this._material || ( this._material = new NodeMaterial() ); + material.fragmentNode = blur().context( builder.getSharedContext() ); + material.name = 'Bilateral_blur'; + material.needsUpdate = true; + + // + + const properties = builder.getNodeProperties( this ); + properties.textureNode = textureNode; + + // + + return this._textureNode; + + } + + /** + * Frees internal resources. This method should be called + * when the effect is no longer required. + */ + dispose() { + + this._horizontalRT.dispose(); + this._verticalRT.dispose(); + + } + + /** + * Computes spatial (Gaussian) coefficients depending on the given kernel radius. + * These coefficients are used for the spatial component of the bilateral filter. + * + * @private + * @param {number} kernelRadius - The kernel radius. + * @return {Array} + */ + _getSpatialCoefficients( kernelRadius ) { + + const coefficients = []; + const sigma = kernelRadius / 3; + + for ( let i = 0; i < kernelRadius; i ++ ) { + + coefficients.push( 0.39894 * Math.exp( - 0.5 * i * i / ( sigma * sigma ) ) / sigma ); + + } + + return coefficients; + + } + +} + +export default BilateralBlurNode; + +/** + * TSL function for creating a bilateral blur node for post processing. + * + * Bilateral blur smooths an image while preserving sharp edges by considering + * both spatial distance and color/intensity differences between pixels. + * + * @tsl + * @function + * @param {Node} node - The node that represents the input of the effect. + * @param {Node} directionNode - Defines the direction and radius of the blur. + * @param {number} sigma - Controls the spatial kernel of the blur filter. Higher values mean a wider blur radius. + * @param {number} sigmaColor - Controls the intensity kernel. Higher values allow more color difference to be blurred together. + * @returns {BilateralBlurNode} + */ +export const bilateralBlur = ( node, directionNode, sigma, sigmaColor ) => nodeObject( new BilateralBlurNode( convertToTexture( node ), directionNode, sigma, sigmaColor ) ); diff --git a/examples/jsm/tsl/display/GodraysNode.js b/examples/jsm/tsl/display/GodraysNode.js new file mode 100644 index 00000000000000..8ae571fd78dc27 --- /dev/null +++ b/examples/jsm/tsl/display/GodraysNode.js @@ -0,0 +1,624 @@ +import { Frustum, Matrix4, RenderTarget, Vector2, RendererUtils, QuadMesh, TempNode, NodeMaterial, NodeUpdateType, Vector3, Plane, WebGPUCoordinateSystem } from 'three/webgpu'; +import { cubeTexture, clamp, viewZToPerspectiveDepth, logarithmicDepthToViewZ, float, Loop, max, nodeObject, 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} + * @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} + * @default 0.7 + */ + this.density = uniform( float( 0.7 ) ); + + /** + * The maximum density of the godrays. Limits the maximum brightness of the godrays. + * + * @type {UniformNode} + * @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} + * @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} + */ + this._cameraMatrixWorld = uniform( camera.matrixWorld ); + + /** + * Represents the inverse projection matrix of the scene's camera. + * + * @private + * @type {UniformNode} + */ + this._cameraProjectionMatrixInverse = uniform( camera.projectionMatrixInverse ); + + /** + * Represents the inverse projection matrix of the scene's camera. + * + * @private + * @type {UniformNode} + */ + this._premultipliedLightCameraMatrix = uniform( new Matrix4() ); + + /** + * Represents the world position of the scene's camera. + * + * @private + * @type {UniformNode} + */ + this._cameraPosition = uniform( new Vector3() ); + + /** + * 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 near value of the shadow camera. + * + * @private + * @type {ReferenceNode} + */ + this._shadowCameraNear = reference( 'near', 'float', light.shadow.camera ); + + /** + * The far value of the shadow camera. + * + * @private + * @type {ReferenceNode} + */ + 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 ) => nodeObject( new GodraysNode( depthNode, camera, light ) ); diff --git a/examples/jsm/tsl/display/depthAwareBlend.js b/examples/jsm/tsl/display/depthAwareBlend.js new file mode 100644 index 00000000000000..496a40c7cddccf --- /dev/null +++ b/examples/jsm/tsl/display/depthAwareBlend.js @@ -0,0 +1,80 @@ +import { abs, color, float, Fn, Loop, mix, nodeObject, perspectiveDepthToViewZ, reference, textureSize, uv, vec2, vec4, viewZToOrthographicDepth, int, If, array, ivec2 } from 'three/tsl'; + +/** + * Performs a depth-aware blend between a base scene and a secondary effect (like godrays). + * This function uses a Poisson disk sampling pattern to detect depth discontinuities + * in the neighborhood of the current pixel. If an edge is detected, it shifts the + * sampling coordinate for the blend node away from the edge to prevent light leaking/haloing. + * + * @param {Node} baseNode - The main scene/beauty pass texture node. + * @param {Node} blendNode - The effect to be blended (e.g., Godrays, Bloom). + * @param {Node} depthNode - The scene depth texture node. + * @param {Camera} camera - The camera used for the scene. + * @param {Object} [options={}] - Configuration for the blend effect. + * @param {Node|Color} [options.blendColor=Color(0xff0000)] - The color applied to the blend node. + * @param {Node | number} [options.edgeRadius=2] - The search radius (in pixels) for detecting depth edges. + * @param {Node | number} [options.edgeStrength=2] - How far to "push" the UV away from detected edges. + * @returns {Node} The resulting blended color node. + */ +export const depthAwareBlend = /*#__PURE__*/ Fn( ( [ baseNode, blendNode, depthNode, camera, options = {} ] ) => { + + const uvNode = baseNode.uvNode || uv(); + + const cameraNear = reference( 'near', 'float', camera ); + const cameraFar = reference( 'far', 'float', camera ); + + const blendColor = nodeObject( options.blendColor ) || color( 0xffffff ); + const edgeRadius = nodeObject( options.edgeRadius ) || int( 2 ); + const edgeStrength = nodeObject( options.edgeStrength ) || float( 2 ); + + const viewZ = perspectiveDepthToViewZ( depthNode, cameraNear, cameraFar ); + const correctDepth = viewZToOrthographicDepth( viewZ, cameraNear, cameraFar ); + + const pushDir = vec2( 0.0 ).toVar(); + const count = float( 0 ).toVar(); + + const resolution = ivec2( textureSize( baseNode ) ).toConst(); + const pixelStep = vec2( 1 ).div( resolution ); + + const poissonDisk = array( [ + vec2( 0.493393, 0.394269 ), + vec2( 0.798547, 0.885922 ), + vec2( 0.259143, 0.650754 ), + vec2( 0.605322, 0.023588 ), + vec2( - 0.574681, 0.137452 ), + vec2( - 0.430397, - 0.638423 ), + vec2( - 0.849487, - 0.366258 ), + vec2( 0.170621, - 0.569941 ) + ] ); + + Loop( 8, ( { i } ) => { + + const offset = poissonDisk.element( i ).mul( edgeRadius ); + + const sampleUv = uvNode.add( offset.mul( pixelStep ) ); + const sampleDepth = depthNode.sample( sampleUv ); + + const sampleViewZ = perspectiveDepthToViewZ( sampleDepth, cameraNear, cameraFar ); + const sampleLinearDepth = viewZToOrthographicDepth( sampleViewZ, cameraNear, cameraFar ); + + If( abs( sampleLinearDepth.sub( correctDepth ) ).lessThan( float( 0.05 ).mul( correctDepth ) ), () => { + + pushDir.addAssign( offset ); + count.addAssign( 1 ); + + } ); + + } ); + + count.assign( count.equal( 0 ).select( 1, count ) ); + + pushDir.divAssign( count ).normalize(); + + const sampleUv = pushDir.length().greaterThan( 0 ).select( uvNode.add( edgeStrength.mul( pushDir.div( resolution ) ) ), uvNode ); + + const bestChoice = blendNode.sample( sampleUv ).r; + const baseColor = baseNode.sample( uvNode ); + + return vec4( mix( baseColor, vec4( blendColor, 1 ), bestChoice ) ); + +} ); diff --git a/examples/jsm/utils/LDrawUtils.js b/examples/jsm/utils/LDrawUtils.js index 96726fff040120..595ebcc346e5d3 100644 --- a/examples/jsm/utils/LDrawUtils.js +++ b/examples/jsm/utils/LDrawUtils.js @@ -119,7 +119,7 @@ class LDrawUtils { object.traverse( c => { - if ( c.isMesh | c.isLineSegments ) { + if ( c.isMesh || c.isLineSegments ) { const elemSize = c.isMesh ? 3 : 2; diff --git a/examples/screenshots/webgpu_postprocessing_godrays.jpg b/examples/screenshots/webgpu_postprocessing_godrays.jpg new file mode 100644 index 00000000000000..0a8a358541c7bd Binary files /dev/null and b/examples/screenshots/webgpu_postprocessing_godrays.jpg differ diff --git a/examples/webgpu_postprocessing_godrays.html b/examples/webgpu_postprocessing_godrays.html new file mode 100644 index 00000000000000..385bf887d4b6e5 --- /dev/null +++ b/examples/webgpu_postprocessing_godrays.html @@ -0,0 +1,257 @@ + + + + three.js webgpu - postprocessing - godrays + + + + + + +
+ + +
+ three.jsGodrays +
+ + + Screen-space raymarched Godrays. + +
+ + + + + + diff --git a/test/e2e/puppeteer.js b/test/e2e/puppeteer.js index d2046fcf42d85c..d17e72cd02c84f 100644 --- a/test/e2e/puppeteer.js +++ b/test/e2e/puppeteer.js @@ -31,6 +31,7 @@ const exceptionList = [ 'webgpu_portal', 'webgpu_postprocessing_ao', 'webgpu_postprocessing_dof', + 'webgpu_postprocessing_godrays', 'webgpu_postprocessing_ssgi', 'webgpu_postprocessing_sss', 'webgpu_postprocessing_traa', diff --git a/test/unit/utils/SmartComparer.js b/test/unit/utils/SmartComparer.js index 8c84bebe76ee80..776a927489a788 100644 --- a/test/unit/utils/SmartComparer.js +++ b/test/unit/utils/SmartComparer.js @@ -30,7 +30,6 @@ function SmartComparer() { if ( val1 === val2 ) return true; // Null or undefined values. - /* jshint eqnull:true */ if ( val1 == null || val2 == null ) { if ( val1 != val2 ) {