Kaynağa Gözat

Merge branch 'dev' into city

Mr.doob 3 hafta önce
ebeveyn
işleme
50ee328d11
35 değiştirilmiş dosya ile 1730 ekleme ve 247 silme
  1. 8 8
      examples/jsm/helpers/LightProbeGridHelperWebGL.js
  2. 3 0
      examples/jsm/inspector/ui/Profiler.js
  3. 5 5
      examples/jsm/lighting/LightProbeGridWebGL.js
  4. 596 0
      examples/jsm/tsl/display/DualKawaseBloomNode.js
  5. 33 14
      examples/jsm/tsl/display/GTAONode.js
  6. 428 0
      examples/jsm/tsl/display/SSAONode.js
  7. 63 0
      examples/jsm/tsl/display/depthAwareBlur.js
  8. 3 12
      examples/jsm/tsl/lighting/ClusteredLightsNode.js
  9. BIN
      examples/screenshots/webgl_lightprobes.jpg
  10. BIN
      examples/screenshots/webgl_postprocessing_pixel.jpg
  11. BIN
      examples/screenshots/webgpu_lights_clustered.jpg
  12. 7 11
      examples/webgl_lightprobes.html
  13. 9 9
      examples/webgl_lightprobes_complex.html
  14. 5 5
      examples/webgl_lightprobes_sponza.html
  15. 145 9
      examples/webgl_postprocessing_pixel.html
  16. 2 2
      examples/webgpu_compute_particles_snow.html
  17. 4 35
      examples/webgpu_lights_clustered.html
  18. 89 30
      examples/webgpu_postprocessing_ao.html
  19. 24 21
      examples/webgpu_postprocessing_bloom.html
  20. 23 5
      examples/webgpu_postprocessing_bloom_emissive.html
  21. 25 5
      examples/webgpu_postprocessing_bloom_selective.html
  22. 1 1
      examples/webgpu_postprocessing_ssgi.html
  23. 1 1
      examples/webgpu_postprocessing_ssgi_ballpool.html
  24. 2 0
      examples/webgpu_tsl_editor.html
  25. 2 2
      src/materials/nodes/manager/NodeMaterialObserver.js
  26. 18 21
      src/nodes/accessors/TextureNode.js
  27. 12 2
      src/nodes/code/FunctionNode.js
  28. 22 42
      src/nodes/core/NodeBuilder.js
  29. 82 0
      src/nodes/core/NodeUtils.js
  30. 20 4
      src/nodes/lighting/LightsNode.js
  31. 8 0
      src/renderers/WebGLRenderer.js
  32. 5 0
      src/renderers/common/Renderer.js
  33. 36 0
      src/renderers/webgl-fallback/nodes/GLSLNodeBuilder.js
  34. 44 2
      src/renderers/webgpu/nodes/WGSLNodeBuilder.js
  35. 5 1
      src/renderers/webgpu/utils/WebGPUBindingUtils.js

+ 8 - 8
examples/jsm/helpers/LightProbeGridHelper.js → examples/jsm/helpers/LightProbeGridHelperWebGL.js

@@ -8,25 +8,25 @@ import {
 } from 'three';
 
 /**
- * Visualizes an {@link LightProbeGrid} by rendering a sphere at each
+ * Visualizes an {@link LightProbeGridWebGL} by rendering a sphere at each
  * probe position, shaded with the probe's L2 spherical harmonics.
  *
  * Uses a single `InstancedMesh` draw call for all probes.
  *
  * ```js
- * const helper = new LightProbeGridHelper( probes );
+ * const helper = new LightProbeGridHelperWebGL( probes );
  * scene.add( helper );
  * ```
  *
  * @augments InstancedMesh
- * @three_import import { LightProbeGridHelper } from 'three/addons/helpers/LightProbeGridHelper.js';
+ * @three_import import { LightProbeGridHelperWebGL } from 'three/addons/helpers/LightProbeGridHelperWebGL.js';
  */
-class LightProbeGridHelper extends InstancedMesh {
+class LightProbeGridHelperWebGL extends InstancedMesh {
 
 	/**
 	 * Constructs a new irradiance probe grid helper.
 	 *
-	 * @param {LightProbeGrid} probes - The probe grid to visualize.
+	 * @param {LightProbeGridWebGL} probes - The probe grid to visualize.
 	 * @param {number} [sphereSize=0.12] - The radius of each probe sphere.
 	 */
 	constructor( probes, sphereSize = 0.12 ) {
@@ -135,11 +135,11 @@ class LightProbeGridHelper extends InstancedMesh {
 		/**
 		 * The probe grid to visualize.
 		 *
-		 * @type {LightProbeGrid}
+		 * @type {LightProbeGridWebGL}
 		 */
 		this.probes = probes;
 
-		this.type = 'LightProbeGridHelper';
+		this.type = 'LightProbeGridHelperWebGL';
 
 		this.update();
 
@@ -218,4 +218,4 @@ class LightProbeGridHelper extends InstancedMesh {
 
 }
 
-export { LightProbeGridHelper };
+export { LightProbeGridHelperWebGL };

+ 3 - 0
examples/jsm/inspector/ui/Profiler.js

@@ -254,6 +254,9 @@ export class Profiler extends EventDispatcher {
 
 		this.domElement.classList.add( 'three-inspector' );
 
+		this.domElement.addEventListener( 'keydown', ( e ) => e.stopPropagation() );
+		this.domElement.addEventListener( 'keyup', ( e ) => e.stopPropagation() );
+
 		this.toggleButton = document.createElement( 'button' );
 		this.toggleButton.classList.add( 'profiler-toggle' );
 		this.toggleButton.innerHTML = `

+ 5 - 5
examples/jsm/lighting/LightProbeGrid.js → examples/jsm/lighting/LightProbeGridWebGL.js

@@ -78,9 +78,9 @@ const ATLAS_PADDING = 1;
  * Baking is fully GPU-resident: cubemap rendering, SH projection, and
  * texture packing all happen on the GPU with zero CPU readback.
  *
- * @three_import import { LightProbeGrid } from 'three/addons/lighting/LightProbeGrid.js';
+ * @three_import import { LightProbeGridWebGL } from 'three/addons/lighting/LightProbeGridWebGL.js';
  */
-class LightProbeGrid extends Object3D {
+class LightProbeGridWebGL extends Object3D {
 
 	/**
 	 * Constructs a new irradiance probe grid.
@@ -141,7 +141,7 @@ class LightProbeGrid extends Object3D {
 
 		/**
 		 * The world-space bounding box for the grid. Updated automatically
-		 * by {@link LightProbeGrid#bake}.
+		 * by {@link LightProbeGridWebGL#bake}.
 		 *
 		 * @type {Box3}
 		 */
@@ -362,7 +362,7 @@ class LightProbeGrid extends Object3D {
 
 		scene.matrixWorldAutoUpdate = currentMatrixWorldAutoUpdate;
 
-		// console.log( `LightProbeGrid: bake complete ${ ( performance.now() - t0 ).toFixed( 1 ) }ms` );
+		// console.log( `LightProbeGridWebGL: bake complete ${ ( performance.now() - t0 ).toFixed( 1 ) }ms` );
 
 		this.visible = true;
 
@@ -678,4 +678,4 @@ function _ensureBatchTarget( totalProbes ) {
 
 }
 
-export { LightProbeGrid };
+export { LightProbeGridWebGL };

+ 596 - 0
examples/jsm/tsl/display/DualKawaseBloomNode.js

@@ -0,0 +1,596 @@
+import { HalfFloatType, RenderTarget, Vector2, TempNode, QuadMesh, NodeMaterial, RendererUtils, NodeUpdateType } from 'three/webgpu';
+import { nodeObject, Fn, float, vec2, uv, passTexture, uniform, texture, luminance, smoothstep, mix, vec4 } from 'three/tsl';
+
+const _quadMesh = /*@__PURE__*/ new QuadMesh();
+const _size = /*@__PURE__*/ new Vector2();
+
+let _rendererState;
+
+const luminosityHighPass = Fn( ( { input, threshold, smoothWidth } ) => {
+
+	const v = luminance( input.rgb );
+	const alpha = smoothstep( threshold, threshold.add( smoothWidth ), v );
+
+	return mix( vec4( 0 ), input, alpha );
+
+} );
+
+/**
+ * Post processing node for creating a bloom effect.
+ *
+ * The bloom is produced with a Dual Kawase blur: the bright areas are
+ * progressively downsampled with a 5-tap filter and then upsampled with an
+ * 8-tap filter, accumulating the levels back together.
+ * ```js
+ * const renderPipeline = new THREE.RenderPipeline( renderer );
+ *
+ * const scenePass = pass( scene, camera );
+ * const scenePassColor = scenePass.getTextureNode( 'output' );
+ *
+ * const bloomPass = dualKawaseBloom( scenePassColor );
+ *
+ * renderPipeline.outputNode = scenePassColor.add( bloomPass );
+ * ```
+ * By default, the node affects the entire image. For a selective bloom,
+ * use the `emissive` material property to control which objects should
+ * contribute to bloom or not. This can be achieved via MRT.
+ * ```js
+ * const renderPipeline = new THREE.RenderPipeline( renderer );
+ *
+ * const scenePass = pass( scene, camera );
+ * scenePass.setMRT( mrt( {
+ * 	output,
+ * 	emissive
+ * } ) );
+ *
+ * const scenePassColor = scenePass.getTextureNode( 'output' );
+ * const emissivePass = scenePass.getTextureNode( 'emissive' );
+ *
+ * const bloomPass = dualKawaseBloom( emissivePass );
+ * renderPipeline.outputNode = scenePassColor.add( bloomPass );
+ * ```
+ * @augments TempNode
+ * @three_import import { dualKawaseBloom } from 'three/addons/tsl/display/DualKawaseBloomNode.js';
+ */
+class DualKawaseBloomNode extends TempNode {
+
+	static get type() {
+
+		return 'DualKawaseBloomNode';
+
+	}
+
+	/**
+	 * Constructs a new bloom node.
+	 *
+	 * @param {Node<vec4>} inputNode - The node that represents the input of the effect.
+	 * @param {number} [strength=1] - The strength of the bloom.
+	 * @param {number} [radius=0] - The radius of the bloom.
+	 * @param {number} [threshold=0] - The luminance threshold limits which bright areas contribute to the bloom effect.
+	 */
+	constructor( inputNode, strength = 1, radius = 0, threshold = 0 ) {
+
+		super( 'vec4' );
+
+		/**
+		 * The node that represents the input of the effect.
+		 *
+		 * @type {Node<vec4>}
+		 */
+		this.inputNode = inputNode;
+
+		/**
+		 * The strength of the bloom.
+		 *
+		 * @type {UniformNode<float>}
+		 */
+		this.strength = strength.isNode ? strength : uniform( strength );
+
+		/**
+		 * The radius of the bloom. Must be in the range `[0,1]`.
+		 *
+		 * @type {UniformNode<float>}
+		 */
+		this.radius = radius.isNode ? radius : uniform( radius );
+
+		/**
+		 * The luminance threshold limits which bright areas contribute to the bloom effect.
+		 *
+		 * @type {UniformNode<float>}
+		 */
+		this.threshold = threshold.isNode ? threshold : uniform( threshold );
+
+		/**
+		 * Can be used to tweak the extracted luminance from the scene.
+		 *
+		 * @type {UniformNode<float>}
+		 */
+		this.smoothWidth = uniform( 0.01 );
+
+		/**
+		 * Scale factor for the internal render targets.
+		 *
+		 * @private
+		 * @type {number}
+		 * @default 0.5
+		 */
+		this._resolutionScale = 0.5;
+
+		/**
+		 * Can be used to inject a custom high pass filter (e.g., for anamorphic effects).
+		 *
+		 * @type {Function}
+		 */
+		this.highPassFn = luminosityHighPass;
+
+		/**
+		 * The number of downsample / upsample levels in the Dual Kawase pyramid.
+		 *
+		 * @private
+		 * @type {number}
+		 */
+		this._nMips = 6;
+
+		/**
+		 * Sample spread of the Dual Kawase filters. Kept small so each level's kernel
+		 * stays round (a wide offset makes the diamond sampling pattern visible as a
+		 * square halo); the bloom width comes from the depth of the pyramid instead.
+		 *
+		 * @private
+		 * @type {UniformNode<float>}
+		 */
+		this._offset = uniform( 3 );
+
+		/**
+		 * Per-level mixing factors, redistributed by `radius` between a tight and a wide bloom.
+		 * A linear ramp from `1.0` to `0.2` (mean `0.6`) keeps the total bloom energy constant
+		 * as `radius` shifts weight between the fine and coarse levels.
+		 *
+		 * @private
+		 * @type {Array<number>}
+		 */
+		this._bloomFactors = [];
+
+		for ( let i = 0; i < this._nMips; i ++ ) {
+
+			this._bloomFactors.push( this._nMips === 1 ? 0.6 : 1.0 - 0.8 * i / ( this._nMips - 1 ) );
+
+		}
+
+		/**
+		 * The render target for the luminance pass.
+		 *
+		 * @private
+		 * @type {RenderTarget}
+		 */
+		this._renderTargetBright = new RenderTarget( 1, 1, { depthBuffer: false, type: HalfFloatType } );
+		this._renderTargetBright.texture.name = 'DualKawaseBloom.bright';
+		this._renderTargetBright.texture.generateMipmaps = false;
+
+		/**
+		 * The render targets for the downsample chain.
+		 *
+		 * @private
+		 * @type {Array<RenderTarget>}
+		 */
+		this._downsampleRTs = [];
+
+		/**
+		 * The render targets for the upsample / accumulation chain.
+		 *
+		 * @private
+		 * @type {Array<RenderTarget>}
+		 */
+		this._accumRTs = [];
+
+		/**
+		 * The resolution of each pyramid level.
+		 *
+		 * @private
+		 * @type {Array<Vector2>}
+		 */
+		this._levelSizes = [];
+
+		/**
+		 * The resolution of the bright pass.
+		 *
+		 * @private
+		 * @type {Vector2}
+		 */
+		this._brightSize = new Vector2();
+
+		for ( let i = 0; i < this._nMips; i ++ ) {
+
+			const downsampleRT = new RenderTarget( 1, 1, { depthBuffer: false, type: HalfFloatType } );
+			downsampleRT.texture.name = 'DualKawaseBloom.down' + i;
+			downsampleRT.texture.generateMipmaps = false;
+			this._downsampleRTs.push( downsampleRT );
+
+			const accumRT = new RenderTarget( 1, 1, { depthBuffer: false, type: HalfFloatType } );
+			accumRT.texture.name = 'DualKawaseBloom.up' + i;
+			accumRT.texture.generateMipmaps = false;
+			this._accumRTs.push( accumRT );
+
+			this._levelSizes.push( new Vector2() );
+
+		}
+
+		/**
+		 * The material for the luminance pass.
+		 *
+		 * @private
+		 * @type {?NodeMaterial}
+		 */
+		this._highPassFilterMaterial = null;
+
+		/**
+		 * The material for the downsample pass.
+		 *
+		 * @private
+		 * @type {?NodeMaterial}
+		 */
+		this._downsampleMaterial = null;
+
+		/**
+		 * The material for the upsample / accumulation pass.
+		 *
+		 * @private
+		 * @type {?NodeMaterial}
+		 */
+		this._upsampleMaterial = null;
+
+		/**
+		 * The result of the effect is represented as a separate texture node.
+		 * The finest accumulation target holds the composited bloom.
+		 *
+		 * @private
+		 * @type {PassTextureNode}
+		 */
+		this._textureOutput = passTexture( this, this._accumRTs[ 0 ].texture );
+
+		/**
+		 * 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;
+
+	}
+
+	/**
+	 * 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._textureOutput;
+
+	}
+
+	/**
+	 * Sets the resolution scale for the pass.
+	 * The resolution scale is a factor that is multiplied with the renderer's width and height.
+	 *
+	 * @param {number} resolutionScale - The resolution scale to set. A value of `1` means full resolution.
+	 * @return {DualKawaseBloomNode} A reference to this node.
+	 */
+	setResolutionScale( resolutionScale ) {
+
+		this._resolutionScale = resolutionScale;
+
+		return this;
+
+	}
+
+	/**
+	 * Gets the current resolution scale of the pass.
+	 *
+	 * @return {number} The current resolution scale. A value of `1` means full resolution.
+	 */
+	getResolutionScale() {
+
+		return this._resolutionScale;
+
+	}
+
+	/**
+	 * 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 ) {
+
+		const resx = Math.max( 1, Math.floor( width * this._resolutionScale ) );
+		const resy = Math.max( 1, Math.floor( height * this._resolutionScale ) );
+
+		this._renderTargetBright.setSize( resx, resy );
+		this._brightSize.set( resx, resy );
+
+		let rx = resx;
+		let ry = resy;
+
+		for ( let i = 0; i < this._nMips; i ++ ) {
+
+			// Level 0 blurs in place at bright resolution; the rest halve.
+
+			if ( i > 0 ) {
+
+				rx = Math.max( 1, Math.floor( rx / 2 ) );
+				ry = Math.max( 1, Math.floor( ry / 2 ) );
+
+			}
+
+			this._downsampleRTs[ i ].setSize( rx, ry );
+			this._accumRTs[ i ].setSize( rx, ry );
+			this._levelSizes[ i ].set( rx, ry );
+
+		}
+
+	}
+
+	/**
+	 * 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 );
+
+		const nMips = this._nMips;
+		const downMaterial = this._downsampleMaterial;
+		const upMaterial = this._upsampleMaterial;
+
+		// 1. Extract bright areas
+
+		renderer.setRenderTarget( this._renderTargetBright );
+		_quadMesh.material = this._highPassFilterMaterial;
+		_quadMesh.name = 'Dual Kawase Bloom [ High Pass ]';
+		_quadMesh.render( renderer );
+
+		// 2. Downsample chain ( bright -> down0 -> down1 -> ... ). The coarsest level
+		// pre-multiplies its `radius` weight so the upsample chain can seed from it.
+
+		_quadMesh.material = downMaterial;
+
+		let sourceTexture = this._renderTargetBright.texture;
+		let sourceSize = this._brightSize;
+
+		for ( let i = 0; i < nMips; i ++ ) {
+
+			const isCoarsest = ( i === nMips - 1 );
+
+			downMaterial.colorTexture.value = sourceTexture;
+			downMaterial.texelSize.value.set( 1 / sourceSize.x, 1 / sourceSize.y );
+			downMaterial.applyWeight.value = isCoarsest ? 1 : 0;
+			downMaterial.bloomFactor.value = this._bloomFactors[ i ];
+			renderer.setRenderTarget( this._downsampleRTs[ i ] );
+			_quadMesh.name = `Dual Kawase Bloom [ Downsample - ${ i } ]`;
+			_quadMesh.render( renderer );
+
+			sourceTexture = this._downsampleRTs[ i ].texture;
+			sourceSize = this._levelSizes[ i ];
+
+		}
+
+		// 3. Upsample chain with weighted accumulation ( ... -> up1 -> up0 ).
+		// Each level adds its matching downsample weighted by `radius`; the
+		// coarsest downsample seeds the chain. The finest step ( i = 0 ) lands at
+		// bright resolution and applies `strength`, so it doubles as the composite.
+
+		_quadMesh.material = upMaterial;
+
+		for ( let i = nMips - 2; i >= 0; i -- ) {
+
+			const isSeed = ( i === nMips - 2 );
+
+			upMaterial.finalFlag.value = ( i === 0 ) ? 1 : 0;
+			upMaterial.bloomFactor.value = this._bloomFactors[ i ];
+			upMaterial.prevTexture.value = isSeed ? this._downsampleRTs[ i + 1 ].texture : this._accumRTs[ i + 1 ].texture;
+			upMaterial.addTexture.value = this._downsampleRTs[ i ].texture;
+			upMaterial.texelSize.value.set( 1 / this._levelSizes[ i + 1 ].x, 1 / this._levelSizes[ i + 1 ].y );
+
+			renderer.setRenderTarget( this._accumRTs[ i ] );
+			_quadMesh.name = ( i === 0 ) ? 'Dual Kawase Bloom [ Composite ]' : `Dual Kawase Bloom [ Upsample - ${ i } ]`;
+			_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 ) {
+
+		// luminosity high pass material
+
+		this._highPassFilterMaterial = this._highPassFilterMaterial || new NodeMaterial();
+		this._highPassFilterMaterial.fragmentNode = this.highPassFn( { input: this.inputNode, threshold: this.threshold, smoothWidth: this.smoothWidth } ).context( builder.getSharedContext() );
+		this._highPassFilterMaterial.name = 'DualKawaseBloom_highPass';
+		this._highPassFilterMaterial.needsUpdate = true;
+
+		// downsample material ( Dual Kawase, 5 taps )
+
+		this._downsampleMaterial = this._downsampleMaterial || this._getDownsampleMaterial( builder );
+
+		// upsample material ( Dual Kawase, 8 taps, weighted accumulation )
+
+		this._upsampleMaterial = this._upsampleMaterial || this._getUpsampleMaterial( builder );
+
+		//
+
+		return this._textureOutput;
+
+	}
+
+	/**
+	 * Frees internal resources. This method should be called
+	 * when the effect is no longer required.
+	 */
+	dispose() {
+
+		this._renderTargetBright.dispose();
+
+		for ( let i = 0; i < this._nMips; i ++ ) {
+
+			this._downsampleRTs[ i ].dispose();
+			this._accumRTs[ i ].dispose();
+
+		}
+
+		if ( this._highPassFilterMaterial !== null ) this._highPassFilterMaterial.dispose();
+		if ( this._downsampleMaterial !== null ) this._downsampleMaterial.dispose();
+		if ( this._upsampleMaterial !== null ) this._upsampleMaterial.dispose();
+
+	}
+
+	/**
+	 * Creates the Dual Kawase downsample material. Each output texel reads the
+	 * center plus four diagonal corners of the source.
+	 *
+	 * @private
+	 * @param {NodeBuilder} builder - The current node builder.
+	 * @return {NodeMaterial}
+	 */
+	_getDownsampleMaterial( builder ) {
+
+		const colorTexture = texture( null );
+		const texelSize = uniform( new Vector2() );
+		const offset = this._offset;
+
+		const applyWeight = uniform( 0 );
+		const bloomFactor = uniform( 1 );
+
+		const uvNode = uv();
+
+		const downsamplePass = Fn( () => {
+
+			const o = texelSize.mul( 0.5 ).mul( offset );
+
+			const color = colorTexture.sample( uvNode ).mul( 4.0 ).toVar();
+
+			color.addAssign( colorTexture.sample( uvNode.add( vec2( o.x.negate(), o.y.negate() ) ) ) );
+			color.addAssign( colorTexture.sample( uvNode.add( vec2( o.x, o.y.negate() ) ) ) );
+			color.addAssign( colorTexture.sample( uvNode.add( vec2( o.x.negate(), o.y ) ) ) );
+			color.addAssign( colorTexture.sample( uvNode.add( vec2( o.x, o.y ) ) ) );
+
+			// The coarsest level pre-applies its `radius` weight so it can seed the upsample chain.
+			const weight = mix( bloomFactor, float( 1.2 ).sub( bloomFactor ), this.radius );
+
+			return vec4( color.rgb.div( 8.0 ).mul( mix( float( 1.0 ), weight, applyWeight ) ), 1.0 );
+
+		} );
+
+		const material = new NodeMaterial();
+		material.fragmentNode = downsamplePass().context( builder.getSharedContext() );
+		material.name = 'DualKawaseBloom_down';
+		material.needsUpdate = true;
+
+		material.colorTexture = colorTexture;
+		material.texelSize = texelSize;
+		material.applyWeight = applyWeight;
+		material.bloomFactor = bloomFactor;
+
+		return material;
+
+	}
+
+	/**
+	 * Creates the Dual Kawase upsample material. Each output texel reads four
+	 * edge centers and four diagonal corners of the source, then accumulates the
+	 * matching downsample level weighted by `radius`.
+	 *
+	 * @private
+	 * @param {NodeBuilder} builder - The current node builder.
+	 * @return {NodeMaterial}
+	 */
+	_getUpsampleMaterial( builder ) {
+
+		const prevTexture = texture( null );
+		const addTexture = texture( null );
+		const texelSize = uniform( new Vector2() );
+		const offset = this._offset;
+
+		const finalFlag = uniform( 0 );
+		const bloomFactor = uniform( 1 );
+
+		const uvNode = uv();
+
+		const upsamplePass = Fn( () => {
+
+			const o = texelSize.mul( 0.5 ).mul( offset );
+
+			const sum = prevTexture.sample( uvNode.add( vec2( o.x.mul( - 2.0 ), 0.0 ) ) ).rgb.toVar();
+			sum.addAssign( prevTexture.sample( uvNode.add( vec2( o.x.mul( 2.0 ), 0.0 ) ) ).rgb );
+			sum.addAssign( prevTexture.sample( uvNode.add( vec2( 0.0, o.y.mul( - 2.0 ) ) ) ).rgb );
+			sum.addAssign( prevTexture.sample( uvNode.add( vec2( 0.0, o.y.mul( 2.0 ) ) ) ).rgb );
+
+			sum.addAssign( prevTexture.sample( uvNode.add( vec2( o.x.negate(), o.y ) ) ).rgb.mul( 2.0 ) );
+			sum.addAssign( prevTexture.sample( uvNode.add( vec2( o.x, o.y ) ) ).rgb.mul( 2.0 ) );
+			sum.addAssign( prevTexture.sample( uvNode.add( vec2( o.x.negate(), o.y.negate() ) ) ).rgb.mul( 2.0 ) );
+			sum.addAssign( prevTexture.sample( uvNode.add( vec2( o.x, o.y.negate() ) ) ).rgb.mul( 2.0 ) );
+
+			const blurred = sum.div( 12.0 );
+
+			// redistribute the level's contribution between a tight and a wide bloom
+			const weight = mix( bloomFactor, float( 1.2 ).sub( bloomFactor ), this.radius );
+			const added = addTexture.sample( uvNode ).rgb.mul( weight );
+
+			// keep the total intensity independent of the level count ( the factors sum to `_nMips * 0.6` )
+			const norm = 3 / ( this._nMips * 0.6 );
+			const result = blurred.add( added ).mul( mix( float( 1.0 ), this.strength.mul( norm ), finalFlag ) );
+
+			return vec4( result, 1.0 );
+
+		} );
+
+		const material = new NodeMaterial();
+		material.fragmentNode = upsamplePass().context( builder.getSharedContext() );
+		material.name = 'DualKawaseBloom_up';
+		material.needsUpdate = true;
+
+		material.prevTexture = prevTexture;
+		material.addTexture = addTexture;
+		material.texelSize = texelSize;
+		material.finalFlag = finalFlag;
+		material.bloomFactor = bloomFactor;
+
+		return material;
+
+	}
+
+}
+
+/**
+ * TSL function for creating a bloom effect.
+ *
+ * @tsl
+ * @function
+ * @param {Node<vec4>} node - The node that represents the input of the effect.
+ * @param {number} [strength=1] - The strength of the bloom.
+ * @param {number} [radius=0] - The radius of the bloom.
+ * @param {number} [threshold=0] - The luminance threshold limits which bright areas contribute to the bloom effect.
+ * @returns {DualKawaseBloomNode}
+ */
+export const dualKawaseBloom = ( node, strength, radius, threshold ) => new DualKawaseBloomNode( nodeObject( node ), strength, radius, threshold );
+
+export default DualKawaseBloomNode;

+ 33 - 14
examples/jsm/tsl/display/GTAONode.js

@@ -117,14 +117,6 @@ class GTAONode extends TempNode {
 		 */
 		this.radius = uniform( 0.25 );
 
-		/**
-		 * The resolution of the effect. Can be scaled via
-		 * `resolutionScale`.
-		 *
-		 * @type {UniformNode<vec2>}
-		 */
-		this.resolution = uniform( new Vector2() );
-
 		/**
 		 * The thickness of the ambient occlusion.
 		 *
@@ -178,6 +170,14 @@ class GTAONode extends TempNode {
 		 */
 		this.useTemporalFiltering = false;
 
+		/**
+		 * The resolution of the effect. Can be scaled via `resolutionScale`.
+		 *
+		 * @private
+		 * @type {UniformNode<vec2>}
+		 */
+		this._resolution = uniform( new Vector2() );
+
 		/**
 		 * The node represents the internal noise texture used by the AO.
 		 *
@@ -234,6 +234,13 @@ class GTAONode extends TempNode {
 		 */
 		this._temporalOffset = uniform( 0 );
 
+		/**
+		 * Resolution scale uniform.
+		 *
+		 * @private
+		 * @type {UniformNode<float>}
+		 */
+		this._resolutionScale = uniform( 0 );
 
 		/**
 		 * The material that is used to render the effect.
@@ -276,7 +283,8 @@ class GTAONode extends TempNode {
 		width = Math.round( this.resolutionScale * width );
 		height = Math.round( this.resolutionScale * height );
 
-		this.resolution.value.set( width, height );
+		this._resolutionScale.value = this.resolutionScale;
+		this._resolution.value.set( width, height );
 		this._aoRenderTarget.setSize( width, height );
 
 	}
@@ -341,9 +349,7 @@ class GTAONode extends TempNode {
 
 		const uvNode = uv();
 
-		const sampleDepth = ( uv ) => {
-
-			const depth = this.depthNode.sample( uv ).r;
+		const linearizeDepth = ( depth ) => {
 
 			if ( builder.renderer.logarithmicDepthBuffer === true ) {
 
@@ -357,12 +363,25 @@ class GTAONode extends TempNode {
 
 		};
 
+		const sampleDepth = ( uv ) => linearizeDepth( this.depthNode.sample( uv ).r );
+
+		const sampleCenterDepth = ( uv ) => {
+
+			// Sidestep the nearest-rounding during depth access for the unjittered center pixel to avoid banding
+
+			const g = this.depthNode.gather().sample( uv );
+			const depth = min( min( g.x, g.y ), min( g.z, g.w ) );
+
+			return linearizeDepth( depth );
+
+		};
+
 		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 ao = Fn( () => {
 
-			const depth = sampleDepth( uvNode ).toVar();
+			const depth = this._resolutionScale.lessThan( 1 ).select( sampleCenterDepth( uvNode ), sampleDepth( uvNode ) ).toConst();
 
 			depth.greaterThanEqual( 1.0 ).discard();
 
@@ -375,7 +394,7 @@ class GTAONode extends TempNode {
 
 			const noiseResolution = textureSize( this._noiseNode, 0 );
 			let noiseUv = vec2( uvNode.x, uvNode.y.oneMinus() );
-			noiseUv = noiseUv.mul( this.resolution.div( noiseResolution ) );
+			noiseUv = noiseUv.mul( this._resolution.div( noiseResolution ) );
 
 			const noiseTexel = sampleNoise( noiseUv );
 			const randomVec = noiseTexel.xyz.mul( 2.0 ).sub( 1.0 );

+ 428 - 0
examples/jsm/tsl/display/SSAONode.js

@@ -0,0 +1,428 @@
+import { RenderTarget, Vector2, TempNode, QuadMesh, NodeMaterial, RendererUtils, RedFormat } from 'three/webgpu';
+import { reference, logarithmicDepthToViewZ, viewZToPerspectiveDepth, getViewPosition, getScreenPositionFromClip, vogelDiskSample, interleavedGradientNoise, nodeObject, Fn, float, NodeUpdateType, uv, uniform, Loop, vec4, int, dot, max, clamp, length, screenCoordinate, PI2, texture, passTexture } from 'three/tsl';
+import { depthAwareBlur } from './depthAwareBlur.js';
+
+const _quadMesh = /*@__PURE__*/ new QuadMesh();
+const _size = /*@__PURE__*/ new Vector2();
+
+let _rendererState;
+
+/**
+ * Post processing node for a fast, screen-space ambient occlusion (SSAO).
+ *
+ * It point-samples a per-pixel rotated Vogel disk and estimates obscurance with a single depth tap
+ * per sample, trading the ground-truth accuracy of {@link GTAONode}'s horizon ray-marching for
+ * lower cost. A built-in separable, depth-aware blur denoises the result so it can be used without
+ * temporal accumulation.
+ * ```js
+ * const scenePass = pass( scene, camera );
+ * scenePass.setMRT( mrt( { output, normal: normalView } ) );
+ * const scenePassColor = scenePass.getTextureNode( 'output' );
+ * const scenePassDepth = scenePass.getTextureNode( 'depth' );
+ * const scenePassNormal = scenePass.getTextureNode( 'normal' );
+ *
+ * const aoPass = ssao( scenePassDepth, scenePassNormal, camera );
+ *
+ * renderPipeline.outputNode = scenePassColor.mul( aoPass.r );
+ * ```
+ *
+ * @augments TempNode
+ * @three_import import { ssao } from 'three/addons/tsl/display/SSAONode.js';
+ */
+class SSAONode extends TempNode {
+
+	static get type() {
+
+		return 'SSAONode';
+
+	}
+
+	/**
+	 * Constructs a new SSAO node.
+	 *
+	 * @param {Node<float>} depthNode - A node that represents the scene's depth.
+	 * @param {Node<vec3>} normalNode - A node that represents the scene's normals.
+	 * @param {Camera} camera - The camera the scene is rendered with.
+	 */
+	constructor( depthNode, normalNode, camera ) {
+
+		super( 'float' );
+
+		/**
+		 * A node that represents the scene's depth.
+		 *
+		 * @type {Node<float>}
+		 */
+		this.depthNode = depthNode;
+
+		/**
+		 * A node that represents the scene's normals.
+		 *
+		 * @type {Node<vec3>}
+		 */
+		this.normalNode = normalNode;
+
+		/**
+		 * The resolution scale. The effect renders at a fraction of the drawing buffer
+		 * for extra speed; `0.5` is a good default for a low-frequency signal like AO.
+		 *
+		 * @type {number}
+		 * @default 0.5
+		 */
+		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;
+
+		/**
+		 * The world-space radius the occlusion is gathered within.
+		 *
+		 * @type {UniformNode<float>}
+		 */
+		this.radius = uniform( 0.5 );
+
+		/**
+		 * The strength of the occlusion.
+		 *
+		 * @type {UniformNode<float>}
+		 */
+		this.intensity = uniform( 1 );
+
+		/**
+		 * An angle bias that suppresses self-occlusion on near-flat surfaces.
+		 *
+		 * @type {UniformNode<float>}
+		 */
+		this.bias = uniform( 0.025 );
+
+		/**
+		 * How many samples are used to estimate the occlusion. A higher value
+		 * results in a smoother result at a higher runtime cost.
+		 *
+		 * @type {UniformNode<float>}
+		 */
+		this.samples = uniform( 16 );
+
+		/**
+		 * Whether the depth-aware blur that denoises the raw AO is applied or not.
+		 *
+		 * @type {boolean}
+		 * @default true
+		 */
+		this.blurEnabled = true;
+
+		/**
+		 * How strongly the blur rejects samples across depth discontinuities,
+		 * relative to the AO radius. A higher value keeps edges crisper.
+		 *
+		 * @type {UniformNode<float>}
+		 */
+		this.blurSharpness = uniform( 2 );
+
+		/**
+		 * The resolution of the effect. Set from the drawing buffer size and `resolutionScale`.
+		 *
+		 * @type {Vector2}
+		 */
+		this.resolution = new Vector2();
+
+		/**
+		 * The render target the raw ambient occlusion is rendered into. Also holds the
+		 * final result after the separable blur has ping-ponged back into it.
+		 *
+		 * @private
+		 * @type {RenderTarget}
+		 */
+		this._aoRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, format: RedFormat } );
+		this._aoRenderTarget.texture.name = 'SSAONode.AO';
+
+		/**
+		 * The render target the intermediate (horizontally blurred) result is rendered into.
+		 *
+		 * @private
+		 * @type {RenderTarget}
+		 */
+		this._blurRenderTarget = new RenderTarget( 1, 1, { depthBuffer: false, format: RedFormat } );
+		this._blurRenderTarget.texture.name = 'SSAONode.Blur';
+
+		/**
+		 * 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 );
+
+		/**
+		 * The camera the scene is rendered with.
+		 *
+		 * @private
+		 * @type {Camera}
+		 */
+		this._camera = camera;
+
+		/**
+		 * The input texture the blur material reads from. Swapped between the two render
+		 * targets to run the horizontal and vertical passes with a single material.
+		 *
+		 * @private
+		 * @type {TextureNode}
+		 */
+		this._blurInput = texture( this._aoRenderTarget.texture );
+
+		/**
+		 * The blur direction (one texel along x or y).
+		 *
+		 * @private
+		 * @type {UniformNode<vec2>}
+		 */
+		this._blurDirection = uniform( new Vector2() );
+
+		/**
+		 * The material that computes the raw ambient occlusion.
+		 *
+		 * @private
+		 * @type {NodeMaterial}
+		 */
+		this._aoMaterial = new NodeMaterial();
+		this._aoMaterial.name = 'SSAO.AO';
+
+		/**
+		 * The material that applies the separable, depth-aware blur.
+		 *
+		 * @private
+		 * @type {NodeMaterial}
+		 */
+		this._blurMaterial = new NodeMaterial();
+		this._blurMaterial.name = 'SSAO.Blur';
+
+		/**
+		 * The result of the effect is represented as a separate texture node.
+		 *
+		 * @private
+		 * @type {PassTextureNode}
+		 */
+		this._textureNode = passTexture( this, this._aoRenderTarget.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.max( 1, Math.round( this.resolutionScale * width ) );
+		height = Math.max( 1, Math.round( this.resolutionScale * height ) );
+
+		this.resolution.set( width, height );
+		this._aoRenderTarget.setSize( width, height );
+		this._blurRenderTarget.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 );
+
+		renderer.setClearColor( 0xffffff, 1 );
+
+		// ambient occlusion
+
+		_quadMesh.material = this._aoMaterial;
+		_quadMesh.name = 'SSAO.AO';
+		renderer.setRenderTarget( this._aoRenderTarget );
+		_quadMesh.render( renderer );
+
+		// separable, depth-aware blur: horizontal ( AO -> blur ) then vertical ( blur -> AO )
+
+		if ( this.blurEnabled === true ) {
+
+			_quadMesh.material = this._blurMaterial;
+			_quadMesh.name = 'SSAO.Blur';
+
+			this._blurInput.value = this._aoRenderTarget.texture;
+			this._blurDirection.value.set( 1 / this.resolution.x, 0 );
+			renderer.setRenderTarget( this._blurRenderTarget );
+			_quadMesh.render( renderer );
+
+			this._blurInput.value = this._blurRenderTarget.texture;
+			this._blurDirection.value.set( 0, 1 / this.resolution.y );
+			renderer.setRenderTarget( this._aoRenderTarget );
+			_quadMesh.render( renderer );
+
+		}
+
+		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 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;
+
+		};
+
+		// ambient occlusion
+
+		const ao = Fn( () => {
+
+			const depth = sampleDepth( uvNode ).toVar();
+
+			depth.greaterThanEqual( 1.0 ).discard();
+
+			const viewPosition = getViewPosition( uvNode, depth, this._cameraProjectionMatrixInverse ).toVar();
+			const viewNormal = this.normalNode.sample( uvNode ).rgb.normalize().toVar();
+
+			// a low-discrepancy Vogel disk rotated per-pixel decorrelates the samples spatially,
+			// so a small blur is enough to hide the sampling pattern
+
+			const phi = interleavedGradientNoise( screenCoordinate ).mul( PI2 ).toVar();
+			const samples = this.samples;
+
+			// the fragment's clip position is loop-invariant; projection is linear, so each
+			// sample only has to project its (view-plane) offset and add it
+
+			const clipPosition = this._cameraProjectionMatrix.mul( vec4( viewPosition, 1.0 ) ).toVar();
+
+			const occlusion = float( 0 ).toVar();
+
+			Loop( { start: int( 0 ), end: samples, type: 'int', condition: '<' }, ( { i } ) => {
+
+				// disk offset in the view-aligned plane at the fragment's depth, then reprojected
+
+				const offset = vogelDiskSample( i, samples, phi ).mul( this.radius );
+				const clipOffset = this._cameraProjectionMatrix.mul( vec4( offset, 0.0, 0.0 ) );
+				const sampleUv = getScreenPositionFromClip( clipPosition.add( clipOffset ) );
+
+				const sampleViewPosition = getViewPosition( sampleUv, sampleDepth( sampleUv ), this._cameraProjectionMatrixInverse );
+
+				// normalized obscurance: only occluders above the tangent plane count, faded by distance
+
+				const v = sampleViewPosition.sub( viewPosition ).toVar();
+				const dist = length( v ).toVar();
+				const cosAngle = dot( v, viewNormal ).div( max( dist, float( 0.0001 ) ) );
+				const falloff = this.radius.div( this.radius.add( dist ) );
+
+				occlusion.addAssign( max( cosAngle.sub( this.bias ), 0.0 ).mul( falloff ) );
+
+			} );
+
+			return clamp( occlusion.div( samples ).mul( this.intensity ).oneMinus(), 0.0, 1.0 );
+
+		} );
+
+		this._aoMaterial.fragmentNode = ao().context( builder.getSharedContext() );
+		this._aoMaterial.needsUpdate = true;
+
+		// separable, depth-aware blur ( run horizontally then vertically via `_blurDirection` )
+
+		this._blurMaterial.fragmentNode = depthAwareBlur( this._blurInput, this.depthNode, this._blurDirection, this._camera, this.blurSharpness, this.radius ).context( builder.getSharedContext() );
+		this._blurMaterial.needsUpdate = true;
+
+		return this._textureNode;
+
+	}
+
+	/**
+	 * Frees internal resources. This method should be called
+	 * when the effect is no longer required.
+	 */
+	dispose() {
+
+		this._aoRenderTarget.dispose();
+		this._blurRenderTarget.dispose();
+
+		this._aoMaterial.dispose();
+		this._blurMaterial.dispose();
+
+	}
+
+}
+
+export default SSAONode;
+
+/**
+ * TSL function for creating a fast screen-space ambient occlusion (SSAO) effect.
+ *
+ * @tsl
+ * @function
+ * @param {Node<float>} depthNode - A node that represents the scene's depth.
+ * @param {Node<vec3>} normalNode - A node that represents the scene's normals.
+ * @param {Camera} camera - The camera the scene is rendered with.
+ * @returns {SSAONode}
+ */
+export const ssao = ( depthNode, normalNode, camera ) => new SSAONode( nodeObject( depthNode ), nodeObject( normalNode ), camera );

+ 63 - 0
examples/jsm/tsl/display/depthAwareBlur.js

@@ -0,0 +1,63 @@
+import { abs, exp, float, Fn, int, logarithmicDepthToViewZ, Loop, max, perspectiveDepthToViewZ, reference, uv } from 'three/tsl';
+
+/**
+ * Applies one pass of a separable, depth-aware (bilateral) blur to a screen-space signal.
+ *
+ * The spatial term is a fixed 5-tap gaussian along `directionNode`; the edge-stopping term
+ * rejects neighbours whose view-space depth differs from the center, so the signal is smoothed
+ * across surfaces but not across silhouettes. Depth rejection is measured relative to `radius`,
+ * which keeps it scale invariant. Run it twice (horizontal then vertical) for a full separable
+ * blur. Handy for denoising a half-resolution effect such as ambient occlusion.
+ *
+ * @tsl
+ * @function
+ * @param {Node<vec4>} inputNode - The texture node to blur; its red channel is filtered.
+ * @param {Node<float>} depthNode - The scene depth texture node.
+ * @param {Node<vec2>} directionNode - One texel step along the blur axis, e.g. `vec2( 1 / width, 0 )`.
+ * @param {Camera} camera - The camera the scene is rendered with.
+ * @param {Node<float> | number} [sharpness=2] - How strongly a depth difference rejects a neighbour.
+ * @param {Node<float> | number} [radius=1] - The world-space scale the depth rejection is relative to.
+ * @returns {Node<float>} The blurred value.
+ */
+export const depthAwareBlur = /*#__PURE__*/ Fn( ( [ inputNode, depthNode, directionNode, camera, sharpness = 2, radius = 1 ], builder ) => {
+
+	const cameraNear = reference( 'near', 'float', camera );
+	const cameraFar = reference( 'far', 'float', camera );
+
+	const viewZ = ( uvNode ) => {
+
+		const depth = depthNode.sample( uvNode ).r;
+
+		if ( builder.renderer.logarithmicDepthBuffer === true ) {
+
+			return logarithmicDepthToViewZ( depth, cameraNear, cameraFar );
+
+		}
+
+		return perspectiveDepthToViewZ( depth, cameraNear, cameraFar );
+
+	};
+
+	const uvNode = uv();
+	const centerViewZ = viewZ( uvNode ).toVar();
+
+	const sum = float( 0 ).toVar();
+	const weightSum = float( 0 ).toVar();
+
+	Loop( { start: int( - 2 ), end: int( 3 ), type: 'int', condition: '<' }, ( { i } ) => {
+
+		const fi = float( i );
+		const sampleUv = uvNode.add( directionNode.mul( fi ) );
+
+		const spatialWeight = exp( fi.mul( fi ).mul( - 0.5 ) );
+		const depthWeight = exp( abs( viewZ( sampleUv ).sub( centerViewZ ) ).div( radius ).mul( sharpness ).negate() );
+		const weight = spatialWeight.mul( depthWeight );
+
+		sum.addAssign( inputNode.sample( sampleUv ).r.mul( weight ) );
+		weightSum.addAssign( weight );
+
+	} );
+
+	return sum.div( max( weightSum, float( 0.0001 ) ) );
+
+} );

+ 3 - 12
examples/jsm/tsl/lighting/ClusteredLightsNode.js

@@ -44,7 +44,6 @@ class ClusteredLightsNode extends LightsNode {
 
 		this.materialLights = [];
 		this.clusteredLights = [];
-		this._allLights = [];
 
 		this.maxLights = maxLights;
 		this.tileSize = tileSize;
@@ -206,8 +205,6 @@ class ClusteredLightsNode extends LightsNode {
 
 	setLights( lights ) {
 
-		this._allLights = lights;
-
 		const { clusteredLights, materialLights } = this;
 
 		let materialIndex = 0;
@@ -230,13 +227,13 @@ class ClusteredLightsNode extends LightsNode {
 		materialLights.length = materialIndex;
 		clusteredLights.length = clusteredIndex;
 
-		return super.setLights( materialLights );
+		return super.setLights( lights );
 
 	}
 
-	getLights() {
+	getBuiltinLights() {
 
-		return this._allLights;
+		return this.materialLights;
 
 	}
 
@@ -598,12 +595,6 @@ class ClusteredLightsNode extends LightsNode {
 
 	}
 
-	get hasLights() {
-
-		return super.hasLights || this.clusteredLights.length > 0;
-
-	}
-
 }
 
 export default ClusteredLightsNode;

BIN
examples/screenshots/webgl_lightprobes.jpg


BIN
examples/screenshots/webgl_postprocessing_pixel.jpg


BIN
examples/screenshots/webgpu_lights_clustered.jpg


+ 7 - 11
examples/webgl_lightprobes.html

@@ -32,8 +32,8 @@
 
 			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 			import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
-			import { LightProbeGrid } from 'three/addons/lighting/LightProbeGrid.js';
-			import { LightProbeGridHelper } from 'three/addons/helpers/LightProbeGridHelper.js';
+			import { LightProbeGridWebGL } from 'three/addons/lighting/LightProbeGridWebGL.js';
+			import { LightProbeGridHelperWebGL } from 'three/addons/helpers/LightProbeGridHelperWebGL.js';
 
 			let camera, scene, renderer, controls;
 			let probes, probesHelper;
@@ -134,10 +134,6 @@
 				light.shadow.normalBias = - 0.02;
 				scene.add( light );
 
-				// Dim ambient to see GI effect better
-				const ambient = new THREE.AmbientLight( 0xffffff, 0.05 );
-				scene.add( ambient );
-
 				// Renderer
 
 				renderer = new THREE.WebGLRenderer( { antialias: true } );
@@ -157,7 +153,7 @@
 
 				// Bake light probe volume
 
-				async function bakeWithResolution( resolution ) {
+				async function bake( resolution ) {
 
 					if ( probes ) {
 
@@ -166,7 +162,7 @@
 
 					}
 
-					probes = new LightProbeGrid( 5.6, 4.7, 5.6, resolution, resolution, resolution );
+					probes = new LightProbeGridWebGL( 5.6, 4.7, 5.6, resolution, resolution, resolution );
 					probes.position.set( 0, 2.45, 0 );
 					probes.bake( renderer, scene, { cubemapSize: 32, near: 0.05, far: 20 } );
 					probes.visible = params.enabled;
@@ -176,7 +172,7 @@
 
 					if ( ! probesHelper ) {
 
-						probesHelper = new LightProbeGridHelper( probes );
+						probesHelper = new LightProbeGridHelperWebGL( probes );
 						probesHelper.visible = params.showProbes;
 						scene.add( probesHelper );
 
@@ -195,7 +191,7 @@
 					resolution: 6
 				};
 
-				await bakeWithResolution( params.resolution );
+				await bake( params.resolution );
 
 				// GUI
 
@@ -207,7 +203,7 @@
 				} );
 				gui.add( params, 'resolution', 2, 12, 1 ).name( 'Resolution' ).onFinishChange( ( value ) => {
 
-					bakeWithResolution( value );
+					bake( value );
 
 				} );
 				gui.add( params, 'showProbes' ).name( 'Show Probes' ).onChange( ( value ) => {

+ 9 - 9
examples/webgl_lightprobes_complex.html

@@ -32,8 +32,8 @@
 
 			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 			import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
-			import { LightProbeGrid } from 'three/addons/lighting/LightProbeGrid.js';
-			import { LightProbeGridHelper } from 'three/addons/helpers/LightProbeGridHelper.js';
+			import { LightProbeGridWebGL } from 'three/addons/lighting/LightProbeGridWebGL.js';
+			import { LightProbeGridHelperWebGL } from 'three/addons/helpers/LightProbeGridHelperWebGL.js';
 
 			let camera, scene, renderer, controls;
 			let probesLeft, probesRight;
@@ -279,7 +279,7 @@
 					resolution: 6
 				};
 
-				async function bakeWithResolution( resolution ) {
+				async function bake( resolution ) {
 
 					// Remove both volumes before baking to prevent feedback
 
@@ -299,12 +299,12 @@
 
 					// Bake both volumes
 
-					probesLeft = new LightProbeGrid( 7.8, 4.7, 7.6, resolution, resolution, resolution );
+					probesLeft = new LightProbeGridWebGL( 7.8, 4.7, 7.6, resolution, resolution, resolution );
 					probesLeft.position.set( - 3.9, 2.45, 0 );
 					probesLeft.bake( renderer, scene, { cubemapSize: 32, near: 0.05, far: 20 } );
 					probesLeft.visible = params.enabled;
 
-					probesRight = new LightProbeGrid( 7.8, 4.7, 7.6, resolution, resolution, resolution );
+					probesRight = new LightProbeGridWebGL( 7.8, 4.7, 7.6, resolution, resolution, resolution );
 					probesRight.position.set( 3.9, 2.45, 0 );
 					probesRight.bake( renderer, scene, { cubemapSize: 32, near: 0.05, far: 20 } );
 					probesRight.visible = params.enabled;
@@ -318,11 +318,11 @@
 
 					if ( ! probesHelperLeft ) {
 
-						probesHelperLeft = new LightProbeGridHelper( probesLeft );
+						probesHelperLeft = new LightProbeGridHelperWebGL( probesLeft );
 						probesHelperLeft.visible = params.showProbes;
 						scene.add( probesHelperLeft );
 
-						probesHelperRight = new LightProbeGridHelper( probesRight );
+						probesHelperRight = new LightProbeGridHelperWebGL( probesRight );
 						probesHelperRight.visible = params.showProbes;
 						scene.add( probesHelperRight );
 
@@ -338,7 +338,7 @@
 
 				}
 
-				await bakeWithResolution( params.resolution );
+				await bake( params.resolution );
 
 				// GUI
 
@@ -351,7 +351,7 @@
 				} );
 				gui.add( params, 'resolution', 2, 12, 1 ).name( 'Resolution' ).onFinishChange( ( value ) => {
 
-					bakeWithResolution( value );
+					bake( value );
 
 				} );
 				gui.add( params, 'showProbes' ).name( 'Show Probes' ).onChange( ( value ) => {

+ 5 - 5
examples/webgl_lightprobes_sponza.html

@@ -36,8 +36,8 @@
 			import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
 			import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
 			import { Sky } from 'three/addons/objects/Sky.js';
-			import { LightProbeGrid } from 'three/addons/lighting/LightProbeGrid.js';
-			import { LightProbeGridHelper } from 'three/addons/helpers/LightProbeGridHelper.js';
+			import { LightProbeGridWebGL } from 'three/addons/lighting/LightProbeGridWebGL.js';
+			import { LightProbeGridHelperWebGL } from 'three/addons/helpers/LightProbeGridHelperWebGL.js';
 
 			const MODEL_INDEX_URL = 'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/model-index.json';
 			const SAMPLE_ASSETS_BASE_URL = 'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/';
@@ -233,7 +233,7 @@
 
 						}
 
-						probes = new LightProbeGrid(
+						probes = new LightProbeGridWebGL(
 							params.sizeX, params.sizeY, params.sizeZ,
 							params.countX, params.countY, params.countZ
 						);
@@ -252,7 +252,7 @@
 
 						if ( ! probesHelper ) {
 
-							probesHelper = new LightProbeGridHelper( probes, params.probeSize );
+							probesHelper = new LightProbeGridHelperWebGL( probes, params.probeSize );
 							probesHelper.visible = params.showProbes;
 							scene.add( probesHelper );
 
@@ -320,7 +320,7 @@
 
 						scene.remove( probesHelper );
 						probesHelper.dispose();
-						probesHelper = new LightProbeGridHelper( probes, value );
+						probesHelper = new LightProbeGridHelperWebGL( probes, value );
 						probesHelper.visible = params.showProbes;
 						scene.add( probesHelper );
 

+ 145 - 9
examples/webgl_postprocessing_pixel.html

@@ -39,7 +39,7 @@
 		import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
 		import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
 
-		let camera, scene, renderer, composer, crystalMesh, timer;
+		let camera, scene, renderer, composer, controls, crystalMesh, timer;
 		let gui, params;
 
 		init();
@@ -74,13 +74,13 @@
 
 			window.addEventListener( 'resize', onWindowResize );
 
-			const controls = new OrbitControls( camera, renderer.domElement );
+			controls = new OrbitControls( camera, renderer.domElement );
 			controls.maxZoom = 2;
 
 			// gui
 
 			gui = new GUI();
-			params = { pixelSize: 6, normalEdgeStrength: .3, depthEdgeStrength: .4, pixelAlignedPanning: true };
+			params = { pixelSize: 6, normalEdgeStrength: .3, depthEdgeStrength: .4, snapping: true, pixelAlignedPanning: true, pixelAlignedObjects: true, rotationSnap: 15, cameraRotationSnap: 9, cameraZoomSnap: 0.1 };
 			gui.add( params, 'pixelSize' ).min( 1 ).max( 16 ).step( 1 )
 				.onChange( () => {
 
@@ -89,7 +89,15 @@
 				} );
 			gui.add( renderPixelatedPass, 'normalEdgeStrength' ).min( 0 ).max( 2 ).step( .05 );
 			gui.add( renderPixelatedPass, 'depthEdgeStrength' ).min( 0 ).max( 1 ).step( .05 );
-			gui.add( params, 'pixelAlignedPanning' );
+
+			const snappingControl = gui.add( params, 'snapping' ).name( 'Enable Pixel Snapping' );
+			const snappingFolder = gui.addFolder( 'Snapping' ).close();
+			snappingControl.onChange( enabled => snappingFolder.controllers.forEach( c => c.enable( enabled ) ) );
+			snappingFolder.add( params, 'pixelAlignedPanning' );
+			snappingFolder.add( params, 'pixelAlignedObjects' );
+			snappingFolder.add( params, 'rotationSnap' ).min( 0 ).max( 90 ).step( 1 ).name( 'rotationSnap (deg, 0 = off)' );
+			snappingFolder.add( params, 'cameraRotationSnap' ).min( 0 ).max( 90 ).step( 1 ).name( 'cameraRotationSnap (deg, 0 = off)' );
+			snappingFolder.add( params, 'cameraZoomSnap' ).min( 0 ).max( 1 ).step( .05 ).name( 'cameraZoomSnap (0 = off)' );
 
 			// textures
 
@@ -181,16 +189,37 @@
 
 			const t = timer.getElapsed();
 
-			crystalMesh.material.emissiveIntensity = Math.sin( t * 3 ) * .5 + .5;
+			// Quantize the pulsing glow to a few discrete brightness levels to match the pixelated aesthetic
+			const brightnessLevels = 6;
+			crystalMesh.material.emissiveIntensity =
+				Math.round( ( Math.sin( t * 3 ) * .5 + .5 ) * ( brightnessLevels - 1 ) ) / ( brightnessLevels - 1 );
 			crystalMesh.position.y = .7 + Math.sin( t * 2 ) * .05;
-			crystalMesh.rotation.y = stopGoEased( t, 2, 4 ) * 2 * Math.PI;
+			crystalMesh.rotation.y = stopGoEased( t, 6, 8 ) * 2 * Math.PI;
 
 			const rendererSize = renderer.getSize( new THREE.Vector2() );
 			const aspectRatio = rendererSize.x / rendererSize.y;
-			if ( params[ 'pixelAlignedPanning' ] ) {
+			const pixelsPerScreenWidth = Math.floor( rendererSize.x / params[ 'pixelSize' ] );
+			const pixelsPerScreenHeight = Math.floor( rendererSize.y / params[ 'pixelSize' ] );
+
+			// The top-level 'snapping' checkbox gates every individual snapping effect at once
+			const snapping = params[ 'snapping' ];
+
+			// Remember the camera's true (unsnapped) transform so OrbitControls interaction is unaffected by snapping
+			const cameraPosition = camera.position.clone();
+			const cameraQuaternion = camera.quaternion.clone();
+			const cameraZoom = camera.zoom;
+
+			// Snap the camera's rotation and scale first, so the translation snap below derives its pixel grid
+			// and screen-space basis from the already-snapped orientation and zoom
+			if ( snapping && ( params[ 'cameraRotationSnap' ] > 0 || params[ 'cameraZoomSnap' ] > 0 ) ) {
+
+				pixelAlignCamera( camera, controls.target, params[ 'cameraRotationSnap' ], params[ 'cameraZoomSnap' ] );
+
+			}
 
-				pixelAlignFrustum( camera, aspectRatio, Math.floor( rendererSize.x / params[ 'pixelSize' ] ),
-					Math.floor( rendererSize.y / params[ 'pixelSize' ] ) );
+			if ( snapping && params[ 'pixelAlignedPanning' ] ) {
+
+				pixelAlignFrustum( camera, aspectRatio, pixelsPerScreenWidth, pixelsPerScreenHeight );
 
 			} else if ( camera.left != - aspectRatio || camera.top != 1.0 ) {
 
@@ -203,8 +232,39 @@
 
 			}
 
+			// Remember the crystal's true (unsnapped) transform so its bulk movement is unaffected by snapping.
+			// We snapshot the euler rotation (not the quaternion) because that's what drives the spin: restoring
+			// the quaternion would let three.js re-derive a gimbal-equivalent euler and corrupt the y rotation.
+			const crystalPosition = crystalMesh.position.clone();
+			const crystalRotation = crystalMesh.rotation.clone();
+
+			if ( snapping && params[ 'pixelAlignedObjects' ] ) {
+
+				// Snap the moving objects to the screen-space pixel grid so they don't shimmer when pixelated
+				const worldScreenWidth = ( ( camera.right - camera.left ) / camera.zoom );
+				const worldScreenHeight = ( ( camera.top - camera.bottom ) / camera.zoom );
+				pixelAlignObject( crystalMesh, camera, worldScreenWidth / pixelsPerScreenWidth,
+					worldScreenHeight / pixelsPerScreenHeight );
+
+			}
+
+			if ( snapping && params[ 'rotationSnap' ] > 0 ) {
+
+				// Snap the moving objects' rotation to the nearest camera-relative euler increment
+				snapObjectRotation( crystalMesh, camera, params[ 'rotationSnap' ] );
+
+			}
+
 			composer.render();
 
+			// Restore the true transforms after rendering so the next frame's motion builds on the real trajectory
+			crystalMesh.position.copy( crystalPosition );
+			crystalMesh.rotation.copy( crystalRotation );
+			camera.position.copy( cameraPosition );
+			camera.quaternion.copy( cameraQuaternion );
+			camera.zoom = cameraZoom;
+			camera.updateProjectionMatrix();
+
 		}
 
 		// Helper functions
@@ -245,6 +305,36 @@
 
 		}
 
+		function pixelAlignCamera( camera, target, rotationIncrementDegrees, zoomIncrement ) {
+
+			// Snap the camera's orbit to the nearest increment of azimuth/polar angle around the target. Working
+			// in spherical coordinates (rather than snapping the euler angles) keeps the orbit translation locked
+			// onto the target and guarantees a roll-free orientation, since we re-aim with lookAt and world up.
+			if ( rotationIncrementDegrees > 0 ) {
+
+				const increment = THREE.MathUtils.degToRad( rotationIncrementDegrees );
+				const offset = new THREE.Vector3().subVectors( camera.position, target );
+				const spherical = new THREE.Spherical().setFromVector3( offset );
+				spherical.theta = Math.round( spherical.theta / increment ) * increment;
+				spherical.phi = Math.round( spherical.phi / increment ) * increment;
+				spherical.makeSafe();
+				offset.setFromSpherical( spherical );
+				camera.position.copy( target ).add( offset );
+				camera.up.set( 0.0, 1.0, 0.0 );
+				camera.lookAt( target );
+
+			}
+
+			// Snap the camera's zoom to the nearest increment so the pixel grid stays a stable size while scaling
+			if ( zoomIncrement > 0 ) {
+
+				camera.zoom = Math.max( zoomIncrement, Math.round( camera.zoom / zoomIncrement ) * zoomIncrement );
+				camera.updateProjectionMatrix();
+
+			}
+
+		}
+
 		function pixelAlignFrustum( camera, aspectRatio, pixelsPerScreenWidth, pixelsPerScreenHeight ) {
 
 			// 0. Get Pixel Grid Units
@@ -278,6 +368,52 @@
 
 		}
 
+		function pixelAlignObject( object, camera, pixelWidth, pixelHeight ) {
+
+			// 1. Build the camera's orthonormal screen-space basis
+			const camRot = new THREE.Quaternion(); camera.getWorldQuaternion( camRot );
+			const camRight = new THREE.Vector3( 1.0, 0.0, 0.0 ).applyQuaternion( camRot );
+			const camUp = new THREE.Vector3( 0.0, 1.0, 0.0 ).applyQuaternion( camRot );
+			const camForward = new THREE.Vector3( 0.0, 0.0, - 1.0 ).applyQuaternion( camRot );
+
+			// 2. Decompose the object's world position onto that basis
+			const pos = object.position;
+			const posRight = pos.dot( camRight );
+			const posUp = pos.dot( camUp );
+			const posForward = pos.dot( camForward );
+
+			// 3. Snap the screen-space (right/up) components to the pixel grid, leaving depth untouched
+			const snappedRight = Math.round( posRight / pixelWidth ) * pixelWidth;
+			const snappedUp = Math.round( posUp / pixelHeight ) * pixelHeight;
+
+			// 4. Recompose the world position from the snapped basis components
+			pos.set( 0, 0, 0 );
+			pos.addScaledVector( camRight, snappedRight );
+			pos.addScaledVector( camUp, snappedUp );
+			pos.addScaledVector( camForward, posForward );
+
+		}
+
+		function snapObjectRotation( object, camera, incrementDegrees ) {
+
+			const increment = THREE.MathUtils.degToRad( incrementDegrees );
+
+			// 1. Express the object's rotation relative to the camera
+			const camRot = new THREE.Quaternion(); camera.getWorldQuaternion( camRot );
+			const relative = camRot.clone().invert().multiply( object.quaternion );
+
+			// 2. Snap each euler axis (in camera space) to the nearest increment
+			const euler = new THREE.Euler().setFromQuaternion( relative );
+			euler.x = Math.round( euler.x / increment ) * increment;
+			euler.y = Math.round( euler.y / increment ) * increment;
+			euler.z = Math.round( euler.z / increment ) * increment;
+
+			// 3. Convert back to a world-space rotation
+			relative.setFromEuler( euler );
+			object.quaternion.copy( camRot.multiply( relative ) );
+
+		}
+
 	</script>
 </body>
 

+ 2 - 2
examples/webgpu_compute_particles_snow.html

@@ -152,11 +152,11 @@
 
 					const rippleOnSurface = texture( collisionPosRT.texture, getCoord( position.xz ) ).toInspector( 'Collision Test', () => {
 
-						return texture( collisionPosRT.texture ).r; // .div( collisionCamera.position.y );
+						return texture( collisionPosRT.texture ); // .div( collisionCamera.position.y );
 
 					} );
 
-					const rippleFloorArea = rippleOnSurface.r.add( scale.x.mul( surfaceOffset ) );
+					const rippleFloorArea = rippleOnSurface.add( scale.x.mul( surfaceOffset ) );
 
 					If( position.y.greaterThan( rippleFloorArea ), () => {
 

+ 4 - 35
examples/webgpu_lights_clustered.html

@@ -45,7 +45,6 @@
 			import { Inspector } from 'three/addons/inspector/Inspector.js';
 
 			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-			import { SkyMesh } from 'three/addons/objects/SkyMesh.js';
 
 			import WebGPU from 'three/addons/capabilities/WebGPU.js';
 
@@ -79,7 +78,7 @@
 
 				}
 
-				camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 1, 20000 );
+				camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 1, 200 );
 				camera.position.set( 36, 18, 36 );
 
 				scene = new THREE.Scene();
@@ -97,39 +96,11 @@
 
 				await renderer.init();
 
-				// a physical sky drives the backdrop and the image-based lighting; the
-				// sun sits just below the horizon for an almost-night mood
-
-				const sky = new SkyMesh();
-				sky.scale.setScalar( 10000 );
-				sky.turbidity.value = 10;
-				sky.rayleigh.value = 3;
-				sky.mieCoefficient.value = 0.005;
-				sky.mieDirectionalG.value = 0.7;
-
-				const sun = new THREE.Vector3().setFromSphericalCoords(
-					1,
-					THREE.MathUtils.degToRad( 92 ), // elevation: 2° below the horizon (almost night)
-					THREE.MathUtils.degToRad( 225 ) // azimuth
-				);
-				sky.sunPosition.value.copy( sun );
-
-				scene.add( sky );
-
-				// the sun sits below the horizon, so keep its disc hidden; bake the sky
-				// into an environment map for the faint ambient colour it still casts
-
-				const pmremGenerator = new THREE.PMREMGenerator( renderer );
-
-				sky.showSunDisc.value = false;
-				scene.environment = pmremGenerator.fromScene( scene ).texture;
-				scene.environmentIntensity = 0.75;
-
 				// ground
 
 				const ground = new THREE.Mesh(
 					new THREE.PlaneGeometry( 1000, 1000 ).rotateX( - Math.PI / 2 ),
-					new THREE.MeshStandardNodeMaterial( { color: 0x2a2a2a, roughness: 0.6, metalness: 0 } )
+					new THREE.MeshPhongNodeMaterial( { color: 0x2a2a2a, shininess: 80 } )
 				);
 				scene.add( ground );
 
@@ -143,7 +114,7 @@
 				];
 
 				const bigGeometry = new THREE.SphereGeometry( BIG_RADIUS, 64, 32 );
-				const bigMaterial = new THREE.MeshStandardNodeMaterial( { color: 0xdddddd, roughness: 0.5, metalness: 0 } );
+				const bigMaterial = new THREE.MeshPhongNodeMaterial( { color: 0xdddddd, shininess: 80 } );
 
 				for ( const position of bigPositions ) {
 
@@ -221,9 +192,7 @@
 				scenePass = pass( scene, camera );
 				clusterInfluence = uniform( 0 );
 
-				// the large far plane spreads the exponential z-slices out, so the orb
-				// field falls around slices 7–11; default into that range
-				debugZSliceNode = uniform( 10, 'int' );
+				debugZSliceNode = uniform( 20, 'int' );
 
 				renderPipeline = new THREE.RenderPipeline( renderer );
 

+ 89 - 30
examples/webgpu_postprocessing_ao.html

@@ -20,7 +20,7 @@
 			</div>
 
 			<small>
-				Ambient Occlusion based on GTAO.<br />
+				Ambient Occlusion: switchable between GTAO and SSAO.<br />
 				Tennyson bust from <a href="https://threedscans.com/lincoln/tennyson/" target="_blank" rel="noopener">Three D Scans</a>.
 			</small>
 		</div>
@@ -41,6 +41,7 @@
 			import * as THREE from 'three/webgpu';
 			import { sample, pass, mrt, screenUV, normalView, velocity, vec3, vec4, packNormalToRGB, unpackRGBToNormal, colorSpaceToWorking, builtinAOContext } from 'three/tsl';
 			import { ao } from 'three/addons/tsl/display/GTAONode.js';
+			import { ssao } from 'three/addons/tsl/display/SSAONode.js';
 			import { traa } from 'three/addons/tsl/display/TRAANode.js';
 
 			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
@@ -56,10 +57,15 @@
 			let aoPass, traaPass, transparentMesh;
 
 			const params = {
+				aoType: 'GTAO',
 				samples: 16,
 				radius: 0.5,
-				scale: 0.8,
-				thickness: 1,
+				scale: 0.8, // GTAO
+				thickness: 1, // GTAO
+				temporalFiltering: true, // GTAO
+				intensity: 2, // SSAO
+				bias: 0.025, // SSAO
+				blurSharpness: 2, // SSAO
 				aoOnly: false,
 				transparentOpacity: 0.3
 			};
@@ -135,24 +141,43 @@
 
 				const scenePass = pass( scene, camera ).toInspector( 'Color' );
 
-				// ao
+				// final output + traa
 
-				aoPass = ao( prePassDepth, prePassNormal, camera ).toInspector( 'GTAO', ( inspectNode ) => inspectNode.r );
-				aoPass.resolutionScale = 0.5; // running AO in half resolution is often sufficient
-				aoPass.useTemporalFiltering = true;
+				traaPass = traa( scenePass, prePassDepth, prePassVelocity, camera );
+				traaPass.useSubpixelCorrection = false;
 
-				const aoPassOutput = aoPass.getTextureNode();
+				// ao: the AO node feeds the scene's ambient term via `builtinAOContext`, and
+				// can be switched between GTAO ( ground-truth, needs temporal denoise ) and the
+				// cheaper SSAO ( self-denoised )
 
-				// scene context
+				function updateOutput() {
 
-				scenePass.contextNode = builtinAOContext( aoPassOutput.sample( screenUV ).r );
+					renderPipeline.outputNode = params.aoOnly ? vec4( vec3( aoPass.getTextureNode().sample( screenUV ).r ), 1 ) : traaPass;
+					renderer.toneMapping = params.aoOnly ? THREE.NoToneMapping : THREE.NeutralToneMapping;
+					renderPipeline.needsUpdate = true;
 
-				// final output + traa
+				}
 
-				traaPass = traa( scenePass, prePassDepth, prePassVelocity, camera );
-				traaPass.useSubpixelCorrection = false;
+				function createAO() {
+
+					if ( aoPass ) aoPass.dispose();
+
+					aoPass = ( params.aoType === 'SSAO' )
+						? ssao( prePassDepth, prePassNormal, camera ).toInspector( 'SSAO', ( inspectNode ) => inspectNode.r )
+						: ao( prePassDepth, prePassNormal, camera ).toInspector( 'GTAO', ( inspectNode ) => inspectNode.r );
 
-				renderPipeline.outputNode = traaPass;
+					aoPass.resolutionScale = 0.5; // running AO in half resolution is often sufficient
+
+					updateParameters();
+
+					scenePass.contextNode = builtinAOContext( aoPass.getTextureNode().sample( screenUV ).r );
+					scenePass.needsUpdate = true;
+
+					updateOutput();
+
+				}
+
+				createAO();
 
 				// models
 
@@ -496,31 +521,55 @@
 				// GUI
 
 				const gui = renderer.inspector.createParameters( 'Settings' );
+				gui.add( params, 'aoType', [ 'GTAO', 'SSAO' ] ).name( 'AO type' ).onChange( () => {
+
+					createAO();
+					updateControls();
+
+				} );
 				gui.add( params, 'samples', 4, 32, 1 ).onChange( updateParameters );
 				gui.add( params, 'radius', 0.1, 1 ).onChange( updateParameters );
-				gui.add( params, 'scale', 0.01, 1 ).onChange( updateParameters );
-				gui.add( params, 'thickness', 0.01, 2 ).onChange( updateParameters );
-				gui.add( aoPass, 'useTemporalFiltering' ).name( 'temporal filtering' );
+
+				const gtaoControls = [
+					gui.add( params, 'scale', 0.01, 1 ).onChange( updateParameters ),
+					gui.add( params, 'thickness', 0.01, 2 ).onChange( updateParameters ),
+					gui.add( aoPass, 'resolutionScale', 0, 1 ).name( 'resolution scale' ),
+					gui.add( params, 'temporalFiltering' ).name( 'temporal filtering' ).onChange( updateParameters )
+				];
+
+				const ssaoControls = [
+					gui.add( params, 'intensity', 0, 4 ).onChange( updateParameters ),
+					gui.add( params, 'bias', 0, 0.2 ).onChange( updateParameters ),
+					gui.add( params, 'blurSharpness', 0, 8 ).name( 'blur sharpness' ).onChange( updateParameters )
+				];
+
 				gui.add( transparentMesh, 'visible' ).name( 'show transparent mesh' );
-				gui.add( params, 'transparentOpacity', 0, 1, 0.01 ).name( 'transparent opacity' ).onChange( updateParameters );
-				gui.add( params, 'aoOnly' ).onChange( ( value ) => {
+				gui.add( params, 'transparentOpacity', 0, 1, 0.01 ).name( 'transparent opacity' ).onChange( ( value ) => {
 
-					if ( value === true ) {
+					transparentMesh.material.opacity = value;
 
-						renderPipeline.outputNode = vec4( vec3( aoPass.r ), 1 );
-						renderer.toneMapping = THREE.NoToneMapping;
+				} );
+				gui.add( params, 'aoOnly' ).onChange( updateOutput );
 
-					} else {
+				// show only the controls that apply to the active AO type
+
+				function updateControls() {
 
+					if ( params.aoType === 'GTAO' ) {
 
-						renderPipeline.outputNode = traaPass;
-						renderer.toneMapping = THREE.NeutralToneMapping;
+						gtaoControls.forEach( ( control ) => control.show() );
+						ssaoControls.forEach( ( control ) => control.hide() );
+
+					} else {
+
+						gtaoControls.forEach( ( control ) => control.hide() );
+						ssaoControls.forEach( ( control ) => control.show() );
 
 					}
 
-					renderPipeline.needsUpdate = true;
+				}
 
-				} );
+				updateControls();
 
 			}
 
@@ -528,10 +577,20 @@
 
 				aoPass.samples.value = params.samples;
 				aoPass.radius.value = params.radius;
-				aoPass.scale.value = params.scale;
-				aoPass.thickness.value = params.thickness;
 
-				transparentMesh.material.opacity = params.transparentOpacity;
+				if ( params.aoType === 'SSAO' ) {
+
+					aoPass.intensity.value = params.intensity;
+					aoPass.bias.value = params.bias;
+					aoPass.blurSharpness.value = params.blurSharpness;
+
+				} else {
+
+					aoPass.scale.value = params.scale;
+					aoPass.thickness.value = params.thickness;
+					aoPass.useTemporalFiltering = params.temporalFiltering;
+
+				}
 
 			}
 

+ 24 - 21
examples/webgpu_postprocessing_bloom.html

@@ -20,7 +20,7 @@
 			</div>
 
 			<small>
-				Bloom pass by <a href="http://eduperiment.com" target="_blank" rel="noopener">Prashant Sharma</a> and <a href="https://clara.io" target="_blank" rel="noopener">Ben Houston</a>.<br/>
+				Gaussian bloom by <a href="http://eduperiment.com" target="_blank" rel="noopener">Prashant Sharma</a> and <a href="https://clara.io" target="_blank" rel="noopener">Ben Houston</a>.<br/>
 				<a href="https://blog.sketchfab.com/art-spotlight-primary-ion-drive/" target="_blank" rel="noopener">Primary Ion Drive</a>
 				by <a href="http://mjmurdock.com/" target="_blank" rel="noopener">Mike Murdock</a> is licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">Creative Commons Attribution</a>.<br />
 			</small>
@@ -40,8 +40,9 @@
 		<script type="module">
 
 			import * as THREE from 'three/webgpu';
-			import { pass } from 'three/tsl';
+			import { pass, uniform } from 'three/tsl';
 			import { bloom } from 'three/addons/tsl/display/BloomNode.js';
+			import { dualKawaseBloom } from 'three/addons/tsl/display/DualKawaseBloomNode.js';
 
 			import { Inspector } from 'three/addons/inspector/Inspector.js';
 
@@ -53,9 +54,7 @@
 			let renderPipeline, renderer, mixer, timer;
 
 			const params = {
-				threshold: 0,
-				strength: 1,
-				radius: 0,
+				type: 'Gaussian',
 				exposure: 1
 			};
 
@@ -104,9 +103,23 @@
 				const scenePass = pass( scene, camera );
 				const scenePassColor = scenePass.getTextureNode( 'output' ).toInspector( 'Color' );
 
-				const bloomPass = bloom( scenePassColor ).toInspector( 'Bloom' );
+				const strength = uniform( 1 );
+				const radius = uniform( 0 );
+				const threshold = uniform( 0 );
 
-				renderPipeline.outputNode = scenePassColor.add( bloomPass );
+				const bloomPasses = {
+					'Gaussian': bloom( scenePassColor, strength, radius, threshold ),
+					'Dual Kawase': dualKawaseBloom( scenePassColor, strength, radius, threshold )
+				};
+
+				function updateBloom() {
+
+					renderPipeline.outputNode = scenePassColor.add( bloomPasses[ params.type ] );
+					renderPipeline.needsUpdate = true;
+
+				}
+
+				updateBloom();
 
 				//
 
@@ -121,23 +134,13 @@
 
 				const bloomFolder = gui.addFolder( 'bloom' );
 
-				bloomFolder.add( params, 'threshold', 0.0, 1.0 ).onChange( function ( value ) {
-
-					bloomPass.threshold.value = value;
+				bloomFolder.add( params, 'type', [ 'Gaussian', 'Dual Kawase' ] ).onChange( updateBloom );
 
-				} );
+				bloomFolder.add( threshold, 'value', 0.0, 1.0 ).name( 'threshold' );
 
-				bloomFolder.add( params, 'strength', 0.0, 3.0 ).onChange( function ( value ) {
+				bloomFolder.add( strength, 'value', 0.0, 3.0 ).name( 'strength' );
 
-					bloomPass.strength.value = value;
-
-				} );
-
-				bloomFolder.add( params, 'radius', 0.0, 1.0, 0.01 ).onChange( function ( value ) {
-
-					bloomPass.radius.value = value;
-
-				} );
+				bloomFolder.add( radius, 'value', 0.0, 1.0, 0.01 ).name( 'radius' );
 
 				const toneMappingFolder = gui.addFolder( 'tone mapping' );
 

+ 23 - 5
examples/webgpu_postprocessing_bloom_emissive.html

@@ -36,8 +36,9 @@
 		<script type="module">
 
 			import * as THREE from 'three/webgpu';
-			import { pass, mrt, output, emissive, vec4 } from 'three/tsl';
+			import { pass, mrt, output, emissive, vec4, uniform } from 'three/tsl';
 			import { bloom } from 'three/addons/tsl/display/BloomNode.js';
+			import { dualKawaseBloom } from 'three/addons/tsl/display/DualKawaseBloomNode.js';
 
 			import { HDRLoader } from 'three/addons/loaders/HDRLoader.js';
 
@@ -118,10 +119,26 @@
 				const outputPass = scenePass.getTextureNode().toInspector( 'Color' );
 				const emissivePass = scenePass.getTextureNode( 'emissive' ).toInspector( 'Emissive' );
 
-				const bloomPass = bloom( emissivePass, 2.5, .5 );
+				const params = { type: 'Gaussian' };
+
+				const strength = uniform( 2.5 );
+				const radius = uniform( 0.5 );
+
+				const bloomPasses = {
+					'Gaussian': bloom( emissivePass, strength, radius ),
+					'Dual Kawase': dualKawaseBloom( emissivePass, strength, radius )
+				};
 
 				renderPipeline = new THREE.RenderPipeline( renderer );
-				renderPipeline.outputNode = outputPass.add( bloomPass );
+
+				function updateBloom() {
+
+					renderPipeline.outputNode = outputPass.add( bloomPasses[ params.type ] );
+					renderPipeline.needsUpdate = true;
+
+				}
+
+				updateBloom();
 
 				//
 
@@ -138,8 +155,9 @@
 				const gui = renderer.inspector.createParameters( 'Settings' );
 
 				const bloomFolder = gui.addFolder( 'Bloom' );
-				bloomFolder.add( bloomPass.strength, 'value', 0.0, 5.0 ).name( 'strength' );
-				bloomFolder.add( bloomPass.radius, 'value', 0.0, 1.0 ).name( 'radius' );
+				bloomFolder.add( params, 'type', [ 'Gaussian', 'Dual Kawase' ] ).onChange( updateBloom );
+				bloomFolder.add( strength, 'value', 0.0, 5.0 ).name( 'strength' );
+				bloomFolder.add( radius, 'value', 0.0, 1.0 ).name( 'radius' );
 
 				const toneMappingFolder = gui.addFolder( 'Tone Mapping' );
 				toneMappingFolder.add( renderer, 'toneMappingExposure', 0.1, 2 ).name( 'exposure' );

+ 25 - 5
examples/webgpu_postprocessing_bloom_selective.html

@@ -38,6 +38,7 @@
 			import * as THREE from 'three/webgpu';
 			import { pass, mrt, output, float, uniform } from 'three/tsl';
 			import { bloom } from 'three/addons/tsl/display/BloomNode.js';
+			import { dualKawaseBloom } from 'three/addons/tsl/display/DualKawaseBloomNode.js';
 
 			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 
@@ -96,11 +97,29 @@
 			const outputPass = scenePass.getTextureNode().toInspector( 'Color' );
 			const bloomIntensityPass = scenePass.getTextureNode( 'bloomIntensity' ).toInspector( 'Bloom Intensity' );
 
-			const bloomPass = bloom( outputPass.mul( bloomIntensityPass ) );
+			const params = { type: 'Gaussian' };
+			const bloomInput = outputPass.mul( bloomIntensityPass );
+
+			const strength = uniform( 1 );
+			const radius = uniform( 0 );
+			const threshold = uniform( 0 );
+
+			const bloomPasses = {
+				'Gaussian': bloom( bloomInput, strength, radius, threshold ),
+				'Dual Kawase': dualKawaseBloom( bloomInput, strength, radius, threshold )
+			};
 
 			const renderPipeline = new THREE.RenderPipeline( renderer );
 			renderPipeline.outputColorTransform = false;
-			renderPipeline.outputNode = outputPass.add( bloomPass ).renderOutput();
+
+			function updateBloom() {
+
+				renderPipeline.outputNode = outputPass.add( bloomPasses[ params.type ] ).renderOutput();
+				renderPipeline.needsUpdate = true;
+
+			}
+
+			updateBloom();
 
 			// controls
 
@@ -139,9 +158,10 @@
 			const gui = renderer.inspector.createParameters( 'Settings' );
 
 			const bloomFolder = gui.addFolder( 'Bloom' );
-			bloomFolder.add( bloomPass.threshold, 'value', 0.0, 1.0 ).name( 'threshold' );
-			bloomFolder.add( bloomPass.strength, 'value', 0.0, 3 ).name( 'strength' );
-			bloomFolder.add( bloomPass.radius, 'value', 0.0, 1.0 ).name( 'radius' );
+			bloomFolder.add( params, 'type', [ 'Gaussian', 'Dual Kawase' ] ).onChange( updateBloom );
+			bloomFolder.add( threshold, 'value', 0.0, 1.0 ).name( 'threshold' );
+			bloomFolder.add( strength, 'value', 0.0, 3 ).name( 'strength' );
+			bloomFolder.add( radius, 'value', 0.0, 1.0 ).name( 'radius' );
 
 			const toneMappingFolder = gui.addFolder( 'Tone Mapping' );
 			toneMappingFolder.add( renderer, 'toneMappingExposure', 0.1, 3 ).name( 'exposure' );

+ 1 - 1
examples/webgpu_postprocessing_ssgi.html

@@ -121,7 +121,7 @@
 				const ao = giPass.getAONode().toInspector( 'SSGI.AO' );
 				const gi = giPass.getGINode().toInspector( 'SSGI.GI' );
 
-				const compositePass = vec4( add( scenePassColor.rgb.mul( ao.r ), ( scenePassDiffuse.rgb.mul( gi.rgb ) ) ), scenePassColor.a );
+				const compositePass = vec4( add( scenePassColor.rgb.mul( ao ), ( scenePassDiffuse.rgb.mul( gi.rgb ) ) ), scenePassColor.a );
 				compositePass.name = 'Composite';
 
 				// traa

+ 1 - 1
examples/webgpu_postprocessing_ssgi_ballpool.html

@@ -141,7 +141,7 @@
 				const gi = giPass.getGINode().toInspector( 'SSGI.GI' );
 
 				const compositePass = vec4(
-					add( scenePassColor.rgb.mul( ao.r ), scenePassDiffuse.rgb.mul( gi.rgb ) ),
+					add( scenePassColor.rgb.mul( ao ), scenePassDiffuse.rgb.mul( gi.rgb ) ),
 					scenePassColor.a
 				);
 

+ 2 - 0
examples/webgpu_tsl_editor.html

@@ -81,6 +81,7 @@
 				const renderer = new THREE.WebGPURenderer( { antialias: true } );
 				renderer.setPixelRatio( window.devicePixelRatio );
 				renderer.setSize( 200, 200 );
+				renderer.debug.diagnostics.keywords = true;
 				rendererDOM.appendChild( renderer.domElement );
 
 				const material = new THREE.NodeMaterial();
@@ -169,6 +170,7 @@ output = vec4( finalColor, opacity );
 					};
 
 					const webGLRenderer = new THREE.WebGPURenderer( { forceWebGL: true } );
+					webGLRenderer.debug.diagnostics.keywords = true;
 
 					const build = async () => {
 

+ 2 - 2
src/materials/nodes/manager/NodeMaterialObserver.js

@@ -222,7 +222,7 @@ class NodeMaterialObserver {
 			data.environmentIntensity = environmentIntensity;
 			data.environmentRotation = environmentRotation.clone();
 
-			data.lights = this.getLightsData( renderObject.lightsNode.getLights(), [] );
+			data.lights = this.getLightsData( renderObject.lightsNode.getBuiltinLights(), [] );
 
 			this.renderObjects.set( renderObject, data );
 
@@ -701,7 +701,7 @@ class NodeMaterialObserver {
 		}
 
 		cached.renderId = renderId;
-		this.getLightsData( lightsNode.getLights(), cached.lightsData );
+		this.getLightsData( lightsNode.getBuiltinLights(), cached.lightsData );
 
 		return cached.lightsData;
 

+ 18 - 21
src/nodes/accessors/TextureNode.js

@@ -7,6 +7,7 @@ import { maxMipLevel } from '../utils/MaxMipLevelNode.js';
 import { nodeProxy, vec3, nodeObject, int, Fn } from '../tsl/TSLBase.js';
 import { step } from '../math/MathNode.js';
 import { NodeUpdateType } from '../core/constants.js';
+import { getTextureType } from '../core/NodeUtils.js';
 
 import { Compatibility, GreaterCompare, GreaterEqualCompare, IntType, LessCompare, LessEqualCompare, NearestFilter, UnsignedIntType } from '../../constants.js';
 
@@ -228,25 +229,13 @@ class TextureNode extends UniformNode {
 	 */
 	generateNodeType( /*builder*/ ) {
 
-		if ( this.value.isDepthTexture === true ) {
-
-			if ( this.gatherNode === null ) return 'float';
+		if ( this.gatherNode !== null ) {
 
 			return 'vec4';
 
 		}
 
-		if ( this.value.type === UnsignedIntType ) {
-
-			return 'uvec4';
-
-		} else if ( this.value.type === IntType ) {
-
-			return 'ivec4';
-
-		}
-
-		return 'vec4';
+		return getTextureType( this.value );
 
 	}
 
@@ -566,7 +555,7 @@ class TextureNode extends UniformNode {
 
 			const nodeData = builder.getDataFromNode( this );
 
-			let nodeType = this.getNodeType( builder );
+			const nodeType = this.getNodeType( builder );
 
 			let propertyName = nodeData.propertyName;
 
@@ -585,12 +574,6 @@ class TextureNode extends UniformNode {
 				const offsetSnippet = offsetNode ? this.generateOffset( builder, offsetNode ) : null;
 				const flipYSnippet = this._flipYUniform ? this._flipYUniform.build( builder, 'bool' ) : null;
 
-				if ( gatherSnippet ) {
-
-					nodeType = 'vec4';
-
-				}
-
 				let finalDepthSnippet = depthSnippet;
 
 				if ( finalDepthSnippet === null && texture.isArrayTexture && this.isTexture3DNode !== true ) {
@@ -605,6 +588,20 @@ class TextureNode extends UniformNode {
 
 				let snippet = this.generateSnippet( builder, textureProperty, uvSnippet, levelSnippet, biasSnippet, finalDepthSnippet, compareSnippet, gradSnippet, gatherSnippet, offsetSnippet, flipYSnippet );
 
+				let snippetType;
+
+				if ( texture.isDepthTexture === true && gatherSnippet === null ) {
+
+					snippetType = 'float';
+
+				} else {
+
+					snippetType = texture.type === UnsignedIntType ? 'uvec4' : ( texture.type === IntType ? 'ivec4' : 'vec4' );
+
+				}
+
+				snippet = builder.format( snippet, snippetType, nodeType );
+
 				if ( compareStepSnippet !== null ) {
 
 					const compareFunction = texture.compareFunction;

+ 12 - 2
src/nodes/code/FunctionNode.js

@@ -126,9 +126,19 @@ class FunctionNode extends CodeNode {
 
 		if ( name !== '' ) {
 
-			// use a custom property name
+			const nodeData = builder.getDataFromNode( this );
 
-			nodeCode.name = name;
+			if ( nodeData.declarationRegistered !== true ) {
+
+				// use a custom property name
+
+				nodeCode.name = name;
+
+				builder.registerDeclaration( nodeCode );
+
+				nodeData.declarationRegistered = true;
+
+			}
 
 		}
 

+ 22 - 42
src/nodes/core/NodeBuilder.js

@@ -8,7 +8,7 @@ import ParameterNode from './ParameterNode.js';
 import StructType from './StructType.js';
 import FunctionNode from '../code/FunctionNode.js';
 import NodeMaterial from '../../materials/nodes/NodeMaterial.js';
-import { getDataFromObject, getTypeFromLength, hashString } from './NodeUtils.js';
+import { getDataFromObject, getTypeFromLength, getTextureType, hashString } from './NodeUtils.js';
 import { NodeUpdateType, defaultBuildStages, shaderStages } from './constants.js';
 
 import {
@@ -23,7 +23,7 @@ import CubeRenderTarget from '../../renderers/common/CubeRenderTarget.js';
 
 import BindGroup from '../../renderers/common/BindGroup.js';
 
-import { REVISION, IntType, UnsignedIntType, LinearFilter, LinearMipmapNearestFilter, NearestMipmapLinearFilter, LinearMipmapLinearFilter, NormalBlending, RedFormat, RGFormat, RGBFormat, RedIntegerFormat, RGIntegerFormat, RGBIntegerFormat } from '../../constants.js';
+import { REVISION, IntType, UnsignedIntType, LinearFilter, LinearMipmapNearestFilter, NearestMipmapLinearFilter, LinearMipmapLinearFilter, NormalBlending } from '../../constants.js';
 import { RenderTarget } from '../../core/RenderTarget.js';
 import { Color } from '../../math/Color.js';
 import { Vector2 } from '../../math/Vector2.js';
@@ -573,50 +573,15 @@ class NodeBuilder {
 	 */
 	getOutputType( index = 0 ) {
 
-		let type = 'vec4';
-
 		const renderTarget = this.renderer.getRenderTarget();
 
 		if ( renderTarget !== null ) {
 
-			const renderTargetType = renderTarget.textures[ index ].type;
-			const renderTargetFormat = renderTarget.textures[ index ].format;
-
-			let typeStr = 'vec';
-
-			if ( renderTargetType === IntType ) {
-
-				typeStr = 'ivec';
-
-			} else if ( renderTargetType === UnsignedIntType ) {
-
-				typeStr = 'uvec';
-
-			}
-
-			if ( renderTargetFormat === RedFormat || renderTargetFormat === RedIntegerFormat ) {
-
-				if ( renderTargetType === IntType ) type = 'int';
-				else if ( renderTargetType === UnsignedIntType ) type = 'uint';
-				else type = 'float';
-
-			} else if ( renderTargetFormat === RGFormat || renderTargetFormat === RGIntegerFormat ) {
-
-				type = `${ typeStr }2`;
-
-			} else if ( renderTargetFormat === RGBFormat || renderTargetFormat === RGBIntegerFormat ) {
-
-				type = `${ typeStr }3`;
-
-			} else {
-
-				type = `${ typeStr }4`;
-
-			}
+			return getTextureType( renderTarget.textures[ index ] );
 
 		}
 
-		return type;
+		return 'vec4';
 
 	}
 
@@ -1542,6 +1507,20 @@ class NodeBuilder {
 
 	}
 
+	/**
+	 * Returns whether the given name is a reserved keyword of the backend's
+	 * shading language. Backends override this method to provide their
+	 * language-specific keywords.
+	 *
+	 * @param {string} name - The name to test.
+	 * @return {boolean} Whether the name is a reserved keyword or not.
+	 */
+	isReservedKeyword( /* name */ ) {
+
+		return false;
+
+	}
+
 	/**
 	 * Whether the given type is a vector type or not.
 	 *
@@ -2267,6 +2246,7 @@ class NodeBuilder {
 
 		const shaderStage = this.shaderStage;
 		const declarations = this.declarations[ shaderStage ] || ( this.declarations[ shaderStage ] = {} );
+		const checkKeywords = this.renderer.debug.diagnostics.keywords;
 
 		const baseName = node.name;
 
@@ -2274,9 +2254,9 @@ class NodeBuilder {
 		let property = this.getPropertyName( node );
 		let index = 1;
 
-		// Automatically renames the property if the name is already in use.
+		// Automatically renames the property if the name is already in use or reserved.
 
-		while ( declarations[ property ] !== undefined ) {
+		while ( ( checkKeywords && this.isReservedKeyword( name ) ) || declarations[ property ] !== undefined ) {
 
 			name = baseName + '_' + index ++;
 			node.name = name;
@@ -2286,7 +2266,7 @@ class NodeBuilder {
 
 		if ( name !== baseName ) {
 
-			warn( `TSL: Declaration name '${ baseName }' of '${ node.type }' already in use. Renamed to '${ name }'.` );
+			warn( `TSL: Declaration name '${ baseName }' of '${ node.type }' is a reserved keyword or already in use. Renamed to '${ name }'.` );
 
 		}
 

+ 82 - 0
src/nodes/core/NodeUtils.js

@@ -6,6 +6,20 @@ import { Vector2 } from '../../math/Vector2.js';
 import { Vector3 } from '../../math/Vector3.js';
 import { Vector4 } from '../../math/Vector4.js';
 
+import {
+	IntType,
+	UnsignedIntType,
+	AlphaFormat,
+	RedFormat,
+	RedIntegerFormat,
+	RGFormat,
+	RGIntegerFormat,
+	RGBFormat,
+	RGBIntegerFormat,
+	DepthFormat,
+	DepthStencilFormat
+} from '../../constants.js';
+
 import { error } from '../../utils.js';
 import StackTrace from '../core/StackTrace.js';
 
@@ -276,6 +290,74 @@ export function getValueType( value ) {
 
 }
 
+/**
+ * Returns the node data type for the given texture.
+ *
+ * @private
+ * @method
+ * @param {Texture} texture - The texture.
+ * @return {string} The data type.
+ */
+export function getTextureType( texture ) {
+
+	if ( texture.isDepthTexture === true ) {
+
+		return 'float';
+
+	}
+
+	const format = texture.format;
+
+	let length;
+
+	if ( format === RedFormat || format === RedIntegerFormat || format === DepthFormat || format === DepthStencilFormat || format === AlphaFormat ) {
+
+		length = 1;
+
+	} else if ( format === RGFormat || format === RGIntegerFormat ) {
+
+		length = 2;
+
+	} else if ( format === RGBFormat || format === RGBIntegerFormat ) {
+
+		length = 3;
+
+	} else {
+
+		length = 4;
+
+	}
+
+	let componentType;
+
+	if ( texture.type === UnsignedIntType ) {
+
+		componentType = 'uint';
+
+	} else if ( texture.type === IntType ) {
+
+		componentType = 'int';
+
+	} else {
+
+		componentType = 'float';
+
+	}
+
+	if ( length === 1 ) return componentType;
+
+	let baseType = getTypeFromLength( length );
+
+	if ( componentType !== 'float' ) {
+
+		baseType = componentType[ 0 ] + baseType;
+
+	}
+
+	return baseType;
+
+}
+
 /**
  * Returns the value/object for the given data type and parameters.
  *

+ 20 - 4
src/nodes/lighting/LightsNode.js

@@ -146,11 +146,11 @@ class LightsNode extends Node {
 	 */
 	customCacheKey() {
 
-		const lights = this._lights;
+		const builtinLights = this.getBuiltinLights();
 
-		for ( let i = 0; i < lights.length; i ++ ) {
+		for ( let i = 0; i < builtinLights.length; i ++ ) {
 
-			const light = lights[ i ];
+			const light = builtinLights[ i ];
 
 			_hashData.push( light.id );
 			_hashData.push( light.castShadow ? 1 : 0 );
@@ -241,7 +241,9 @@ class LightsNode extends Node {
 		const previousLightNodes = nodeData.lightNodes || null;
 		const materialLightings = builder.context.materialLightings;
 
-		const lights = sortLights( [ ...materialLightings, ...this._lights ] );
+		const builtinLights = this.getBuiltinLights();
+
+		const lights = sortLights( [ ...materialLightings, ...builtinLights ] );
 		const nodeLibrary = builder.renderer.library;
 
 		for ( const light of lights ) {
@@ -458,6 +460,20 @@ class LightsNode extends Node {
 
 	}
 
+	/**
+	 * Returns an array of the scene's lights.
+	 *
+	 * The light variations are shader-dependent;
+	 * if this array changes, the shader needs to be recreated.
+	 *
+	 * @return {Array<Light>} The scene's lights.
+	 */
+	getBuiltinLights() {
+
+		return this._lights;
+
+	}
+
 	/**
 	 * Whether the scene has lights or not.
 	 *

+ 8 - 0
src/renderers/WebGLRenderer.js

@@ -180,6 +180,14 @@ class WebGLRenderer {
 			 * @type {boolean}
 			 */
 			checkShaderErrors: true,
+			/**
+			 * Diagnostics configuration for the shader generation. Only relevant for TSL.
+			 * @type {Object}
+			 * @property {boolean} keywords - Whether declaration names that collide with reserved keywords of the shading language should be renamed or not.
+			 */
+			diagnostics: {
+				keywords: false
+			},
 			/**
 			 * Callback for custom error reporting.
 			 * @type {?Function}

+ 5 - 0
src/renderers/common/Renderer.js

@@ -723,6 +723,8 @@ class Renderer {
 		 * Debug configuration.
 		 * @typedef {Object} DebugConfig
 		 * @property {boolean} checkShaderErrors - Whether shader errors should be checked or not.
+		 * @property {Object} diagnostics - Diagnostics configuration for the shader generation.
+		 * @property {boolean} diagnostics.keywords - Whether declaration names that collide with reserved keywords should be renamed or not.
 		 * @property {?Function} onShaderError - A callback function that is executed when a shader error happens. Only supported with WebGL 2 right now.
 		 * @property {Function} getShaderAsync - Allows the get the raw shader code for the given scene, camera and 3D object.
 		 */
@@ -734,6 +736,9 @@ class Renderer {
 		 */
 		this.debug = {
 			checkShaderErrors: true,
+			diagnostics: {
+				keywords: false
+			},
 			onShaderError: null,
 			getShaderAsync: async ( scene, camera, object ) => {
 

+ 36 - 0
src/renderers/webgl-fallback/nodes/GLSLNodeBuilder.js

@@ -134,6 +134,30 @@ precision highp sampler2DArrayShadow;
 precision highp samplerCubeShadow;
 `;
 
+const glslReservedKeywords = new Set( [
+	// keywords
+	'const', 'uniform', 'buffer', 'shared', 'attribute', 'varying', 'coherent', 'volatile', 'restrict',
+	'readonly', 'writeonly', 'atomic_uint', 'layout', 'centroid', 'flat', 'smooth', 'noperspective',
+	'patch', 'sample', 'invariant', 'precise', 'break', 'continue', 'do', 'for', 'while', 'switch',
+	'case', 'default', 'if', 'else', 'subroutine', 'in', 'out', 'inout', 'int', 'void', 'bool', 'true',
+	'false', 'float', 'double', 'discard', 'return', 'vec2', 'vec3', 'vec4', 'ivec2', 'ivec3', 'ivec4',
+	'bvec2', 'bvec3', 'bvec4', 'uint', 'uvec2', 'uvec3', 'uvec4', 'dvec2', 'dvec3', 'dvec4', 'mat2',
+	'mat3', 'mat4', 'mat2x2', 'mat2x3', 'mat2x4', 'mat3x2', 'mat3x3', 'mat3x4', 'mat4x2', 'mat4x3',
+	'mat4x4', 'dmat2', 'dmat3', 'dmat4', 'dmat2x2', 'dmat2x3', 'dmat2x4', 'dmat3x2', 'dmat3x3',
+	'dmat3x4', 'dmat4x2', 'dmat4x3', 'dmat4x4', 'lowp', 'mediump', 'highp', 'precision', 'sampler2D',
+	'sampler3D', 'samplerCube', 'sampler2DShadow', 'samplerCubeShadow', 'sampler2DArray',
+	'sampler2DArrayShadow', 'isampler2D', 'isampler3D', 'isamplerCube', 'isampler2DArray', 'usampler2D',
+	'usampler3D', 'usamplerCube', 'usampler2DArray', 'struct',
+	// reserved for future use
+	'common', 'partition', 'active', 'asm', 'class', 'union', 'enum', 'typedef', 'template', 'this',
+	'resource', 'goto', 'inline', 'noinline', 'public', 'static', 'extern', 'external', 'interface',
+	'long', 'short', 'half', 'fixed', 'unsigned', 'superp', 'input', 'output', 'hvec2', 'hvec3',
+	'hvec4', 'fvec2', 'fvec3', 'fvec4', 'sampler3DRect', 'filter', 'sizeof', 'cast', 'namespace',
+	'using',
+	// generated entry points
+	'main'
+] );
+
 /**
  * A node builder targeting GLSL.
  *
@@ -428,6 +452,18 @@ ${ flowData.code }
 
 	}
 
+	/**
+	 * Returns whether the given name is a reserved keyword of GLSL.
+	 *
+	 * @param {string} name - The name to test.
+	 * @return {boolean} Whether the name is a reserved keyword or not.
+	 */
+	isReservedKeyword( name ) {
+
+		return glslReservedKeywords.has( name );
+
+	}
+
 	/**
 	 * Setups the Pixel Buffer Object (PBO) for the given storage
 	 * buffer node.

+ 44 - 2
src/renderers/webgpu/nodes/WGSLNodeBuilder.js

@@ -16,7 +16,7 @@ import { NodeAccess } from '../../../nodes/core/constants.js';
 import VarNode from '../../../nodes/core/VarNode.js';
 import ExpressionNode from '../../../nodes/code/ExpressionNode.js';
 
-import { FloatType, RepeatWrapping, ClampToEdgeWrapping, MirroredRepeatWrapping, NearestFilter, Compatibility } from '../../../constants.js';
+import { FloatType, RepeatWrapping, ClampToEdgeWrapping, MirroredRepeatWrapping, NearestFilter, Compatibility, ShortType, UnsignedShortType } from '../../../constants.js';
 import { warn, error } from '../../../utils.js';
 
 import { GPUShaderStage } from '../utils/WebGPUConstants.js';
@@ -240,6 +240,35 @@ const wgslMethods = {
 	floatunpack_float16_2x16: 'unpack2x16float'
 };
 
+// See: https://www.w3.org/TR/WGSL/#keyword-summary and #reserved-words-section
+
+const wgslReservedKeywords = new Set( [
+	// keywords
+	'alias', 'break', 'case', 'const', 'const_assert', 'continue', 'continuing', 'default', 'diagnostic',
+	'discard', 'else', 'enable', 'false', 'fn', 'for', 'if', 'let', 'loop', 'override', 'requires',
+	'return', 'struct', 'switch', 'true', 'var', 'while',
+	// reserved words
+	'NULL', 'Self', 'abstract', 'active', 'alignas', 'alignof', 'as', 'asm', 'asm_fragment', 'async',
+	'attribute', 'auto', 'await', 'become', 'binding_array', 'cast', 'catch', 'class', 'co_await',
+	'co_return', 'co_yield', 'coherent', 'column_major', 'common', 'compile', 'compile_fragment',
+	'concept', 'const_cast', 'consteval', 'constexpr', 'constinit', 'crate', 'debugger', 'decltype',
+	'delete', 'demote', 'demote_to_helper', 'do', 'dynamic_cast', 'enum', 'explicit', 'export',
+	'extends', 'extern', 'external', 'fallthrough', 'filter', 'final', 'finally', 'friend', 'from',
+	'fxgroup', 'get', 'goto', 'groupshared', 'highp', 'impl', 'implements', 'import', 'inline',
+	'instanceof', 'interface', 'layout', 'lowp', 'macro', 'macro_rules', 'match', 'mediump', 'meta',
+	'mod', 'module', 'move', 'mut', 'mutable', 'namespace', 'new', 'nil', 'noexcept', 'noinline',
+	'nointerpolation', 'non_coherent', 'noncoherent', 'noperspective', 'null', 'nullptr', 'of',
+	'operator', 'package', 'packoffset', 'partition', 'pass', 'patch', 'pixelfragment', 'precise',
+	'precision', 'premerge', 'priv', 'protected', 'pub', 'public', 'readonly', 'ref', 'regardless',
+	'register', 'reinterpret_cast', 'require', 'resource', 'restrict', 'self', 'set', 'shared',
+	'sizeof', 'smooth', 'snorm', 'static', 'static_assert', 'static_cast', 'std', 'subroutine',
+	'super', 'target', 'template', 'this', 'thread_local', 'throw', 'trait', 'try', 'type', 'typedef',
+	'typeid', 'typename', 'typeof', 'union', 'unless', 'unorm', 'unsafe', 'unsized', 'use', 'using',
+	'varying', 'virtual', 'volatile', 'wgsl', 'where', 'with', 'writeonly', 'yield',
+	// generated entry points
+	'main'
+] );
+
 //
 
 let diagnostics = '';
@@ -806,7 +835,8 @@ class WGSLNodeBuilder extends NodeBuilder {
 		return this.getComponentTypeFromTexture( texture ) !== 'float' ||
 			( ! this.isAvailable( 'float32Filterable' ) && texture.type === FloatType ) ||
 			( this.isSampleCompare( texture ) === false && texture.minFilter === NearestFilter && texture.magFilter === NearestFilter ) ||
-			this.renderer.backend.utils.getTextureSampleData( texture ).primarySamples > 1;
+			this.renderer.backend.utils.getTextureSampleData( texture ).primarySamples > 1 ||
+			texture.normalized === true && ( texture.type === ShortType || texture.type === UnsignedShortType );
 
 	}
 
@@ -1148,6 +1178,18 @@ class WGSLNodeBuilder extends NodeBuilder {
 
 	}
 
+	/**
+	 * Returns whether the given name is a reserved keyword of WGSL.
+	 *
+	 * @param {string} name - The name to test.
+	 * @return {boolean} Whether the name is a reserved keyword or not.
+	 */
+	isReservedKeyword( name ) {
+
+		return wgslReservedKeywords.has( name );
+
+	}
+
 	/**
 	 * Returns the output struct name.
 	 *

+ 5 - 1
src/renderers/webgpu/utils/WebGPUBindingUtils.js

@@ -3,7 +3,7 @@ import {
 	GPUSamplerBindingType, GPUShaderStage
 } from './WebGPUConstants.js';
 
-import { FloatType, IntType, UnsignedIntType, Compatibility } from '../../../constants.js';
+import { FloatType, IntType, UnsignedIntType, Compatibility, UnsignedShortType, ShortType } from '../../../constants.js';
 import { NodeAccess } from '../../../nodes/core/constants.js';
 import { isTypedArray, error } from '../../../utils.js';
 import { hashString } from '../../../nodes/core/NodeUtils.js';
@@ -536,6 +536,10 @@ class WebGPUBindingUtils {
 
 						texture.sampleType = GPUTextureSampleType.UInt;
 
+					} else if ( binding.texture.normalized === true && ( type === ShortType || type === UnsignedShortType ) ) {
+
+						texture.sampleType = GPUTextureSampleType.UnfilterableFloat;
+
 					} else if ( type === FloatType ) {
 
 						if ( this.backend.hasFeature( 'float32-filterable' ) ) {

粤ICP备19079148号