Browse Source

SSGINode: New node for screen-space global illumination. (#31839)

* SSGINode: New node for screen-space global illumination.

* E2E: Add example to exception list.

* Example: Improve presets.
Michael Herzog 5 months ago
parent
commit
22e1b94beb

+ 1 - 0
examples/files.json

@@ -418,6 +418,7 @@
 		"webgpu_postprocessing_smaa",
 		"webgpu_postprocessing_sobel",
 		"webgpu_postprocessing_ssaa",
+		"webgpu_postprocessing_ssgi",
 		"webgpu_postprocessing_ssr",
 		"webgpu_postprocessing_traa",
 		"webgpu_postprocessing_transition",

+ 650 - 0
examples/jsm/tsl/display/SSGINode.js

@@ -0,0 +1,650 @@
+import { RenderTarget, Vector2, TempNode, QuadMesh, NodeMaterial, RendererUtils, MathUtils } from 'three/webgpu';
+import { clamp, normalize, reference, nodeObject, Fn, NodeUpdateType, uniform, vec4, passTexture, uv, logarithmicDepthToViewZ, viewZToPerspectiveDepth, getViewPosition, screenCoordinate, float, sub, fract, dot, vec2, rand, vec3, Loop, mul, PI, cos, sin, uint, cross, acos, sign, pow, luminance, If, max, abs, Break, sqrt, HALF_PI, div, ceil, shiftRight, uvec2, convertToTexture, bool, getNormalFromDepth } from 'three/tsl';
+
+const _quadMesh = /*@__PURE__*/ new QuadMesh();
+const _size = /*@__PURE__*/ new Vector2();
+
+// From Activision GTAO paper: https://www.activision.com/cdn/research/s2016_pbs_activision_occlusion.pptx
+const _temporalRotations = [ 60, 300, 180, 240, 120, 0 ];
+const _spatialOffsets = [ 0, 0.5, 0.25, 0.75 ];
+
+let _rendererState;
+
+/**
+ * Post processing node for applying Screen Space Global Illumination (SSGI) to a scene.
+ *
+ * References:
+ * - {@link https://github.com/cdrinmatane/SSRT3}.
+ * - {@link https://cdrinmatane.github.io/posts/ssaovb-code/}.
+ * - {@link https://cdrinmatane.github.io/cgspotlight-slides/ssilvb_slides.pdf}.
+ *
+ * The quality and performance of the effect mainly depend on `sliceCount` and `stepCount`.
+ * The total number of samples taken per pixel is `sliceCount` * `stepCount` * `2`. Here are some
+ * recommened presets depending on whether temporal filtering is used or not.
+ *
+ * With temporal filtering (recommended):
+ *
+ * - Low: `sliceCount` of `1`, `stepCount` of `12`.
+ * - Medium: `sliceCount` of `2`, `stepCount` of `8`.
+ * - High: `sliceCount` of `3`, `stepCount` of `16`.
+ *
+ * Use for a higher slice count if you notice temporal instabilties like flickering. Reduce the sample
+ * count then to mitigate the performance lost.
+ *
+ * Without temporal filtering:
+ *
+ * - Low: `sliceCount` of `2`, `stepCount` of `6`.
+ * - Medium: `sliceCount` of `3`, `stepCount` of `8`.
+ * - High: `sliceCount` of `4`, `stepCount` of `12`.
+ *
+ * @augments TempNode
+ * @three_import import { ssgi } from 'three/addons/tsl/display/SSGINode.js';
+ */
+class SSGINode extends TempNode {
+
+	static get type() {
+
+		return 'SSGINode';
+
+	}
+
+	/**
+	 * Constructs a new SSGI node.
+	 *
+	 * @param {TextureNode} beautyNode - The texture node that represents the input of the effect.
+	 * @param {TextureNode} depthNode - A texture node that represents the scene's depth.
+	 * @param {TextureNode} normalNode - A texture node that represents the scene's normals.
+	 * @param {PerspectiveCamera} camera - The camera the scene is rendered with.
+	 */
+	constructor( beautyNode, depthNode, normalNode, camera ) {
+
+		super( 'vec4' );
+
+		/**
+		 * A node that represents the scene's depth.
+		 *
+		 * @type {Node<float>}
+		 */
+		this.beautyNode = beautyNode;
+
+		/**
+		 * A node that represents the scene's depth.
+		 *
+		 * @type {TextureNode}
+		 */
+		this.depthNode = depthNode;
+
+		/**
+		 * A node that represents the scene's normals. If no normals are passed to the
+		 * constructor (because MRT is not available), normals can be automatically
+		 * reconstructed from depth values in the shader.
+		 *
+		 * @type {TextureNode}
+		 */
+		this.normalNode = normalNode;
+
+		/**
+		 * 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;
+
+		/**
+		 * Number of per-pixel hemisphere slices. This has a big performance cost and should be kept as low as possible.
+		 * Should be in the range `[1, 4]`.
+		 *
+		 * @type {UniformNode<int>}
+		 * @default 1
+		 */
+		this.sliceCount = uniform( 1, 'uint' );
+
+		/**
+		 * Number of samples taken along one side of a given hemisphere slice. This has a big performance cost and should
+		 * be kept as low as possible.  Should be in the range `[1, 32]`.
+		 *
+		 * @type {UniformNode<int>}
+		 * @default 12
+		 */
+		this.stepCount = uniform( 12, 'uint' );
+
+		/**
+		 * Power function applied to AO to make it appear darker/lighter. Should be in the range `[0, 4]`.
+		 *
+		 * @type {UniformNode<float>}
+		 * @default 1
+		 */
+		this.aoIntensity = uniform( 1, 'float' );
+
+		/**
+		 * Intensity of the indirect diffuse light. Should be in the range `[0, 100]`.
+		 *
+		 * @type {UniformNode<float>}
+		 * @default 10
+		 */
+		this.giIntensity = uniform( 10, 'float' );
+
+		/**
+		 * Effective sampling radius in world space. AO and GI can only have influence within that radius.
+		 * Should be in the range `[1, 25]`.
+		 *
+		 * @type {UniformNode<float>}
+		 * @default 12
+		 */
+		this.radius = uniform( 12, 'float' );
+
+		/**
+		 * Makes the sample distance in screen space instead of world-space (helps having more detail up close).
+		 *
+		 * @type {UniformNode<bool>}
+		 * @default false
+		 */
+		this.useScreenSpaceSampling = uniform( true, 'bool' );
+
+		/**
+		 * Controls samples distribution. It's an exponent applied at each step get increasing step size over the distance.
+		 * Should be in the range `[1, 3]`.
+		 *
+		 * @type {UniformNode<float>}
+		 * @default 2
+		 */
+		this.expFactor = uniform( 2, 'float' );
+
+		/**
+		 * Constant thickness value of objects on the screen in world units. Allows light to pass behind surfaces past that thickness value.
+		 * Should be in the range `[0.01, 10]`.
+		 *
+		 * @type {UniformNode<float>}
+		 * @default 1
+		 */
+		this.thickness = uniform( 1, 'float' );
+
+		/**
+		 * Whether to increase thickness linearly over distance or not (avoid losing detail over the distance).
+		 *
+		 * @type {UniformNode<bool>}
+		 * @default false
+		 */
+		this.useLinearThickness = uniform( false, 'bool' );
+
+		/**
+		 * How much light backface surfaces emit.
+		 * Should be in the range `[0, 1]`.
+		 *
+		 * @type {UniformNode<float>}
+		 * @default 0
+		 */
+		this.backfaceLighting = uniform( 0, 'float' );
+
+		/**
+		 * Whether to use temporal filtering or not. Setting this property to
+		 * `true` requires the usage of `TRAANode`. This will help to reduce noice
+		 * although it introduces typical TAA artifacts like ghosting and temporal
+		 * instabilities.
+		 *
+		 * If setting this property to `false`, a manual denoise via `DenoiseNode`
+		 * is required.
+		 *
+		 * @type {boolean}
+		 * @default true
+		 */
+		this.useTemporalFiltering = true;
+
+		// private uniforms
+
+		/**
+		 * The resolution of the effect.
+		 *
+		 * @type {UniformNode<vec2>}
+		 */
+		this._resolution = uniform( new Vector2() );
+
+		/**
+		 * Used to compute the effective step radius when viewSpaceSampling is `false`.
+		 *
+		 * @type {UniformNode<vec2>}
+		 */
+		this._halfProjScale = uniform( 1 );
+
+		/**
+		 * Temporal direction that influences the rotation angle for each slice.
+		 *
+		 * @type {UniformNode<float>}
+		 */
+		this._temporalDirection = uniform( 0 );
+
+		/**
+		 * Temporal offset added to the initial ray step.
+		 *
+		 * @type {UniformNode<vec2>}
+		 */
+		this._temporalOffset = uniform( 0 );
+
+		/**
+		 * Represents the projection matrix of the scene's camera.
+		 *
+		 * @private
+		 * @type {UniformNode<mat4>}
+		 */
+		this._cameraProjectionMatrix = uniform( camera.projectionMatrix );
+
+		/**
+		 * Represents the inverse projection matrix of the scene's camera.
+		 *
+		 * @private
+		 * @type {UniformNode<mat4>}
+		 */
+		this._cameraProjectionMatrixInverse = uniform( camera.projectionMatrixInverse );
+
+		/**
+		 * 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 );
+
+		/**
+		 * A reference to the scene's camera.
+		 *
+		 * @private
+		 * @type {PerspectiveCamera}
+		 */
+		this._camera = camera;
+
+		/**
+		 * The render target the GI is rendered into.
+		 *
+		 * @private
+		 * @type {RenderTarget}
+		 */
+		this._ssgiRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false } );
+		this._ssgiRenderTarget.texture.name = 'SSGI';
+
+		/**
+		 * The material that is used to render the effect.
+		 *
+		 * @private
+		 * @type {NodeMaterial}
+		 */
+		this._material = new NodeMaterial();
+		this._material.name = 'SSGI';
+
+		/**
+		 * The result of the effect is represented as a separate texture node.
+		 *
+		 * @private
+		 * @type {PassTextureNode}
+		 */
+		this._textureNode = passTexture( this, this._ssgiRenderTarget.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 ) {
+
+		this._resolution.value.set( width, height );
+		this._ssgiRenderTarget.setSize( width, height );
+
+		this._halfProjScale.value = height / ( Math.tan( this._camera.fov * MathUtils.DEG2RAD * 0.5 ) * 2 ) * 0.5;
+
+	}
+
+	/**
+	 * This method is used to render the effect once per frame.
+	 *
+	 * @param {NodeFrame} frame - The current node frame.
+	 */
+	updateBefore( frame ) {
+
+		const { renderer } = frame;
+
+		_rendererState = RendererUtils.resetRendererState( renderer, _rendererState );
+
+		//
+
+		const size = renderer.getDrawingBufferSize( _size );
+		this.setSize( size.width, size.height );
+
+		// update temporal uniforms
+
+		if ( this.useTemporalFiltering === true ) {
+
+			const frameId = frame.frameId;
+
+			this._temporalDirection.value = _temporalRotations[ frameId % 6 ] / 360;
+			this._temporalOffset.value = _spatialOffsets[ frameId % 4 ];
+
+		} else {
+
+			this._temporalDirection.value = 1;
+			this._temporalOffset.value = 1;
+
+		}
+
+		//
+
+		_quadMesh.material = this._material;
+
+		// clear
+
+		renderer.setClearColor( 0xffffff, 1 );
+
+		// gi
+
+		renderer.setRenderTarget( this._ssgiRenderTarget );
+		_quadMesh.render( renderer );
+
+		// restore
+
+		RendererUtils.restoreRendererState( renderer, _rendererState );
+
+	}
+
+	/**
+	 * This method is used to setup the effect's TSL code.
+	 *
+	 * @param {NodeBuilder} builder - The current node builder.
+	 * @return {PassTextureNode}
+	 */
+	setup( builder ) {
+
+		const uvNode = uv();
+		const MAX_RAY = uint( 32 );
+
+		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 sampleNormal = ( uv ) => ( this.normalNode !== null ) ? this.normalNode.sample( uv ).rgb.normalize() : getNormalFromDepth( uv, this.depthNode.value, this._cameraProjectionMatrixInverse );
+		const sampleBeauty = ( uv ) => this.beautyNode.sample( uv );
+
+		// From Activision GTAO paper: https://www.activision.com/cdn/research/s2016_pbs_activision_occlusion.pptx
+
+		const spatialOffsets = Fn( ( [ position ] ) => {
+
+			return float( 0.25 ).mul( sub( position.y, position.x ).bitAnd( 3 ) );
+
+		} );
+
+		// Interleaved gradient function from Jimenez 2014 http://goo.gl/eomGso
+
+		const gradientNoise = Fn( ( [ position ] ) => {
+
+			return fract( float( 52.9829189 ).mul( fract( dot( position, vec2( 0.06711056, 0.00583715 ) ) ) ) );
+
+		} );
+
+		const GTAOFastAcos = Fn( ( [ value ] ) => {
+
+			const outVal = abs( value ).mul( float( - 0.156583 ) ).add( HALF_PI );
+			outVal.mulAssign( sqrt( abs( value ).oneMinus() ) );
+
+			const x = value.x.greaterThanEqual( 0 ).select( outVal.x, PI.sub( outVal.x ) );
+			const y = value.y.greaterThanEqual( 0 ).select( outVal.y, PI.sub( outVal.y ) );
+
+			return vec2( x, y );
+
+		} );
+
+		const bitCount = Fn( ( [ value_immutable ] ) => {
+
+			const value = value_immutable;
+			value.assign( value.sub( value.shiftRight( uint( 1 ) ).bitAnd( uint( 0x55555555 ) ) ) );
+			value.assign( value.bitAnd( uint( 0x33333333 ) ).add( value.shiftRight( uint( 2 ) ).bitAnd( uint( 0x33333333 ) ) ) );
+
+			return value.add( value.shiftRight( uint( 4 ) ) ).bitAnd( uint( 0xF0F0F0F ) ).mul( uint( 0x1010101 ) ).shiftRight( uint( 24 ) );
+
+		} );
+
+		const computeOccludedBitfield = Fn( ( [ minHorizon, maxHorizon, globalOccludedBitfield ] ) => {
+
+			const startHorizonInt = uint( minHorizon.mul( float( MAX_RAY ) ) ).toConst();
+			const angleHorizonInt = uint( ceil( maxHorizon.sub( minHorizon ).mul( float( MAX_RAY ) ) ) ).toConst();
+			const angleHorizonBitfield = angleHorizonInt.greaterThan( uint( 0 ) ).select( uint( shiftRight( uint( 0xFFFFFFFF ), uint( 32 ).sub( MAX_RAY ).add( MAX_RAY.sub( angleHorizonInt ) ) ) ), uint( 0 ) ).toConst();
+			let currentOccludedBitfield = angleHorizonBitfield.shiftLeft( startHorizonInt );
+			currentOccludedBitfield = currentOccludedBitfield.bitAnd( globalOccludedBitfield.bitNot() );
+
+			const result = uvec2();
+			result.x = globalOccludedBitfield.bitOr( currentOccludedBitfield );
+			result.y = bitCount( currentOccludedBitfield );
+
+			return result;
+
+		} );
+
+		const horizonSampling = Fn( ( [ directionIsRight, RADIUS, viewPosition, slideDirTexelSize, initialRayStep, uvNode, viewDir, viewNormal, n, globalOccludedBitfield ] ) => {
+
+			const STEP_COUNT = this.stepCount.toConst();
+			const EXP_FACTOR = this.expFactor.toConst();
+			const THICKNESS = this.thickness.toConst();
+			const BACKFACE_LIGHTING = this.backfaceLighting.toConst();
+
+			const stepRadius = float( 0 );
+
+			If( this.useScreenSpaceSampling.equal( true ), () => {
+
+				stepRadius.assign( RADIUS.mul( this._resolution.x.div( 2 ) ).div( float( STEP_COUNT ) ) );
+
+			} ).Else( () => {
+
+				stepRadius.assign( max( RADIUS.mul( this._halfProjScale ).div( viewPosition.z.negate() ), float( STEP_COUNT ) ) ); // Port note: viewZ is negative so a negate is requried
+
+			} );
+
+			stepRadius.divAssign( float( STEP_COUNT ).add( 1 ) );
+			const radiusVS = max( 1, float( STEP_COUNT.sub( 1 ) ) ).mul( stepRadius );
+			const samplingDirection = directionIsRight.equal( true ).select( vec2( 1, - 1 ), vec2( - 1, 1 ) ); // Port note: Because of different uv conventions, uv-y has a different sign
+
+			const color = vec3( 0 );
+			const occludedBitfield = uint( globalOccludedBitfield ).toVar();
+
+			const lastSampleViewPosition = vec3( viewPosition ).toVar();
+
+			Loop( { start: uint( 0 ), end: STEP_COUNT, type: 'uint', condition: '<' }, ( { i } ) => {
+
+				const offset = pow( abs( mul( stepRadius, float( i ).add( initialRayStep ) ).div( radiusVS ) ), EXP_FACTOR ).mul( radiusVS ).toConst();
+				const uvOffset = slideDirTexelSize.mul( max( offset, float( i ).add( 1 ) ) ).toConst();
+				const sampleUV = uvNode.add( uvOffset.mul( samplingDirection ) ).toConst();
+
+				If( sampleUV.x.lessThanEqual( 0 ).or( sampleUV.y.lessThanEqual( 0 ) ).or( sampleUV.x.greaterThanEqual( 1 ) ).or( sampleUV.y.greaterThanEqual( 1 ) ), () => {
+
+					Break();
+
+				} );
+
+				const sampleViewPosition = getViewPosition( sampleUV, sampleDepth( sampleUV ), this._cameraProjectionMatrixInverse ).toConst();
+				const pixelToSample = sampleViewPosition.sub( viewPosition ).normalize().toConst();
+				const linearThicknessMultiplier = this.useLinearThickness.equal( true ).select( sampleViewPosition.z.div( this._cameraFar ).clamp().mul( 100 ), float( 1 ) );
+				const pixelToSampleBackface = normalize( sampleViewPosition.sub( linearThicknessMultiplier.mul( viewDir ).mul( THICKNESS ) ).sub( viewPosition ) );
+
+				let frontBackHorizon = vec2( dot( pixelToSample, viewDir ), dot( pixelToSampleBackface, viewDir ) );
+				frontBackHorizon = GTAOFastAcos( clamp( frontBackHorizon, - 1, 1 ) );
+				frontBackHorizon = clamp( div( mul( samplingDirection, vec2( frontBackHorizon.x.negate(), frontBackHorizon.y ) ).sub( n.sub( HALF_PI ) ), PI ) ); // Port note: This line also required an update because of different uv conventions
+				frontBackHorizon = directionIsRight.equal( true ).select( frontBackHorizon.yx, frontBackHorizon.xy ); // Front/Back get inverted depending on angle
+
+				const result = computeOccludedBitfield( frontBackHorizon.x, frontBackHorizon.y, occludedBitfield );
+				occludedBitfield.assign( result.x );
+				const numOccludedZones = result.y;
+
+				If( numOccludedZones.greaterThan( 0 ), () => { // If a ray hit the sample, that sample is visible from shading point
+
+					const lightColor = sampleBeauty( sampleUV );
+
+					If( luminance( lightColor ).greaterThan( 0.001 ), () => { // Continue if there is light at that location (intensity > 0)
+
+						const lightDirectionVS = normalize( pixelToSample );
+						const normalDotLightDirection = clamp( dot( viewNormal, lightDirectionVS ) );
+
+						If( normalDotLightDirection.greaterThan( 0.001 ), () => { // Continue if light is facing surface normal
+
+							const lightNormalVS = sampleNormal( sampleUV );
+
+							// Intensity of outgoing light in the direction of the shading point
+
+							let lightNormalDotLightDirection = dot( lightNormalVS, lightDirectionVS.negate() );
+
+							const d = sign( lightNormalDotLightDirection ).lessThan( 0 ).select( abs( lightNormalDotLightDirection ).mul( BACKFACE_LIGHTING ), abs( lightNormalDotLightDirection ) );
+							lightNormalDotLightDirection = BACKFACE_LIGHTING.greaterThan( 0 ).and( dot( lightNormalVS, viewDir ).greaterThan( 0 ) ).select( d, clamp( lightNormalDotLightDirection ) );
+
+							color.rgb.addAssign( float( numOccludedZones ).div( float( MAX_RAY ) ).mul( lightColor ).mul( normalDotLightDirection ).mul( lightNormalDotLightDirection ) );
+
+						} );
+
+					} );
+
+				} );
+
+				lastSampleViewPosition.assign( sampleViewPosition );
+
+			} );
+
+			return vec4( color, occludedBitfield );
+
+		} );
+
+		const gi = Fn( () => {
+
+			const depth = sampleDepth( uvNode ).toVar();
+
+			depth.greaterThanEqual( 1.0 ).discard();
+
+			const viewPosition = getViewPosition( uvNode, depth, this._cameraProjectionMatrixInverse ).toVar();
+			const viewNormal = sampleNormal( uvNode ).toVar();
+			const viewDir = normalize( viewPosition.xyz.negate() ).toVar();
+
+			//
+
+			const noiseOffset = spatialOffsets( screenCoordinate );
+			const noiseDirection = gradientNoise( screenCoordinate );
+			const initialRayStep = fract( noiseOffset.add( this._temporalOffset ) ).add( rand( uvNode ).mul( 2 ).sub( 1 ) );
+
+			const ao = float( 0 );
+			const color = vec3( 0 );
+
+			const ROTATION_COUNT = this.sliceCount.toConst();
+			const AO_INTENSITY = this.aoIntensity.toConst();
+			const GI_INTENSITY = this.giIntensity.toConst();
+			const RADIUS = this.radius.toConst();
+
+			Loop( { start: uint( 0 ), end: ROTATION_COUNT, type: 'uint', condition: '<' }, ( { i } ) => {
+
+				const rotationAngle = mul( float( i ).add( noiseDirection ).add( this._temporalDirection ), PI.div( float( ROTATION_COUNT ) ) ).toConst();
+				const sliceDir = vec3( vec2( cos( rotationAngle ), sin( rotationAngle ) ), 0 ).toConst();
+				const slideDirTexelSize = sliceDir.xy.mul( float( 1 ).div( this._resolution ) ).toConst();
+
+				const planeNormal = normalize( cross( sliceDir, viewDir ) ).toConst();
+				const tangent = cross( viewDir, planeNormal ).toConst();
+				const projectedNormal = viewNormal.sub( planeNormal.mul( dot( viewNormal, planeNormal ) ) ).toConst();
+				const projectedNormalNormalized = normalize( projectedNormal ).toConst();
+
+				const cos_n = clamp( dot( projectedNormalNormalized, viewDir ), - 1, 1 ).toConst();
+				const n = sign( dot( projectedNormal, tangent ) ).negate().mul( acos( cos_n ) ).toConst();
+
+				const globalOccludedBitfield = uint( 0 );
+
+				const resultRight = horizonSampling( bool( true ), RADIUS, viewPosition, slideDirTexelSize, initialRayStep, uvNode, viewDir, viewNormal, n, globalOccludedBitfield );
+				color.addAssign( resultRight.xyz );
+				globalOccludedBitfield.assign( resultRight.a );
+
+				const resultLeft = horizonSampling( bool( false ), RADIUS, viewPosition, slideDirTexelSize, initialRayStep, uvNode, viewDir, viewNormal, n, globalOccludedBitfield );
+				color.addAssign( resultLeft.xyz );
+				globalOccludedBitfield.assign( resultLeft.a );
+
+				ao.addAssign( float( bitCount( globalOccludedBitfield ) ).div( float( MAX_RAY ) ) );
+
+			} );
+
+			ao.divAssign( float( ROTATION_COUNT ) );
+			ao.assign( pow( ao.clamp().oneMinus(), AO_INTENSITY ).clamp() );
+
+			color.divAssign( float( ROTATION_COUNT ) );
+			color.mulAssign( GI_INTENSITY );
+
+			// scale color based on luminance
+
+			const maxLuminance = float( 7 ).toConst(); // 7 represent a HDR luminance value
+			const currentLuminance = luminance( color );
+
+			const scale = currentLuminance.greaterThan( maxLuminance ).select( maxLuminance.div( currentLuminance ), float( 1 ) );
+			color.mulAssign( scale );
+
+			return vec4( color, ao );
+
+		} );
+
+		this._material.fragmentNode = gi().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._ssgiRenderTarget.dispose();
+
+		this._material.dispose();
+
+	}
+
+}
+
+export default SSGINode;
+
+/**
+ * TSL function for creating a SSGI effect.
+ *
+ * @tsl
+ * @function
+ * @param {TextureNode} beautyNode - The texture node that represents the input of the effect.
+ * @param {TextureNode} depthNode - A texture node that represents the scene's depth.
+ * @param {TextureNode} normalNode - A texture node that represents the scene's normals.
+ * @param {Camera} camera - The camera the scene is rendered with.
+ * @returns {SSGINode}
+ */
+export const ssgi = ( beautyNode, depthNode, normalNode, camera ) => nodeObject( new SSGINode( convertToTexture( beautyNode ), depthNode, normalNode, camera ) );

BIN
examples/models/gltf/mirrors_edge.glb


BIN
examples/screenshots/webgpu_postprocessing_ssgi.jpg


+ 1 - 0
examples/tags.json

@@ -155,6 +155,7 @@
 	"webgpu_postprocessing_ca": [ "chromatic aberration" ],
 	"webgpu_postprocessing_sobel": [ "filter", "edge detection" ],
 	"webgpu_postprocessing_ssaa": [ "msaa", "multisampled" ],
+	"webgpu_postprocessing_ssgi": [ "global illumination", "indirect diffuse" ],
 	"webgpu_refraction": [ "water" ],
 	"webgpu_rtt": [ "renderTarget", "texture" ],
 	"webgpu_rendertarget_2d-array_3d": [ "renderTarget", "2d-array", "3d" ],

+ 195 - 0
examples/webgpu_postprocessing_ssgi.html

@@ -0,0 +1,195 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js WebGPU - SSGI</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="main.css">
+	</head>
+	<body>
+
+		<div id="info">
+			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> WebGPU - Post-Processing - SSGI<br />
+			<a href="https://skfb.ly/YZoC" target="_blank" rel="noopener">Mirror's Edge Apartment</a> by 
+			<a href="https://sketchfab.com/aurelien_martel" target="_blank" rel="noopener">Aurélien Martel</a> is licensed under <a href="https://creativecommons.org/licenses/by-nc/4.0/" target="_blank" rel="noopener">Creative Commons Attribution-NonCommercial</a>.<br />
+		</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, mrt, output, normalView, velocity, add, vec3, vec4 } from 'three/tsl';
+			import { ssgi } from 'three/addons/tsl/display/SSGINode.js';
+			import { traa } from 'three/addons/tsl/display/TRAANode.js';
+
+			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+			import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
+			import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+
+			import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
+			import Stats from 'three/addons/libs/stats.module.js';
+
+			let camera, scene, renderer, postProcessing, controls, stats;
+
+			init();
+
+			async function init() {
+
+				camera = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.1, 50 );
+				camera.position.set( 2.8, 1.8, 5 );
+
+				scene = new THREE.Scene();
+
+				renderer = new THREE.WebGPURenderer();
+				//renderer.setPixelRatio( window.devicePixelRatio ); // probably too costly for most hardware
+				renderer.setSize( window.innerWidth, window.innerHeight );
+				renderer.setAnimationLoop( animate );
+				renderer.toneMapping = THREE.NeutralToneMapping;
+				renderer.toneMappingExposure = 1.5;
+				document.body.appendChild( renderer.domElement );
+
+				stats = new Stats();
+				document.body.appendChild( stats.domElement );
+
+				//
+
+				controls = new OrbitControls( camera, renderer.domElement );
+				controls.target.set( 0, 1, 0 );
+				controls.enablePan = false;
+				controls.minDistance = 1;
+				controls.maxDistance = 6;
+				controls.update();
+
+				//
+
+				postProcessing = new THREE.PostProcessing( renderer );
+
+				const scenePass = pass( scene, camera );
+				scenePass.setMRT( mrt( {
+					output: output,
+					normal: normalView,
+					velocity: velocity
+				} ) );
+
+				const scenePassColor = scenePass.getTextureNode( 'output' );
+				const scenePassDepth = scenePass.getTextureNode( 'depth' );
+				const scenePassNormal = scenePass.getTextureNode( 'normal' );
+				const scenePassVelocity = scenePass.getTextureNode( 'velocity' );
+
+				// gi
+
+				const giPass = ssgi( scenePassColor, scenePassDepth, scenePassNormal, camera );
+				giPass.sliceCount.value = 2;
+				giPass.stepCount.value = 8;
+
+				// composite
+
+				const gi = giPass.rgb;
+				const ao = giPass.a;
+			
+				const compositePass = vec4( add( scenePassColor.rgb, gi ).mul( ao ), scenePassColor.a );
+			
+				// traa
+
+				const traaPass = traa( compositePass, scenePassDepth, scenePassVelocity, camera );
+				postProcessing.outputNode = traaPass;
+
+				//
+
+				const dracoLoader = new DRACOLoader();
+				dracoLoader.setDecoderPath( 'jsm/libs/draco/' );
+				dracoLoader.setDecoderConfig( { type: 'js' } );
+				const loader = new GLTFLoader();
+				loader.setDRACOLoader( dracoLoader );
+				loader.setPath( 'models/gltf/' );
+
+				const gltf = await loader.loadAsync( 'mirrors_edge.glb' );
+
+				const model = gltf.scene;
+				scene.add( model );
+
+				window.addEventListener( 'resize', onWindowResize );
+
+				//
+
+				const gui = new GUI();
+				gui.title( 'SSGI settings' );
+				gui.add( giPass.sliceCount, 'value', 1, 4 ).step( 1 ).name( 'slice count' );
+				gui.add( giPass.stepCount, 'value', 1, 32 ).step( 1 ).name( 'step count' );
+				gui.add( giPass.radius, 'value', 1, 25 ).name( 'radius' );
+				gui.add( giPass.expFactor, 'value', 1, 3 ).name( 'exp factor' );
+				gui.add( giPass.thickness, 'value', 0.01, 10 ).name( 'thickness' );
+				gui.add( giPass.backfaceLighting, 'value', 0, 1 ).name( 'backface lighting' );
+				gui.add( giPass.aoIntensity, 'value', 0, 4 ).name( 'AO intenstiy' );
+				gui.add( giPass.giIntensity, 'value', 0, 100 ).name( 'GI intenstiy' );
+				gui.add( giPass.useScreenSpaceSampling, 'value' ).name( 'screen-space sampling' );
+				gui.add( giPass, 'useTemporalFiltering' ).name( 'temporal filtering' );
+				//gui.add( giPass.useLinearThickness, 'value' ).name( 'use linear thickness' );
+
+				const params = {
+					output: 0
+				};
+
+				const types = { Default: 0, AO: 1, GI: 2, Beauty: 3 };
+
+				gui.add( params, 'output', types ).onChange( value => {
+			
+					if ( value === 1 ) {
+
+						postProcessing.outputNode = vec4( vec3( ao ), 1 );
+
+					} else if ( value === 2 ) {
+
+						postProcessing.outputNode = vec4( gi, 1 );
+
+					} else if ( value === 3 ) {
+
+						postProcessing.outputNode = scenePassColor;
+
+					} else {
+
+						postProcessing.outputNode = traaPass;
+
+					}
+
+					postProcessing.needsUpdate = true;
+
+				} );
+
+			}
+
+			function onWindowResize() {
+
+				const width = window.innerWidth;
+				const height = window.innerHeight;
+
+				camera.aspect = width / height;
+				camera.updateProjectionMatrix();
+
+				renderer.setSize( width, height );
+
+			}
+
+			function animate() {
+
+				controls.update();
+
+				stats.update();
+
+				postProcessing.render();
+
+			}
+
+		</script>
+	</body>
+</html>

+ 1 - 0
test/e2e/puppeteer.js

@@ -181,6 +181,7 @@ const exceptionList = [
 	'webgpu_postprocessing_fxaa',
 	'webgpu_postprocessing_afterimage',
 	'webgpu_postprocessing_ca',
+	'webgpu_postprocessing_ssgi',
 	'webgpu_xr_native_layers',
 	'webgpu_volume_caustics',
 

粤ICP备19079148号