|
|
@@ -1,5 +1,5 @@
|
|
|
-import { DataTexture, RenderTarget, RepeatWrapping, Vector2, Vector3, TempNode, QuadMesh, NodeMaterial, RendererUtils, RedFormat } from 'three/webgpu';
|
|
|
-import { reference, logarithmicDepthToViewZ, viewZToPerspectiveDepth, getNormalFromDepth, getScreenPosition, getViewPosition, nodeObject, Fn, float, NodeUpdateType, uv, uniform, Loop, vec2, vec3, int, dot, max, pow, abs, If, textureSize, sin, cos, PI, texture, passTexture, mat3, add, normalize, mul, cross, mix, acos, clamp } from 'three/tsl';
|
|
|
+import { DataTexture, RenderTarget, RepeatWrapping, Vector2, Vector3, TempNode, QuadMesh, NodeMaterial, RendererUtils, RedFormat, FloatType, NearestFilter } from 'three/webgpu';
|
|
|
+import { reference, logarithmicDepthToViewZ, viewZToPerspectiveDepth, getNormalFromDepth, getScreenPosition, getViewPosition, nodeObject, Fn, float, NodeUpdateType, uv, uniform, Loop, vec2, vec3, vec4, int, dot, max, min, pow, abs, If, textureSize, sin, cos, PI, texture, passTexture, mat3, add, normalize, mul, cross, mix, acos, clamp } from 'three/tsl';
|
|
|
|
|
|
const _quadMesh = /*@__PURE__*/ new QuadMesh();
|
|
|
const _size = /*@__PURE__*/ new Vector2();
|
|
|
@@ -71,13 +71,12 @@ class GTAONode extends TempNode {
|
|
|
this.normalNode = normalNode;
|
|
|
|
|
|
/**
|
|
|
- * The resolution scale. By default the effect is rendered in full resolution
|
|
|
- * for best quality but a value of `0.5` should be sufficient for most scenes.
|
|
|
+ * Backing field for {@link GTAONode#resolutionScale}.
|
|
|
*
|
|
|
+ * @private
|
|
|
* @type {number}
|
|
|
- * @default 1
|
|
|
*/
|
|
|
- this.resolutionScale = 1;
|
|
|
+ this._resolutionScale = 1;
|
|
|
|
|
|
/**
|
|
|
* The `updateBeforeType` is set to `NodeUpdateType.FRAME` since the node renders
|
|
|
@@ -97,6 +96,14 @@ class GTAONode extends TempNode {
|
|
|
this._aoRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, format: RedFormat } );
|
|
|
this._aoRenderTarget.texture.name = 'GTAONode.AO';
|
|
|
|
|
|
+ // Half-res depth target, populated by the downsample pass when
|
|
|
+ // resolutionScale < 1. Depth uses Float32 because perspective depth values
|
|
|
+ // cluster near 1.0 where Float16 quantizes horizon angles into visible rings.
|
|
|
+ this._depthHalfRT = new RenderTarget( 1, 1, { depthBuffer: false, format: RedFormat, type: FloatType } );
|
|
|
+ this._depthHalfRT.texture.name = 'GTAONode.DepthHalf';
|
|
|
+ this._depthHalfRT.texture.minFilter = NearestFilter;
|
|
|
+ this._depthHalfRT.texture.magFilter = NearestFilter;
|
|
|
+
|
|
|
// uniforms
|
|
|
|
|
|
/**
|
|
|
@@ -114,6 +121,15 @@ class GTAONode extends TempNode {
|
|
|
*/
|
|
|
this.resolution = uniform( new Vector2() );
|
|
|
|
|
|
+ /**
|
|
|
+ * Full screen resolution; used by the downsample passes to map each half-res
|
|
|
+ * texel to its full-res footprint.
|
|
|
+ *
|
|
|
+ * @private
|
|
|
+ * @type {UniformNode<vec2>}
|
|
|
+ */
|
|
|
+ this._fullResolution = uniform( new Vector2() );
|
|
|
+
|
|
|
/**
|
|
|
* The thickness of the ambient occlusion.
|
|
|
*
|
|
|
@@ -226,6 +242,9 @@ class GTAONode extends TempNode {
|
|
|
this._material = new NodeMaterial();
|
|
|
this._material.name = 'GTAO';
|
|
|
|
|
|
+ this._depthDownsampleMaterial = new NodeMaterial();
|
|
|
+ this._depthDownsampleMaterial.name = 'GTAO.DepthDownsample';
|
|
|
+
|
|
|
/**
|
|
|
* The result of the effect is represented as a separate texture node.
|
|
|
*
|
|
|
@@ -247,6 +266,38 @@ class GTAONode extends TempNode {
|
|
|
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Render resolution as a fraction of the screen size (`1` = full, `0.5` = half).
|
|
|
+ * At `< 1`, a min-Z depth downsample runs first and the AO shader reads from that
|
|
|
+ * half-res target, which avoids the banding caused by silhouette-bleed when the
|
|
|
+ * AO is computed at half-res against the full-res depth input. Normals are still
|
|
|
+ * sampled at full resolution. Crossing the `1` ↔ `< 1` boundary triggers a shader
|
|
|
+ * recompile (the texture binding is decided at compile time).
|
|
|
+ *
|
|
|
+ * @type {number}
|
|
|
+ * @default 1
|
|
|
+ */
|
|
|
+ get resolutionScale() {
|
|
|
+
|
|
|
+ return this._resolutionScale;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ set resolutionScale( value ) {
|
|
|
+
|
|
|
+ if ( value === this._resolutionScale ) return;
|
|
|
+
|
|
|
+ const crossedBoundary = ( value === 1 ) !== ( this._resolutionScale === 1 );
|
|
|
+ this._resolutionScale = value;
|
|
|
+
|
|
|
+ if ( crossedBoundary && this._material ) {
|
|
|
+
|
|
|
+ this._material.needsUpdate = true;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Sets the size of the effect.
|
|
|
*
|
|
|
@@ -255,11 +306,22 @@ class GTAONode extends TempNode {
|
|
|
*/
|
|
|
setSize( width, height ) {
|
|
|
|
|
|
- width = Math.round( this.resolutionScale * width );
|
|
|
- height = Math.round( this.resolutionScale * height );
|
|
|
+ this._fullResolution.value.set( width, height );
|
|
|
+
|
|
|
+ const lowW = Math.max( 1, Math.round( this._resolutionScale * width ) );
|
|
|
+ const lowH = Math.max( 1, Math.round( this._resolutionScale * height ) );
|
|
|
+
|
|
|
+ this.resolution.value.set( lowW, lowH );
|
|
|
+ this._aoRenderTarget.setSize( lowW, lowH );
|
|
|
|
|
|
- this.resolution.value.set( width, height );
|
|
|
- this._aoRenderTarget.setSize( width, height );
|
|
|
+ // Only resize the half-res RT when we'll actually use it. Shrinking it
|
|
|
+ // here at scale=1 while the renderer holds cached bindings from a previous
|
|
|
+ // scale<1 compile would corrupt later frames.
|
|
|
+ if ( this._resolutionScale !== 1 ) {
|
|
|
+
|
|
|
+ this._depthHalfRT.setSize( lowW, lowH );
|
|
|
+
|
|
|
+ }
|
|
|
|
|
|
}
|
|
|
|
|
|
@@ -293,15 +355,27 @@ class GTAONode extends TempNode {
|
|
|
const size = renderer.getDrawingBufferSize( _size );
|
|
|
this.setSize( size.width, size.height );
|
|
|
|
|
|
- _quadMesh.material = this._material;
|
|
|
- _quadMesh.name = 'AO';
|
|
|
-
|
|
|
// clear
|
|
|
|
|
|
renderer.setClearColor( 0xffffff, 1 );
|
|
|
|
|
|
+ // Downsample depth to half-res (min-Z foreground-priority) so the AO horizon
|
|
|
+ // search runs on a consistent low-res grid and doesn't band on silhouettes
|
|
|
+ // from bilinear bleed in the full-res depth input.
|
|
|
+
|
|
|
+ if ( this._resolutionScale !== 1 ) {
|
|
|
+
|
|
|
+ _quadMesh.material = this._depthDownsampleMaterial;
|
|
|
+ _quadMesh.name = 'GTAO.DepthDownsample';
|
|
|
+ renderer.setRenderTarget( this._depthHalfRT );
|
|
|
+ _quadMesh.render( renderer );
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
// ao
|
|
|
|
|
|
+ _quadMesh.material = this._material;
|
|
|
+ _quadMesh.name = 'AO';
|
|
|
renderer.setRenderTarget( this._aoRenderTarget );
|
|
|
_quadMesh.render( renderer );
|
|
|
|
|
|
@@ -321,9 +395,18 @@ class GTAONode extends TempNode {
|
|
|
|
|
|
const uvNode = uv();
|
|
|
|
|
|
+ // At scale<1 the AO shader reads depth from the half-res RT populated by the
|
|
|
+ // downsample pass; at scale=1 it samples the source node directly. The choice
|
|
|
+ // is compile-time — the resolutionScale setter triggers a recompile when
|
|
|
+ // crossing the boundary. Normals are always sampled at full-res.
|
|
|
+ const useHalfRes = this._resolutionScale !== 1;
|
|
|
+ const depthSourceNode = useHalfRes ? texture( this._depthHalfRT.texture ) : this.depthNode;
|
|
|
+ const normalSourceNode = this.normalNode;
|
|
|
+ const fallbackDepthForNormal = useHalfRes ? this._depthHalfRT.texture : this.depthNode.value;
|
|
|
+
|
|
|
const sampleDepth = ( uv ) => {
|
|
|
|
|
|
- const depth = this.depthNode.sample( uv ).r;
|
|
|
+ const depth = depthSourceNode.sample( uv ).r;
|
|
|
|
|
|
if ( builder.renderer.logarithmicDepthBuffer === true ) {
|
|
|
|
|
|
@@ -338,7 +421,9 @@ class GTAONode extends TempNode {
|
|
|
};
|
|
|
|
|
|
const sampleNoise = ( uv ) => this._noiseNode.sample( uv );
|
|
|
- const sampleNormal = ( uv ) => ( this.normalNode !== null ) ? this.normalNode.sample( uv ).rgb.normalize() : getNormalFromDepth( uv, this.depthNode.value, this._cameraProjectionMatrixInverse );
|
|
|
+ const sampleNormal = ( uv ) => ( normalSourceNode !== null )
|
|
|
+ ? normalSourceNode.sample( uv ).rgb.normalize()
|
|
|
+ : getNormalFromDepth( uv, fallbackDepthForNormal, this._cameraProjectionMatrixInverse );
|
|
|
|
|
|
const ao = Fn( () => {
|
|
|
|
|
|
@@ -486,7 +571,41 @@ class GTAONode extends TempNode {
|
|
|
this._material.fragmentNode = ao().context( builder.getSharedContext() );
|
|
|
this._material.needsUpdate = true;
|
|
|
|
|
|
- //
|
|
|
+ // At scale=1 the downsample passes are skipped, so building their fragment
|
|
|
+ // nodes is dead work. Recompile when crossing the boundary picks the right path.
|
|
|
+ if ( ! useHalfRes ) {
|
|
|
+
|
|
|
+ return this._textureNode;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ // Each half-res output texel covers ratio×ratio full-res texels (ratio =
|
|
|
+ // fullRes / lowRes). Sample at integer-pixel-center UVs so `.sample()` reads
|
|
|
+ // the exact texel with no bilinear bleed and stays valid on multisampled depth.
|
|
|
+ const computeFullResFootprint = () => {
|
|
|
+
|
|
|
+ const ratio = this._fullResolution.div( this.resolution );
|
|
|
+ const baseFull = uv().mul( this.resolution ).floor().mul( ratio );
|
|
|
+ const sampleAt = ( i, j ) => baseFull.add( vec2( i, j ).mul( ratio.sub( 1 ).max( 0 ) ) ).add( 0.5 ).div( this._fullResolution );
|
|
|
+ return { sampleAt };
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ // Depth: min-Z over the 2×2 footprint (foreground priority — half-res depth
|
|
|
+ // is always a real surface depth, never a phantom mid-silhouette value).
|
|
|
+ const depthDownsample = Fn( () => {
|
|
|
+
|
|
|
+ const { sampleAt } = computeFullResFootprint();
|
|
|
+ const d00 = this.depthNode.sample( sampleAt( 0, 0 ) ).r.toVar();
|
|
|
+ const d10 = this.depthNode.sample( sampleAt( 1, 0 ) ).r.toVar();
|
|
|
+ const d01 = this.depthNode.sample( sampleAt( 0, 1 ) ).r.toVar();
|
|
|
+ const d11 = this.depthNode.sample( sampleAt( 1, 1 ) ).r.toVar();
|
|
|
+ return vec4( min( min( d00, d10 ), min( d01, d11 ) ), 0, 0, 1 );
|
|
|
+
|
|
|
+ } );
|
|
|
+
|
|
|
+ this._depthDownsampleMaterial.fragmentNode = depthDownsample().context( builder.getSharedContext() );
|
|
|
+ this._depthDownsampleMaterial.needsUpdate = true;
|
|
|
|
|
|
return this._textureNode;
|
|
|
|
|
|
@@ -499,8 +618,10 @@ class GTAONode extends TempNode {
|
|
|
dispose() {
|
|
|
|
|
|
this._aoRenderTarget.dispose();
|
|
|
+ this._depthHalfRT.dispose();
|
|
|
|
|
|
this._material.dispose();
|
|
|
+ this._depthDownsampleMaterial.dispose();
|
|
|
|
|
|
}
|
|
|
|