Browse Source

webgpu_generator_city: Add LightProbeGrid global illumination.

Bakes a diffuse irradiance grid against a lightweight box proxy of the city (CityGenerator.buildProxy) so the bake stays cheap; the proxy reuses each tower's palette colour via the extracted buildingColorNode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mr.doob 3 weeks ago
parent
commit
d9ce1c273f
2 changed files with 189 additions and 11 deletions
  1. 76 7
      examples/jsm/generators/CityGenerator.js
  2. 113 4
      examples/webgpu_generator_city.html

+ 76 - 7
examples/jsm/generators/CityGenerator.js

@@ -1,5 +1,7 @@
 import {
+	BoxGeometry,
 	Group,
+	InstancedMesh,
 	Matrix4,
 	Quaternion,
 	Vector3
@@ -42,6 +44,10 @@ class CityGenerator {
 		this.layout = cityLayout( this.parameters );
 
 		this.generators = [];
+
+		// per-tower box specs recorded during build(), consumed by buildProxy()
+		this.towers = [];
+
 		this.sidewalk = new SidewalkGenerator( {
 			width: this.layout.blockW,
 			depth: this.layout.blockD,
@@ -73,6 +79,8 @@ class CityGenerator {
 		const group = new Group();
 		group.name = 'City';
 
+		this.towers = [];
+
 		const L = this.layout;
 		const random = createRandom( this.parameters.seed );
 
@@ -118,9 +126,11 @@ class CityGenerator {
 						const fw = L.innerLotX - ( 0.4 + random() * 1 );
 						const fd = L.innerLotZ - ( 0.4 + random() * 1 );
 
+						const totalHeight = 38 + tall * tall * 114; // a few tall towers, mostly mid-rise
+
 						const generator = new SkyscraperGenerator( {
 							seed: Math.floor( random() * 100000 ),
-							totalHeight: 38 + tall * tall * 114, // a few tall towers, mostly mid-rise
+							totalHeight,
 							footprint: { width: fw, depth: fd },
 							floorHeight: 3.4 + random() * 1.8,
 							bayWidth: 1.9 + random() * 2.1,
@@ -146,6 +156,9 @@ class CityGenerator {
 						group.add( building );
 						this.generators.push( generator );
 
+						// record a plain box matching this tower for the GI proxy
+						this.towers.push( { x: cx, y: curb + totalHeight / 2, z: cz, w: fw, h: totalHeight, d: fd } );
+
 					}
 
 				}
@@ -164,6 +177,46 @@ class CityGenerator {
 
 	}
 
+	/**
+	 * Builds a lightweight stand-in for the city: one instanced box per tower,
+	 * sized to match, in a single draw call. Intended for cheap global-illumination
+	 * bakes, where the detailed facades and street furniture are unnecessary and the
+	 * boxes still cast the same street shadows and bounce the same warm fill.
+	 *
+	 * Call after {@link CityGenerator#build}, which records the tower boxes.
+	 *
+	 * @return {InstancedMesh} The proxy mesh.
+	 */
+	buildProxy() {
+
+		const towers = this.towers;
+
+		// each box takes its tower's own masonry colour ( same node, keyed off world
+		// position ), so the GI bleeds the real per-building tints. mid roughness / metalness
+		// give the boxes a soft, part-glazed sky reflection.
+		const material = new MeshStandardNodeMaterial( { roughness: 0.4, metalness: 0.4 } );
+		material.colorNode = buildingColorNode( this.layout, this.parameters.seed );
+		const mesh = new InstancedMesh( new BoxGeometry( 1, 1, 1 ), material, towers.length );
+		mesh.name = 'CityProxy';
+
+		const matrix = new Matrix4();
+
+		for ( let i = 0; i < towers.length; i ++ ) {
+
+			const t = towers[ i ];
+			matrix.makeScale( t.w, t.h, t.d );
+			matrix.setPosition( t.x, t.y, t.z );
+			mesh.setMatrixAt( i, matrix );
+
+		}
+
+		mesh.castShadow = true;
+		mesh.receiveShadow = true;
+
+		return mesh;
+
+	}
+
 	// dresses the sidewalks and roadway with instanced street furniture, walking the
 	// curb edge of every block so poles, baskets, trees, pedestrians and parked cars
 	// all align to the same grid the buildings and road markings use
@@ -447,13 +500,19 @@ function gridLine( coord, period, halfWidth ) {
 }
 
 /**
- * The shared material every tower in a {@link CityGenerator} is dressed with: one flat
- * masonry colour per lot, picked from a palette by hashing the lot's grid cell.
+ * The per-tower flat masonry colour: one palette entry per lot, picked by hashing the
+ * lot's grid cell from world position. Keyed off `positionWorld`, so it colours anything
+ * standing on the city grid identically, the towers and their {@link CityGenerator#buildProxy}
+ * boxes alike.
+ *
+ * @param {Object} layout - The city layout.
+ * @param {number} [seed] - The city seed.
+ * @return {Node<vec3>} The tower colour.
  */
-function createBuildingMaterial( layout, seed = 0 ) {
+function buildingColorNode( layout, seed = 0 ) {
 
-	// every tower takes one flat colour, picked by hashing its lot — one shared material
-	// dresses the whole skyline; common tones repeat so the equal-probability pick feels real
+	// every tower takes one flat colour, picked by hashing its lot; common tones repeat
+	// so the equal-probability pick feels real
 	const palette = buildingPalette.map( hex => color( hex ) );
 
 	const periodX = layout.blockW + layout.street;
@@ -476,8 +535,18 @@ function createBuildingMaterial( layout, seed = 0 ) {
 	for ( let i = 1; i < palette.length; i ++ ) buildingBase = mix( buildingBase, palette[ i ], step( i / palette.length, pick ) );
 	buildingBase = buildingBase.mul( cellHash( 269.5, 183.3 ).mul( 0.12 ).add( 0.94 ) ); // subtle per-building brightness
 
+	return buildingBase;
+
+}
+
+/**
+ * The shared material every tower in a {@link CityGenerator} is dressed with: the per-lot
+ * {@link buildingColorNode} resolved once per vertex on a skyscraper material.
+ */
+function createBuildingMaterial( layout, seed = 0 ) {
+
 	// the pick is constant across a tower, so resolve it once per vertex ( varying )
-	return createSkyscraperMaterial( varying( buildingBase ) );
+	return createSkyscraperMaterial( varying( buildingColorNode( layout, seed ) ) );
 
 }
 

+ 113 - 4
examples/webgpu_generator_city.html

@@ -47,14 +47,26 @@
 			import { dualKawaseBloom } from 'three/addons/tsl/display/DualKawaseBloomNode.js';
 			import { ssao } from 'three/addons/tsl/display/SSAONode.js';
 			import { CityGenerator, createBuildingMaterial, createRoadMaterial } from 'three/addons/generators/CityGenerator.js';
+			import { LightProbeGrid } from 'three/addons/lighting/LightProbeGrid.js';
+			import { LightProbeGridHelper } from 'three/addons/helpers/LightProbeGridHelper.js';
 
 			let camera, scene, renderer, controls, timer;
-			let cityGroup, materials, city, renderPipeline;
+			let cityGroup, cityProxy, materials, city, renderPipeline;
 			let sky, sun, sunLight, pmremGenerator, envScene, envRenderTarget;
+			let probes, probesHelper, rebakeTimer = null;
+
+			// the irradiance grid spans the whole footprint plus the streets, rising to
+			// the mid-rise rooftops. diffuse GI is low frequency, so a coarse grid still
+			// reads the urban canyon: streets sit in shade, walls glow with terracotta bounce
+
+			const GRID_SIZE = new THREE.Vector3( 240, 120, 170 );
+			const GRID_PROBES = new THREE.Vector3( 10, 7, 8 );
 
 			const parameters = {
 				seed: 94,
-				timeOfDay: 6.4 // hours: 6 sunrise, 12 noon, 18 sunset
+				timeOfDay: 6.3, // hours: 6 sunrise, 12 noon, 18 sunset
+				gi: true,
+				showProbes: false
 			};
 
 			init();
@@ -134,6 +146,10 @@
 				materials = { building: createBuildingMaterial( city.layout, parameters.seed ) };
 				generateCity();
 
+				// bake the irradiance grid once the city and sun are in place
+
+				bakeProbes();
+
 				// screen-space ambient occlusion for the facade's contact shadows. a cheap
 				// pre-pass lays down view normals and depth, so the AO is ready before the
 				// lit scene pass and feeds in through its lighting context: the occlusion
@@ -151,6 +167,7 @@
 
 				const aoPass = ssao( prePassDepth, prePassNormal, camera );
 				aoPass.resolutionScale = 0.5; // half-res is plenty for soft contact shading
+				aoPass.samples.value = 8; // few taps: the AO only tints the soft sky fill in crevices
 				aoPass.radius.value = 1.5;
 				aoPass.intensity.value = 4;
 				aoPass.blurEnabled = false;
@@ -171,9 +188,25 @@
 				// parameters
 
 				const gui = renderer.inspector.createParameters( 'City' );
-				gui.add( parameters, 'seed', 0, 100, 1 ).onChange( generateCity );
-				gui.add( parameters, 'timeOfDay', 6, 18, 0.1 ).name( 'time of day' ).onChange( updateSun );
+				gui.add( parameters, 'seed', 0, 100, 1 ).onChange( () => {
+
+					generateCity();
+					scheduleRebake();
+
+				} );
+				gui.add( parameters, 'timeOfDay', 6, 18, 0.1 ).name( 'time of day' ).onChange( () => {
+
+					updateSun();
+					scheduleRebake();
+
+				} );
 				gui.add( renderer, 'toneMappingExposure', 0.01, 1 ).name( 'exposure' );
+				gui.add( parameters, 'gi' ).name( 'global illumination' ).onChange( ( value ) => {
+
+					if ( probes ) probes.visible = value;
+
+				} );
+				gui.add( parameters, 'showProbes' ).name( 'show probes' ).onChange( toggleProbesHelper );
 
 				window.addEventListener( 'resize', onWindowResize );
 
@@ -220,11 +253,87 @@
 			function generateCity() {
 
 				if ( cityGroup ) scene.remove( cityGroup );
+				if ( cityProxy ) {
+
+					scene.remove( cityProxy );
+					cityProxy.geometry.dispose();
+					cityProxy.material.dispose();
+
+				}
 
 				city.parameters.seed = parameters.seed;
 				cityGroup = city.build( materials );
 				scene.add( cityGroup );
 
+				// the box proxy shares the towers just built; it stands in only during the bake
+				cityProxy = city.buildProxy();
+				cityProxy.visible = false;
+				scene.add( cityProxy );
+
+			}
+
+			function bakeProbes() {
+
+				if ( probes === undefined ) {
+
+					probes = new LightProbeGrid( GRID_SIZE.x, GRID_SIZE.y, GRID_SIZE.z, GRID_PROBES.x, GRID_PROBES.y, GRID_PROBES.z );
+					probes.position.set( 0, GRID_SIZE.y / 2, 0 );
+					scene.add( probes );
+
+				}
+
+				// swap the detailed city for its box proxy, and hide the probe spheres and the
+				// sun disc, so each cubemap captures only the boxes, ground and sky fill. the
+				// DirectionalLight supplies the direct sun; a single bounce pass folds the warm
+				// stone back into the shade. the proxy is a single draw, so the bake stays cheap
+
+				if ( probesHelper ) probesHelper.visible = false;
+				cityGroup.visible = false;
+				cityProxy.visible = true;
+				sky.showSunDisc.value = false;
+
+				probes.bake( renderer, scene, { cubemapSize: 16, near: 0.1, far: 20000, bounces: 1 } );
+
+				sky.showSunDisc.value = true;
+				cityProxy.visible = false;
+				cityGroup.visible = true;
+				probes.visible = parameters.gi;
+
+				if ( probesHelper ) {
+
+					probesHelper.update();
+					probesHelper.visible = parameters.showProbes;
+
+				}
+
+			}
+
+			// re-baking walks the whole city cubemap by cubemap, so coalesce the rapid
+			// onChange calls from dragging a slider into a single bake once it settles
+
+			function scheduleRebake() {
+
+				if ( rebakeTimer !== null ) clearTimeout( rebakeTimer );
+				rebakeTimer = setTimeout( () => {
+
+					rebakeTimer = null;
+					bakeProbes();
+
+				}, 300 );
+
+			}
+
+			function toggleProbesHelper( value ) {
+
+				if ( probesHelper === undefined ) {
+
+					probesHelper = new LightProbeGridHelper( probes, 3 );
+					scene.add( probesHelper );
+
+				}
+
+				probesHelper.visible = value;
+
 			}
 
 			function onWindowResize() {

粤ICP备19079148号