UNPKG

three

Version:

JavaScript 3D library

1,024 lines (757 loc) 33 kB
import { EPSILON, Fn, If, abs, convertToTexture, dFdx, dFdy, dot, exp, float, floor, fwidth, getViewPosition, ivec2, luminance, max, min, mix, nodeObject, normalize, passTexture, screenCoordinate, select, smoothstep, sqrt, struct, texture, textureLoad, uniform, unpackRGBToNormal, uv, vec2, vec3, vec4, velocity } from 'three/tsl'; import { DepthTexture, HalfFloatType, Matrix4, NodeMaterial, NodeUpdateType, QuadMesh, RenderTarget, RendererUtils, TempNode, Vector2, Vector3 } from 'three/webgpu'; import { ENV_RAY_LENGTH, ENV_RAY_LENGTH_THRESHOLD } from '../utils/SpecularHelpers.js'; // Reprojection helpers /** * Maps a resolve (screen) texel to the corresponding beauty-input texel when resolutions differ. * * @tsl */ const beautyTexelFromScreen = Fn( ( [ screenTexel, beautySize, resolveSize ] ) => { return ivec2( floor( vec2( screenTexel ).mul( beautySize ).div( resolveSize ) ) ); } ).setLayout( { name: 'beautyTexelFromScreen', type: 'ivec2', inputs: [ { name: 'screenTexel', type: 'ivec2' }, { name: 'beautySize', type: 'vec2' }, { name: 'resolveSize', type: 'vec2' } ] } ); /** * Projects a world-space position into previous-frame UV coordinates. * * @tsl */ const projectWorldToUV = Fn( ( [ worldPos, previousViewMatrix, previousProjectionMatrix ] ) => { const resultUV = vec2( - 1 ).toVar(); const viewSpace = previousViewMatrix.mul( vec4( worldPos, 1.0 ) ); const clipSpace = previousProjectionMatrix.mul( viewSpace ).toVar(); const clipW = clipSpace.w.toVar(); If( abs( clipW ).greaterThan( float( 1e-5 ) ), () => { const ndc = clipSpace.xyz.div( clipW ); resultUV.assign( ndc.xy.mul( 0.5 ).add( 0.5 ) ); resultUV.y.assign( resultUV.y.oneMinus() ); } ); return resultUV; } ).setLayout( { name: 'projectWorldToUV', type: 'vec2', inputs: [ { name: 'worldPos', type: 'vec3' }, { name: 'previousViewMatrix', type: 'mat4' }, { name: 'previousProjectionMatrix', type: 'mat4' } ] } ); // YCoCg variance clipping /** * @param {Node<vec3>} c * @returns {Node<vec3>} */ const rgbToYCoCg = ( c ) => vec3( dot( c, vec3( 0.25, 0.5, 0.25 ) ), dot( c, vec3( 0.5, 0.0, - 0.5 ) ), dot( c, vec3( - 0.25, 0.5, - 0.25 ) ) ); /** * @param {Node<vec3>} c * @returns {Node<vec3>} */ const ycocgToRGB = ( c ) => vec3( c.x.add( c.y ).sub( c.z ), c.x.add( c.z ), c.x.sub( c.y ).sub( c.z ) ); const VARIANCE_CLIP_LUMA_SCALE = 10; /** * Inverse-luminance compression for HDR variance clipping (Karis-style). * Bright samples contribute less to neighbourhood moments so sun pixels do not * inflate the YCoCg AABB and cause aggressive clipping flicker. * * @param {Node<vec3>} rgb * @param {Node<float>} flickerSuppression * @returns {Node<vec3>} */ const dampenForVarianceClip = ( rgb, flickerSuppression ) => { const scale = luminance( rgb ).mul( flickerSuppression ).mul( VARIANCE_CLIP_LUMA_SCALE ).add( 1 ); return rgb.div( scale ); }; /** * Clips the history sample to the neighbourhood AABB by projecting it toward the box centre. * Reference: https://github.com/playdeadgames/temporal * * @tsl */ const clipToAABB = Fn( ( [ history, boxMin, boxMax ] ) => { const pClip = boxMax.add( boxMin ).mul( 0.5 ); const eClip = boxMax.sub( boxMin ).mul( 0.5 ).add( 1e-7 ); const vClip = history.sub( pClip ); const vUnit = vClip.div( eClip ); const absUnit = vUnit.abs(); const maxUnit = max( absUnit.x, absUnit.y, absUnit.z ); return maxUnit.greaterThan( 1 ).select( pClip.add( vClip.div( maxUnit ) ), history ); } ).setLayout( { name: 'clipToAABB', type: 'vec3', inputs: [ { name: 'history', type: 'vec3' }, { name: 'boxMin', type: 'vec3' }, { name: 'boxMax', type: 'vec3' } ] } ); const neighborhoodStruct = struct( { mean: 'vec3', stdColor: 'vec3', rayLength: 'float', envProbability: 'float', stdDevRayLength: 'float' } ); /** * Single 3×3 neighbourhood pass over the beauty buffer. One textureLoad per tap feeds both the * YCoCg variance-clipping box (colour) and the SSR ray-length statistics (alpha), which previously * required two separate 3×3 fetches of the same texture. * * Sampling is done on the beauty-texel grid (`beautyTexel + offset`), so the taps are distinct * source texels even when the beauty buffer is lower resolution than the resolve pass (upscaling). * * @tsl */ const collectNeighborhood = Fn( ( [ beautyTexture, beautyTexel, inputColor, flickerSuppression ] ) => { const offsets = [ [ - 1, - 1 ], [ - 1, 1 ], [ 1, - 1 ], [ 1, 1 ], [ 1, 0 ], [ 0, - 1 ], [ 0, 1 ], [ - 1, 0 ], ]; // Colour moments (YCoCg) — centre reuses the already-fetched inputColor. const center = rgbToYCoCg( dampenForVarianceClip( inputColor.rgb, flickerSuppression ) ); const moment1 = center.toVar(); const moment2 = center.pow2().toVar(); // Ray-length statistics (Welford) over screen-space hits only. const rayLengthSum = float( 0 ).toVar(); const rayLengthCount = float( 0 ).toVar(); const meanRayLength = float( 0 ).toVar(); const m2RayLength = float( 0 ).toVar(); const accumulateRayLength = ( alpha ) => { If( alpha.lessThan( ENV_RAY_LENGTH_THRESHOLD ), () => { rayLengthSum.addAssign( alpha ); rayLengthCount.addAssign( 1 ); const delta = alpha.sub( meanRayLength ).toVar(); meanRayLength.addAssign( delta.div( rayLengthCount ) ); m2RayLength.addAssign( delta.mul( alpha.sub( meanRayLength ) ) ); } ); }; accumulateRayLength( inputColor.a ); for ( const [ x, y ] of offsets ) { const neighbor = textureLoad( beautyTexture, beautyTexel.add( ivec2( x, y ) ) ).max( 0 ).toVar(); const c = rgbToYCoCg( dampenForVarianceClip( neighbor.rgb, flickerSuppression ) ); moment1.addAssign( c ); moment2.addAssign( c.pow2() ); accumulateRayLength( neighbor.a ); } const N = float( offsets.length + 1 ); const mean = moment1.div( N ); const stdColor = moment2.div( N ).sub( mean.pow2() ).max( 0 ).sqrt(); // Continuous environment probability: fraction of the 3×3 neighbourhood that missed in screen space // and fell back to env (0 = all hits, 1 = all env), for smooth reflection/environment transitions. const envProbability = rayLengthCount.div( float( 9 ) ).oneMinus(); const rayLength = rayLengthCount.lessThan( 0.5 ).select( float( ENV_RAY_LENGTH ), rayLengthSum.div( max( rayLengthCount, float( 1e-4 ) ) ) ); const stdDevRayLength = sqrt( m2RayLength.div( max( rayLengthCount, float( 1.0 ) ) ) ).max( 1e-3 ); return neighborhoodStruct( mean, stdColor, rayLength, envProbability, stdDevRayLength ); } ); /** * Variance clipping in YCoCg space (Salvi, GDC 2016). Uses the colour moments gathered by * {@link collectNeighborhood}; `gamma` widens the AABB and is kept out of the gather so the * neighbourhood pass stays independent of the per-pixel motion factor. * * @tsl */ const applyVarianceClipping = Fn( ( [ historyColor, mean, stdColor, gamma, flickerSuppression ] ) => { const stddev = stdColor.mul( gamma ); const boxMin = mean.sub( stddev ); const boxMax = mean.add( stddev ); const historyRGB = historyColor.rgb.toVar(); const historyScale = luminance( historyRGB ).mul( flickerSuppression ).mul( VARIANCE_CLIP_LUMA_SCALE ).add( 1 ); const clipped = clipToAABB( rgbToYCoCg( historyRGB.div( historyScale ) ), boxMin, boxMax ); return ycocgToRGB( clipped ).mul( historyScale ); } ); // History sampling const bilinearTapStruct = struct( { color: 'vec4', weight: 'float', confidence: 'float' } ); const historyResultStruct = struct( { color: 'vec4', tapConfidence: 'float', minConfidence: 'float' } ); /** * Single bilinear history tap with plane-distance and normal confidence. * * @tsl */ const sampleBilinearTap = Fn( ( [ historyTexture, previousDepthNode, previousNormalNode, resolution, previousProjectionMatrixInverse, previousCameraWorldMatrix, previousCameraViewMatrix, tapCoord, bilinearWeight, worldPosition, worldNormal ] ) => { const color = textureLoad( historyTexture, tapCoord ).max( 0 ); const reprojDepth = textureLoad( previousDepthNode, tapCoord ).r; const reprojViewPos = getViewPosition( vec2( tapCoord ).add( 0.5 ).div( resolution ), reprojDepth, previousProjectionMatrixInverse ); const reprojWorldPos = previousCameraWorldMatrix.mul( vec4( reprojViewPos, 1.0 ) ).xyz; const reprojWorldNorm = unpackRGBToNormal( textureLoad( previousNormalNode, tapCoord ).rgb ).transformDirection( previousCameraViewMatrix ); const planeDiff = abs( dot( reprojWorldPos.sub( worldPosition ), worldNormal ) ).toVar(); planeDiff.divAssign( abs( reprojViewPos.z ) ); const normalConfidence = smoothstep( 0.95, 0.999, reprojWorldNorm.dot( worldNormal ) ); const confidence = smoothstep( 0, 0.01, planeDiff ).oneMinus().mul( normalConfidence ); const weight = bilinearWeight.mul( confidence ); return bilinearTapStruct( color.mul( weight ), weight, confidence ); } ); /** * @param {Object} ctx - Shared {@link sampleBilinearTap} inputs plus `reprojICoord`. * @param {Node<ivec2>} tapOffset * @param {Node<float>} bilinearWeight */ function bilinearHistoryTap( ctx, tapOffset, bilinearWeight ) { return sampleBilinearTap( ctx.historyTexture, ctx.previousDepthNode, ctx.previousNormalNode, ctx.resolution, ctx.previousProjectionMatrixInverse, ctx.previousCameraWorldMatrix, ctx.previousCameraViewMatrix, ctx.reprojICoord.add( tapOffset ), bilinearWeight, ctx.worldPosition, ctx.worldNormal ); } /** * Geometrically-weighted 4-tap bilinear history sample. * * @tsl */ const sampleHistory4Tap = Fn( ( [ historyTexture, previousDepthNode, previousNormalNode, resolution, previousProjectionMatrixInverse, previousCameraWorldMatrix, previousCameraViewMatrix, reprojUV, worldPosition, worldNormal, inputColor ] ) => { const reprojPixelCoord = reprojUV.mul( resolution ).sub( 0.5 ).toVar(); const reprojICoord = ivec2( floor( reprojPixelCoord ) ); const fCoord = reprojPixelCoord.fract(); const fx = fCoord.x; const fy = fCoord.y; const f00 = float( 1 ).sub( fx ).mul( float( 1 ).sub( fy ) ); const f10 = fx.mul( float( 1 ).sub( fy ) ); const f01 = float( 1 ).sub( fx ).mul( fy ); const f11 = fx.mul( fy ); const tapCtx = { historyTexture, previousDepthNode, previousNormalNode, resolution, previousProjectionMatrixInverse, previousCameraWorldMatrix, previousCameraViewMatrix, reprojICoord, worldPosition, worldNormal }; const tap00 = bilinearHistoryTap( tapCtx, ivec2( 0, 0 ), f00 ); const tap10 = bilinearHistoryTap( tapCtx, ivec2( 1, 0 ), f10 ); const tap01 = bilinearHistoryTap( tapCtx, ivec2( 0, 1 ), f01 ); const tap11 = bilinearHistoryTap( tapCtx, ivec2( 1, 1 ), f11 ); const colorSum = tap00.get( 'color' ).add( tap10.get( 'color' ) ).add( tap01.get( 'color' ) ).add( tap11.get( 'color' ) ); const weightSum = tap00.get( 'weight' ).add( tap10.get( 'weight' ) ).add( tap01.get( 'weight' ) ).add( tap11.get( 'weight' ) ); const maxConf = max( max( tap00.get( 'confidence' ), tap10.get( 'confidence' ) ), max( tap01.get( 'confidence' ), tap11.get( 'confidence' ) ) ); const minConf = min( min( tap00.get( 'confidence' ), tap10.get( 'confidence' ) ), min( tap01.get( 'confidence' ), tap11.get( 'confidence' ) ) ); return historyResultStruct( select( weightSum.greaterThan( 0.01 ), colorSum.div( weightSum ), vec4( inputColor.rgb, float( 1 ) ) ), maxConf, minConf ); } ); // Diffuse reprojection /** * Reprojection-stretch confidence — detects history magnification (surface stretching). * * Differentiates the per-pixel history UV with hardware screen-space derivatives to form the * reprojection Jacobian `J = ∂(historyPixel)/∂(screenPixel)`, then returns its **minimum * singular value**, clamped to `[0,1]`. * * `σ_min < 1` means the most-stretched axis magnifies history — a few history pixels are smeared * over many current pixels (e.g. a surface seen at grazing in the previous frame, face-on now), so * history is undersampled and its confidence should be reduced. `σ_min ≥ 1` (history minified) is * safe and clamps to 1. Using the minimum singular value rather than the Jacobian determinant * catches anisotropic 1-D stretch that an area-only measure would smear out. * * Works for any reprojection (surface-velocity or parallax hit-point) since it differentiates the * final history UV, so the same factor applies to both the diffuse and specular paths. * * @tsl */ const reprojectionStretchConfidence = Fn( ( [ historyUV, resolution ] ) => { // Jacobian columns in pixels: how the history sample position moves per screen pixel. const jx = dFdx( historyUV ).mul( resolution ).toVar(); const jy = dFdy( historyUV ).mul( resolution ).toVar(); // Singular values of the 2×2 J are sqrt( eigenvalues of JᵀJ ), with // trace( JᵀJ ) = ‖J‖²_F and det( JᵀJ ) = det( J )². const det = jx.x.mul( jy.y ).sub( jx.y.mul( jy.x ) ); const fro2 = dot( jx, jx ).add( dot( jy, jy ) ); const disc = fro2.mul( fro2 ).mul( 0.25 ).sub( det.mul( det ) ).max( 0 ).sqrt(); const sigMin = fro2.mul( 0.5 ).sub( disc ).max( 0 ).sqrt(); return sigMin.saturate(); } ); // Specular reprojection /** * Parallax-corrected hit-point reprojection into previous-frame UVs. * * @tsl */ const reprojectHitPoint = Fn( ( [ rayOrig, rayLength, cameraWorldPosition, previousViewMatrix, previousProjectionMatrix ] ) => { const cameraRay = normalize( rayOrig.sub( cameraWorldPosition ) ).toVar(); const parallaxHitPoint = rayOrig.add( cameraRay.mul( rayLength ) ); return projectWorldToUV( parallaxHitPoint, previousViewMatrix, previousProjectionMatrix ); } ); /** * Converts screen-space velocity (NDC derivative) to a UV reprojection offset. * * @tsl */ const velocityToUVOffset = Fn( ( [ velocity ] ) => { return velocity.mul( vec2( 0.5, - 0.5 ) ); } ).setLayout( { name: 'velocityToUVOffset', type: 'vec2', inputs: [ { name: 'velocity', type: 'vec2' } ] } ); /** * Current and previous-frame camera matrices for temporal reprojection passes. * * @param {Camera} camera */ function bindTemporalCameraUniforms( camera ) { const worldMatrix = uniform( new Matrix4().copy( camera.matrixWorld ) ); const viewMatrix = uniform( new Matrix4().copy( camera.matrixWorldInverse ) ); const projectionMatrix = uniform( new Matrix4().copy( camera.projectionMatrix ) ); const projectionMatrixInverse = uniform( new Matrix4().copy( camera.projectionMatrixInverse ) ); const worldPosition = uniform( new Vector3().copy( camera.position ) ); const previousWorldMatrix = uniform( new Matrix4().copy( camera.matrixWorld ) ); const previousViewMatrix = uniform( new Matrix4().copy( camera.matrixWorldInverse ) ); const previousProjectionMatrix = uniform( new Matrix4().copy( camera.projectionMatrix ) ); const previousProjectionMatrixInverse = uniform( new Matrix4().copy( camera.projectionMatrixInverse ) ); /** * @param {Camera} cam */ function updateFromCamera( cam ) { previousWorldMatrix.value.copy( worldMatrix.value ); previousViewMatrix.value.copy( viewMatrix.value ); previousProjectionMatrix.value.copy( projectionMatrix.value ); previousProjectionMatrixInverse.value.copy( projectionMatrixInverse.value ); worldMatrix.value.copy( cam.matrixWorld ); viewMatrix.value.copy( cam.matrixWorldInverse ); projectionMatrix.value.copy( cam.projectionMatrix ); projectionMatrixInverse.value.copy( cam.projectionMatrixInverse ); worldPosition.value.copy( cam.position ); } return { worldMatrix, viewMatrix, projectionMatrix, projectionMatrixInverse, worldPosition, previousWorldMatrix, previousViewMatrix, previousProjectionMatrix, previousProjectionMatrixInverse, updateFromCamera }; } const _quadMesh = /*@__PURE__*/ new QuadMesh(); const _size = /*@__PURE__*/ new Vector2(); let _rendererState; const DEFAULT_MAX_VELOCITY_LENGTH = 128; const VARIANCE_GAMMA_MIN = 0.5; const VARIANCE_GAMMA_MAX = 1; /** * @typedef {'diffuse' | 'specular'} TemporalReprojectMode */ /** * @typedef {Object} TemporalReprojectNodeOptions * @property {TemporalReprojectMode} [mode='diffuse'] - `diffuse` for SSGI/scene colour; `specular` for SSR reflections. * @property {boolean} [hitPointReprojection] - Parallax hit-point reprojection (specular mode only). Defaults to `true` in specular mode. * @property {boolean} [accumulate=false] - When `true`, history is stored in this pass (classic temporal resolve). When `false`, * use {@link TemporalReprojectNode#setHistoryTexture} to read history from another pass (e.g. denoise output). */ /** * Temporal reprojection pass for denoising screen-space effects (SSGI, SSR, etc.). * * Both modes share geometrically-weighted 4-tap bilinear history sampling and YCoCg variance clipping. * Surface velocity reprojection is always sampled first. Specular mode then blends in * hit-point parallax history on top of that surface result. * Diffuse mode applies velocity-field divergence to detect surface stretching. * * Unlike jitter-based TAA/TAAU, this node does not apply camera sub-pixel jitter — it only * reprojects and accumulates history using motion vectors. * * References: * - {@link https://alextardif.com/TAA.html} * - {@link https://www.elopezr.com/temporal-aa-and-the-quest-for-the-holy-trail/} * * @augments TempNode * @three_import import { temporalReproject } from 'three/addons/tsl/display/TemporalReprojectNode.js'; */ class TemporalReprojectNode extends TempNode { static get type() { return 'TemporalReprojectNode'; } /** * @param {TextureNode} beautyNode * @param {TextureNode} depthNode * @param {TextureNode} normalNode * @param {TextureNode} velocityNode * @param {Camera} camera * @param {TemporalReprojectNodeOptions} [options] */ constructor( beautyNode, depthNode, normalNode, velocityNode, camera, options = {} ) { super( 'vec4' ); const { mode = 'diffuse', hitPointReprojection = mode === 'specular', accumulate = false } = options; if ( mode !== 'specular' && mode !== 'diffuse' ) { throw new Error( 'TemporalReprojectNode: `mode` must be `diffuse` or `specular`.' ); } this.isTemporalReprojectNode = true; this.updateBeforeType = NodeUpdateType.FRAME; this.beautyNode = beautyNode; this.depthNode = depthNode; this.normalNode = normalNode; this.velocityNode = velocityNode; this.camera = camera; /** * @type {TemporalReprojectMode} */ this.mode = mode; /** * When `true`, resolve output is copied into the internal history buffer each frame. * When `false`, history is supplied externally via {@link TemporalReprojectNode#setHistoryTexture}. * * @type {boolean} */ this.accumulate = accumulate; this.maxVelocityLength = DEFAULT_MAX_VELOCITY_LENGTH; this._resolution = uniform( new Vector2() ); this._cameraUniforms = bindTemporalCameraUniforms( camera ); this.maxFrames = uniform( 32 ); this.hitPointReprojection = uniform( hitPointReprojection, 'bool' ); this.clampIntensity = uniform( 1 ); this.flickerSuppression = uniform( 1 ); this._historyRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, type: HalfFloatType, depthTexture: new DepthTexture() } ); this._historyRenderTarget.texture.name = 'TemporalReprojectNode.history'; this._historyTextureNode = texture( this._historyRenderTarget.texture ); this._resolveRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, type: HalfFloatType } ); this._resolveRenderTarget.texture.name = 'TemporalReprojectNode.resolve'; this._resolveMaterial = new NodeMaterial(); this._resolveMaterial.name = 'TemporalReproject.resolve'; this._seedMaterial = new NodeMaterial(); this._seedMaterial.name = 'TemporalReproject.seed'; this._textureNode = passTexture( this, this._resolveRenderTarget.texture ); this._originalProjectionMatrix = new Matrix4(); this._placeholderPreviousDepthTexture = new DepthTexture( 1, 1 ); this._previousDepthNode = texture( this._placeholderPreviousDepthTexture ); this._previousNormalTexture = normalNode.value.clone(); this._previousNormalNode = texture( this._previousNormalTexture ); this._needsPostProcessingSync = false; this._externalHistoryTexture = null; this._syncHistoryTextureBinding(); } getTextureNode() { return this._textureNode; } setSize( width, height ) { if ( width === null || height === null ) return; this._historyRenderTarget.setSize( width, height ); this._resolveRenderTarget.setSize( width, height ); this._resolution.value.set( width, height ); } setViewOffset() { this.camera.updateProjectionMatrix(); this._originalProjectionMatrix.copy( this.camera.projectionMatrix ); velocity.setProjectionMatrix( this._originalProjectionMatrix ); } clearViewOffset() { velocity.setProjectionMatrix( null ); } updateBefore( frame ) { const { renderer } = frame; this._cameraUniforms.updateFromCamera( this.camera ); const drawingBufferSize = renderer.getDrawingBufferSize( _size ); const width = drawingBufferSize.width; const height = drawingBufferSize.height; if ( this._needsPostProcessingSync === true ) { this.setViewOffset(); this._needsPostProcessingSync = false; } _rendererState = RendererUtils.resetRendererState( renderer, _rendererState ); const needsRestart = this._historyRenderTarget.width !== width || this._historyRenderTarget.height !== height; this.setSize( width, height ); let historySwappedForRestart = false; if ( needsRestart === true ) { renderer.initRenderTarget( this._historyRenderTarget ); renderer.initRenderTarget( this._resolveRenderTarget ); this._previousNormalTexture.dispose(); this._previousNormalTexture = this.normalNode.value.clone(); this._previousNormalNode.value = this._previousNormalTexture; // External history (e.g. denoise feedback) is stale at the old resolution — use // freshly seeded internal history for this frame instead. if ( this.accumulate === false && this._externalHistoryTexture !== null ) { this._historyTextureNode.value = this._historyRenderTarget.texture; historySwappedForRestart = true; } renderer.setRenderTarget( this._historyRenderTarget ); _quadMesh.material = this._seedMaterial; _quadMesh.name = 'TemporalReproject.seed'; _quadMesh.render( renderer ); renderer.setRenderTarget( null ); } renderer.setRenderTarget( this._resolveRenderTarget ); _quadMesh.material = this._resolveMaterial; _quadMesh.name = 'TemporalReproject'; _quadMesh.render( renderer ); renderer.setRenderTarget( null ); if ( historySwappedForRestart === true ) { this._syncHistoryTextureBinding(); } else if ( this.accumulate === true ) { renderer.copyTextureToTexture( this._resolveRenderTarget.texture, this._historyRenderTarget.texture ); } const currentDepth = this.depthNode.value; const srcW = currentDepth.image !== null && currentDepth.image !== undefined ? currentDepth.image.width : 0; const srcH = currentDepth.image !== null && currentDepth.image !== undefined ? currentDepth.image.height : 0; if ( srcW > 0 && srcH > 0 ) { renderer.copyTextureToTexture( currentDepth, this._historyRenderTarget.depthTexture ); renderer.copyTextureToTexture( this.normalNode.value, this._previousNormalTexture ); this._previousDepthNode.value = this._historyRenderTarget.depthTexture; } RendererUtils.restoreRendererState( renderer, _rendererState ); } setup( builder ) { const renderPipeline = builder.context.renderPipeline; if ( renderPipeline ) { this._needsPostProcessingSync = true; renderPipeline.context.onBeforeRenderPipeline = () => { this.setViewOffset(); }; renderPipeline.context.onAfterRenderPipeline = () => { this.clearViewOffset(); }; } this._resolveMaterial.fragmentNode = this._buildResolve( builder ); this._resolveMaterial.needsUpdate = true; this._buildSeed( builder ); return this._textureNode; } _buildSeed( builder ) { const seed = Fn( () => { const screenTexel = ivec2( floor( screenCoordinate.xy.sub( 0.5 ) ) ); const beautySize = this.beautyNode.size(); const beautyTexel = beautyTexelFromScreen( screenTexel, beautySize, this._resolution ); return textureLoad( this.beautyNode, beautyTexel ).max( 0 ); } ); this._seedMaterial.fragmentNode = seed().context( builder.getSharedContext() ); this._seedMaterial.needsUpdate = true; } _buildResolve( builder ) { const isSpecular = this.mode === 'specular'; const cameraUniforms = this._cameraUniforms; const resolve = Fn( () => { const uvNode = uv(); const screenTexel = ivec2( floor( screenCoordinate.xy.sub( 0.5 ) ) ); const depth = textureLoad( this.depthNode, screenTexel ).r.toVar(); depth.greaterThanEqual( 1.0 ).discard(); const beautySize = this.beautyNode.size(); const beautyTexel = beautyTexelFromScreen( screenTexel, beautySize, this._resolution ); const inputColor = textureLoad( this.beautyNode, beautyTexel ).max( 0 ).toVar(); const viewNormal = unpackRGBToNormal( textureLoad( this.normalNode, screenTexel ).rgb ).toVar(); // Shared 3×3 beauty fetch: feeds both the variance-clip box and the SSR ray-length stats. const neighborhood = collectNeighborhood( this.beautyNode, beautyTexel, inputColor, this.flickerSuppression ); const worldNormal = viewNormal.transformDirection( cameraUniforms.viewMatrix ).toVar(); const viewPosition = getViewPosition( uvNode, depth, cameraUniforms.projectionMatrixInverse ).toVar(); const worldPosition = cameraUniforms.worldMatrix.mul( vec4( viewPosition, 1.0 ) ).xyz.toVar(); const sampleHistory = ( reprojUV ) => sampleHistory4Tap( this._historyTextureNode, this._previousDepthNode, this._previousNormalNode, this._resolution, cameraUniforms.previousProjectionMatrixInverse, cameraUniforms.previousWorldMatrix, cameraUniforms.previousViewMatrix, reprojUV, worldPosition, worldNormal, inputColor.rgb ); // Surface-velocity reprojection — the base history for both modes. `historyUV` is // reused below for the stretch guard, so it is computed once here. const velocityOff = velocityToUVOffset( textureLoad( this.velocityNode, screenTexel ).xy ).toVar(); const motionFactor = velocityOff.mul( this._resolution ).length().div( float( this.maxVelocityLength ) ).saturate(); const historyUV = uvNode.sub( velocityOff ).toVar(); const surf = sampleHistory( historyUV ); const historyColor = surf.get( 'color' ).toVar(); const totalConfidence = float( 1 ).toVar(); const historyTrust = float( 0 ).toVar(); // Specular: blend parallax hit-point history on top of the surface result. Returns the resolved // color (rgb from the blend, alpha from the surface tap), its confidence, and the hit-vs-surface trust. const resolveSpecularHistory = () => { const surfValid = historyUV.x.greaterThanEqual( 0 ).and( historyUV.x.lessThanEqual( 1 ) ) .and( historyUV.y.greaterThanEqual( 0 ) ).and( historyUV.y.lessThanEqual( 1 ) ); const historyUV_hit = reprojectHitPoint( worldPosition, neighborhood.get( 'rayLength' ), cameraUniforms.worldPosition, cameraUniforms.previousViewMatrix, cameraUniforms.previousProjectionMatrix ).toVar(); const hitValid = historyUV_hit.x.greaterThanEqual( 0 ).and( historyUV_hit.x.lessThanEqual( 1 ) ) .and( historyUV_hit.y.greaterThanEqual( 0 ) ).and( historyUV_hit.y.lessThanEqual( 1 ) ) .and( this.hitPointReprojection ); const hit = sampleHistory( historyUV_hit ); const hcHit = hit.get( 'color' ).rgb.max( 0 ); const hcSurf = surf.get( 'color' ).rgb.max( 0 ); const confHit = hitValid.select( hit.get( 'tapConfidence' ), float( 0 ) ); const confSurf = surfValid.select( surf.get( 'tapConfidence' ), float( 0 ) ); const minConfHit = hit.get( 'minConfidence' ); const reflectionEdgeFactor = neighborhood.get( 'stdDevRayLength' ); reflectionEdgeFactor.assign( reflectionEdgeFactor.mul( motionFactor.mul( 100 ).min( 1 ) ).mul( 3.5 ).min( 1 ).oneMinus() ); const curvatureFactor = fwidth( worldNormal.xyz ).length().mul( 50 ).clamp(); const envProbability = neighborhood.get( 'envProbability' ); const wHitRaw = minConfHit .mul( reflectionEdgeFactor ) .mul( curvatureFactor.oneMinus() ) .mul( confHit ).toConst(); const wHit = wHitRaw.mul( envProbability.pow2().oneMinus() ); const wSurf = wHit.oneMinus().mul( confSurf ); const wSum = max( wHit.add( wSurf ), float( EPSILON ) ); const color = vec4( hcHit.mul( wHit ).add( hcSurf.mul( wSurf ) ).div( wSum ), surf.get( 'color' ).a ).toVar(); const confidence = confHit.mul( wHit ).add( confSurf.mul( wSurf ) ).div( wSum ); // Near-black blend means neither tap was usable — fall back to the current frame. If( color.rgb.length().lessThan( EPSILON ), () => { color.assign( vec4( inputColor.rgb, 1 ) ); } ); return { color, confidence, trust: wHitRaw }; // without env probability }; if ( isSpecular ) { const spec = resolveSpecularHistory(); historyColor.assign( spec.color ); totalConfidence.assign( spec.confidence ); historyTrust.assign( spec.trust ); } const a = historyColor.a.max( EPSILON ); // Universal stretch guard: reduce confidence where a "small area" is projected over a "large area". const stretchConfidence = reprojectionStretchConfidence( historyUV, this._resolution ); totalConfidence.mulAssign( stretchConfidence.pow( 2 ) ); const varianceGamma = mix( float( VARIANCE_GAMMA_MIN ), float( VARIANCE_GAMMA_MAX ), motionFactor.oneMinus().pow2() ); const clippedRGB = applyVarianceClipping( historyColor, neighborhood.get( 'mean' ), neighborhood.get( 'stdColor' ), varianceGamma, this.flickerSuppression ).toVar(); const clampIntensity = this.clampIntensity.mul( max( motionFactor.mul( 10 ).min( 1 ), 0.25 ) ).mul( float( 1 ).add( stretchConfidence.oneMinus().add( historyTrust.oneMinus() ).clamp() ) ); const originalHistoryColor = vec3( historyColor.rgb ); historyColor.rgb.assign( mix( historyColor.rgb, clippedRGB, clampIntensity ) ); totalConfidence.mulAssign( exp( originalHistoryColor.sub( clippedRGB ).length().mul( clampIntensity ).mul( 30 ).negate() ) ); totalConfidence.mulAssign( mix( float( 1 ), historyTrust.mul( 0.05 ).add( 0.95 ), motionFactor.mul( 100 ).clamp() ) ); If( totalConfidence.lessThan( EPSILON ), () => { historyColor.assign( vec4( inputColor.rgb, 1 ) ); } ); const currentFrameCount = float( 1 ).div( a ).mul( totalConfidence ).add( 1 ).min( this.maxFrames ).toVar(); if ( isSpecular ) { // A black current sample means no reflection was found this frame (a miss, not dark). // Since no valid sample was found, decrement the frame count (as the next accumulating pass will increase it). If( inputColor.rgb.length().lessThan( EPSILON ), () => { currentFrameCount.assign( currentFrameCount.sub( 1 ).max( 1 ) ); } ); } return vec4( historyColor.rgb, float( 1 ).div( currentFrameCount ) ); } ); return resolve().context( builder.getSharedContext() ); } _syncHistoryTextureBinding() { if ( this.accumulate === true || this._externalHistoryTexture === null ) { this._historyTextureNode.value = this._historyRenderTarget.texture; } else { this._historyTextureNode.value = this._externalHistoryTexture; } } /** * Supplies an external history source (e.g. a {@link RecurrentDenoiseNode} or its * texture). Only used when {@link TemporalReprojectNode#accumulate} is `false`. * * @param {?(Object|Texture)} source */ setHistoryTexture( source ) { this._externalHistoryTexture = ( source && typeof source.getRenderTarget === 'function' ) ? source.getRenderTarget().texture : source; this._syncHistoryTextureBinding(); } dispose() { this._previousNormalTexture.dispose(); if ( this._previousDepthNode.value !== this._historyRenderTarget.depthTexture ) { this._previousDepthNode.value.dispose(); } if ( this._placeholderPreviousDepthTexture !== this._historyRenderTarget.depthTexture ) { this._placeholderPreviousDepthTexture.dispose(); } this._historyRenderTarget.dispose(); this._resolveRenderTarget.dispose(); this._resolveMaterial.dispose(); this._seedMaterial.dispose(); } } export default TemporalReprojectNode; /** * @param {TextureNode} beautyNode * @param {TextureNode} depthNode * @param {TextureNode} normalNode * @param {TextureNode} velocityNode * @param {Camera} camera * @param {TemporalReprojectNodeOptions} [options] * @returns {TemporalReprojectNode} */ export const temporalReproject = ( beautyNode, depthNode, normalNode, velocityNode, camera, options = {} ) => nodeObject( new TemporalReprojectNode( convertToTexture( beautyNode ), nodeObject( depthNode ), nodeObject( normalNode ), nodeObject( velocityNode ), camera, options ) );