UNPKG

three

Version:

JavaScript 3D library

661 lines (477 loc) 19.4 kB
import { RenderTarget, Vector2, Vector3, TempNode, QuadMesh, NodeMaterial, RendererUtils, HalfFloatType, RGBAFormat, RedFormat, UnsignedByteType } from 'three/webgpu'; import { Fn, NodeUpdateType, uniform, reference, vec2, vec3, vec4, float, uint, uv, mix, passTexture, getViewPosition, getNormalFromDepth, logarithmicDepthToViewZ, viewZToPerspectiveDepth, screenCoordinate, interleavedGradientNoise, normalize, cross, abs, select, sqrt, fract, floor, cos, sin, exp2, pow, PI, Loop, If, Break, property, outputStruct, context } from 'three/tsl'; import { VXGIVolume } from './VXGIVolume.js'; import { createConeTracer, intersectVolume, sampleDirectional } from './VXGIConeTracer.js'; const _quadMesh = /*@__PURE__*/ new QuadMesh(); const _size = /*@__PURE__*/ new Vector2(); const TEMPORAL_SHIFT = 5.588238; const TEMPORAL_CYCLE = 64; let _rendererState; const _neutralContext = /*@__PURE__*/ context(); /** * Post processing node for voxel based global illumination. The scene is voxelized into a * {@link VXGIVolume} and indirect diffuse light and ambient occlusion are gathered per * pixel with approximate voxel cone tracing. * * The node is a middle path between SSGI and Light Probe Grids: * * - Compared to `SSGINode` it is free of screen-space artifacts and provides noticeably more * consistent lighting, since off-screen surfaces and thin occluders contribute. * However, it is less dynamic: objects should stay static, because geometry changes require * a re-voxelization (`needsUpdate = true`) which is too expensive for per-frame animation. * - Compared to `LightProbeGrid` it supports dynamic lighting without a new baking process and * produces a better overall lighting quality with less light bleeding. However, it is more * expensive and therefore less suitable for performance restricted use cases. * * The quality/performance of the effect mainly depend on the voxel resolution, the number of cones * traced per pixel as well as the apeture of the cones. * * Lights and their shadow maps are picked up automatically. Only direct lights are injected. * * References: * - {@link https://research.nvidia.com/publication/2011-09_interactive-indirect-illumination-using-voxel-cone-tracing}: * Crassin et al., Interactive Indirect Illumination Using Voxel Cone Tracing, Pacific Graphics 2011. * * Note: This node can only be used with `WebGPURenderer` and a WebGPU backend. * * @augments TempNode * @three_import import { vxgi } from 'three/addons/lighting/vxgi/VXGINode.js'; */ class VXGINode extends TempNode { static get type() { return 'VXGINode'; } /** * Constructs a new voxel GI node. * * @param {TextureNode} depthNode - A texture node that represents the scene's depth. * @param {?TextureNode} normalNode - A texture node that represents the scene's view space normals. * @param {Scene} scene - The scene to voxelize. * @param {Camera} camera - The camera the scene is rendered with. * @param {number} [resolution=128] - Number of voxels along the longest axis of the volume. Should not exceed `256`, higher values exceed the maximum storage buffer size of the voxelizer. */ constructor( depthNode, normalNode, scene, camera, resolution = 128 ) { super( 'vec4' ); /** * A node that represents the scene's depth. * * @type {TextureNode} */ this.depthNode = depthNode; /** * A node that represents the scene's normals. If `null`, normals are reconstructed from depth. * * @type {?TextureNode} */ this.normalNode = normalNode; /** * The scene to voxelize. * * @type {Scene} */ this.scene = scene; /** * The camera the scene is rendered with. * * @type {Camera} */ this.camera = camera; /** * The voxel volume. Use it to configure bounds, layers and bounces. * * @type {VXGIVolume} */ this.volume = new VXGIVolume( resolution ); /** * 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 cones traced per pixel. Should be in the range `[2, 8]`. * * Mainly defines the quality and precision of the Voxel Cone Tracing. A value of * `2` - `4` is the recommended setting. Use `2` for performance restricted use cases. * * @type {UniformNode<uint>} * @default 3 */ this.coneCount = uniform( 3, 'uint' ); /** * Aperture of the diffuse cones in degrees. Wider cones are faster (fewer steps) but leak * and over-occlude more, narrow cones are more precise but noisier and take more steps. * Choose it together with {@link VXGINode#coneCount}. Should be in the range `[10, 90]`. * * @type {UniformNode<float>} * @default 40 */ this.coneAngle = uniform( 40 ); /** * Intensity of the indirect diffuse irradiance. * * @type {UniformNode<float>} * @default 1 */ this.giIntensity = uniform( 1 ); /** * Power function applied to AO to make it appear darker/lighter. * * @type {UniformNode<float>} * @default 1 */ this.aoIntensity = uniform( 1 ); /** * The darkest value the ambient occlusion can reach. Lifts creases and contact regions out * of pure black, which voxel-traced occlusion tends to overestimate at the resolution of * a voxel. `0` keeps the full occlusion range. * * @type {UniformNode<float>} * @default 0 */ this.aoMinVisibility = uniform( 0 ); /** * Occlusion is weighted by `1 / ( 1 + distance / aoDistance )` for AO, so occluders at this * world-space distance count half. `0` disables the falloff. * * @type {UniformNode<float>} * @default 1 */ this.aoDistance = uniform( 1 ); /** * Offset of the cone origins along the surface normal in voxels. The surface's own voxel * can extend up to half a voxel above the surface and the trilinear footprint of a sample * spans another half voxel, so 1.5 voxels avoid self-occlusion in every case. * * @type {UniformNode<float>} * @default 1.5 */ this.normalOffset = uniform( 1.5 ); /** * Debug visualization of the volume: `0` = off, `1` = radiance voxels, `2` = per-axis opacity voxels. * The visualization replaces the GI output. * * @type {UniformNode<int>} * @default 0 */ this.debug = uniform( 0, 'int' ); /** * The mip level shown by the debug visualization. * * @type {UniformNode<float>} * @default 0 */ this.debugLevel = uniform( 0 ); /** * Whether to use temporal filtering or not. Setting this property to `true` requires the * usage of `TRAANode`. Cone directions are then rotated per frame to converge the noise. * * @type {boolean} * @default true */ this.useTemporalFiltering = true; // private uniforms this._resolution = uniform( new Vector2() ); this._frame = uniform( 0 ); this._cameraProjectionMatrix = uniform( camera.projectionMatrix ); this._cameraProjectionMatrixInverse = uniform( camera.projectionMatrixInverse ); this._cameraWorldMatrix = uniform( camera.matrixWorld ); this._cameraPosition = uniform( new Vector3() ).onRenderUpdate( ( _, self ) => self.value.setFromMatrixPosition( camera.matrixWorld ) ); this._cameraNear = reference( 'near', 'float', camera ); this._cameraFar = reference( 'far', 'float', camera ); /** * The render target the effect is rendered into. The first texture holds the AO, * the second one the GI. * * @private * @type {RenderTarget} */ this._renderTarget = new RenderTarget( 1, 1, { depthBuffer: false, count: 2 } ); const aoTexture = this._renderTarget.textures[ 0 ]; aoTexture.name = 'VXGI.AO'; aoTexture.type = UnsignedByteType; aoTexture.format = RedFormat; const giTexture = this._renderTarget.textures[ 1 ]; giTexture.name = 'VXGI.GI'; giTexture.type = HalfFloatType; giTexture.format = RGBAFormat; this._material = new NodeMaterial(); this._material.name = 'VXGI'; // the shared context of the last build and the directional setting the material was built with this._sharedContext = null; this._directional = null; this._aoNode = passTexture( this, aoTexture ); this._giNode = passTexture( this, giTexture ); } /** * Number of cached indirect bounces. See {@link VXGIVolume#bounces}. * * @type {number} */ get bounces() { return this.volume.bounces; } set bounces( value ) { this.volume.bounces = value; this.volume.lightingNeedsUpdate = true; } /** * Whether the coarser radiance levels are filtered directionally to reduce light bleeding * through thin walls and floors. Off by default since it costs memory and performance. * See {@link VXGIVolume#directionalRadiance}. * * @type {boolean} */ get directionalRadiance() { return this.volume.directionalRadiance; } set directionalRadiance( value ) { this.volume.directionalRadiance = value; } /** * Set to `true` to re-voxelize the scene in the next frame. * * @type {boolean} */ get needsUpdate() { return this.volume.needsUpdate; } set needsUpdate( value ) { this.volume.needsUpdate = value; } /** * Set to `true` to re-inject lighting in the next frame. * * @type {boolean} */ get lightingNeedsUpdate() { return this.volume.lightingNeedsUpdate; } set lightingNeedsUpdate( value ) { this.volume.lightingNeedsUpdate = value; } /** * Returns the AO result of the effect as a texture node. * * @return {PassTextureNode} A texture node that represents the AO result of the effect. */ getAONode() { return this._aoNode; } /** * Returns the GI result of the effect as a texture node. The texture holds the indirect * diffuse irradiance, ready to be added to the lighting via `builtinGIContext()`. * * @return {PassTextureNode} A texture node that represents the GI result of the effect. */ getGINode() { return this._giNode; } /** * 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._renderTarget.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 ); // this node is usually updated while a scene pass renders whose context injects the // AO/GI of this node into the materials; the nested renders below must not inherit it const currentContextNode = renderer.contextNode; renderer.contextNode = _neutralContext; // const size = renderer.getDrawingBufferSize( _size ); this.setSize( size.width, size.height ); // voxelization and light injection this.volume.update( renderer, this.scene ); // the sampling code depends on the volume's directional setting if ( this._sharedContext !== null && this._directional !== this.volume.directionalRadiance ) { this._setupMaterial( renderer ); } // update temporal uniforms if ( this.useTemporalFiltering === true ) { this._frame.value = frame.frameId % TEMPORAL_CYCLE; } else { this._frame.value = 0; } // _quadMesh.material = this._material; _quadMesh.name = 'VXGI'; // clear (white for the AO attachment) renderer.setClearColor( 0xffffff, 1 ); renderer.setRenderTarget( this._renderTarget ); _quadMesh.render( renderer ); // restore renderer.contextNode = currentContextNode; 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 ) { this._sharedContext = builder.getSharedContext(); this._setupMaterial( builder.renderer ); return this._aoNode; } /** * Builds the TSL code of the effect into the internal pass material. * * @private * @param {Renderer} renderer - The renderer. */ _setupMaterial( renderer ) { const volume = this.volume; const directional = this._directional = volume.directionalRadiance; const uvNode = uv(); const sampleDepth = ( uv ) => { const depth = this.depthNode.sample( uv ).r; if ( 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 voxelSize = volume.voxelSizeNode; const projectionMatrixInverse = this._cameraProjectionMatrixInverse; const trace = createConeTracer( volume, { radianceNode: volume.radianceNode, directionalNode: directional === true ? volume.directionalNode : null } ); const aoField = property( 'float' ); const giField = property( 'vec4' ); const outputNode = outputStruct( aoField, giField ); const gi = Fn( () => { const depth = sampleDepth( uvNode ).toConst(); depth.greaterThanEqual( 1.0 ).discard(); const viewPosition = getViewPosition( uvNode, depth, projectionMatrixInverse ).toConst(); const worldPosition = this._cameraWorldMatrix.mul( vec4( viewPosition, 1 ) ).xyz.toConst(); const viewNormal = sampleNormal( uvNode ).toConst(); const worldNormal = normalize( this._cameraWorldMatrix.mul( vec4( viewNormal, 0 ) ).xyz ).toConst(); // interleaved gradient noise for both values: its spatial structure converges well under temporal filtering const temporalShift = this._frame.mul( TEMPORAL_SHIFT ).toConst(); const rotationNoise = interleavedGradientNoise( screenCoordinate.add( temporalShift ) ).toConst(); const elevationNoise = interleavedGradientNoise( screenCoordinate.add( temporalShift ).add( vec2( 5.588238, 3.14159 ) ) ).toConst(); // tangent frame const up = select( abs( worldNormal.y ).lessThan( 0.99 ), vec3( 0, 1, 0 ), vec3( 1, 0, 0 ) ); const tangent = normalize( cross( worldNormal, up ) ).toConst(); const bitangent = cross( worldNormal, tangent ).toConst(); const CONE_COUNT = this.coneCount.toConst(); const tanHalfAngle = this.coneAngle.mul( 0.5 ).radians().tan().toConst(); const traceDistance = volume.traceDistanceNode; const aoDistance = this.aoDistance; const originOffset = worldNormal.mul( voxelSize.mul( this.normalOffset ) ).toConst(); const color = vec3( 0 ).toVar(); const occlusion = float( 0 ).toVar(); Loop( { start: uint( 0 ), end: CONE_COUNT, type: 'uint', condition: '<', name: 'c' }, ( { c } ) => { // stratified cosine-weighted directions const u1 = float( c ).add( elevationNoise ).div( float( CONE_COUNT ) ).toConst(); const u2 = fract( float( c ).mul( 0.618034 ).add( rotationNoise ) ).toConst(); const sinTheta = sqrt( u1 ).toConst(); const cosTheta = sqrt( u1.oneMinus() ).toConst(); const phi = u2.mul( PI.mul( 2 ) ).toConst(); const direction = normalize( tangent.mul( cos( phi ).mul( sinTheta ) ).add( bitangent.mul( sin( phi ).mul( sinTheta ) ) ).add( worldNormal.mul( cosTheta ) ) ).toConst(); const origin = worldPosition.add( originOffset ).toConst(); const cone = trace( origin, direction, tanHalfAngle, traceDistance, aoDistance ); color.addAssign( cone.color ); occlusion.addAssign( cone.ao ); } ); // the mean radiance over the cosine-weighted hemisphere times PI is the irradiance color.divAssign( float( CONE_COUNT ) ); color.mulAssign( this.giIntensity.mul( PI ) ); const ao = mix( this.aoMinVisibility, float( 1 ), pow( occlusion.div( float( CONE_COUNT ) ).oneMinus().clamp(), this.aoIntensity ) ).toVar(); // debug visualization: march the voxels from the camera If( this.debug.greaterThan( 0 ), () => { const cameraPosition = this._cameraPosition; const direction = normalize( worldPosition.sub( cameraPosition ) ).toConst(); const surfaceDistance = worldPosition.sub( cameraPosition ).length().toConst(); const level = this.debugLevel.toConst(); const texel = voxelSize.mul( exp2( level ) ).toConst(); const levelSize = volume.volumeSizeNode.div( texel ).toConst(); const boundsMin = volume.boundsMinNode; const { tEnter, tExit } = intersectVolume( volume, cameraPosition, direction ); const tMax = tExit.min( surfaceDistance ).toConst(); const t = tEnter.toVar(); color.assign( 0 ); ao.assign( 1 ); Loop( { start: 0, end: 512, type: 'int', condition: '<', name: 's' }, () => { If( t.greaterThanEqual( tMax ), () => { Break(); } ); const uvw = cameraPosition.add( direction.mul( t ) ).sub( boundsMin ).div( volume.volumeSizeNode ); const snapped = floor( uvw.mul( levelSize ) ).add( 0.5 ).div( levelSize ).toConst(); If( this.debug.equal( 1 ), () => { let radiance; if ( directional === false ) { radiance = volume.radianceNode.sample( snapped ).level( level ).toConst(); } else { radiance = vec4( 0 ).toVar(); If( level.lessThan( 1 ), () => { radiance.assign( volume.radianceNode.sample( snapped ).level( float( 0 ) ) ); } ).Else( () => { radiance.assign( sampleDirectional( volume, volume.directionalNode, snapped, level.sub( 1 ), direction ) ); } ); } If( radiance.a.greaterThan( 0.01 ), () => { color.assign( radiance.rgb.div( radiance.a ) ); Break(); } ); } ).Else( () => { const opacity = volume.opacityNode.sample( snapped ).level( level ).toConst(); If( opacity.w.greaterThan( 0.01 ), () => { color.assign( opacity.xyz ); Break(); } ); } ); t.addAssign( texel.mul( 0.25 ) ); } ); } ); aoField.assign( ao ); giField.assign( vec4( color, 1 ) ); return vec4( 0 ); } ); this._material.contextNode = context( this._sharedContext ); this._material.colorNode = gi(); this._material.outputNode = outputNode; this._material.needsUpdate = true; } /** * Frees internal resources. This method should be called * when the effect is no longer required. */ dispose() { super.dispose(); this._renderTarget.dispose(); this._material.dispose(); this.volume.dispose(); } } export default VXGINode; /** * TSL function for creating a voxel GI effect. * * @tsl * @function * @param {TextureNode} depthNode - A texture node that represents the scene's depth. * @param {?TextureNode} normalNode - A texture node that represents the scene's view space normals. * @param {Scene} scene - The scene to voxelize. * @param {Camera} camera - The camera the scene is rendered with. * @param {number} [resolution=128] - Number of voxels along the longest axis of the volume. Should not exceed `256`, higher values exceed the maximum storage buffer size of the voxelizer. * @returns {VXGINode} */ export const vxgi = ( depthNode, normalNode, scene, camera, resolution ) => new VXGINode( depthNode, normalNode, scene, camera, resolution );