Przeglądaj źródła

LightProbeGrid: Add toJSON / fromJSON serialization

Adds toJSON() / static fromJSON() so a baked grid can be saved to a JSON
file and reconstructed (texture and all) without a renderer or a re-bake.
Coefficients are stored as fixed-point integers (value * 4096, rounded),
which gzips well and stays human-readable — Sponza's 10x7x7 grid is
~17 KB raw, ~6.7 KB gzipped.

The Sponza example gains Save / Load buttons, and a new
webgl_lightprobes_sponza_baked example loads a pre-baked grid from
examples/probes/sponza-lightprobes-bake.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mr.doob 2 miesięcy temu
rodzic
commit
34309a2353

+ 1 - 0
examples/files.json

@@ -60,6 +60,7 @@
 		"webgl_lightprobes",
 		"webgl_lightprobes_complex",
 		"webgl_lightprobes_sponza",
+		"webgl_lightprobes_sponza_baked",
 		"webgl_lights_hemisphere",
 		"webgl_lights_physical",
 		"webgl_lights_spotlight",

+ 217 - 3
examples/jsm/lighting/LightProbeGrid.js

@@ -1,6 +1,8 @@
 import {
 	Box3,
 	CubeCamera,
+	Data3DTexture,
+	DataUtils,
 	FloatType,
 	HalfFloatType,
 	LinearFilter,
@@ -19,6 +21,14 @@ import {
 	WebGLRenderTarget
 } from 'three';
 
+// Number of SH coefficients stored per probe: 9 L2 bands x 3 colour channels.
+const COEFFICIENTS_PER_PROBE = 27;
+
+// Fixed-point scale used by toJSON(): coefficients are stored as
+// Math.round( value * SERIALIZE_SCALE ) integers. 4096 keeps the quantization
+// error around 1e-4 (negligible for irradiance) while gzipping very well.
+const SERIALIZE_SCALE = 4096;
+
 // Shared fullscreen-quad scene / camera
 let _scene = null;
 let _camera = null;
@@ -75,8 +85,10 @@ const ATLAS_PADDING = 1;
  * ```
  * 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.
+ * Baking happens almost entirely on the GPU — cubemap rendering, SH
+ * projection, and texture packing all run as GPU passes; only a small
+ * `9 x totalProbes` SH coefficient buffer is read back to the CPU at the
+ * end of baking so the result can be serialized via {@link LightProbeGrid#toJSON}.
  *
  * @three_import import { LightProbeGrid } from 'three/addons/lighting/LightProbeGrid.js';
  */
@@ -155,6 +167,20 @@ class LightProbeGrid extends Object3D {
 		 */
 		this.texture = null;
 
+		/**
+		 * The raw baked SH coefficients, laid out as `27` floats per probe
+		 * (`9` L2 bands times `3` colour channels), in
+		 * `ix + iy * nx + iz * nx * ny` probe order.
+		 *
+		 * Populated by {@link LightProbeGrid#bake} (read back from the GPU) and
+		 * by {@link LightProbeGrid.fromJSON}. This is the unpadded, minimal
+		 * representation used by {@link LightProbeGrid#toJSON}.
+		 *
+		 * @type {?Float32Array}
+		 * @default null
+		 */
+		this.coefficients = null;
+
 		/**
 		 * Internal render target for GPU-resident baking.
 		 *
@@ -354,6 +380,35 @@ class LightProbeGrid extends Object3D {
 
 		renderer.shadowMap.autoUpdate = currentShadowAutoUpdate;
 
+		// Read the final pass's SH coefficients back to the CPU so the bake can
+		// be serialized (see toJSON). The batch target is a compact 9 x totalProbes
+		// RGBA buffer; only the RGB of each of the 9 texels per row carries data.
+		{
+
+			const batchPixels = new Float32Array( 9 * totalProbes * 4 );
+			renderer.readRenderTargetPixels( batchTarget, 0, 0, 9, totalProbes, batchPixels );
+
+			const coefficients = new Float32Array( totalProbes * COEFFICIENTS_PER_PROBE );
+
+			for ( let p = 0; p < totalProbes; p ++ ) {
+
+				for ( let k = 0; k < 9; k ++ ) {
+
+					const src = ( p * 9 + k ) * 4;
+					const dst = p * COEFFICIENTS_PER_PROBE + k * 3;
+
+					coefficients[ dst ] = batchPixels[ src ];
+					coefficients[ dst + 1 ] = batchPixels[ src + 1 ];
+					coefficients[ dst + 2 ] = batchPixels[ src + 2 ];
+
+				}
+
+			}
+
+			this.coefficients = coefficients;
+
+		}
+
 		// Restore renderer state
 		renderer.setRenderTarget( currentRenderTarget );
 		renderer.setViewport( _currentViewport );
@@ -377,6 +432,9 @@ class LightProbeGrid extends Object3D {
 
 		if ( this._renderTarget !== null ) return;
 
+		// Re-baking a deserialized grid: drop the standalone atlas texture.
+		if ( this.texture !== null ) this.texture.dispose();
+
 		const res = this.resolution;
 		const nx = res.x, ny = res.y, nz = res.z;
 
@@ -404,12 +462,168 @@ class LightProbeGrid extends Object3D {
 
 		if ( this._renderTarget !== null ) {
 
+			// Baked grid: the texture is owned by the render target.
 			this._renderTarget.dispose();
 			this._renderTarget = null;
-			this.texture = null;
+
+		} else if ( this.texture !== null ) {
+
+			// Deserialized grid: the texture is a standalone Data3DTexture.
+			this.texture.dispose();
+
+		}
+
+		this.texture = null;
+
+	}
+
+	/**
+	 * Serializes the baked grid to a JSON-compatible object.
+	 *
+	 * Only the `27` SH coefficients per probe are stored — the padded atlas
+	 * layout used on the GPU is fully reconstructed by {@link LightProbeGrid.fromJSON}.
+	 * Coefficients are stored as fixed-point integers (`value * scale`,
+	 * rounded). This is simple, human-readable, and — because the many
+	 * near-zero higher-order coefficients collapse to short tokens — gzips
+	 * better than a binary layout: a `7 x 7 x 3` grid is a ~16 KB JSON file
+	 * that gzips to ~6.7 KB.
+	 *
+	 * Requires the grid to have been baked (see {@link LightProbeGrid#bake}).
+	 *
+	 * @return {Object} A JSON-compatible representation of the grid.
+	 */
+	toJSON() {
+
+		if ( this.coefficients === null ) {
+
+			throw new Error( 'THREE.LightProbeGrid: toJSON() requires a baked grid. Call bake() first.' );
+
+		}
+
+		const source = this.coefficients;
+		const data = new Array( source.length );
+
+		for ( let i = 0; i < source.length; i ++ ) {
+
+			data[ i ] = Math.round( source[ i ] * SERIALIZE_SCALE );
 
 		}
 
+		return {
+			metadata: {
+				version: 1,
+				type: 'LightProbeGrid',
+				generator: 'LightProbeGrid.toJSON'
+			},
+			width: this.width,
+			height: this.height,
+			depth: this.depth,
+			resolution: [ this.resolution.x, this.resolution.y, this.resolution.z ],
+			position: [ this.position.x, this.position.y, this.position.z ],
+			coefficients: {
+				scale: SERIALIZE_SCALE,
+				data: data
+			}
+		};
+
+	}
+
+	/**
+	 * Reconstructs a baked grid from data produced by {@link LightProbeGrid#toJSON}.
+	 *
+	 * The returned grid carries a ready-to-use {@link Data3DTexture} and does
+	 * not need a renderer — baking can be skipped entirely.
+	 *
+	 * @param {Object} json - The serialized grid.
+	 * @return {LightProbeGrid} The reconstructed grid.
+	 */
+	static fromJSON( json ) {
+
+		const [ nx, ny, nz ] = json.resolution;
+
+		const grid = new LightProbeGrid( json.width, json.height, json.depth, nx, ny, nz );
+		grid.position.fromArray( json.position );
+		grid.updateBoundingBox();
+
+		const { scale, data } = json.coefficients;
+		const coefficients = new Float32Array( data.length );
+
+		for ( let i = 0; i < data.length; i ++ ) {
+
+			coefficients[ i ] = data[ i ] / scale;
+
+		}
+
+		grid.coefficients = coefficients;
+		grid.texture = grid._createDataTexture( coefficients );
+
+		return grid;
+
+	}
+
+	/**
+	 * Builds the padded atlas {@link Data3DTexture} from the raw SH
+	 * coefficients (`27` per probe). The atlas layout matches the one produced
+	 * by {@link LightProbeGrid#bake} so the same shader code can sample either.
+	 *
+	 * @private
+	 * @param {Float32Array} coefficients - Raw coefficients (`27` per probe).
+	 * @return {Data3DTexture} The atlas texture.
+	 */
+	_createDataTexture( coefficients ) {
+
+		const res = this.resolution;
+		const nx = res.x, ny = res.y, nz = res.z;
+		const sliceTexels = nx * ny;
+
+		const paddedSlices = nz + 2 * ATLAS_PADDING;
+		const atlasDepth = 7 * paddedSlices;
+
+		const atlas = new Uint16Array( sliceTexels * atlasDepth * 4 );
+
+		for ( let t = 0; t < 7; t ++ ) {
+
+			for ( let s = 0; s < paddedSlices; s ++ ) {
+
+				// Padding slices clamp to the first / last data slice.
+				const iz = Math.min( Math.max( s - ATLAS_PADDING, 0 ), nz - 1 );
+				const layer = t * paddedSlices + s;
+
+				for ( let iy = 0; iy < ny; iy ++ ) {
+
+					for ( let ix = 0; ix < nx; ix ++ ) {
+
+						const probeIndex = ix + iy * nx + iz * sliceTexels;
+						const src = probeIndex * COEFFICIENTS_PER_PROBE;
+						const dst = ( layer * sliceTexels + iy * nx + ix ) * 4;
+
+						for ( let ch = 0; ch < 4; ch ++ ) {
+
+							// Channels map sequentially onto the 27 coefficients;
+							// the very last channel (sub-volume 6, alpha) is unused.
+							const ci = t * 4 + ch;
+							atlas[ dst + ch ] = ci < COEFFICIENTS_PER_PROBE ? DataUtils.toHalfFloat( coefficients[ src + ci ] ) : 0;
+
+						}
+
+					}
+
+				}
+
+			}
+
+		}
+
+		const texture = new Data3DTexture( atlas, nx, ny, atlasDepth );
+		texture.format = RGBAFormat;
+		texture.type = HalfFloatType;
+		texture.minFilter = LinearFilter;
+		texture.magFilter = LinearFilter;
+		texture.generateMipmaps = false;
+		texture.needsUpdate = true;
+
+		return texture;
+
 	}
 
 }

Plik diff jest za duży
+ 0 - 0
examples/probes/sponza-lightprobes-bake.json


BIN
examples/screenshots/webgl_lightprobes_sponza.jpg


BIN
examples/screenshots/webgl_lightprobes_sponza_baked.jpg


+ 81 - 6
examples/webgl_lightprobes_sponza.html

@@ -170,7 +170,7 @@
 					countY: 7,
 					countZ: 7,
 					bounces: 1,
-					lightAzimuth: - 45,
+					lightAzimuth: - 165,
 					lightElevation: 55,
 					lightIntensity: 100.0,
 					shadows: true
@@ -314,7 +314,7 @@
 					if ( probesHelper ) probesHelper.visible = value;
 
 				} );
-				gui.add( params, 'probeSize', 0.05, 2.0, 0.05 ).name( 'Probe Size' ).onChange( ( value ) => {
+				gui.add( params, 'probeSize', 0.05, 0.5, 0.05 ).name( 'Probe Size' ).onChange( ( value ) => {
 
 					if ( probesHelper ) {
 
@@ -328,12 +328,87 @@
 
 				} );
 
-				gui.add( { log: () => {
+				gui.add( { load: loadProbes }, 'load' ).name( 'Load Bake' );
+				gui.add( { save: saveProbes }, 'save' ).name( 'Save Bake' );
 
-					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 ) );
+				function applyProbes( newProbes ) {
 
-				} }, 'log' ).name( 'Log Camera' );
+					if ( probes ) {
+
+						scene.remove( probes );
+						probes.dispose();
+
+					}
+
+					probes = newProbes;
+					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();
+
+					}
+
+				}
+
+				function saveProbes() {
+
+					if ( ! probes ) return;
+
+					const json = JSON.stringify( probes.toJSON() );
+					const blob = new Blob( [ json ], { type: 'application/json' } );
+					const url = URL.createObjectURL( blob );
+
+					const link = document.createElement( 'a' );
+					link.href = url;
+					link.download = 'sponza-lightprobes.json';
+					link.click();
+
+					URL.revokeObjectURL( url );
+
+					console.log( `LightProbeGrid: serialized ${ ( blob.size / 1024 ).toFixed( 1 ) } KB` );
+
+				}
+
+				function loadProbes() {
+
+					const input = document.createElement( 'input' );
+					input.type = 'file';
+					input.accept = 'application/json,.json';
+					input.onchange = async () => {
+
+						const file = input.files[ 0 ];
+						if ( ! file ) return;
+
+						const json = JSON.parse( await file.text() );
+						const loaded = LightProbeGrid.fromJSON( json );
+
+						params.sizeX = loaded.width;
+						params.sizeY = loaded.height;
+						params.sizeZ = loaded.depth;
+						params.boundsX = loaded.position.x;
+						params.boundsY = loaded.position.y;
+						params.boundsZ = loaded.position.z;
+						params.countX = loaded.resolution.x;
+						params.countY = loaded.resolution.y;
+						params.countZ = loaded.resolution.z;
+						gui.controllersRecursive().forEach( ( c ) => c.updateDisplay() );
+
+						applyProbes( loaded );
+
+					};
+
+					input.click();
+
+				}
 
 				setShadowsEnabled( params.shadows );
 				await bakeWithSettings();

+ 249 - 0
examples/webgl_lightprobes_sponza_baked.html

@@ -0,0 +1,249 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js webgl - light probe volume (Sponza, baked)</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 webgl - light probe volume (Sponza, baked)">
+		<meta property="og:type" content="website">
+		<meta property="og:url" content="https://threejs.org/examples/webgl_lightprobes_sponza_baked.html">
+		<meta property="og:image" content="https://threejs.org/examples/screenshots/webgl_lightprobes_sponza_baked.jpg">
+		<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, baked)<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/';
+			const PROBES_URL = './probes/sponza-lightprobes-bake.json';
+
+			// Light settings that matched the bake.
+			const LIGHT_AZIMUTH = - 165;
+			const LIGHT_ELEVATION = 55;
+			const LIGHT_INTENSITY = 100.0;
+
+			let camera, scene, renderer, controls, timer;
+
+			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();
+
+				const 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, probesJSON ] = await Promise.all( [
+					loader.loadAsync( modelURL ),
+					fetch( PROBES_URL ).then( ( r ) => r.json() )
+				] );
+
+				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 );
+				const 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 azimuth = THREE.MathUtils.degToRad( LIGHT_AZIMUTH );
+				const elevation = THREE.MathUtils.degToRad( LIGHT_ELEVATION );
+				const horizontal = Math.cos( elevation ) * lightBaseDistance;
+				const vertical = Math.sin( elevation ) * lightBaseDistance;
+
+				const dirLight = new THREE.DirectionalLight( 0xfff2dc, LIGHT_INTENSITY );
+				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 );
+				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 sun = new THREE.Vector3();
+				const phi = THREE.MathUtils.degToRad( 90 - LIGHT_ELEVATION );
+				const theta = THREE.MathUtils.degToRad( LIGHT_AZIMUTH );
+				sun.setFromSphericalCoords( 1, phi, theta );
+				sky.material.uniforms[ 'sunPosition' ].value.copy( sun );
+
+				const probes = LightProbeGrid.fromJSON( probesJSON );
+				scene.add( probes );
+
+				const params = {
+					enabled: true,
+					showProbes: false
+				};
+
+				const probesHelper = new LightProbeGridHelper( probes, 0.1 );
+				probesHelper.visible = params.showProbes;
+				scene.add( probesHelper );
+
+				const gui = new GUI();
+				gui.add( params, 'enabled' ).name( 'GI' ).onChange( ( value ) => {
+
+					probes.visible = value;
+
+				} );
+				gui.add( params, 'showProbes' ).name( 'Show Probes' ).onChange( ( value ) => {
+
+					probesHelper.visible = value;
+
+				} );
+
+				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 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>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików

粤ICP备19079148号