Просмотр исходного кода

LightProbeGrid: Add position-dependent diffuse Global Illumination (#33125)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Mugen87 <michael.herzog@human-interactive.org>
mrdoob 4 дней назад
Родитель
Сommit
1da9cef7a6

+ 3 - 0
examples/files.json

@@ -57,6 +57,9 @@
 		"webgl_lensflares",
 		"webgl_lightprobe",
 		"webgl_lightprobe_cubecamera",
+		"webgl_lightprobes",
+		"webgl_lightprobes_complex",
+		"webgl_lightprobes_sponza",
 		"webgl_lights_hemisphere",
 		"webgl_lights_physical",
 		"webgl_lights_spotlight",

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

@@ -0,0 +1,221 @@
+import {
+	InstancedBufferAttribute,
+	InstancedMesh,
+	Matrix4,
+	ShaderMaterial,
+	SphereGeometry,
+	Vector3
+} from 'three';
+
+/**
+ * Visualizes an {@link LightProbeGrid} by rendering a sphere at each
+ * probe position, shaded with the probe's L1 spherical harmonics.
+ *
+ * Uses a single `InstancedMesh` draw call for all probes.
+ *
+ * ```js
+ * const helper = new LightProbeGridHelper( probes );
+ * scene.add( helper );
+ * ```
+ *
+ * @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 ShaderMaterial( {
+
+			uniforms: {
+
+				probesSH: { value: null },
+				probesResolution: { value: new Vector3() },
+
+			},
+
+			vertexShader: /* glsl */`
+
+				attribute vec3 instanceUVW;
+
+				varying vec3 vWorldNormal;
+				varying vec3 vUVW;
+
+				void main() {
+
+					vUVW = instanceUVW;
+					vWorldNormal = normalize( mat3( modelMatrix ) * normal );
+					gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4( position, 1.0 );
+
+				}
+
+			`,
+
+			fragmentShader: /* glsl */`
+
+				precision highp sampler3D;
+
+				uniform sampler3D probesSH;
+				uniform vec3 probesResolution;
+
+				varying vec3 vWorldNormal;
+				varying vec3 vUVW;
+
+				void main() {
+
+					// Atlas UV mapping — must match lightprobes_pars_fragment.glsl.js
+					float nz          = probesResolution.z;
+					float paddedSlices = nz + 2.0;
+					float atlasDepth  = 7.0 * paddedSlices;
+					float uvZBase     = vUVW.z * nz + 1.0;
+
+					vec4 s0 = texture( probesSH, vec3( vUVW.xy, ( uvZBase                       ) / atlasDepth ) );
+					vec4 s1 = texture( probesSH, vec3( vUVW.xy, ( uvZBase +       paddedSlices   ) / atlasDepth ) );
+					vec4 s2 = texture( probesSH, vec3( vUVW.xy, ( uvZBase + 2.0 * paddedSlices   ) / atlasDepth ) );
+					vec4 s3 = texture( probesSH, vec3( vUVW.xy, ( uvZBase + 3.0 * paddedSlices   ) / atlasDepth ) );
+					vec4 s4 = texture( probesSH, vec3( vUVW.xy, ( uvZBase + 4.0 * paddedSlices   ) / atlasDepth ) );
+					vec4 s5 = texture( probesSH, vec3( vUVW.xy, ( uvZBase + 5.0 * paddedSlices   ) / atlasDepth ) );
+					vec4 s6 = texture( probesSH, vec3( vUVW.xy, ( uvZBase + 6.0 * paddedSlices   ) / atlasDepth ) );
+
+					// Unpack 9 vec3 SH L2 coefficients
+
+					vec3 c0 = s0.xyz;
+					vec3 c1 = vec3( s0.w, s1.xy );
+					vec3 c2 = vec3( s1.zw, s2.x );
+					vec3 c3 = s2.yzw;
+					vec3 c4 = s3.xyz;
+					vec3 c5 = vec3( s3.w, s4.xy );
+					vec3 c6 = vec3( s4.zw, s5.x );
+					vec3 c7 = s5.yzw;
+					vec3 c8 = s6.xyz;
+
+					vec3 n = normalize( vWorldNormal );
+
+					float x = n.x, y = n.y, z = n.z;
+
+					// band 0
+					vec3 result = c0 * 0.886227;
+
+					// band 1,
+					result += c1 * 2.0 * 0.511664 * y;
+					result += c2 * 2.0 * 0.511664 * z;
+					result += c3 * 2.0 * 0.511664 * x;
+
+					// band 2,
+					result += c4 * 2.0 * 0.429043 * x * y;
+					result += c5 * 2.0 * 0.429043 * y * z;
+					result += c6 * ( 0.743125 * z * z - 0.247708 );
+					result += c7 * 2.0 * 0.429043 * x * z;
+					result += c8 * 0.429043 * ( x * x - y * y );
+
+					gl_FragColor = vec4( max( result, vec3( 0.0 ) ), 1.0 );
+
+					#include <tonemapping_fragment>
+					#include <colorspace_fragment>
+
+				}
+
+			`
+
+		} );
+
+		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';
+
+		this.update();
+
+	}
+
+	/**
+	 * Rebuilds instance matrices and UVW attributes from the current probe grid.
+	 * 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 lightprobes_pars_fragment.glsl.js)
+					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 ) );
+
+		// Update texture uniforms
+
+		this.material.uniforms.probesSH.value = probes.texture;
+		this.material.uniforms.probesResolution.value.copy( probes.resolution );
+
+	}
+
+	/**
+	 * 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 };

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

@@ -0,0 +1,651 @@
+import {
+	Box3,
+	CubeCamera,
+	FloatType,
+	HalfFloatType,
+	LinearFilter,
+	Mesh,
+	NearestFilter,
+	Object3D,
+	OrthographicCamera,
+	PlaneGeometry,
+	RGBAFormat,
+	Scene,
+	ShaderMaterial,
+	Vector3,
+	Vector4,
+	WebGL3DRenderTarget,
+	WebGLCubeRenderTarget,
+	WebGLRenderTarget
+} from 'three';
+
+// Shared fullscreen-quad scene / camera
+let _scene = null;
+let _camera = null;
+let _mesh = null;
+
+// SH projection material (depends on cubemapSize)
+let _shMaterial = null;
+let _lastCubemapSize = 0;
+
+// Repack materials (one per output sub-volume / texture index)
+let _repackMaterials = null;
+
+// Cached bake resources
+let _cubeRenderTarget = null;
+let _cubeCamera = null;
+let _cachedCubemapSize = 0;
+let _cachedNear = 0;
+let _cachedFar = 0;
+
+// Cached batch render target
+let _batchTarget = null;
+let _batchTargetProbes = 0;
+
+// Reusable temp objects
+const _position = /*@__PURE__*/ new Vector3();
+const _size = /*@__PURE__*/ new Vector3();
+const _savedViewport = /*@__PURE__*/ new Vector4();
+const _savedScissor = /*@__PURE__*/ new Vector4();
+
+// Number of padding texels added at each boundary of every sub-volume in the atlas.
+const ATLAS_PADDING = 1;
+
+/**
+ * A 3D grid of L2 Spherical Harmonic irradiance probes that provides
+ * position-dependent diffuse global illumination.
+ *
+ * All seven packed SH sub-volumes are stored in a **single** RGBA
+ * `WebGL3DRenderTarget` using a texture-atlas layout along the Z axis.
+ * Each sub-volume occupies `( nz + 2 )` atlas slices: one padding slice at
+ * each end (a copy of the nearest edge data slice) to prevent color bleeding
+ * when the hardware trilinear filter reads across a sub-volume boundary.
+ *
+ * Atlas layout (nz = resolution.z, PADDING = 1):
+ * ```
+ *   slice   0              : padding  (copy of sub-volume 0, data slice 0)
+ *   slices  1 … nz         : sub-volume 0 data
+ *   slice   nz + 1         : padding  (copy of sub-volume 0, data slice nz-1)
+ *   slice   nz + 2         : padding  (copy of sub-volume 1, data slice 0)
+ *   slices  nz+3 … 2*nz+2  : sub-volume 1 data
+ *   …
+ * ```
+ * Total atlas depth = `7 * ( nz + 2 )`.
+ *
+ * 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';
+ */
+class LightProbeGrid extends Object3D {
+
+	/**
+	 * 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();
+
+		/**
+		 * This flag can be used for type testing.
+		 *
+		 * @type {boolean}
+		 * @readonly
+		 * @default true
+		 */
+		this.isLightProbeGrid = true;
+
+		/**
+		 * 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();
+
+		/**
+		 * The single RGBA atlas 3D texture storing all seven packed SH sub-volumes.
+		 *
+		 * @type {?Data3DTexture}
+		 * @default null
+		 */
+		this.texture = null;
+
+		/**
+		 * Internal render target for GPU-resident baking.
+		 *
+		 * @private
+		 * @type {?WebGL3DRenderTarget}
+		 * @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. Fully GPU-resident with zero CPU readback.
+	 *
+	 * @param {WebGLRenderer} 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.
+	 */
+	bake( renderer, scene, options = {} ) {
+
+		const { cubeRenderTarget, cubeCamera } = _ensureBakeResources( options );
+
+		this._ensureTextures();
+		this.updateBoundingBox();
+
+		// Prevent feedback: temporarily hide the volume during baking
+		this.visible = false;
+
+		const res = this.resolution;
+		const totalProbes = res.x * res.y * res.z;
+
+		// Batch render target for SH coefficients: 9 pixels wide, one row per probe
+		const batchTarget = _ensureBatchTarget( totalProbes );
+
+		// Save renderer state
+		const savedRenderTarget = renderer.getRenderTarget();
+		renderer.getViewport( _savedViewport );
+		renderer.getScissor( _savedScissor );
+		const savedScissorTest = renderer.getScissorTest();
+
+		// Clear pooled batch target so skipped probes read as zero
+		batchTarget.scissorTest = false;
+		batchTarget.viewport.set( 0, 0, 9, totalProbes );
+		renderer.setRenderTarget( batchTarget );
+		renderer.clear();
+
+		// const t0 = performance.now();
+
+		// Phase 1: Render cubemaps and project to SH into batch target
+		// Note: set viewport/scissor on the render target directly to avoid pixel ratio scaling
+		batchTarget.scissorTest = true;
+
+		// Disable shadow map auto-update during bake — lights don't move between probes.
+		// Force one shadow update on the first render so maps are initialized.
+		const savedShadowAutoUpdate = renderer.shadowMap.autoUpdate;
+		renderer.shadowMap.autoUpdate = false;
+		renderer.shadowMap.needsUpdate = true;
+
+		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 );
+					cubeCamera.update( renderer, scene );
+
+					// SH projection
+					_shMaterial.uniforms.envMap.value = cubeRenderTarget.texture;
+					_mesh.material = _shMaterial;
+					batchTarget.viewport.set( 0, probeIndex, 9, 1 );
+					batchTarget.scissor.set( 0, probeIndex, 9, 1 );
+					renderer.setRenderTarget( batchTarget );
+					renderer.render( _scene, _camera );
+
+				}
+
+			}
+
+		}
+
+		renderer.shadowMap.autoUpdate = savedShadowAutoUpdate;
+
+		// Phase 2: Repack SH data from batch target into the atlas 3D texture (GPU-to-GPU).
+		//
+		// For each of the 7 packed sub-volumes (texture index t) we write:
+		//   - A leading padding slice  (copy of data slice iz = 0)
+		//   - All nz data slices       (iz = 0 … nz-1)
+		//   - A trailing padding slice (copy of data slice iz = nz-1)
+		//
+		// In the atlas the slices for sub-volume t occupy the range:
+		//   [ t * paddedSlices, t * paddedSlices + paddedSlices - 1 ]
+		// where paddedSlices = nz + 2 * ATLAS_PADDING.
+
+		_ensureRepackResources();
+
+		const paddedSlices = res.z + 2 * ATLAS_PADDING;
+		const rt = this._renderTarget;
+		rt.scissorTest = false;
+		rt.viewport.set( 0, 0, res.x, res.y );
+
+		for ( let t = 0; t < 7; t ++ ) {
+
+			_repackMaterials[ t ].uniforms.batchTexture.value = batchTarget.texture;
+			_repackMaterials[ t ].uniforms.resolution.value.copy( res );
+
+			// Write data slices
+			for ( let iz = 0; iz < res.z; iz ++ ) {
+
+				_repackMaterials[ t ].uniforms.sliceZ.value = iz;
+				_mesh.material = _repackMaterials[ t ];
+				renderer.setRenderTarget( rt, t * paddedSlices + ATLAS_PADDING + iz );
+				renderer.render( _scene, _camera );
+
+			}
+
+			// Leading padding: copy of data slice iz = 0
+			_repackMaterials[ t ].uniforms.sliceZ.value = 0;
+			_mesh.material = _repackMaterials[ t ];
+			renderer.setRenderTarget( rt, t * paddedSlices );
+			renderer.render( _scene, _camera );
+
+			// Trailing padding: copy of data slice iz = nz - 1
+			_repackMaterials[ t ].uniforms.sliceZ.value = res.z - 1;
+			_mesh.material = _repackMaterials[ t ];
+			renderer.setRenderTarget( rt, t * paddedSlices + ATLAS_PADDING + res.z );
+			renderer.render( _scene, _camera );
+
+		}
+
+		// Restore renderer state
+		renderer.setRenderTarget( savedRenderTarget );
+		renderer.setViewport( _savedViewport );
+		renderer.setScissor( _savedScissor );
+		renderer.setScissorTest( savedScissorTest );
+
+		// console.log( `LightProbeGrid: bake complete ${ ( performance.now() - t0 ).toFixed( 1 ) }ms` );
+
+		this.visible = true;
+
+	}
+
+	/**
+	 * Ensures the atlas 3D render target 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 );
+
+		const rt = new WebGL3DRenderTarget( nx, ny, atlasDepth, {
+			format: RGBAFormat,
+			type: FloatType,
+			minFilter: LinearFilter,
+			magFilter: LinearFilter,
+			generateMipmaps: false,
+			depthBuffer: false
+		} );
+
+		this._renderTarget = rt;
+		this.texture = rt.texture;
+
+	}
+
+	/**
+	 * Frees GPU resources.
+	 */
+	dispose() {
+
+		if ( this._renderTarget !== null ) {
+
+			this._renderTarget.dispose();
+			this._renderTarget = null;
+			this.texture = null;
+
+		}
+
+	}
+
+}
+
+// Internal: Ensure the shared fullscreen-quad scene exists
+function _ensureScene() {
+
+	if ( _scene === null ) {
+
+		_camera = new OrthographicCamera( - 1, 1, 1, - 1, 0, 1 );
+		_mesh = new Mesh( new PlaneGeometry( 2, 2 ) );
+		_scene = new Scene();
+		_scene.add( _mesh );
+
+	}
+
+}
+
+// Internal: Ensure GPU resources for SH projection are created
+function _ensureGPUResources( cubemapSize ) {
+
+	_ensureScene();
+
+	// Recreate material when cubemap size changes
+	if ( cubemapSize !== _lastCubemapSize ) {
+
+		if ( _shMaterial !== null ) _shMaterial.dispose();
+
+		_shMaterial = new ShaderMaterial( {
+			precision: 'highp',
+			defines: {
+				CUBEMAP_SIZE: cubemapSize
+			},
+			uniforms: {
+				envMap: { value: null }
+			},
+			vertexShader: /* glsl */`
+				void main() {
+					gl_Position = vec4( position.xy, 0.0, 1.0 );
+				}
+			`,
+			fragmentShader: /* glsl */`
+				#include <common>
+
+				uniform samplerCube envMap;
+
+				void main() {
+
+					int coefIndex = int( gl_FragCoord.x );
+
+					vec3 accum0 = vec3( 0.0 );
+					vec3 accum1 = vec3( 0.0 );
+					vec3 accum2 = vec3( 0.0 );
+					vec3 accum3 = vec3( 0.0 );
+					vec3 accum4 = vec3( 0.0 );
+					vec3 accum5 = vec3( 0.0 );
+					vec3 accum6 = vec3( 0.0 );
+					vec3 accum7 = vec3( 0.0 );
+					vec3 accum8 = vec3( 0.0 );
+					float totalWeight = 0.0;
+					float pixelSize = 2.0 / float( CUBEMAP_SIZE );
+
+					for ( int face = 0; face < 6; face ++ ) {
+
+						for ( int iy = 0; iy < CUBEMAP_SIZE; iy ++ ) {
+
+							for ( int ix = 0; ix < CUBEMAP_SIZE; ix ++ ) {
+
+								// WebGL cubemaps have a left-handed orientation (flip = -1)
+								float col = ( float( ix ) + 0.5 ) * pixelSize - 1.0;
+								float row = 1.0 - ( float( iy ) + 0.5 ) * pixelSize;
+
+								vec3 coord;
+
+								if ( face == 0 ) coord = vec3( 1.0, row, -col );
+								else if ( face == 1 ) coord = vec3( -1.0, row, col );
+								else if ( face == 2 ) coord = vec3( col, 1.0, -row );
+								else if ( face == 3 ) coord = vec3( col, -1.0, row );
+								else if ( face == 4 ) coord = vec3( col, row, 1.0 );
+								else coord = vec3( -col, row, -1.0 );
+
+								float lengthSq = dot( coord, coord );
+								float weight = 4.0 / ( sqrt( lengthSq ) * lengthSq );
+								totalWeight += weight;
+
+								vec3 dir = normalize( coord );
+								vec3 cw = textureCube( envMap, coord ).rgb * weight;
+
+								// band 0
+								accum0 += cw * 0.282095;
+
+								// band 1
+								accum1 += cw * ( 0.488603 * dir.y );
+								accum2 += cw * ( 0.488603 * dir.z );
+								accum3 += cw * ( 0.488603 * dir.x );
+
+								// band 2
+								accum4 += cw * ( 1.092548 * ( dir.x * dir.y ) );
+								accum5 += cw * ( 1.092548 * ( dir.y * dir.z ) );
+								accum6 += cw * ( 0.315392 * ( 3.0 * dir.z * dir.z - 1.0 ) );
+								accum7 += cw * ( 1.092548 * ( dir.x * dir.z ) );
+								accum8 += cw * ( 0.546274 * ( dir.x * dir.x - dir.y * dir.y ) );
+
+							}
+
+						}
+
+					}
+
+					float norm = 4.0 * PI / totalWeight;
+
+					vec3 accum;
+					if ( coefIndex == 0 ) accum = accum0;
+					else if ( coefIndex == 1 ) accum = accum1;
+					else if ( coefIndex == 2 ) accum = accum2;
+					else if ( coefIndex == 3 ) accum = accum3;
+					else if ( coefIndex == 4 ) accum = accum4;
+					else if ( coefIndex == 5 ) accum = accum5;
+					else if ( coefIndex == 6 ) accum = accum6;
+					else if ( coefIndex == 7 ) accum = accum7;
+					else accum = accum8;
+
+					gl_FragColor = vec4( accum * norm, 1.0 );
+
+				}
+			`
+		} );
+
+		_lastCubemapSize = cubemapSize;
+
+	}
+
+}
+
+// Internal: Ensure GPU resources for repacking SH into the atlas 3D texture
+function _ensureRepackResources() {
+
+	if ( _repackMaterials !== null ) return;
+
+	_ensureScene();
+
+	// Create 7 materials, one per output texture packing
+	// Texture 0: (c0.r, c0.g, c0.b, c1.r)
+	// Texture 1: (c1.g, c1.b, c2.r, c2.g)
+	// Texture 2: (c2.b, c3.r, c3.g, c3.b)
+	// Texture 3: (c4.r, c4.g, c4.b, c5.r)
+	// Texture 4: (c5.g, c5.b, c6.r, c6.g)
+	// Texture 5: (c6.b, c7.r, c7.g, c7.b)
+	// Texture 6: (c8.r, c8.g, c8.b, 0.0)
+
+	const repackVertexShader = /* glsl */`
+		void main() {
+			gl_Position = vec4( position.xy, 0.0, 1.0 );
+		}
+	`;
+
+	_repackMaterials = [];
+
+	for ( let t = 0; t < 7; t ++ ) {
+
+		_repackMaterials[ t ] = new ShaderMaterial( {
+			precision: 'highp',
+			defines: {
+				TEXTURE_INDEX: t
+			},
+			uniforms: {
+				batchTexture: { value: null },
+				resolution: { value: new Vector3() },
+				sliceZ: { value: 0 }
+			},
+			vertexShader: repackVertexShader,
+			fragmentShader: /* glsl */`
+				uniform sampler2D batchTexture;
+				uniform vec3 resolution;
+				uniform int sliceZ;
+
+				void main() {
+
+					int ix = int( gl_FragCoord.x );
+					int iy = int( gl_FragCoord.y );
+					int iz = sliceZ;
+
+					int probeIndex = ix + iy * int( resolution.x ) + iz * int( resolution.x ) * int( resolution.y );
+
+					// Read 9 SH coefficients from the batch texture row
+					vec4 c0 = texelFetch( batchTexture, ivec2( 0, probeIndex ), 0 );
+					vec4 c1 = texelFetch( batchTexture, ivec2( 1, probeIndex ), 0 );
+					vec4 c2 = texelFetch( batchTexture, ivec2( 2, probeIndex ), 0 );
+					vec4 c3 = texelFetch( batchTexture, ivec2( 3, probeIndex ), 0 );
+					vec4 c4 = texelFetch( batchTexture, ivec2( 4, probeIndex ), 0 );
+					vec4 c5 = texelFetch( batchTexture, ivec2( 5, probeIndex ), 0 );
+					vec4 c6 = texelFetch( batchTexture, ivec2( 6, probeIndex ), 0 );
+					vec4 c7 = texelFetch( batchTexture, ivec2( 7, probeIndex ), 0 );
+					vec4 c8 = texelFetch( batchTexture, ivec2( 8, probeIndex ), 0 );
+
+					// Pack into the output format for this texture index
+					#if TEXTURE_INDEX == 0
+						gl_FragColor = vec4( c0.rgb, c1.r );
+					#elif TEXTURE_INDEX == 1
+						gl_FragColor = vec4( c1.gb, c2.rg );
+					#elif TEXTURE_INDEX == 2
+						gl_FragColor = vec4( c2.b, c3.rgb );
+					#elif TEXTURE_INDEX == 3
+						gl_FragColor = vec4( c4.rgb, c5.r );
+					#elif TEXTURE_INDEX == 4
+						gl_FragColor = vec4( c5.gb, c6.rg );
+					#elif TEXTURE_INDEX == 5
+						gl_FragColor = vec4( c6.b, c7.rgb );
+					#else
+						gl_FragColor = vec4( c8.rgb, 0.0 );
+					#endif
+
+				}
+			`
+		} );
+
+	}
+
+}
+
+// Internal: Ensure cube render target and camera exist with the right parameters
+function _ensureBakeResources( options ) {
+
+	const {
+		cubemapSize = 8,
+		near = 0.1,
+		far = 100
+	} = options;
+
+	if ( _cubeRenderTarget === null || cubemapSize !== _cachedCubemapSize || near !== _cachedNear || far !== _cachedFar ) {
+
+		if ( _cubeRenderTarget !== null ) _cubeRenderTarget.dispose();
+
+		_cubeRenderTarget = new WebGLCubeRenderTarget( cubemapSize, { type: HalfFloatType } );
+		_cubeCamera = new CubeCamera( near, far, _cubeRenderTarget );
+		_cachedCubemapSize = cubemapSize;
+		_cachedNear = near;
+		_cachedFar = far;
+
+	}
+
+	_ensureGPUResources( cubemapSize );
+
+	return { cubeRenderTarget: _cubeRenderTarget, cubeCamera: _cubeCamera };
+
+}
+
+function _ensureBatchTarget( totalProbes ) {
+
+	if ( _batchTarget === null || _batchTargetProbes !== totalProbes ) {
+
+		if ( _batchTarget !== null ) _batchTarget.dispose();
+
+		_batchTarget = new WebGLRenderTarget( 9, totalProbes, {
+			type: FloatType,
+			minFilter: NearestFilter,
+			magFilter: NearestFilter,
+			depthBuffer: false
+		} );
+
+		_batchTargetProbes = totalProbes;
+
+	}
+
+	return _batchTarget;
+
+}
+
+export { LightProbeGrid };

BIN
examples/screenshots/webgl_lightprobes.jpg


BIN
examples/screenshots/webgl_lightprobes_complex.jpg


BIN
examples/screenshots/webgl_lightprobes_sponza.jpg


BIN
examples/screenshots/webgpu_xr_native_layers.jpg


+ 238 - 0
examples/webgl_lightprobes.html

@@ -0,0 +1,238 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js webgl - 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">
+		<link type="text/css" rel="stylesheet" href="main.css">
+	</head>
+	<body>
+
+		<div id="info">
+			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> - light probe volume<br/>
+			Position-dependent diffuse global illumination via L1 SH probe grid
+		</div>
+
+		<script type="importmap">
+			{
+				"imports": {
+					"three": "../build/three.module.js",
+					"three/addons/": "./jsm/"
+				}
+			}
+		</script>
+
+		<script type="module">
+
+			import * as THREE from 'three';
+
+			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';
+
+			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 );
+
+				// 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 } );
+				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;
+				document.body.appendChild( renderer.domElement );
+
+				// Controls
+
+				controls = new OrbitControls( camera, renderer.domElement );
+				controls.target.set( 0, 2.5, 0 );
+				controls.update();
+
+				// Bake light probe volume
+
+				async function bakeWithResolution( 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 );
+					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 bakeWithResolution( params.resolution );
+
+				// GUI
+
+				const gui = new GUI();
+				gui.add( params, 'enabled' ).name( 'GI' ).onChange( ( value ) => {
+
+					probes.visible = value;
+
+				} );
+				gui.add( params, 'resolution', 2, 12, 1 ).name( 'Resolution' ).onFinishChange( ( value ) => {
+
+					bakeWithResolution( value );
+
+				} );
+				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>

+ 387 - 0
examples/webgl_lightprobes_complex.html

@@ -0,0 +1,387 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js webgl - 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">
+		<link type="text/css" rel="stylesheet" href="main.css">
+	</head>
+	<body>
+
+		<div id="info">
+			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> - light probe volume (multi-room)<br/>
+			Two rooms with independent probe volumes showcasing multi-volume scene.add() API
+		</div>
+
+		<script type="importmap">
+			{
+				"imports": {
+					"three": "../build/three.module.js",
+					"three/addons/": "./jsm/"
+				}
+			}
+		</script>
+
+		<script type="module">
+
+			import * as THREE from 'three';
+
+			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';
+
+			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 } );
+
+				// Left room (warm/red): x=-8 to 0, z=-4 to 4, y=0 to 5
+
+				const leftRoom = new THREE.Mesh( new THREE.BoxGeometry( 8, 5, 8 ), wallMaterial );
+				leftRoom.position.set( - 4, 2.5, 0 );
+				leftRoom.receiveShadow = true;
+				scene.add( leftRoom );
+
+				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 );
+
+				// Right room (cool/blue): x=0 to 8, z=-4 to 4, y=0 to 5
+
+				const rightRoom = new THREE.Mesh( new THREE.BoxGeometry( 8, 5, 8 ), wallMaterial.clone() );
+				rightRoom.position.set( 4, 2.5, 0 );
+				rightRoom.receiveShadow = true;
+				scene.add( rightRoom );
+
+				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( - 6.5, 0.25, 2 );
+				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( - 6.5, 0.5, 2 );
+				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( - 6.5, 0.75, 2 );
+				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.5, 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 ),
+					new THREE.MeshStandardMaterial( { color: 0xeeeeee } )
+				);
+				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.WebGLRenderer( { 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;
+				document.body.appendChild( renderer.domElement );
+
+				// 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 bakeWithResolution( resolution ) {
+
+					// Left volume
+
+					if ( probesLeft ) {
+
+						scene.remove( probesLeft );
+						probesLeft.dispose();
+
+					}
+
+					probesLeft = new LightProbeGrid( 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;
+					scene.add( probesLeft );
+
+					// Right volume
+
+					if ( probesRight ) {
+
+						scene.remove( probesRight );
+						probesRight.dispose();
+
+					}
+
+					probesRight = new LightProbeGrid( 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;
+					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 bakeWithResolution( params.resolution );
+
+				// GUI
+
+				const gui = new GUI();
+				gui.add( params, 'enabled' ).name( 'GI' ).onChange( ( value ) => {
+
+					probesLeft.visible = value;
+					probesRight.visible = value;
+
+				} );
+				gui.add( params, 'resolution', 2, 12, 1 ).name( 'Resolution' ).onFinishChange( ( value ) => {
+
+					bakeWithResolution( value );
+
+				} );
+				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>

+ 382 - 0
examples/webgl_lightprobes_sponza.html

@@ -0,0 +1,382 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js webgl - 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">
+		<link type="text/css" rel="stylesheet" href="main.css">
+	</head>
+	<body>
+
+		<div id="info">
+			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> - light probe volume (Sponza)<br/>
+			WASD to move, mouse to look
+		</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.module.js",
+					"three/addons/": "./jsm/"
+				}
+			}
+		</script>
+
+		<script type="module">
+
+			import * as THREE from 'three';
+
+			import { FirstPersonControls } from 'three/addons/controls/FirstPersonControls.js';
+			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';
+
+			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, 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 Sky();
+				sky.scale.setScalar( 450000 );
+				scene.add( sky );
+
+				const skyUniforms = sky.material.uniforms;
+				skyUniforms[ 'turbidity' ].value = 10;
+				skyUniforms[ 'rayleigh' ].value = 2;
+				skyUniforms[ 'mieCoefficient' ].value = 0.005;
+				skyUniforms[ 'mieDirectionalG' ].value = 0.8;
+
+				renderer = new THREE.WebGLRenderer( { 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;
+				document.body.appendChild( renderer.domElement );
+
+				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, 50.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.25,
+					boundsX: - 0.5,
+					boundsY: 6,
+					boundsZ: - 0.3,
+					sizeX: 19,
+					sizeY: 11,
+					sizeZ: 7,
+					countX: 7,
+					countY: 7,
+					countZ: 3,
+					lightAzimuth: - 45,
+					lightElevation: 55,
+					lightIntensity: 50.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.material.uniforms[ '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 );
+						probes.bake( renderer, scene, {
+							cubemapSize: 32,
+							near: 0.05,
+							far: probeFar
+						} );
+						probes.visible = params.enabled;
+						scene.add( probes );
+
+						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 = new GUI();
+				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, 50, 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, '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>

+ 62 - 1
src/renderers/WebGLRenderer.js

@@ -130,6 +130,7 @@ class WebGLRenderer {
 
 		const uintClearColor = new Uint32Array( 4 );
 		const intClearColor = new Int32Array( 4 );
+		const objectPosition = new Vector3();
 
 		let currentRenderList = null;
 		let currentRenderState = null;
@@ -1835,6 +1836,10 @@ class WebGLRenderer {
 
 					if ( object.autoUpdate === true ) object.update( camera );
 
+				} else if ( object.isLightProbeGrid ) {
+
+					currentRenderState.pushLightProbeGrid( object );
+
 				} else if ( object.isLight ) {
 
 					currentRenderState.pushLight( object );
@@ -2150,7 +2155,7 @@ class WebGLRenderer {
 
 			const lightsStateVersion = lights.state.version;
 
-			const parameters = programCache.getParameters( material, lights.state, shadowsArray, scene, object );
+			const parameters = programCache.getParameters( material, lights.state, shadowsArray, scene, object, currentRenderState.state.lightProbeGridArray );
 			const programCacheKey = programCache.getProgramCacheKey( parameters );
 
 			let programs = materialProperties.programs;
@@ -2249,6 +2254,8 @@ class WebGLRenderer {
 
 			}
 
+			materialProperties.lightProbeGrid = currentRenderState.state.lightProbeGridArray.length > 0;
+
 			materialProperties.currentProgram = program;
 			materialProperties.uniformsList = null;
 
@@ -2292,6 +2299,30 @@ class WebGLRenderer {
 
 		}
 
+		function findLightProbeGrid( volumes, object ) {
+
+			if ( volumes.length === 0 ) return null;
+
+			if ( volumes.length === 1 ) {
+
+				return volumes[ 0 ].texture !== null ? volumes[ 0 ] : null;
+
+			}
+
+			objectPosition.setFromMatrixPosition( object.matrixWorld );
+
+			for ( let i = 0, l = volumes.length; i < l; i ++ ) {
+
+				const v = volumes[ i ];
+
+				if ( v.texture !== null && v.boundingBox.containsPoint( objectPosition ) ) return v;
+
+			}
+
+			return null;
+
+		}
+
 		function setProgram( camera, scene, geometry, material, object ) {
 
 			if ( scene.isScene !== true ) scene = _emptyScene; // scene could be a Mesh, Line, Points, ...
@@ -2448,6 +2479,10 @@ class WebGLRenderer {
 
 					needsProgramChange = true;
 
+				} else if ( !! materialProperties.lightProbeGrid !== ( currentRenderState.state.lightProbeGridArray.length > 0 ) ) {
+
+					needsProgramChange = true;
+
 				}
 
 			} else {
@@ -2498,6 +2533,19 @@ class WebGLRenderer {
 
 			}
 
+			if ( materialProperties.needsLights ) {
+
+				const objectVolume = findLightProbeGrid( currentRenderState.state.lightProbeGridArray, object );
+
+				if ( materialProperties.lightProbeGrid !== objectVolume ) {
+
+					materialProperties.lightProbeGrid = objectVolume;
+					refreshMaterial = true;
+
+				}
+
+			}
+
 			if ( refreshProgram || _currentCamera !== camera ) {
 
 				// common camera uniforms
@@ -2677,6 +2725,19 @@ class WebGLRenderer {
 
 				materials.refreshMaterialUniforms( m_uniforms, material, _pixelRatio, _height, currentRenderState.state.transmissionRenderTarget[ camera.id ] );
 
+				// light probe volume
+
+				if ( materialProperties.needsLights && materialProperties.lightProbeGrid ) {
+
+					const volume = materialProperties.lightProbeGrid;
+
+					m_uniforms.probesSH.value = volume.texture;
+					m_uniforms.probesMin.value.copy( volume.boundingBox.min );
+					m_uniforms.probesMax.value.copy( volume.boundingBox.max );
+					m_uniforms.probesResolution.value.copy( volume.resolution );
+
+				}
+
 				WebGLUniforms.upload( _gl, getUniformList( materialProperties ), m_uniforms, textures );
 
 			}

+ 2 - 0
src/renderers/shaders/ShaderChunk.js

@@ -54,6 +54,7 @@ import lights_physical_pars_fragment from './ShaderChunk/lights_physical_pars_fr
 import lights_fragment_begin from './ShaderChunk/lights_fragment_begin.glsl.js';
 import lights_fragment_maps from './ShaderChunk/lights_fragment_maps.glsl.js';
 import lights_fragment_end from './ShaderChunk/lights_fragment_end.glsl.js';
+import lightprobes_pars_fragment from './ShaderChunk/lightprobes_pars_fragment.glsl.js';
 import logdepthbuf_fragment from './ShaderChunk/logdepthbuf_fragment.glsl.js';
 import logdepthbuf_pars_fragment from './ShaderChunk/logdepthbuf_pars_fragment.glsl.js';
 import logdepthbuf_pars_vertex from './ShaderChunk/logdepthbuf_pars_vertex.glsl.js';
@@ -181,6 +182,7 @@ export const ShaderChunk = {
 	lights_fragment_begin: lights_fragment_begin,
 	lights_fragment_maps: lights_fragment_maps,
 	lights_fragment_end: lights_fragment_end,
+	lightprobes_pars_fragment: lightprobes_pars_fragment,
 	logdepthbuf_fragment: logdepthbuf_fragment,
 	logdepthbuf_pars_fragment: logdepthbuf_pars_fragment,
 	logdepthbuf_pars_vertex: logdepthbuf_pars_vertex,

+ 80 - 0
src/renderers/shaders/ShaderChunk/lightprobes_pars_fragment.glsl.js

@@ -0,0 +1,80 @@
+export default /* glsl */`
+#ifdef USE_LIGHT_PROBES_GRID
+
+// Single atlas 3D texture that stores all 7 SH sub-volumes stacked along Z.
+// Atlas depth = 7 * ( nz + 2 ) where nz = probesResolution.z.
+// Each sub-volume occupies ( nz + 2 ) slices: 1 padding + nz data + 1 padding.
+// Padding is a copy of the first / last data slice and prevents color bleeding
+// when the hardware linear filter reads across a sub-volume boundary.
+uniform highp sampler3D probesSH;
+
+uniform vec3 probesMin;
+uniform vec3 probesMax;
+uniform vec3 probesResolution;
+
+vec3 getLightProbeGridIrradiance( vec3 worldPos, vec3 worldNormal ) {
+
+	vec3 res = probesResolution;
+	vec3 gridRange = probesMax - probesMin;
+	vec3 resMinusOne = res - 1.0;
+	vec3 probeSpacing = gridRange / resMinusOne;
+
+	// Offset sample position along normal by half a probe spacing
+	vec3 samplePos = worldPos + worldNormal * probeSpacing * 0.5;
+	vec3 uvw = clamp( ( samplePos - probesMin ) / gridRange, 0.0, 1.0 );
+
+	// Remap to texel centers of the probe grid (XY and Z)
+	uvw = uvw * resMinusOne / res + 0.5 / res;
+
+	// Atlas UV mapping along Z:
+	//   paddedSlices = nz + 2  (1 padding texel at each end of every sub-volume)
+	//   atlasDepth   = 7 * paddedSlices
+	//   For sub-volume t the first DATA texel sits at atlas slice t*paddedSlices + 1.
+	//   Given probe-grid texel-centre UVZ = ( iz + 0.5 ) / nz the atlas UV is:
+	//     atlasUvZ = ( uvw.z * nz + t * paddedSlices + 1 ) / atlasDepth
+	//
+	// uvZBase encodes the nz-scaled Z plus the intra-volume offset (+ 1 for padding),
+	// so adding t*paddedSlices steps to each successive sub-volume.
+	float nz          = res.z;
+	float paddedSlices = nz + 2.0;
+	float atlasDepth  = 7.0 * paddedSlices;
+	float uvZBase     = uvw.z * nz + 1.0;
+
+	vec4 s0 = texture( probesSH, vec3( uvw.xy, ( uvZBase                       ) / atlasDepth ) );
+	vec4 s1 = texture( probesSH, vec3( uvw.xy, ( uvZBase +       paddedSlices   ) / atlasDepth ) );
+	vec4 s2 = texture( probesSH, vec3( uvw.xy, ( uvZBase + 2.0 * paddedSlices   ) / atlasDepth ) );
+	vec4 s3 = texture( probesSH, vec3( uvw.xy, ( uvZBase + 3.0 * paddedSlices   ) / atlasDepth ) );
+	vec4 s4 = texture( probesSH, vec3( uvw.xy, ( uvZBase + 4.0 * paddedSlices   ) / atlasDepth ) );
+	vec4 s5 = texture( probesSH, vec3( uvw.xy, ( uvZBase + 5.0 * paddedSlices   ) / atlasDepth ) );
+	vec4 s6 = texture( probesSH, vec3( uvw.xy, ( uvZBase + 6.0 * paddedSlices   ) / atlasDepth ) );
+
+	// Unpack 9 vec3 SH L2 coefficients
+	vec3 c0 = s0.xyz;
+	vec3 c1 = vec3( s0.w, s1.xy );
+	vec3 c2 = vec3( s1.zw, s2.x );
+	vec3 c3 = s2.yzw;
+	vec3 c4 = s3.xyz;
+	vec3 c5 = vec3( s3.w, s4.xy );
+	vec3 c6 = vec3( s4.zw, s5.x );
+	vec3 c7 = s5.yzw;
+	vec3 c8 = s6.xyz;
+
+	// Evaluate L2 irradiance
+	float x = worldNormal.x, y = worldNormal.y, z = worldNormal.z;
+
+	vec3 result = c0 * 0.886227;
+	result += c1 * 2.0 * 0.511664 * y;
+	result += c2 * 2.0 * 0.511664 * z;
+	result += c3 * 2.0 * 0.511664 * x;
+	result += c4 * 2.0 * 0.429043 * x * y;
+	result += c5 * 2.0 * 0.429043 * y * z;
+	result += c6 * ( 0.743125 * z * z - 0.247708 );
+	result += c7 * 2.0 * 0.429043 * x * z;
+	result += c8 * 0.429043 * ( x * x - y * y );
+
+	return max( result, vec3( 0.0 ) );
+
+}
+
+#endif
+`;

+ 8 - 0
src/renderers/shaders/ShaderChunk/lights_fragment_begin.glsl.js

@@ -194,6 +194,14 @@ IncidentLight directLight;
 
 	#endif
 
+	#ifdef USE_LIGHT_PROBES_GRID
+
+		vec3 probeWorldPos = ( ( vec4( geometryPosition, 1.0 ) - viewMatrix[ 3 ] ) * viewMatrix ).xyz;
+		vec3 probeWorldNormal = inverseTransformDirection( geometryNormal, viewMatrix );
+		irradiance += getLightProbeGridIrradiance( probeWorldPos, probeWorldNormal );
+
+	#endif
+
 #endif
 
 #if defined( RE_IndirectSpecular )

+ 2 - 0
src/renderers/shaders/ShaderChunk/lights_pars_begin.glsl.js

@@ -211,4 +211,6 @@ float getSpotAttenuation( const in float coneCosine, const in float penumbraCosi
 	}
 
 #endif
+
+#include <lightprobes_pars_fragment>
 `;

+ 7 - 1
src/renderers/shaders/UniformsLib.js

@@ -1,5 +1,6 @@
 import { Color } from '../../math/Color.js';
 import { Vector2 } from '../../math/Vector2.js';
+import { Vector3 } from '../../math/Vector3.js';
 import { Matrix3 } from '../../math/Matrix3.js';
 
 // Uniforms library for shared webgl shaders
@@ -191,7 +192,12 @@ const UniformsLib = {
 		} },
 
 		ltc_1: { value: null },
-		ltc_2: { value: null }
+		ltc_2: { value: null },
+
+		probesSH: { value: null },
+		probesMin: { value: /*@__PURE__*/ new Vector3() },
+		probesMax: { value: /*@__PURE__*/ new Vector3() },
+		probesResolution: { value: /*@__PURE__*/ new Vector3() }
 
 	},
 

+ 2 - 0
src/renderers/webgl/WebGLProgram.js

@@ -756,6 +756,8 @@ function WebGLProgram( renderer, cacheKey, parameters, bindingStates ) {
 
 			parameters.numLightProbes > 0 ? '#define USE_LIGHT_PROBES' : '',
 
+			parameters.numLightProbeGrids > 0 ? '#define USE_LIGHT_PROBES_GRID' : '',
+
 			parameters.decodeVideoTexture ? '#define DECODE_VIDEO_TEXTURE' : '',
 			parameters.decodeVideoTextureEmissive ? '#define DECODE_VIDEO_TEXTURE_EMISSIVE' : '',
 

+ 5 - 1
src/renderers/webgl/WebGLPrograms.js

@@ -53,7 +53,7 @@ function WebGLPrograms( renderer, environments, extensions, capabilities, bindin
 
 	}
 
-	function getParameters( material, lights, shadows, scene, object ) {
+	function getParameters( material, lights, shadows, scene, object, lightProbeGrids ) {
 
 		const fog = scene.fog;
 		const geometry = object.geometry;
@@ -344,6 +344,8 @@ function WebGLPrograms( renderer, environments, extensions, capabilities, bindin
 
 			numLightProbes: lights.numLightProbes,
 
+			numLightProbeGrids: lightProbeGrids.length,
+
 			numClippingPlanes: clipping.numPlanes,
 			numClipIntersection: clipping.numIntersection,
 
@@ -581,6 +583,8 @@ function WebGLPrograms( renderer, environments, extensions, capabilities, bindin
 			_programLayers.enable( 20 );
 		if ( parameters.alphaToCoverage )
 			_programLayers.enable( 21 );
+		if ( parameters.numLightProbeGrids > 0 )
+			_programLayers.enable( 22 );
 
 		array.push( _programLayers.mask );
 

+ 11 - 1
src/renderers/webgl/WebGLRenderStates.js

@@ -6,6 +6,7 @@ function WebGLRenderState( extensions ) {
 
 	const lightsArray = [];
 	const shadowsArray = [];
+	const lightProbeGridArray = [];
 
 	function init( camera ) {
 
@@ -13,6 +14,7 @@ function WebGLRenderState( extensions ) {
 
 		lightsArray.length = 0;
 		shadowsArray.length = 0;
+		lightProbeGridArray.length = 0;
 
 	}
 
@@ -28,6 +30,12 @@ function WebGLRenderState( extensions ) {
 
 	}
 
+	function pushLightProbeGrid( volume ) {
+
+		lightProbeGridArray.push( volume );
+
+	}
+
 	function setupLights() {
 
 		lights.setup( lightsArray );
@@ -43,6 +51,7 @@ function WebGLRenderState( extensions ) {
 	const state = {
 		lightsArray: lightsArray,
 		shadowsArray: shadowsArray,
+		lightProbeGridArray: lightProbeGridArray,
 
 		camera: null,
 
@@ -59,7 +68,8 @@ function WebGLRenderState( extensions ) {
 		setupLightsView: setupLightsView,
 
 		pushLight: pushLight,
-		pushShadow: pushShadow
+		pushShadow: pushShadow,
+		pushLightProbeGrid: pushLightProbeGrid
 	};
 
 }

粤ICP备19079148号