Sfoglia il codice sorgente

LightProbeGrid: Add WebGPURenderer version. (#33913)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mrdoob 6 giorni fa
parent
commit
9aff723254

+ 3 - 0
examples/files.json

@@ -359,6 +359,9 @@
 		"webgpu_lensflares",
 		"webgpu_lightprobe",
 		"webgpu_lightprobe_cubecamera",
+		"webgpu_lightprobes",
+		"webgpu_lightprobes_complex",
+		"webgpu_lightprobes_sponza",
 		"webgpu_lights_clustered",
 		"webgpu_lights_custom",
 		"webgpu_lights_dynamic",

+ 166 - 0
examples/jsm/helpers/LightProbeGridHelper.js

@@ -0,0 +1,166 @@
+import {
+	InstancedBufferAttribute,
+	InstancedMesh,
+	Matrix4,
+	NodeMaterial,
+	SphereGeometry,
+	Vector3
+} from 'three/webgpu';
+import { array, attribute, Fn, getShIrradianceAt, normalWorld, texture3D, uniform, vec3, vec4 } from 'three/tsl';
+
+/**
+ * Visualizes a {@link LightProbeGrid} 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.
+ *
+ * This helper can only be used with {@link WebGPURenderer}.
+ * When using {@link WebGLRenderer}, import from `LightProbeGridHelperWebGL.js`.
+ *
+ * ```js
+ * const helper = new LightProbeGridHelper( probes );
+ * scene.add( helper );
+ * ```
+ *
+ * @private
+ * @augments InstancedMesh
+ * @three_import import { LightProbeGridHelper } from 'three/addons/helpers/LightProbeGridHelper.js';
+ */
+class LightProbeGridHelper extends InstancedMesh {
+
+	/**
+	 * Constructs a new irradiance probe grid helper.
+	 *
+	 * @param {LightProbeGrid} probes - The probe grid to visualize.
+	 * @param {number} [sphereSize=0.12] - The radius of each probe sphere.
+	 */
+	constructor( probes, sphereSize = 0.12 ) {
+
+		const geometry = new SphereGeometry( sphereSize, 16, 16 );
+		const material = new NodeMaterial();
+
+		const res = probes.resolution;
+		const count = res.x * res.y * res.z;
+
+		super( geometry, material, count );
+
+		/**
+		 * The probe grid to visualize.
+		 *
+		 * @type {LightProbeGrid}
+		 */
+		this.probes = probes;
+
+		this.type = 'LightProbeGridHelper';
+
+		// Atlas and resolution are swappable uniforms, so the shading node builds once.
+
+		this._atlas = texture3D( probes.texture );
+		this._resolution = uniform( new Vector3() );
+
+		const nz = this._resolution.z;
+		const paddedSlices = nz.add( 2.0 );
+		const atlasDepth = paddedSlices.mul( 7.0 );
+
+		material.fragmentNode = Fn( () => {
+
+			const uvw = attribute( 'instanceUVW', 'vec3' );
+			const uvZBase = uvw.z.mul( nz ).add( 1.0 );
+
+			const slice = ( t ) => this._atlas.sample( vec3( uvw.xy, uvZBase.add( paddedSlices.mul( t ) ).div( atlasDepth ) ) );
+
+			const s0 = slice( 0 ), s1 = slice( 1 ), s2 = slice( 2 ), s3 = slice( 3 );
+			const s4 = slice( 4 ), s5 = slice( 5 ), s6 = slice( 6 );
+
+			const sh = array( [
+				s0.xyz,
+				vec3( s0.w, s1.xy ),
+				vec3( s1.zw, s2.x ),
+				s2.yzw,
+				s3.xyz,
+				vec3( s3.w, s4.xy ),
+				vec3( s4.zw, s5.x ),
+				s5.yzw,
+				s6.xyz
+			] );
+
+			return vec4( getShIrradianceAt( normalWorld, sh ).max( vec3( 0.0 ) ), 1.0 );
+
+		} )();
+
+		this.update();
+
+	}
+
+	/**
+	 * Rebuilds instance matrices and UVW attributes from the current probe grid,
+	 * and rebinds the shading node to its atlas. Call this after changing
+	 * `probes` or after re-baking.
+	 */
+	update() {
+
+		const probes = this.probes;
+		const res = probes.resolution;
+		const count = res.x * res.y * res.z;
+
+		// Resize instance matrix buffer if needed.
+
+		if ( this.instanceMatrix.count !== count ) {
+
+			this.instanceMatrix = new InstancedBufferAttribute( new Float32Array( count * 16 ), 16 );
+
+		}
+
+		this.count = count;
+
+		const uvwArray = new Float32Array( count * 3 );
+		const matrix = new Matrix4();
+		const probePos = new Vector3();
+
+		let i = 0;
+
+		for ( let iz = 0; iz < res.z; iz ++ ) {
+
+			for ( let iy = 0; iy < res.y; iy ++ ) {
+
+				for ( let ix = 0; ix < res.x; ix ++ ) {
+
+					// Remap to texel centers (must match LightProbeGridNode).
+					uvwArray[ i * 3 ] = ( ix + 0.5 ) / res.x;
+					uvwArray[ i * 3 + 1 ] = ( iy + 0.5 ) / res.y;
+					uvwArray[ i * 3 + 2 ] = ( iz + 0.5 ) / res.z;
+
+					probes.getProbePosition( ix, iy, iz, probePos );
+					matrix.makeTranslation( probePos.x, probePos.y, probePos.z );
+					this.setMatrixAt( i, matrix );
+
+					i ++;
+
+				}
+
+			}
+
+		}
+
+		this.instanceMatrix.needsUpdate = true;
+
+		this.geometry.setAttribute( 'instanceUVW', new InstancedBufferAttribute( uvwArray, 3 ) );
+
+		this._atlas.value = probes.texture;
+		this._resolution.value.copy( res );
+
+	}
+
+	/**
+	 * Frees the GPU-related resources allocated by this instance. Call this
+	 * method whenever this instance is no longer used in your app.
+	 */
+	dispose() {
+
+		this.geometry.dispose();
+		this.material.dispose();
+
+	}
+
+}
+
+export { LightProbeGridHelper };

+ 657 - 0
examples/jsm/lighting/LightProbeGrid.js

@@ -0,0 +1,657 @@
+import {
+	Box3,
+	CubeCamera,
+	CubeRenderTarget,
+	FloatType,
+	HalfFloatType,
+	Light,
+	LinearFilter,
+	NearestFilter,
+	NodeMaterial,
+	QuadMesh,
+	RenderTarget,
+	RenderTarget3D,
+	RGBAFormat,
+	Vector3
+} from 'three/webgpu';
+
+import {
+	array,
+	cubeTexture,
+	float,
+	Fn,
+	int,
+	ivec2,
+	Loop,
+	screenCoordinate,
+	texture,
+	uniform,
+	vec3,
+	vec4
+} from 'three/tsl';
+
+import { LightProbeGridNode, ATLAS_PADDING } from '../tsl/lighting/LightProbeGridNode.js';
+
+// Shared fullscreen-quad for the bake passes.
+const _quad = /*@__PURE__*/ new QuadMesh();
+
+// Reusable temp objects.
+const _position = /*@__PURE__*/ new Vector3();
+const _size = /*@__PURE__*/ new Vector3();
+
+// Bake materials, shared across grids so the shaders compile once, not per bake.
+let _shMaterial = null;
+let _shSampleCount = - 1;
+let _cubeNode = null;
+let _batchNode = null;
+let _resolutionUniform = null;
+let _sliceZUniform = null;
+let _repackMaterials = null;
+
+// Bake render targets, pooled by size so rebakes don't churn allocations.
+let _cubeRenderTarget = null;
+let _cubeCamera = null;
+let _cubeKey = '';
+let _batchTarget = null;
+let _batchProbes = - 1;
+
+// Golden-angle increment for the equal-area Fibonacci sphere.
+const GOLDEN_ANGLE = Math.PI * ( 3.0 - Math.sqrt( 5.0 ) );
+
+/**
+ * Returns the output node for the spherical-harmonic projection pass. Each
+ * fragment of the 9-wide batch row computes a single SH coefficient by
+ * integrating the captured cubemap over an equal-area Fibonacci sphere,
+ * selecting the basis function for its column. Sampling the cubemap by world
+ * direction keeps the projection independent of the cube face layout.
+ *
+ * @private
+ * @param {Node} cube - The captured environment cubemap texture node.
+ * @param {number} sampleCount - Number of directions to integrate.
+ * @return {Node<vec4>} The projected coefficient.
+ */
+function projectSHNode( cube, sampleCount ) {
+
+	return Fn( () => {
+
+		const coefIndex = int( screenCoordinate.x ).toVar();
+		const accum = vec3( 0.0 ).toVar();
+
+		Loop( sampleCount, ( { i } ) => {
+
+			const fi = float( i );
+
+			// Equal-area Fibonacci sphere direction.
+			const z = float( 1.0 ).sub( fi.mul( 2.0 ).add( 1.0 ).div( sampleCount ) );
+			const r = z.mul( z ).oneMinus().max( 0.0 ).sqrt();
+			const phi = fi.mul( GOLDEN_ANGLE );
+			const dir = vec3( r.mul( phi.cos() ), z, r.mul( phi.sin() ) ).toVar();
+
+			const radiance = cube.sample( dir ).level( 0 ).rgb;
+
+			// The L2 SH basis function for this fragment's coefficient.
+			const x = dir.x, y = dir.y, zc = dir.z;
+			const basis = array( [
+				float( 0.282095 ),
+				y.mul( 0.488603 ),
+				zc.mul( 0.488603 ),
+				x.mul( 0.488603 ),
+				x.mul( y ).mul( 1.092548 ),
+				y.mul( zc ).mul( 1.092548 ),
+				zc.mul( zc ).mul( 3.0 ).sub( 1.0 ).mul( 0.315392 ),
+				x.mul( zc ).mul( 1.092548 ),
+				x.mul( x ).sub( y.mul( y ) ).mul( 0.546274 )
+			] ).element( coefIndex );
+
+			accum.addAssign( radiance.mul( basis ) );
+
+		} );
+
+		// Equal-area quadrature: each direction covers 4*PI / sampleCount.
+		const norm = float( 4.0 * Math.PI / sampleCount );
+
+		return vec4( accum.mul( norm ), 1.0 );
+
+	} )();
+
+}
+
+/**
+ * Returns the repack output node for one of the seven SH textures. It reads the
+ * 9 projected coefficients from the batch texture for the probe at the current
+ * texel and packs the four floats stored by this texture index.
+ *
+ * @private
+ * @param {Node} batch - The batch texture node holding projected coefficients.
+ * @param {number} textureIndex - The output texture index (0–6).
+ * @param {Node<vec3>} resolution - The probe grid resolution uniform.
+ * @param {Node<int>} sliceZ - The current Z slice being written.
+ * @return {Node<vec4>} The packed texel.
+ */
+function repackNode( batch, textureIndex, resolution, sliceZ ) {
+
+	return Fn( () => {
+
+		const ix = int( screenCoordinate.x );
+		const iy = int( screenCoordinate.y );
+
+		const nx = int( resolution.x );
+		const ny = int( resolution.y );
+		const probeIndex = ix.add( iy.mul( nx ) ).add( sliceZ.mul( nx ).mul( ny ) );
+
+		const c0 = batch.load( ivec2( 0, probeIndex ) );
+		const c1 = batch.load( ivec2( 1, probeIndex ) );
+		const c2 = batch.load( ivec2( 2, probeIndex ) );
+		const c3 = batch.load( ivec2( 3, probeIndex ) );
+		const c4 = batch.load( ivec2( 4, probeIndex ) );
+		const c5 = batch.load( ivec2( 5, probeIndex ) );
+		const c6 = batch.load( ivec2( 6, probeIndex ) );
+		const c7 = batch.load( ivec2( 7, probeIndex ) );
+		const c8 = batch.load( ivec2( 8, probeIndex ) );
+
+		let packed;
+
+		switch ( textureIndex ) {
+
+			case 0: packed = vec4( c0.xyz, c1.x ); break;
+			case 1: packed = vec4( c1.yz, c2.xy ); break;
+			case 2: packed = vec4( c2.z, c3.xyz ); break;
+			case 3: packed = vec4( c4.xyz, c5.x ); break;
+			case 4: packed = vec4( c5.yz, c6.xy ); break;
+			case 5: packed = vec4( c6.z, c7.xyz ); break;
+			default: packed = vec4( c8.xyz, 0.0 ); break;
+
+		}
+
+		return packed;
+
+	} )();
+
+}
+
+/**
+ * Lazily pools the shared cube and batch render targets, recreating them only
+ * when their dimensions change.
+ *
+ * @private
+ * @param {number} cubemapSize - Resolution of each cubemap face.
+ * @param {number} near - Cube camera near plane.
+ * @param {number} far - Cube camera far plane.
+ * @param {number} totalProbes - Number of probes (batch target height).
+ */
+function ensureBakeTargets( cubemapSize, near, far, totalProbes ) {
+
+	const cubeKey = `${ cubemapSize },${ near },${ far }`;
+
+	if ( _cubeRenderTarget === null || _cubeKey !== cubeKey ) {
+
+		if ( _cubeRenderTarget !== null ) _cubeRenderTarget.dispose();
+
+		_cubeRenderTarget = new CubeRenderTarget( cubemapSize, { type: HalfFloatType, generateMipmaps: false } );
+		_cubeCamera = new CubeCamera( near, far, _cubeRenderTarget );
+		_cubeKey = cubeKey;
+
+	}
+
+	if ( _batchTarget === null || _batchProbes !== totalProbes ) {
+
+		if ( _batchTarget !== null ) _batchTarget.dispose();
+
+		_batchTarget = new RenderTarget( 9, totalProbes, {
+			type: FloatType,
+			format: RGBAFormat,
+			minFilter: NearestFilter,
+			magFilter: NearestFilter,
+			depthBuffer: false
+		} );
+
+		_batchProbes = totalProbes;
+
+	}
+
+}
+
+/**
+ * Lazily builds the shared bake materials and rebinds them to the current
+ * cube/batch textures. The SH projection material is rebuilt only when the
+ * sample count changes; the repack materials are static.
+ *
+ * @private
+ * @param {number} sampleCount - Number of directions integrated by the projection.
+ * @param {CubeTexture} cubeMap - The current cube render target texture.
+ * @param {Texture} batchMap - The current batch render target texture.
+ */
+function ensureBakeMaterials( sampleCount, cubeMap, batchMap ) {
+
+	if ( _repackMaterials === null ) {
+
+		_cubeNode = cubeTexture( cubeMap );
+		_batchNode = texture( batchMap );
+		_resolutionUniform = uniform( new Vector3() );
+		_sliceZUniform = uniform( 0, 'int' );
+		_repackMaterials = [];
+
+		for ( let t = 0; t < 7; t ++ ) {
+
+			const material = new NodeMaterial();
+			material.outputNode = repackNode( _batchNode, t, _resolutionUniform, _sliceZUniform );
+			material.depthTest = false;
+			material.depthWrite = false;
+			_repackMaterials.push( material );
+
+		}
+
+	} else {
+
+		_cubeNode.value = cubeMap;
+		_batchNode.value = batchMap;
+
+	}
+
+	if ( _shMaterial === null || _shSampleCount !== sampleCount ) {
+
+		if ( _shMaterial !== null ) _shMaterial.dispose();
+
+		_shMaterial = new NodeMaterial();
+		_shMaterial.outputNode = projectSHNode( _cubeNode, sampleCount );
+		_shMaterial.depthTest = false;
+		_shMaterial.depthWrite = false;
+		_shSampleCount = sampleCount;
+
+	}
+
+}
+
+/**
+ * A 3D grid of L2 Spherical Harmonic irradiance probes that provides
+ * position-dependent diffuse global illumination.
+ *
+ * This is the {@link WebGPURenderer} version of `LightProbeGrid`. The grid is a
+ * {@link Light}, so adding it to the scene applies its baked irradiance to every
+ * lit node material automatically. When using {@link WebGLRenderer}, import the
+ * grid from `LightProbeGridWebGL.js` instead.
+ *
+ * The baked data is stored in a single RGBA `RenderTarget3D` atlas that packs
+ * the nine L2 SH coefficients into seven sub-volumes stacked along Z. Baking is
+ * fully GPU-resident: cubemap rendering, SH projection, and texture packing all
+ * happen on the GPU with zero CPU readback.
+ *
+ * @augments Light
+ * @three_import import { LightProbeGrid } from 'three/addons/lighting/LightProbeGrid.js';
+ */
+class LightProbeGrid extends Light {
+
+	/**
+	 * Constructs a new irradiance probe grid.
+	 *
+	 * The volume is centered at the object's position.
+	 *
+	 * @param {number} [width=1] - Full width of the volume along X.
+	 * @param {number} [height=1] - Full height of the volume along Y.
+	 * @param {number} [depth=1] - Full depth of the volume along Z.
+	 * @param {number} [widthProbes] - Number of probes along X. Defaults to `Math.max( 2, Math.round( width ) + 1 )`.
+	 * @param {number} [heightProbes] - Number of probes along Y. Defaults to `Math.max( 2, Math.round( height ) + 1 )`.
+	 * @param {number} [depthProbes] - Number of probes along Z. Defaults to `Math.max( 2, Math.round( depth ) + 1 )`.
+	 */
+	constructor( width = 1, height = 1, depth = 1, widthProbes, heightProbes, depthProbes ) {
+
+		super( 0xffffff, 1 );
+
+		/**
+		 * This flag can be used for type testing.
+		 *
+		 * @type {boolean}
+		 * @readonly
+		 * @default true
+		 */
+		this.isLightProbeGrid = true;
+
+		this.type = 'LightProbeGrid';
+
+		/**
+		 * The full width of the volume along X.
+		 *
+		 * @type {number}
+		 */
+		this.width = width;
+
+		/**
+		 * The full height of the volume along Y.
+		 *
+		 * @type {number}
+		 */
+		this.height = height;
+
+		/**
+		 * The full depth of the volume along Z.
+		 *
+		 * @type {number}
+		 */
+		this.depth = depth;
+
+		/**
+		 * The number of probes along each axis.
+		 *
+		 * @type {Vector3}
+		 */
+		this.resolution = new Vector3(
+			widthProbes !== undefined ? widthProbes : Math.max( 2, Math.round( width ) + 1 ),
+			heightProbes !== undefined ? heightProbes : Math.max( 2, Math.round( height ) + 1 ),
+			depthProbes !== undefined ? depthProbes : Math.max( 2, Math.round( depth ) + 1 )
+		);
+
+		/**
+		 * The world-space bounding box for the grid. Updated automatically
+		 * by {@link LightProbeGrid#bake}.
+		 *
+		 * @type {Box3}
+		 */
+		this.boundingBox = new Box3();
+
+		/**
+		 * Distance in world units over which the grid contribution fades out
+		 * past the volume boundary. `0` applies the contribution everywhere
+		 * (clamped), which matches a single-volume setup. Use a small positive
+		 * value to blend multiple overlapping grids.
+		 *
+		 * @type {number}
+		 * @default 0
+		 */
+		this.falloff = 0;
+
+		/**
+		 * The single RGBA atlas 3D texture storing all seven packed SH
+		 * sub-volumes stacked along Z.
+		 *
+		 * @type {?Data3DTexture}
+		 * @default null
+		 */
+		this.texture = null;
+
+		/**
+		 * Internal render target for GPU-resident baking.
+		 *
+		 * @private
+		 * @type {?RenderTarget3D}
+		 * @default null
+		 */
+		this._renderTarget = null;
+
+		this.updateBoundingBox();
+
+	}
+
+	/**
+	 * Returns the world-space position of the probe at grid indices (ix, iy, iz).
+	 *
+	 * @param {number} ix - X index.
+	 * @param {number} iy - Y index.
+	 * @param {number} iz - Z index.
+	 * @param {Vector3} target - The target vector.
+	 * @return {Vector3} The world-space position.
+	 */
+	getProbePosition( ix, iy, iz, target ) {
+
+		const pos = this.position;
+		const res = this.resolution;
+		const w = this.width, h = this.height, d = this.depth;
+
+		target.set(
+			res.x > 1 ? pos.x - w / 2 + ix * w / ( res.x - 1 ) : pos.x,
+			res.y > 1 ? pos.y - h / 2 + iy * h / ( res.y - 1 ) : pos.y,
+			res.z > 1 ? pos.z - d / 2 + iz * d / ( res.z - 1 ) : pos.z
+		);
+
+		return target;
+
+	}
+
+	/**
+	 * Updates the world-space bounding box from the current position and size.
+	 */
+	updateBoundingBox() {
+
+		_size.set( this.width, this.height, this.depth );
+		this.boundingBox.setFromCenterAndSize( this.position, _size );
+
+	}
+
+	/**
+	 * Bakes all probes by rendering cubemaps at each probe position and
+	 * projecting to L2 SH. Optionally iterates additional passes to capture
+	 * indirect bounces: each extra pass samples the previous pass's data as
+	 * indirect light, so a grid added to the scene before baking accumulates
+	 * one bounce per extra pass.
+	 *
+	 * @param {WebGPURenderer} renderer - The renderer.
+	 * @param {Scene} scene - The scene to render.
+	 * @param {Object} [options] - Bake options.
+	 * @param {number} [options.cubemapSize=8] - Resolution of each cubemap face.
+	 * @param {number} [options.near=0.1] - Near plane for the cube camera.
+	 * @param {number} [options.far=100] - Far plane for the cube camera.
+	 * @param {number} [options.bounces=0] - Additional bounce passes after the initial direct pass.
+	 * @param {number} [options.sampleCount=512] - Directions integrated when projecting each cubemap to SH.
+	 */
+	bake( renderer, scene, options = {} ) {
+
+		// The bake is node based, so it needs a WebGPURenderer.
+		if ( renderer.isWebGPURenderer !== true ) {
+
+			throw new Error( 'THREE.LightProbeGrid: .bake() requires a WebGPURenderer. For WebGLRenderer, use LightProbeGridWebGL.' );
+
+		}
+
+		// The bake issues GPU work immediately, so the renderer must be ready.
+		if ( renderer.initialized === false ) {
+
+			throw new Error( 'THREE.LightProbeGrid: .bake() called before the renderer is initialized. Use "await renderer.init();" first.' );
+
+		}
+
+		// Register the light node with this renderer (idempotent).
+		if ( renderer.library.getLightNodeClass( LightProbeGrid ) === null ) {
+
+			renderer.library.addLight( LightProbeGridNode, LightProbeGrid );
+
+		}
+
+		const { cubemapSize = 8, near = 0.1, far = 100, bounces = 0, sampleCount = 512 } = options;
+
+		this._ensureTextures();
+		this.updateBoundingBox();
+
+		const res = this.resolution;
+		const nz = res.z;
+		const paddedSlices = nz + 2 * ATLAS_PADDING;
+		const totalProbes = res.x * res.y * res.z;
+
+		// Bind the pooled bake resources to the current textures.
+
+		ensureBakeTargets( cubemapSize, near, far, totalProbes );
+		ensureBakeMaterials( sampleCount, _cubeRenderTarget.texture, _batchTarget.texture );
+		_resolutionUniform.value.copy( res );
+
+		const cubeCamera = _cubeCamera;
+		const batchTarget = _batchTarget;
+		const shMaterial = _shMaterial;
+		const repackMaterials = _repackMaterials;
+		const sliceZ = _sliceZUniform;
+
+		// Save renderer / scene state to restore after the bake.
+
+		const currentRenderTarget = renderer.getRenderTarget();
+		const currentAutoClear = renderer.autoClear;
+		const currentMatrixWorldAutoUpdate = scene.matrixWorldAutoUpdate;
+		const shadowLights = [];
+
+		try {
+
+			// Scene is static during the bake: update once, disable auto-update.
+
+			if ( currentMatrixWorldAutoUpdate === true ) {
+
+				scene.updateMatrixWorld( true );
+				scene.matrixWorldAutoUpdate = false;
+
+			}
+
+			// Render each shadow map once, not once per cube face.
+
+			scene.traverse( ( object ) => {
+
+				if ( object.isLight && object.castShadow && object.shadow ) {
+
+					shadowLights.push( { light: object, autoUpdate: object.shadow.autoUpdate } );
+					object.shadow.autoUpdate = false;
+					object.shadow.needsUpdate = true;
+
+				}
+
+			} );
+
+			for ( let pass = 0; pass <= bounces; pass ++ ) {
+
+				// Pass 0 is direct light (grid hidden); each later pass reads the
+				// previous pass as indirect, adding one bounce.
+
+				this.visible = pass > 0;
+
+				// Clear once, then write each probe's row with autoClear off. The
+				// viewport goes on the render target, not the renderer (which ignores
+				// the canvas viewport when one is bound).
+
+				batchTarget.viewport.set( 0, 0, 9, totalProbes );
+				renderer.setRenderTarget( batchTarget );
+				renderer.clear();
+
+				// Phase 1: render cubemaps and project to SH into the batch target.
+
+				_quad.material = shMaterial;
+
+				for ( let iz = 0; iz < res.z; iz ++ ) {
+
+					for ( let iy = 0; iy < res.y; iy ++ ) {
+
+						for ( let ix = 0; ix < res.x; ix ++ ) {
+
+							const probeIndex = ix + iy * res.x + iz * res.x * res.y;
+
+							this.getProbePosition( ix, iy, iz, _position );
+							cubeCamera.position.copy( _position );
+
+							// The cube faces must be cleared per face.
+							renderer.autoClear = true;
+							cubeCamera.update( renderer, scene );
+
+							// Write only this probe's row, preserving the others.
+							renderer.autoClear = false;
+							batchTarget.viewport.set( 0, probeIndex, 9, 1 );
+							renderer.setRenderTarget( batchTarget );
+							_quad.render( renderer );
+
+						}
+
+					}
+
+				}
+
+				// Phase 2: repack the batch into the atlas, padding each sub-volume
+				// with a copy of its first and last data slice.
+
+				renderer.autoClear = true;
+
+				const renderTarget = this._renderTarget;
+
+				for ( let t = 0; t < 7; t ++ ) {
+
+					_quad.material = repackMaterials[ t ];
+
+					const base = t * paddedSlices;
+
+					// Data slices.
+					for ( let iz = 0; iz < nz; iz ++ ) {
+
+						sliceZ.value = iz;
+						renderer.setRenderTarget( renderTarget, base + ATLAS_PADDING + iz );
+						_quad.render( renderer );
+
+					}
+
+					// Leading padding: copy of data slice 0.
+					sliceZ.value = 0;
+					renderer.setRenderTarget( renderTarget, base );
+					_quad.render( renderer );
+
+					// Trailing padding: copy of data slice nz-1.
+					sliceZ.value = nz - 1;
+					renderer.setRenderTarget( renderTarget, base + ATLAS_PADDING + nz );
+					_quad.render( renderer );
+
+				}
+
+			}
+
+		} finally {
+
+			// Restore renderer / scene state (pooled targets and materials kept).
+
+			renderer.setRenderTarget( currentRenderTarget );
+			renderer.autoClear = currentAutoClear;
+			scene.matrixWorldAutoUpdate = currentMatrixWorldAutoUpdate;
+
+			for ( const { light, autoUpdate } of shadowLights ) light.shadow.autoUpdate = autoUpdate;
+
+			this.visible = true;
+
+		}
+
+	}
+
+	/**
+	 * Ensures the atlas 3D texture exists with the correct dimensions.
+	 *
+	 * @private
+	 */
+	_ensureTextures() {
+
+		if ( this._renderTarget !== null ) return;
+
+		const res = this.resolution;
+		const nx = res.x, ny = res.y, nz = res.z;
+
+		// Atlas depth: 7 sub-volumes, each with ATLAS_PADDING slices at both ends.
+		const atlasDepth = 7 * ( nz + 2 * ATLAS_PADDING );
+
+		this._renderTarget = new RenderTarget3D( nx, ny, atlasDepth, {
+			type: HalfFloatType,
+			format: RGBAFormat,
+			minFilter: LinearFilter,
+			magFilter: LinearFilter,
+			generateMipmaps: false,
+			depthBuffer: false
+		} );
+
+		this.texture = this._renderTarget.texture;
+
+	}
+
+	/**
+	 * Frees GPU resources.
+	 */
+	dispose() {
+
+		if ( this._renderTarget !== null ) {
+
+			this._renderTarget.dispose();
+			this._renderTarget = null;
+			this.texture = null;
+
+		}
+
+		super.dispose();
+
+	}
+
+}
+
+export { LightProbeGrid };

+ 136 - 0
examples/jsm/tsl/lighting/LightProbeGridNode.js

@@ -0,0 +1,136 @@
+import { AnalyticLightNode, Vector3 } from 'three/webgpu';
+import { array, getShIrradianceAt, normalWorld, positionWorld, texture3D, uniform, vec3 } from 'three/tsl';
+
+// Padding texels at each boundary of every atlas sub-volume.
+export const ATLAS_PADDING = 1;
+
+/**
+ * Samples the packed SH atlas and evaluates L2 irradiance for the given normal.
+ *
+ * The atlas stores the seven RGBA sub-volumes stacked along Z, each occupying
+ * `( nz + 2 )` slices: one padding slice (a copy of the nearest edge slice) at
+ * each end to prevent color bleeding when the hardware trilinear filter reads
+ * across a sub-volume boundary.
+ *
+ * @private
+ * @param {Texture3DNode} atlas - The atlas texture node.
+ * @param {Node<vec3>} uvw - The probe-grid sample coordinate (texel centers).
+ * @param {Node<vec3>} res - The probe resolution.
+ * @param {Node<vec3>} normal - The world-space normal.
+ * @return {Node<vec3>} The non-negative irradiance.
+ */
+function evaluateGridIrradiance( atlas, uvw, res, normal ) {
+
+	const nz = res.z;
+	const paddedSlices = nz.add( 2.0 * ATLAS_PADDING );
+	const atlasDepth = paddedSlices.mul( 7.0 );
+	const uvZBase = uvw.z.mul( nz ).add( ATLAS_PADDING );
+
+	const slice = ( t ) => atlas.sample( vec3( uvw.xy, uvZBase.add( paddedSlices.mul( t ) ).div( atlasDepth ) ) );
+
+	const s0 = slice( 0 ), s1 = slice( 1 ), s2 = slice( 2 ), s3 = slice( 3 );
+	const s4 = slice( 4 ), s5 = slice( 5 ), s6 = slice( 6 );
+
+	// Unpack 9 vec3 L2 SH coefficients and evaluate irradiance.
+
+	const sh = array( [
+		s0.xyz,
+		vec3( s0.w, s1.xy ),
+		vec3( s1.zw, s2.x ),
+		s2.yzw,
+		s3.xyz,
+		vec3( s3.w, s4.xy ),
+		vec3( s4.zw, s5.x ),
+		s5.yzw,
+		s6.xyz
+	] );
+
+	return getShIrradianceAt( normal, sh ).max( vec3( 0.0 ) );
+
+}
+
+/**
+ * The light node that applies a {@link LightProbeGrid} to the scene. It samples
+ * the baked L2 spherical-harmonic atlas at the surface position and adds the
+ * resulting irradiance to the lighting context, so every standard node material
+ * picks up the grid automatically (same role as the WebGL `lights_fragment_begin`
+ * integration).
+ *
+ * @private
+ * @augments AnalyticLightNode
+ */
+class LightProbeGridNode extends AnalyticLightNode {
+
+	static get type() {
+
+		return 'LightProbeGridNode';
+
+	}
+
+	constructor( light = null ) {
+
+		super( light );
+
+		this._min = uniform( new Vector3() );
+		this._max = uniform( new Vector3() );
+		this._resolution = uniform( new Vector3() );
+		this._intensity = uniform( 1 );
+		this._falloff = uniform( 0 );
+
+	}
+
+	update( /* frame */ ) {
+
+		const light = this.light;
+
+		this._min.value.copy( light.boundingBox.min );
+		this._max.value.copy( light.boundingBox.max );
+		this._resolution.value.copy( light.resolution );
+		this._intensity.value = light.intensity;
+		this._falloff.value = light.falloff;
+
+	}
+
+	setup( builder ) {
+
+		const light = this.light;
+
+		// No baked data yet: contribute nothing.
+
+		if ( light.texture === null ) return;
+
+		const min = this._min;
+		const max = this._max;
+		const res = this._resolution;
+
+		const range = max.sub( min );
+		const resMinusOne = res.sub( 1.0 );
+		const spacing = range.div( resMinusOne );
+
+		// Offset along the normal by half a probe spacing, then remap to texel centers.
+
+		const samplePos = positionWorld.add( normalWorld.mul( spacing ).mul( 0.5 ) );
+		const uvw = samplePos.sub( min ).div( range ).clamp( 0.0, 1.0 ).mul( resMinusOne ).div( res ).add( vec3( 0.5 ).div( res ) );
+
+		const result = evaluateGridIrradiance( texture3D( light.texture ), uvw, res, normalWorld );
+
+		let irradiance = result.mul( this._intensity );
+
+		// Optional smooth boundary for blending grids; falloff 0 applies everywhere.
+
+		if ( light.falloff > 0 ) {
+
+			const outside = min.sub( positionWorld ).max( 0.0 ).add( positionWorld.sub( max ).max( 0.0 ) );
+			const weight = outside.length().smoothstep( 0.0, this._falloff ).oneMinus();
+
+			irradiance = irradiance.mul( weight );
+
+		}
+
+		builder.context.irradiance.addAssign( irradiance );
+
+	}
+
+}
+
+export { LightProbeGridNode };

BIN
examples/screenshots/webgpu_lightprobes.jpg


BIN
examples/screenshots/webgpu_lightprobes_complex.jpg


BIN
examples/screenshots/webgpu_lightprobes_sponza.jpg


+ 252 - 0
examples/webgpu_lightprobes.html

@@ -0,0 +1,252 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js webgpu - light probe volume</title>
+		<meta charset="utf-8">
+		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+		<meta property="og:title" content="three.js webgpu - light probe volume">
+		<meta property="og:type" content="website">
+		<meta property="og:url" content="https://threejs.org/examples/webgpu_lightprobes.html">
+		<meta property="og:image" content="https://threejs.org/examples/screenshots/webgpu_lightprobes.jpg">
+		<link type="text/css" rel="stylesheet" href="example.css">
+	</head>
+	<body>
+
+		<div id="info">
+			<a href="https://threejs.org/" target="_blank" rel="noopener" class="logo-link"></a>
+
+			<div class="title-wrapper">
+				<a href="https://threejs.org/" target="_blank" rel="noopener">three.js</a><span>Light Probe Volume</span>
+			</div>
+
+			<small>Position-dependent diffuse global illumination via L2 SH probe grid.</small>
+		</div>
+
+		<script type="importmap">
+			{
+				"imports": {
+					"three": "../build/three.webgpu.js",
+					"three/webgpu": "../build/three.webgpu.js",
+					"three/tsl": "../build/three.tsl.js",
+					"three/addons/": "./jsm/"
+				}
+			}
+		</script>
+
+		<script type="module">
+
+			import * as THREE from 'three/webgpu';
+
+			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+			import { Inspector } from 'three/addons/inspector/Inspector.js';
+			import { LightProbeGrid } from 'three/addons/lighting/LightProbeGrid.js';
+			import { LightProbeGridHelper } from 'three/addons/helpers/LightProbeGridHelper.js';
+
+			let camera, scene, renderer, controls;
+			let probes, probesHelper;
+
+			init();
+
+			async function init() {
+
+				// Camera
+
+				camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 0.1, 100 );
+				camera.position.set( 0, 2.5, 8 );
+
+				// Scene
+
+				scene = new THREE.Scene();
+				scene.background = new THREE.Color( 0x111111 );
+
+				// Cornell box
+
+				const wallMaterial = new THREE.MeshStandardMaterial( { color: 0xcccccc } );
+				const redMaterial = new THREE.MeshStandardMaterial( { color: 0xff0000 } );
+				const greenMaterial = new THREE.MeshStandardMaterial( { color: 0x00ff00 } );
+
+				// Floor
+				const floor = new THREE.Mesh( new THREE.PlaneGeometry( 6, 6 ), wallMaterial );
+				floor.rotation.x = - Math.PI / 2;
+				floor.receiveShadow = true;
+				scene.add( floor );
+
+				// Ceiling
+				const ceiling = new THREE.Mesh( new THREE.PlaneGeometry( 6, 6 ), wallMaterial );
+				ceiling.rotation.x = Math.PI / 2;
+				ceiling.position.y = 5;
+				ceiling.receiveShadow = true;
+				scene.add( ceiling );
+
+				// Back wall
+				const backWall = new THREE.Mesh( new THREE.PlaneGeometry( 6, 5 ), wallMaterial );
+				backWall.position.set( 0, 2.5, - 3 );
+				backWall.receiveShadow = true;
+				scene.add( backWall );
+
+				// Front wall
+				const frontWall = new THREE.Mesh( new THREE.PlaneGeometry( 6, 5 ), wallMaterial );
+				frontWall.rotation.y = Math.PI;
+				frontWall.position.set( 0, 2.5, 3 );
+				frontWall.receiveShadow = true;
+				scene.add( frontWall );
+
+				// Left wall (red)
+				const leftWall = new THREE.Mesh( new THREE.PlaneGeometry( 6, 5 ), redMaterial );
+				leftWall.rotation.y = Math.PI / 2;
+				leftWall.position.set( - 3, 2.5, 0 );
+				leftWall.receiveShadow = true;
+				scene.add( leftWall );
+
+				// Right wall (green)
+				const rightWall = new THREE.Mesh( new THREE.PlaneGeometry( 6, 5 ), greenMaterial );
+				rightWall.rotation.y = - Math.PI / 2;
+				rightWall.position.set( 3, 2.5, 0 );
+				rightWall.receiveShadow = true;
+				scene.add( rightWall );
+
+				// Objects inside the box
+
+				const objectMaterial = new THREE.MeshStandardMaterial( { color: 0xeeeeee } );
+
+				// Tall box
+				const tallBox = new THREE.Mesh( new THREE.BoxGeometry( 1.2, 2.5, 1.2 ), objectMaterial );
+				tallBox.position.set( - 0.8, 1.25, - 0.8 );
+				tallBox.rotation.y = Math.PI / 8;
+				tallBox.castShadow = true;
+				tallBox.receiveShadow = true;
+				scene.add( tallBox );
+
+				// Short box
+				const shortBox = new THREE.Mesh( new THREE.BoxGeometry( 1.2, 1.2, 1.2 ), objectMaterial );
+				shortBox.position.set( 1, 0.6, 0.5 );
+				shortBox.rotation.y = - Math.PI / 6;
+				shortBox.castShadow = true;
+				shortBox.receiveShadow = true;
+				scene.add( shortBox );
+
+				// Sphere (to show smooth GI variation)
+				const sphere = new THREE.Mesh( new THREE.SphereGeometry( 0.5, 32, 32 ), objectMaterial.clone() );
+				sphere.position.set( 1, 1.9, 0.5 );
+				sphere.castShadow = true;
+				sphere.receiveShadow = true;
+				scene.add( sphere );
+
+				// Light
+				const light = new THREE.PointLight( 0xffffff, 40 );
+				light.position.set( 0, 4.5, 0 );
+				light.castShadow = true;
+				light.shadow.mapSize.setScalar( 256 );
+				light.shadow.radius = 10;
+				light.shadow.normalBias = - 0.02;
+				scene.add( light );
+
+				// Renderer
+
+				renderer = new THREE.WebGPURenderer( { antialias: true } );
+				renderer.setPixelRatio( window.devicePixelRatio );
+				renderer.setSize( window.innerWidth, window.innerHeight );
+				renderer.setAnimationLoop( animate );
+				renderer.shadowMap.enabled = true;
+				renderer.toneMapping = THREE.ACESFilmicToneMapping;
+				renderer.toneMappingExposure = 1.0;
+				renderer.inspector = new Inspector();
+				document.body.appendChild( renderer.domElement );
+
+				await renderer.init();
+
+				// Controls
+
+				controls = new OrbitControls( camera, renderer.domElement );
+				controls.target.set( 0, 2.5, 0 );
+				controls.update();
+
+				// Bake light probe volume
+
+				async function bake( resolution ) {
+
+					if ( probes ) {
+
+						scene.remove( probes );
+						probes.dispose();
+
+					}
+
+					probes = new LightProbeGrid( 5.6, 4.7, 5.6, resolution, resolution, resolution );
+					probes.position.set( 0, 2.45, 0 );
+					await probes.bake( renderer, scene, { cubemapSize: 32, near: 0.05, far: 20 } );
+					probes.visible = params.enabled;
+					scene.add( probes );
+
+					// Update debug visualization
+
+					if ( ! probesHelper ) {
+
+						probesHelper = new LightProbeGridHelper( probes );
+						probesHelper.visible = params.showProbes;
+						scene.add( probesHelper );
+
+					} else {
+
+						probesHelper.probes = probes;
+						probesHelper.update();
+
+					}
+
+				}
+
+				const params = {
+					enabled: true,
+					showProbes: false,
+					resolution: 6
+				};
+
+				await bake( params.resolution );
+
+				// GUI
+
+				const gui = renderer.inspector.createParameters( 'Light Probes' );
+				gui.add( params, 'enabled' ).name( 'GI' ).onChange( ( value ) => {
+
+					probes.visible = value;
+
+				} );
+
+				let rebakeTimer = null;
+				gui.add( params, 'resolution', 2, 12, 1 ).name( 'Resolution' ).onChange( ( value ) => {
+
+					// Debounce so a slider drag rebakes once it settles, not every step.
+					if ( rebakeTimer !== null ) clearTimeout( rebakeTimer );
+					rebakeTimer = setTimeout( () => bake( value ), 250 );
+
+				} );
+				gui.add( params, 'showProbes' ).name( 'Show Probes' ).onChange( ( value ) => {
+
+					probesHelper.visible = value;
+
+				} );
+
+				//
+
+				window.addEventListener( 'resize', onWindowResize );
+
+			}
+
+			function onWindowResize() {
+
+				camera.aspect = window.innerWidth / window.innerHeight;
+				camera.updateProjectionMatrix();
+
+				renderer.setSize( window.innerWidth, window.innerHeight );
+
+			}
+
+			function animate() {
+
+				renderer.render( scene, camera );
+
+			}
+
+		</script>
+	</body>
+</html>

+ 405 - 0
examples/webgpu_lightprobes_complex.html

@@ -0,0 +1,405 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js webgpu - light probe volume (multi-room)</title>
+		<meta charset="utf-8">
+		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+		<meta property="og:title" content="three.js webgpu - light probe volume (multi-room)">
+		<meta property="og:type" content="website">
+		<meta property="og:url" content="https://threejs.org/examples/webgpu_lightprobes_complex.html">
+		<meta property="og:image" content="https://threejs.org/examples/screenshots/webgpu_lightprobes_complex.jpg">
+		<link type="text/css" rel="stylesheet" href="example.css">
+	</head>
+	<body>
+
+		<div id="info">
+			<a href="https://threejs.org/" target="_blank" rel="noopener" class="logo-link"></a>
+
+			<div class="title-wrapper">
+				<a href="https://threejs.org/" target="_blank" rel="noopener">three.js</a><span>Light Probe Volume (Multi-Room)</span>
+			</div>
+
+			<small>Two rooms with independent probe volumes showcasing the multi-volume scene.add() API.</small>
+		</div>
+
+		<script type="importmap">
+			{
+				"imports": {
+					"three": "../build/three.webgpu.js",
+					"three/webgpu": "../build/three.webgpu.js",
+					"three/tsl": "../build/three.tsl.js",
+					"three/addons/": "./jsm/"
+				}
+			}
+		</script>
+
+		<script type="module">
+
+			import * as THREE from 'three/webgpu';
+
+			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+			import { Inspector } from 'three/addons/inspector/Inspector.js';
+			import { LightProbeGrid } from 'three/addons/lighting/LightProbeGrid.js';
+			import { LightProbeGridHelper } from 'three/addons/helpers/LightProbeGridHelper.js';
+
+			let camera, scene, renderer, controls;
+			let probesLeft, probesRight;
+			let probesHelperLeft, probesHelperRight;
+
+			init();
+
+			async function init() {
+
+				// Camera
+
+				camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 0.1, 100 );
+				camera.position.set( 0, 2.5, 16 );
+
+				// Scene
+
+				scene = new THREE.Scene();
+				scene.background = new THREE.Color( 0x111111 );
+
+				// Materials
+
+				const wallMaterial = new THREE.MeshStandardMaterial( { color: 0xcccccc, side: THREE.BackSide } );
+				const whiteMat = new THREE.MeshStandardMaterial( { color: 0xeeeeee } );
+
+				// Room shell: x=-8 to 8, z=-4 to 4, y=0 to 5
+
+				const room = new THREE.Mesh( new THREE.BoxGeometry( 16, 5, 8 ), wallMaterial );
+				room.position.set( 0, 2.5, 0 );
+				room.receiveShadow = true;
+				scene.add( room );
+
+				const redWall = new THREE.Mesh(
+					new THREE.PlaneGeometry( 8, 5 ),
+					new THREE.MeshStandardMaterial( { color: 0xdd2200 } )
+				);
+				redWall.rotation.y = Math.PI / 2;
+				redWall.position.set( - 7.99, 2.5, 0 );
+				scene.add( redWall );
+
+				const blueWall = new THREE.Mesh(
+					new THREE.PlaneGeometry( 8, 5 ),
+					new THREE.MeshStandardMaterial( { color: 0x0044ff } )
+				);
+				blueWall.rotation.y = - Math.PI / 2;
+				blueWall.position.set( 7.99, 2.5, 0 );
+				scene.add( blueWall );
+
+				// Dividing wall at x=0 with doorway
+
+				const dividerMat = new THREE.MeshStandardMaterial( { color: 0xcccccc } );
+				const doorwayHalfGap = 1.25;
+
+				// Left section of divider (z = -4 to -1.25)
+				const dividerLeft = new THREE.Mesh(
+					new THREE.BoxGeometry( 0.3, 5, 4 - doorwayHalfGap ),
+					dividerMat
+				);
+				dividerLeft.position.set( 0, 2.5, - ( doorwayHalfGap + ( 4 - doorwayHalfGap ) / 2 ) );
+				dividerLeft.castShadow = true;
+				dividerLeft.receiveShadow = true;
+				scene.add( dividerLeft );
+
+				// Right section of divider (z = 1.25 to 4)
+				const dividerRight = new THREE.Mesh(
+					new THREE.BoxGeometry( 0.3, 5, 4 - doorwayHalfGap ),
+					dividerMat
+				);
+				dividerRight.position.set( 0, 2.5, doorwayHalfGap + ( 4 - doorwayHalfGap ) / 2 );
+				dividerRight.castShadow = true;
+				dividerRight.receiveShadow = true;
+				scene.add( dividerRight );
+
+				// Lintel above doorway
+				const lintel = new THREE.Mesh(
+					new THREE.BoxGeometry( 0.3, 1.2, doorwayHalfGap * 2 ),
+					dividerMat
+				);
+				lintel.position.set( 0, 4.4, 0 );
+				lintel.castShadow = true;
+				lintel.receiveShadow = true;
+				scene.add( lintel );
+
+				// Left room objects
+
+				// Columns
+				const columnGeom = new THREE.CylinderGeometry( 0.3, 0.3, 4, 16 );
+
+				for ( const x of [ - 6, - 2 ] ) {
+
+					for ( const z of [ - 2.5, 2.5 ] ) {
+
+						const col = new THREE.Mesh( columnGeom, whiteMat );
+						col.position.set( x, 2, z );
+						col.castShadow = true;
+						col.receiveShadow = true;
+						scene.add( col );
+
+					}
+
+				}
+
+				// Table with golden sphere
+				const tableTop = new THREE.Mesh(
+					new THREE.BoxGeometry( 2.4, 0.12, 1.4 ),
+					new THREE.MeshStandardMaterial( { color: 0x886644 } )
+				);
+				tableTop.position.set( - 4, 1.0, 0 );
+				tableTop.castShadow = true;
+				tableTop.receiveShadow = true;
+				scene.add( tableTop );
+
+				const legGeom = new THREE.BoxGeometry( 0.1, 1, 0.1 );
+
+				for ( const x of [ - 5.05, - 2.95 ] ) {
+
+					for ( const z of [ - 0.55, 0.55 ] ) {
+
+						const leg = new THREE.Mesh( legGeom, tableTop.material );
+						leg.position.set( x, 0.5, z );
+						leg.castShadow = true;
+						scene.add( leg );
+
+					}
+
+				}
+
+				const sphere = new THREE.Mesh(
+					new THREE.SphereGeometry( 0.35, 32, 32 ),
+					new THREE.MeshStandardMaterial( { color: 0xffd700, metalness: 0.3, roughness: 0.4 } )
+				);
+				sphere.position.set( - 4, 1.41, 0 );
+				sphere.castShadow = true;
+				sphere.receiveShadow = true;
+				scene.add( sphere );
+
+				// Stepped blocks near red wall
+				const step1 = new THREE.Mesh( new THREE.BoxGeometry( 1.5, 0.5, 1 ), whiteMat );
+				step1.position.set( - 7, 0.25, 0 );
+				step1.castShadow = true;
+				step1.receiveShadow = true;
+				scene.add( step1 );
+
+				const step2 = new THREE.Mesh( new THREE.BoxGeometry( 1.0, 1.0, 1 ), whiteMat );
+				step2.position.set( - 7, 0.5, 0 );
+				step2.castShadow = true;
+				step2.receiveShadow = true;
+				scene.add( step2 );
+
+				const step3 = new THREE.Mesh( new THREE.BoxGeometry( 0.5, 1.5, 1 ), whiteMat );
+				step3.position.set( - 7, 0.75, 0 );
+				step3.castShadow = true;
+				step3.receiveShadow = true;
+				scene.add( step3 );
+
+				// Right room objects
+
+				// Columns
+				for ( const x of [ 2, 6 ] ) {
+
+					for ( const z of [ - 2.5, 2.5 ] ) {
+
+						const col = new THREE.Mesh( columnGeom, whiteMat );
+						col.position.set( x, 2, z );
+						col.castShadow = true;
+						col.receiveShadow = true;
+						scene.add( col );
+
+					}
+
+				}
+
+				// Pedestal with torus knot
+				const pedestal = new THREE.Mesh(
+					new THREE.BoxGeometry( 0.8, 1.2, 0.8 ),
+					whiteMat
+				);
+				pedestal.position.set( 4, 0.6, 0 );
+				pedestal.castShadow = true;
+				pedestal.receiveShadow = true;
+				scene.add( pedestal );
+
+				const torusKnot = new THREE.Mesh(
+					new THREE.TorusKnotGeometry( 0.3, 0.1, 64, 16 ),
+					new THREE.MeshStandardMaterial( { color: 0xff44aa, metalness: 0.2, roughness: 0.5 } )
+				);
+				torusKnot.position.set( 4, 1.65, 0 );
+				torusKnot.castShadow = true;
+				torusKnot.receiveShadow = true;
+				scene.add( torusKnot );
+
+				// Tall cone sculpture
+				const sculpture = new THREE.Mesh(
+					new THREE.ConeGeometry( 0.4, 2.5, 5 ),
+					whiteMat
+				);
+				sculpture.position.set( 6.5, 1.25, - 1.5 );
+				sculpture.castShadow = true;
+				sculpture.receiveShadow = true;
+				scene.add( sculpture );
+
+				// Lights
+
+				// Warm point light in left room
+				const warmLight = new THREE.PointLight( 0xffaa44, 30 );
+				warmLight.position.set( - 4, 4.5, 0 );
+				warmLight.castShadow = true;
+				warmLight.shadow.mapSize.setScalar( 256 );
+				warmLight.shadow.radius = 12;
+				warmLight.shadow.bias = - 0.002;
+				scene.add( warmLight );
+
+				// Cool point light in right room
+				const coolLight = new THREE.PointLight( 0x88bbff, 30 );
+				coolLight.position.set( 4, 4.5, 0 );
+				coolLight.castShadow = true;
+				coolLight.shadow.mapSize.setScalar( 256 );
+				coolLight.shadow.radius = 12;
+				coolLight.shadow.bias = - 0.002;
+				scene.add( coolLight );
+
+				// Renderer
+
+				renderer = new THREE.WebGPURenderer( { antialias: true } );
+				renderer.setPixelRatio( window.devicePixelRatio );
+				renderer.setSize( window.innerWidth, window.innerHeight );
+				renderer.setAnimationLoop( animate );
+				renderer.shadowMap.enabled = true;
+				renderer.toneMapping = THREE.ACESFilmicToneMapping;
+				renderer.toneMappingExposure = 1.0;
+				renderer.inspector = new Inspector();
+				document.body.appendChild( renderer.domElement );
+
+				await renderer.init();
+
+				// Controls
+
+				controls = new OrbitControls( camera, renderer.domElement );
+				controls.target.set( 0, 2.5, 0 );
+				controls.update();
+
+				// Probe volumes
+
+				const params = {
+					enabled: true,
+					showProbes: false,
+					resolution: 6
+				};
+
+				async function bake( resolution ) {
+
+					// Remove both volumes before baking to prevent feedback
+
+					if ( probesLeft ) {
+
+						scene.remove( probesLeft );
+						probesLeft.dispose();
+
+					}
+
+					if ( probesRight ) {
+
+						scene.remove( probesRight );
+						probesRight.dispose();
+
+					}
+
+					// Bake both volumes. The `falloff` fades each volume past its
+					// boundary so the two rooms blend smoothly without bleeding into
+					// each other.
+
+					probesLeft = new LightProbeGrid( 7.8, 4.7, 7.6, resolution, resolution, resolution );
+					probesLeft.position.set( - 3.9, 2.45, 0 );
+					probesLeft.falloff = 1.0;
+					await 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.position.set( 3.9, 2.45, 0 );
+					probesRight.falloff = 1.0;
+					await probesRight.bake( renderer, scene, { cubemapSize: 32, near: 0.05, far: 20 } );
+					probesRight.visible = params.enabled;
+
+					// Add both after baking
+
+					scene.add( probesLeft );
+					scene.add( probesRight );
+
+					// Update debug visualization
+
+					if ( ! probesHelperLeft ) {
+
+						probesHelperLeft = new LightProbeGridHelper( probesLeft );
+						probesHelperLeft.visible = params.showProbes;
+						scene.add( probesHelperLeft );
+
+						probesHelperRight = new LightProbeGridHelper( probesRight );
+						probesHelperRight.visible = params.showProbes;
+						scene.add( probesHelperRight );
+
+					} else {
+
+						probesHelperLeft.probes = probesLeft;
+						probesHelperLeft.update();
+
+						probesHelperRight.probes = probesRight;
+						probesHelperRight.update();
+
+					}
+
+				}
+
+				await bake( params.resolution );
+
+				// GUI
+
+				const gui = renderer.inspector.createParameters( 'Light Probes' );
+				gui.add( params, 'enabled' ).name( 'GI' ).onChange( ( value ) => {
+
+					probesLeft.visible = value;
+					probesRight.visible = value;
+
+				} );
+
+				let rebakeTimer = null;
+				gui.add( params, 'resolution', 2, 12, 1 ).name( 'Resolution' ).onChange( ( value ) => {
+
+					// Debounce so a slider drag rebakes once it settles, not every step.
+					if ( rebakeTimer !== null ) clearTimeout( rebakeTimer );
+					rebakeTimer = setTimeout( () => bake( value ), 250 );
+
+				} );
+				gui.add( params, 'showProbes' ).name( 'Show Probes' ).onChange( ( value ) => {
+
+					probesHelperLeft.visible = value;
+					probesHelperRight.visible = value;
+
+				} );
+
+				//
+
+				window.addEventListener( 'resize', onWindowResize );
+
+			}
+
+			function onWindowResize() {
+
+				camera.aspect = window.innerWidth / window.innerHeight;
+				camera.updateProjectionMatrix();
+
+				renderer.setSize( window.innerWidth, window.innerHeight );
+
+			}
+
+			function animate() {
+
+				renderer.render( scene, camera );
+
+			}
+
+		</script>
+	</body>
+</html>

+ 407 - 0
examples/webgpu_lightprobes_sponza.html

@@ -0,0 +1,407 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js webgpu - light probe volume (Sponza)</title>
+		<meta charset="utf-8">
+		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+		<meta property="og:title" content="three.js webgpu - light probe volume (Sponza)">
+		<meta property="og:type" content="website">
+		<meta property="og:url" content="https://threejs.org/examples/webgpu_lightprobes_sponza.html">
+		<meta property="og:image" content="https://threejs.org/examples/screenshots/webgpu_lightprobes_sponza.jpg">
+		<link type="text/css" rel="stylesheet" href="example.css">
+	</head>
+	<body>
+
+		<div id="info">
+			<a href="https://threejs.org/" target="_blank" rel="noopener" class="logo-link"></a>
+
+			<div class="title-wrapper">
+				<a href="https://threejs.org/" target="_blank" rel="noopener">three.js</a><span>Light Probe Volume (Sponza)</span>
+			</div>
+
+			<small>Position-dependent diffuse global illumination in Sponza. WASD to move, mouse to look.</small>
+		</div>
+
+		<progress id="progressBar" value="0" max="100" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)"></progress>
+
+		<script type="importmap">
+			{
+				"imports": {
+					"three": "../build/three.webgpu.js",
+					"three/webgpu": "../build/three.webgpu.js",
+					"three/tsl": "../build/three.tsl.js",
+					"three/addons/": "./jsm/"
+				}
+			}
+		</script>
+
+		<script type="module">
+
+			import * as THREE from 'three/webgpu';
+
+			import { FirstPersonControls } from 'three/addons/controls/FirstPersonControls.js';
+			import { Inspector } from 'three/addons/inspector/Inspector.js';
+			import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+			import { SkyMesh } from 'three/addons/objects/SkyMesh.js';
+			import { LightProbeGrid } from 'three/addons/lighting/LightProbeGrid.js';
+			import { LightProbeGridHelper } from 'three/addons/helpers/LightProbeGridHelper.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/';
+
+			let camera, scene, renderer, controls, timer;
+			let probes = null, probesHelper = null;
+			let modelSize = null;
+			let dirLight = null, sky = null;
+
+			const sun = new THREE.Vector3();
+
+			const _box = new THREE.Box3();
+			const _size = new THREE.Vector3();
+			const _center = new THREE.Vector3();
+
+			init();
+
+			async function init() {
+
+				timer = new THREE.Timer();
+
+				camera = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.1, 1000 );
+				camera.position.set( - 10.25, 4.99, 0.40 );
+				camera.rotation.set( 1.6505, - 1.5008, 1.6507 );
+
+				scene = new THREE.Scene();
+
+				sky = new SkyMesh();
+				sky.scale.setScalar( 450000 );
+				sky.turbidity.value = 10;
+				sky.rayleigh.value = 2;
+				sky.mieCoefficient.value = 0.005;
+				sky.mieDirectionalG.value = 0.8;
+				scene.add( sky );
+
+				renderer = new THREE.WebGPURenderer( { antialias: true } );
+				renderer.setPixelRatio( Math.min( window.devicePixelRatio, 1.5 ) );
+				renderer.setSize( window.innerWidth, window.innerHeight );
+				renderer.setAnimationLoop( animate );
+				renderer.shadowMap.enabled = true;
+				renderer.toneMapping = THREE.ACESFilmicToneMapping;
+				renderer.toneMappingExposure = 1.0;
+				renderer.inspector = new Inspector();
+				document.body.appendChild( renderer.domElement );
+
+				await renderer.init();
+
+				controls = new FirstPersonControls( camera, renderer.domElement );
+				controls.movementSpeed = 2.0;
+				controls.lookSpeed = 0.16;
+
+				const progressBar = document.getElementById( 'progressBar' );
+
+				const manager = new THREE.LoadingManager();
+				manager.onProgress = function ( url, loaded, total ) {
+
+					progressBar.value = loaded / total * 100;
+
+				};
+
+				manager.onLoad = function () {
+
+					progressBar.remove();
+
+				};
+
+				const loader = new GLTFLoader( manager );
+				const modelURL = await getSponzaModelURL();
+				const gltf = await loader.loadAsync( modelURL );
+				const model = gltf.scene;
+				const embeddedLights = [];
+
+				model.traverse( ( child ) => {
+
+					if ( child.isMesh ) {
+
+						child.castShadow = true;
+						child.receiveShadow = true;
+
+					} else if ( child.isLight ) {
+
+						embeddedLights.push( child );
+
+					}
+
+				} );
+
+				for ( const light of embeddedLights ) {
+
+					if ( light.parent ) light.parent.remove( light );
+
+				}
+
+				scene.add( model );
+
+				_box.setFromObject( model );
+				modelSize = _box.getSize( _size ).clone();
+				const modelCenter = _box.getCenter( _center ).clone();
+				const targetY = modelCenter.y + modelSize.y * 0.2;
+				const lightBaseDistance = Math.max( modelSize.x, modelSize.z );
+				const probeFar = Math.max( modelSize.x, modelSize.y, modelSize.z ) * 2.0;
+				let rebakeTimer = null;
+				let isBaking = false;
+				let bakeQueued = false;
+
+				dirLight = new THREE.DirectionalLight( 0xfff2dc, 100.0 );
+				dirLight.target.position.set( modelCenter.x, targetY, modelCenter.z );
+				scene.add( dirLight.target );
+				dirLight.castShadow = true;
+				dirLight.shadow.mapSize.setScalar( 2048 );
+				const shadowExtent = Math.max( modelSize.x, modelSize.z ) * 0.7;
+				dirLight.shadow.camera.left = - shadowExtent;
+				dirLight.shadow.camera.right = shadowExtent;
+				dirLight.shadow.camera.top = shadowExtent;
+				dirLight.shadow.camera.bottom = - shadowExtent;
+				dirLight.shadow.camera.near = 0.1;
+				dirLight.shadow.camera.far = modelSize.y * 4.0;
+				scene.add( dirLight );
+
+				const params = {
+					enabled: true,
+					showProbes: false,
+					probeSize: 0.2,
+					boundsX: - 0.5,
+					boundsY: 6,
+					boundsZ: - 0.3,
+					sizeX: 21,
+					sizeY: 11,
+					sizeZ: 9,
+					countX: 10,
+					countY: 7,
+					countZ: 7,
+					bounces: 1,
+					lightAzimuth: - 45,
+					lightElevation: 55,
+					lightIntensity: 100.0,
+					shadows: true
+				};
+
+				function updateLightPosition() {
+
+					const azimuth = THREE.MathUtils.degToRad( params.lightAzimuth );
+					const elevation = THREE.MathUtils.degToRad( params.lightElevation );
+					const radius = lightBaseDistance;
+					const horizontal = Math.cos( elevation ) * radius;
+					const vertical = Math.sin( elevation ) * radius;
+
+					dirLight.position.set(
+						modelCenter.x + Math.cos( azimuth ) * horizontal,
+						targetY + vertical,
+						modelCenter.z + Math.sin( azimuth ) * horizontal
+					);
+					dirLight.target.position.set( modelCenter.x, targetY, modelCenter.z );
+					dirLight.target.updateMatrixWorld();
+
+					const phi = THREE.MathUtils.degToRad( 90 - params.lightElevation );
+					const theta = THREE.MathUtils.degToRad( params.lightAzimuth );
+					sun.setFromSphericalCoords( 1, phi, theta );
+					sky.sunPosition.value.copy( sun );
+
+				}
+
+				function scheduleRebake() {
+
+					if ( rebakeTimer !== null ) clearTimeout( rebakeTimer );
+					rebakeTimer = setTimeout( () => {
+
+						rebakeTimer = null;
+						bakeWithSettings();
+
+					}, 250 );
+
+				}
+
+				async function bakeWithSettings() {
+
+					if ( isBaking ) {
+
+						bakeQueued = true;
+						return;
+
+					}
+
+					isBaking = true;
+
+					do {
+
+						bakeQueued = false;
+
+						if ( probes ) {
+
+							scene.remove( probes );
+							probes.dispose();
+
+						}
+
+						probes = new LightProbeGrid(
+							params.sizeX, params.sizeY, params.sizeZ,
+							params.countX, params.countY, params.countZ
+						);
+						probes.position.set( params.boundsX, params.boundsY, params.boundsZ );
+						// Add to the scene before baking so bounce passes can sample the prior pass's atlas.
+						scene.add( probes );
+						// Hide the helper spheres so they don't appear in the cubemap captures.
+						if ( probesHelper ) probesHelper.visible = false;
+						await probes.bake( renderer, scene, {
+							cubemapSize: 32,
+							near: 0.05,
+							far: probeFar,
+							bounces: params.bounces
+						} );
+						probes.visible = params.enabled;
+
+						if ( ! probesHelper ) {
+
+							probesHelper = new LightProbeGridHelper( probes, params.probeSize );
+							probesHelper.visible = params.showProbes;
+							scene.add( probesHelper );
+
+						} else {
+
+							probesHelper.probes = probes;
+							probesHelper.update();
+							probesHelper.visible = params.showProbes;
+
+						}
+
+					} while ( bakeQueued );
+
+					isBaking = false;
+
+				}
+
+				updateLightPosition();
+
+				const gui = renderer.inspector.createParameters( 'Light Probes' );
+				gui.add( params, 'enabled' ).name( 'GI' ).onChange( ( value ) => {
+
+					if ( probes ) probes.visible = value;
+
+				} );
+
+				gui.add( params, 'lightAzimuth', - 180, 180, 1 ).name( 'Light Azimuth' ).onChange( () => {
+
+					updateLightPosition();
+					scheduleRebake();
+
+				} );
+				gui.add( params, 'lightElevation', 5, 85, 1 ).name( 'Light Elevation' ).onChange( () => {
+
+					updateLightPosition();
+					scheduleRebake();
+
+				} );
+				gui.add( params, 'lightIntensity', 0, 100, 0.1 ).name( 'Light Intensity' ).onChange( ( value ) => {
+
+					dirLight.intensity = value;
+					scheduleRebake();
+
+				} );
+				gui.add( params, 'shadows' ).name( 'Shadows' ).onChange( ( value ) => {
+
+					setShadowsEnabled( value );
+					scheduleRebake();
+
+				} );
+
+				gui.add( params, 'countX', 2, 32, 1 ).name( 'Probes X' ).onChange( scheduleRebake );
+				gui.add( params, 'countY', 2, 16, 1 ).name( 'Probes Y' ).onChange( scheduleRebake );
+				gui.add( params, 'countZ', 2, 16, 1 ).name( 'Probes Z' ).onChange( scheduleRebake );
+				gui.add( params, 'bounces', 0, 2, 1 ).name( 'Bounces' ).onChange( scheduleRebake );
+
+				gui.add( params, 'showProbes' ).name( 'Show Probes' ).onChange( ( value ) => {
+
+					if ( probesHelper ) probesHelper.visible = value;
+
+				} );
+				gui.add( params, 'probeSize', 0.05, 2.0, 0.05 ).name( 'Probe Size' ).onChange( ( value ) => {
+
+					if ( probesHelper ) {
+
+						scene.remove( probesHelper );
+						probesHelper.dispose();
+						probesHelper = new LightProbeGridHelper( probes, value );
+						probesHelper.visible = params.showProbes;
+						scene.add( probesHelper );
+
+					}
+
+				} );
+
+				gui.add( { log: () => {
+
+					console.log( 'position:', camera.position.x.toFixed( 2 ), camera.position.y.toFixed( 2 ), camera.position.z.toFixed( 2 ) );
+					console.log( 'rotation:', camera.rotation.x.toFixed( 4 ), camera.rotation.y.toFixed( 4 ), camera.rotation.z.toFixed( 4 ) );
+
+				} }, 'log' ).name( 'Log Camera' );
+
+				setShadowsEnabled( params.shadows );
+				await bakeWithSettings();
+
+				window.addEventListener( 'resize', onWindowResize );
+
+			}
+
+			async function getSponzaModelURL() {
+
+				const response = await fetch( MODEL_INDEX_URL );
+				const models = await response.json();
+				const sponzaInfo = models.find( ( model ) => model.name === 'Sponza' );
+
+				if ( ! sponzaInfo ) {
+
+					throw new Error( 'Sponza entry was not found in the glTF sample model index.' );
+
+				}
+
+				const variants = sponzaInfo.variants || {};
+				const variantName = variants[ 'glTF-Binary' ] || variants[ 'glTF' ] || variants[ 'glTF-Embedded' ] || Object.values( variants )[ 0 ];
+
+				if ( ! variantName ) {
+
+					throw new Error( 'Sponza has no supported glTF variant in the model index.' );
+
+				}
+
+				const variantFolder = variantName.endsWith( '.glb' ) ? 'glTF-Binary' : 'glTF';
+				return `${ SAMPLE_ASSETS_BASE_URL }${ sponzaInfo.name }/${ variantFolder }/${ variantName }`;
+
+			}
+
+			function setShadowsEnabled( enabled ) {
+
+				if ( ! renderer || ! dirLight ) return;
+
+				renderer.shadowMap.enabled = enabled;
+				dirLight.castShadow = enabled;
+
+			}
+
+			function onWindowResize() {
+
+				camera.aspect = window.innerWidth / window.innerHeight;
+				camera.updateProjectionMatrix();
+
+				renderer.setSize( window.innerWidth, window.innerHeight );
+
+			}
+
+			function animate( timestamp ) {
+
+				timer.update( timestamp );
+				controls.update( timer.getDelta() );
+				renderer.render( scene, camera );
+
+			}
+
+		</script>
+	</body>
+</html>

粤ICP备19079148号