Просмотр исходного кода

WebGPURenderer: Add `GodraysNode`. (#32888)

Michael Herzog 2 месяцев назад
Родитель
Сommit
27966f1631

+ 1 - 0
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",

+ 364 - 0
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<vec2|float>} 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<vec2|float>}
+		 */
+		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<vec2>}
+		 */
+		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<vec2>}
+		 */
+		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<number>}
+	 */
+	_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<vec4>} node - The node that represents the input of the effect.
+ * @param {Node<vec2|float>} 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 ) );

+ 624 - 0
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<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 ) => nodeObject( new GodraysNode( depthNode, camera, light ) );

+ 80 - 0
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<int> | number} [options.edgeRadius=2] - The search radius (in pixels) for detecting depth edges.
+ * @param {Node<float> | number} [options.edgeStrength=2] - How far to "push" the UV away from detected edges.
+ * @returns {Node<vec4>} 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 ) );
+
+} );

BIN
examples/screenshots/webgpu_postprocessing_godrays.jpg


+ 257 - 0
examples/webgpu_postprocessing_godrays.html

@@ -0,0 +1,257 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js webgpu - postprocessing - godrays</title>
+		<meta charset="utf-8">
+		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+		<link type="text/css" rel="stylesheet" href="example.css">
+	</head>
+	<body>
+
+		<div id="info">
+			<a href="https://threejs.org/" target="_blank" rel="noopener" class="logo-link"></a>
+
+			<div class="title-wrapper">
+				<a href="https://threejs.org/" target="_blank" rel="noopener">three.js</a><span>Godrays</span>
+			</div>
+
+			<small>
+				Screen-space raymarched Godrays.
+			</small>
+		</div>
+
+		<script type="importmap">
+			{
+				"imports": {
+					"three": "../build/three.webgpu.js",
+					"three/webgpu": "../build/three.webgpu.js",
+					"three/tsl": "../build/three.tsl.js",
+					"three/addons/": "./jsm/"
+				}
+			}
+		</script>
+
+		<script type="module">
+
+			import * as THREE from 'three/webgpu';
+			import { pass, uniform, float, int, color } from 'three/tsl';
+			import { godrays } from 'three/addons/tsl/display/GodraysNode.js';
+			import { bilateralBlur } from 'three/addons/tsl/display/BilateralBlurNode.js';
+			import { depthAwareBlend } from 'three/addons/tsl/display/depthAwareBlend.js';
+
+			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+			import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+
+			import { Inspector } from 'three/addons/inspector/Inspector.js';
+
+			let camera, controls, scene, renderer, renderPipeline;
+
+			const params = {
+				enabledBlur: true,
+			};
+
+			init();
+
+			async function init() {
+
+				camera = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.1, 1000 );
+				camera.position.set( - 175, 50, 0 );
+
+				scene = new THREE.Scene();
+				scene.background = new THREE.Color( 0x000000 );
+
+				// asset
+
+				const loader = new GLTFLoader();
+				const gltf = await loader.loadAsync( 'models/gltf/godrays_demo.glb' );
+				scene.add( gltf.scene );
+
+				const pillars = gltf.scene.getObjectByName( 'concrete' );
+				pillars.material = new THREE.MeshStandardMaterial( {
+					color: 0x333333,
+				} );
+
+				const base = gltf.scene.getObjectByName( 'base' );
+				base.material = new THREE.MeshStandardMaterial( {
+					color: 0x333333,
+					side: THREE.DoubleSide,
+				} );
+
+				// lights
+
+				const lightPos = new THREE.Vector3( 0, 50, 0 );
+				const lightSphereMaterial = new THREE.MeshBasicMaterial( {
+					color: 0xffffff,
+				} );
+				const lightSphere = new THREE.Mesh( new THREE.SphereGeometry( 0.5, 16, 16 ), lightSphereMaterial );
+				lightSphere.position.copy( lightPos );
+				scene.add( lightSphere );
+
+				scene.add( new THREE.AmbientLight( 0xcccccc, 0.4 ) );
+
+				const pointLight = new THREE.PointLight( 0xffffff, 10000 );
+				pointLight.castShadow = true;
+				pointLight.shadow.bias = - 0.00001;
+				pointLight.shadow.mapSize.width = 2048;
+				pointLight.shadow.mapSize.height = 2048;
+				pointLight.position.copy( lightPos );
+				scene.add( pointLight );
+
+				setupBackdrop();
+
+				// shadow setup
+
+				scene.traverse( obj => {
+
+					if ( obj.isMesh === true ) {
+
+						obj.castShadow = true;
+						obj.receiveShadow = true;
+
+					}
+
+				} );
+
+				lightSphere.castShadow = false;
+				lightSphere.receiveShadow = false;
+
+				// renderer
+
+				renderer = new THREE.WebGPURenderer();
+				renderer.setPixelRatio( window.devicePixelRatio );
+				renderer.setSize( window.innerWidth, window.innerHeight );
+				renderer.setAnimationLoop( animate );
+				renderer.inspector = new Inspector();
+				renderer.shadowMap.enabled = true;
+				document.body.appendChild( renderer.domElement );
+
+				// post processing
+
+				renderPipeline = new THREE.RenderPipeline( renderer );
+
+				// beauty
+
+				const scenePass = pass( scene, camera );
+				const scenePassColor = scenePass.getTextureNode( 'output' );
+				const scenePassDepth = scenePass.getTextureNode( 'depth' );
+
+				// godrays
+			
+				const godraysPass = godrays( scenePassDepth, camera, pointLight );
+				const godraysPassColor = godraysPass.getTextureNode();
+
+				// blur
+
+				const blurPass = bilateralBlur( godraysPassColor );
+				const blurPassColor = blurPass.getTextureNode();
+
+				// composite
+
+				const blendColor = uniform( color( 0xf6287d ) );
+				const edgeRadius = uniform( int( 2 ) );
+				const edgeStrength = uniform( float( 2 ) );
+
+				const outputBlurred = depthAwareBlend( scenePassColor, blurPassColor, scenePassDepth, camera, { blendColor, edgeRadius, edgeStrength } );
+				const outputRaw = depthAwareBlend( scenePassColor, godraysPassColor, scenePassDepth, camera, { blendColor, edgeRadius, edgeStrength } );
+			
+				renderPipeline.outputNode = outputBlurred;
+
+				// GUI
+
+				const gui = renderer.inspector.createParameters( 'Settings' );
+				const godraysFolder = gui.addFolder( 'Godrays' );
+				godraysFolder.add( godraysPass.raymarchSteps, 'value', 24, 120 ).step( 1 ).name( 'raymarch steps' );
+				godraysFolder.add( godraysPass.density, 'value', 0, 1 ).name( 'density' );
+				godraysFolder.add( godraysPass.maxDensity, 'value', 0, 1 ).name( 'max density' );
+				godraysFolder.add( godraysPass.distanceAttenuation, 'value', 0, 5 ).name( 'distance attenuation' );
+			
+				const compositeFolder = gui.addFolder( 'composite' );
+				compositeFolder.add( edgeRadius, 'value', 0, 5, 1 ).name( 'edge radius' );
+				compositeFolder.add( edgeStrength, 'value', 0, 5 ).name( 'edge strength' );
+			
+				const blurFolder = gui.addFolder( 'blur' );
+				blurFolder.add( params, 'enabledBlur' ).name( 'enabled' ).onChange( ( value ) => {
+
+					if ( value === true ) {
+
+						renderPipeline.outputNode = outputBlurred;
+
+					} else {
+
+						renderPipeline.outputNode = outputRaw;
+
+					}
+
+					renderPipeline.needsUpdate = true;
+
+				} );
+
+				//
+
+				controls = new OrbitControls( camera, renderer.domElement );
+				controls.target.set( 0, 0.5, 0 );
+				controls.enableDamping = true;
+				controls.maxDistance = 200;
+				controls.update();
+
+				window.addEventListener( 'resize', onWindowResize );
+
+			}
+
+
+			function setupBackdrop() {
+
+				 const backdropDistance = 200;
+				// Add backdrop walls `backdropDistance` units away from the origin
+				const backdropGeometry = new THREE.PlaneGeometry( 400, 200 );
+				const backdropMaterial = new THREE.MeshBasicMaterial( {
+					color: 0x000000,
+					side: THREE.DoubleSide,
+				} );
+				const backdropLeft = new THREE.Mesh( backdropGeometry, backdropMaterial );
+				backdropLeft.position.set( - backdropDistance, 100, 0 );
+				backdropLeft.rotateY( Math.PI / 2 );
+				scene.add( backdropLeft );
+			
+				const backdropRight = new THREE.Mesh( backdropGeometry, backdropMaterial );
+				backdropRight.position.set( backdropDistance, 100, 0 );
+				backdropRight.rotateY( Math.PI / 2 );
+				scene.add( backdropRight );
+			
+				const backdropFront = new THREE.Mesh( backdropGeometry, backdropMaterial );
+				backdropFront.position.set( 0, 100, - backdropDistance );
+				scene.add( backdropFront );
+			
+				const backdropBack = new THREE.Mesh( backdropGeometry, backdropMaterial );
+				backdropBack.position.set( 0, 100, backdropDistance );
+				scene.add( backdropBack );
+			
+				const backdropTop = new THREE.Mesh( backdropGeometry, backdropMaterial );
+				backdropTop.position.set( 0, 200, 0 );
+				backdropTop.rotateX( Math.PI / 2 );
+				backdropTop.scale.set( 3, 6, 1 );
+				scene.add( backdropTop );
+
+			}
+
+			function onWindowResize() {
+
+				camera.aspect = window.innerWidth / window.innerHeight;
+				camera.updateProjectionMatrix();
+
+				renderer.setSize( window.innerWidth, window.innerHeight );
+
+			}
+
+			function animate() {
+
+				controls.update();
+
+				renderPipeline.render();
+
+
+			}
+
+		</script>
+	</body>
+</html>

+ 1 - 0
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',

粤ICP备19079148号