From d516ced4374562ab39f6ec15148168f9b401915c Mon Sep 17 00:00:00 2001 From: ycw Date: Thu, 29 Jan 2026 21:22:35 +0800 Subject: [PATCH 1/4] Cleanup: remove jshint remnants (#32885) --- .gitignore | 1 - test/unit/utils/SmartComparer.js | 1 - 2 files changed, 2 deletions(-) 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/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 ) { From 26418f0692d7e2bedccd041c80b036b48f482bae Mon Sep 17 00:00:00 2001 From: ycw Date: Thu, 29 Jan 2026 23:53:26 +0800 Subject: [PATCH 2/4] =?UTF-8?q?LDrawLoader:=20De=E2=80=91magic=20condition?= =?UTF-8?q?.=20(#32886)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/jsm/utils/LDrawUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From 27966f1631b919f80a578463137f57d1bfece2f9 Mon Sep 17 00:00:00 2001 From: Michael Herzog Date: Thu, 29 Jan 2026 17:52:24 +0100 Subject: [PATCH 3/4] WebGPURenderer: Add `GodraysNode`. (#32888) --- examples/files.json | 1 + examples/jsm/tsl/display/BilateralBlurNode.js | 364 ++++++++++ examples/jsm/tsl/display/GodraysNode.js | 624 ++++++++++++++++++ examples/jsm/tsl/display/depthAwareBlend.js | 80 +++ .../webgpu_postprocessing_godrays.jpg | Bin 0 -> 19438 bytes examples/webgpu_postprocessing_godrays.html | 257 ++++++++ test/e2e/puppeteer.js | 1 + 7 files changed, 1327 insertions(+) create mode 100644 examples/jsm/tsl/display/BilateralBlurNode.js create mode 100644 examples/jsm/tsl/display/GodraysNode.js create mode 100644 examples/jsm/tsl/display/depthAwareBlend.js create mode 100644 examples/screenshots/webgpu_postprocessing_godrays.jpg create mode 100644 examples/webgpu_postprocessing_godrays.html 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..6212e5d69634c5 --- /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/depth/normal 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/screenshots/webgpu_postprocessing_godrays.jpg b/examples/screenshots/webgpu_postprocessing_godrays.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0a8a358541c7bd61c9fcdb2ba1f20114d1c02e6b GIT binary patch literal 19438 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 Z58^PBcPm^m!Q}S-RQ}0@Pk{db|JjnxUuggU literal 0 HcmV?d00001 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', From 167f5f1aa68c391a699d99091ec06477d07e20cd Mon Sep 17 00:00:00 2001 From: Michael Herzog Date: Thu, 29 Jan 2026 17:55:34 +0100 Subject: [PATCH 4/4] Update BilateralBlurNode.js Clean up. --- examples/jsm/tsl/display/BilateralBlurNode.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/jsm/tsl/display/BilateralBlurNode.js b/examples/jsm/tsl/display/BilateralBlurNode.js index 6212e5d69634c5..aa0a9fc72c6e6e 100644 --- a/examples/jsm/tsl/display/BilateralBlurNode.js +++ b/examples/jsm/tsl/display/BilateralBlurNode.js @@ -274,7 +274,7 @@ class BilateralBlurNode extends TempNode { const colorWeight1 = exp( diff1.mul( diff1 ).mul( colorSigmaFactor ) ).toVar(); const colorWeight2 = exp( diff2.mul( diff2 ).mul( colorSigmaFactor ) ).toVar(); - // Combined bilateral weight = spatial weight * color/depth/normal weight + // Combined bilateral weight = spatial weight * color weight const bilateralWeight1 = spatialWeight.mul( colorWeight1 ); const bilateralWeight2 = spatialWeight.mul( colorWeight2 );