Răsfoiți Sursa

webgpu_generator_city: Improve street furniture realism, remove SSAO pass.

Car fleet gets sedan/SUV/taxi body types with wheel arches, alloys and
door seams; people, trees, benches, hydrants and trashcans refined.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mr.doob 3 săptămâni în urmă
părinte
comite
2895e758fe

+ 14 - 3
examples/jsm/generators/city/BenchGenerator.js

@@ -7,7 +7,7 @@ import {
 } from 'three';
 
 import { MeshStandardNodeMaterial } from 'three/webgpu';
-import { attribute, color, float, select, varying } from 'three/tsl';
+import { attribute, color, float, floor, fract, mix, mx_fractal_noise_float, positionGeometry, select, sin, varying, vec3 } from 'three/tsl';
 
 import { mergeGeometries } from '../../utils/BufferGeometryUtils.js';
 
@@ -142,9 +142,20 @@ function createBenchMaterial() {
 	const partId = varying( attribute( 'partId', 'float' ) ).setInterpolation( InterpolationSamplingType.FLAT, InterpolationSamplingMode.EITHER );
 	const isWood = partId.equal( WOOD );
 
+	const p = positionGeometry;
+
+	// timber: grain streaks stretched along the slats, each slat cut from its own
+	// board ( a tone hashed from the slat's row position ) and greyed by weathering
+	const grain = mx_fractal_noise_float( p.mul( vec3( 3, 60, 60 ) ), 2 ).mul( 0.5 ).add( 0.5 );
+	const board = fract( sin( floor( p.z.mul( 10.5 ) ).add( floor( p.y.mul( 8.0 ) ).mul( 7.3 ) ).mul( 17.53 ) ).mul( 43758.5453 ) );
+	const weather = mx_fractal_noise_float( p.mul( 1.6 ), 2 ).mul( 0.5 ).add( 0.5 );
+
+	const stained = mix( color( 0x5e412a ), color( 0x8a6242 ), grain.mul( 0.7 ).add( board.mul( 0.3 ) ) );
+	const wood = mix( stained, color( 0x7c7468 ), weather.mul( 0.45 ) ); // sun-bleached grey where the stain has worn
+
 	const material = new MeshStandardNodeMaterial();
-	material.colorNode = select( isWood, color( 0x6b4a2e ), color( 0x2a2a28 ) ); // warm stained timber, dark cast iron
-	material.roughnessNode = select( isWood, float( 0.75 ), float( 0.45 ) );
+	material.colorNode = select( isWood, wood, color( 0x232322 ) ); // weathered timber, dark cast iron
+	material.roughnessNode = select( isWood, grain.mul( 0.25 ).add( 0.65 ), float( 0.5 ) );
 	material.metalnessNode = select( isWood, float( 0 ), float( 0.6 ) );
 
 	return material;

+ 322 - 90
examples/jsm/generators/city/CarGenerator.js

@@ -1,28 +1,33 @@
 import {
 	BoxGeometry,
 	BufferAttribute,
-	CircleGeometry,
-	CylinderGeometry,
+	Color,
 	Group,
 	InstancedMesh,
 	InterpolationSamplingMode,
 	InterpolationSamplingType,
+	LatheGeometry,
+	Vector2,
 	Vector3
 } from 'three';
 
 import { MeshStandardNodeMaterial } from 'three/webgpu';
-import { attribute, color, float, select, varying } from 'three/tsl';
+import { atan, attribute, color, float, mix, positionGeometry, select, smoothstep, uniform, uniformArray, varying, vec2 } from 'three/tsl';
 
 import { mergeGeometries } from '../../utils/BufferGeometryUtils.js';
 import { LoftGeometry } from '../../geometries/LoftGeometry.js';
 
 /**
- * A smooth low-poly car: a single body shell lofted through a row of cross sections
- * that swell from the bumpers, rise into a tapered greenhouse over the cabin and
- * tuck back down over the trunk, dressed with four fat tyres, dark glazing and lit
- * head / tail lamps. The shell is built once and shared, but cars are grouped by
- * paint colour so each colour gets its own cheap material ( body paint swapped,
- * everything else fixed ) branching on a baked `partId`.
+ * A low-poly car fleet: smooth body shells lofted through a row of cross sections,
+ * with real wheel-arch cutouts, an asymmetric hood / cabin / trunk, alloy wheels,
+ * door seams and smoked glazing. Two body types ( sedan and SUV ) are mixed
+ * deterministically across the fleet, and the taxi colour gets its own sedan with
+ * a roof sign, so a parked row reads as different vehicles rather than one mould.
+ *
+ * Each geometry is built once per type and shared; cars are grouped by paint and
+ * type so each group is a single instanced draw with one cheap material that
+ * carves paint, glass, tyres, chrome and lamps out of a baked `partId` plus
+ * canonical-space masks.
  *
  * The canonical model stands with its wheels on `y = 0`, centred in X / Z, facing
  * `+Z`, so a placement whose local `+Z` faces the road parks it nose-out.
@@ -38,9 +43,9 @@ class CarGenerator {
 
 		this.parameters = Object.assign( {}, CarGenerator.defaults, parameters );
 
-		this.geometry = null;
+		this.geometries = new Map(); // one shared shell per body type
+		this.materials = new Map(); // one material per paint colour and type
 		this.mesh = null;
-		this.materials = new Map(); // one material per paint colour
 
 	}
 
@@ -48,31 +53,44 @@ class CarGenerator {
 
 		this.dispose();
 
-		if ( this.geometry === null ) this.geometry = buildCarGeometry( this.parameters );
+		// bucket the fleet by body type and paint so each pairing becomes a
+		// single instanced draw. the taxi colour always gets the signed sedan;
+		// the rest split deterministically between sedan and SUV
+		const buckets = new Map();
+
+		for ( let i = 0; i < cars.length; i ++ ) {
 
-		// bucket the fleet by paint so each colour becomes a single instanced draw
-		const byColor = new Map();
-		for ( const car of cars ) {
+			const car = cars[ i ];
+			const type = car.color === CarGenerator.taxiColor ? 'taxi' : ( ( ( i * 2654435761 ) >>> 0 ) % 100 < 42 ? 'suv' : 'sedan' );
+			const key = type + '|' + car.color;
 
-			if ( ! byColor.has( car.color ) ) byColor.set( car.color, [] );
-			byColor.get( car.color ).push( car.matrix );
+			if ( ! buckets.has( key ) ) buckets.set( key, { type, color: car.color, matrices: [] } );
+			buckets.get( key ).matrices.push( car.matrix );
 
 		}
 
 		const group = new Group();
 		group.name = 'Cars';
 
-		for ( const [ paint, matrices ] of byColor ) {
+		for ( const [ key, { type, color: paint, matrices } ] of buckets ) {
+
+			let geometry = this.geometries.get( type );
+			if ( geometry === undefined ) {
+
+				geometry = buildCarGeometry( BODY_SPECS[ type ] );
+				this.geometries.set( type, geometry );
+
+			}
 
-			let material = this.materials.get( paint );
+			let material = this.materials.get( key );
 			if ( material === undefined ) {
 
-				material = createCarMaterial( paint );
-				this.materials.set( paint, material );
+				material = createCarMaterial( paint, BODY_SPECS[ type ] );
+				this.materials.set( key, material );
 
 			}
 
-			const mesh = new InstancedMesh( this.geometry, material, matrices.length );
+			const mesh = new InstancedMesh( geometry, material, matrices.length );
 			for ( let i = 0; i < matrices.length; i ++ ) mesh.setMatrixAt( i, matrices[ i ] );
 			mesh.castShadow = mesh.receiveShadow = true;
 			mesh.name = 'Car';
@@ -88,8 +106,8 @@ class CarGenerator {
 
 	dispose() {
 
-		if ( this.geometry ) this.geometry.dispose();
-		this.geometry = null;
+		for ( const geometry of this.geometries.values() ) geometry.dispose();
+		this.geometries.clear();
 
 		for ( const material of this.materials.values() ) material.dispose();
 		this.materials.clear();
@@ -100,18 +118,80 @@ class CarGenerator {
 
 }
 
-CarGenerator.defaults = {
-	length: 4.5, // bumper to bumper along Z
-	width: 1.8, // body width across X
-	wheelRadius: 0.34
+CarGenerator.defaults = {};
+
+// the paint colour that gets the roof-signed taxi shell
+CarGenerator.taxiColor = 0xf5c518;
+
+const BODY = 0, WHEEL = 1, HEADLIGHT = 2, TAILLIGHT = 3, TRIM = 4, PLATE = 5, SIGN = 6;
+
+/*
+ * Everything that shapes a body type lives in one spec: the loft stations
+ * ( z, bodyHalfW, roofHalfW, yLow, yBelt, yRoof, nose to tail ) plus the
+ * canonical-space constants the material masks reuse. Station yLow rises over
+ * the axles, cutting real wheel arches into the shell's silhouette.
+ */
+const BODY_SPECS = {
+
+	sedan: {
+		stations: [
+			[ 2.05, 0.70, 0.70, 0.50, 0.66, 0.74 ], // front fascia
+			[ 1.96, 0.84, 0.81, 0.44, 0.71, 0.81 ], // hood rounds over the nose
+			[ 1.90, 0.89, 0.86, 0.42, 0.73, 0.84 ], // arch leading edge
+			[ 1.50, 0.92, 0.88, 0.68, 0.76, 0.90 ], // front axle, arch apex
+			[ 1.10, 0.92, 0.88, 0.38, 0.79, 0.96 ], // arch trailing edge
+			[ 0.66, 0.92, 0.84, 0.37, 0.84, 1.02 ], // cowl; the windshield base reaches for the fenders
+			[ 0.10, 0.92, 0.72, 0.36, 0.88, 1.40 ], // windshield top, roof front
+			[ - 0.72, 0.92, 0.73, 0.36, 0.90, 1.42 ], // roof rear
+			[ - 1.10, 0.92, 0.86, 0.37, 0.90, 1.06 ], // backlight base, decklid
+			[ - 1.50, 0.90, 0.84, 0.68, 0.90, 1.00 ], // rear axle, arch apex
+			[ - 1.88, 0.88, 0.83, 0.40, 0.86, 0.94 ], // decklid
+			[ - 1.97, 0.84, 0.80, 0.44, 0.80, 0.88 ], // decklid rounds into the tail
+			[ - 2.08, 0.68, 0.66, 0.52, 0.70, 0.78 ] // tail fascia
+		],
+		nose: 2.05, tail: - 2.08,
+		wheelRadius: 0.4, wheelZ: 1.5, wheelX: 0.78, hubRadius: 0.24,
+		glassY: 1.0, roofCapY: 1.36, cabin: [ - 1.1, 0.62 ], screens: [ 0.13, - 0.75 ],
+		pillars: [[ 0.04, 0.12 ], [ - 0.33, - 0.27 ], [ - 0.76, - 0.68 ]],
+		seams: [ 0.6, - 0.3, - 1.0 ], handles: [ - 0.14, - 0.84 ], handleY: 0.9,
+		mirror: [ 0.98, 0.5 ], lampY: 0.64, tailLampY: 0.7, bumperY: 0.48,
+		sign: false
+	},
+
+	suv: {
+		stations: [
+			[ 2.12, 0.72, 0.72, 0.54, 0.74, 0.82 ], // front fascia
+			[ 2.02, 0.86, 0.83, 0.48, 0.79, 0.90 ], // hood rounds over the nose
+			[ 1.96, 0.91, 0.88, 0.46, 0.81, 0.93 ], // arch leading edge
+			[ 1.55, 0.94, 0.90, 0.74, 0.84, 1.02 ], // front axle, arch apex
+			[ 1.13, 0.94, 0.90, 0.44, 0.88, 1.10 ], // arch trailing edge
+			[ 0.72, 0.94, 0.86, 0.42, 0.94, 1.16 ], // cowl; the windshield base reaches for the fenders
+			[ 0.22, 0.94, 0.78, 0.42, 0.98, 1.58 ], // windshield top, roof front
+			[ - 0.85, 0.94, 0.78, 0.42, 1.00, 1.62 ], // roof rear
+			[ - 1.13, 0.94, 0.88, 0.44, 1.00, 1.52 ], // tail glass slope
+			[ - 1.55, 0.92, 0.86, 0.74, 0.98, 1.46 ], // rear axle, arch apex
+			[ - 1.93, 0.90, 0.84, 0.48, 0.94, 1.38 ],
+			[ - 2.03, 0.85, 0.80, 0.52, 0.88, 1.22 ], // tailgate rounds off the roof
+			[ - 2.12, 0.70, 0.67, 0.58, 0.80, 1.00 ] // tall tailgate
+		],
+		nose: 2.12, tail: - 2.12,
+		wheelRadius: 0.44, wheelZ: 1.55, wheelX: 0.79, hubRadius: 0.27,
+		glassY: 1.12, roofCapY: 1.56, cabin: [ - 1.88, 0.68 ], screens: [ 0.27, - 1.53 ],
+		pillars: [[ 0.16, 0.26 ], [ - 0.28, - 0.22 ], [ - 0.92, - 0.85 ], [ - 1.54, - 1.46 ]],
+		seams: [ 0.68, - 0.25, - 1.05 ], handles: [ - 0.1, - 0.88 ], handleY: 1.02,
+		mirror: [ 1.1, 0.56 ], lampY: 0.78, tailLampY: 0.88, bumperY: 0.54,
+		sign: false, rails: true
+	}
+
 };
 
-const BODY = 0, WHEEL = 1, HEADLIGHT = 2, TAILLIGHT = 3, TRIM = 4;
+// the taxi is the sedan shell plus a lit roof sign
+BODY_SPECS.taxi = Object.assign( {}, BODY_SPECS.sedan, { sign: true } );
 
 function part( geometry, id ) {
 
 	const g = geometry.index ? geometry.toNonIndexed() : geometry;
-	g.deleteAttribute( 'uv' ); // the material is untextured, so drop uvs for a clean merge
+	g.deleteAttribute( 'uv' ); // the material works in canonical space, so drop uvs for a clean merge
 	g.setAttribute( 'partId', new BufferAttribute( new Float32Array( g.attributes.position.count ).fill( id ), 1 ) );
 	return g;
 
@@ -123,12 +203,13 @@ function part( geometry, id ) {
 function carSection( z, bodyHalfW, roofHalfW, yLow, yBelt, yRoof ) {
 
 	const right = [
-		new Vector3( bodyHalfW * 0.72, yLow, z ),
-		new Vector3( bodyHalfW, yLow + ( yBelt - yLow ) * 0.45, z ),
-		new Vector3( bodyHalfW, yBelt, z ),
-		new Vector3( ( bodyHalfW + roofHalfW ) * 0.5, yBelt + ( yRoof - yBelt ) * 0.22, z ),
+		new Vector3( bodyHalfW * 0.70, yLow, z ), // underbody edge
+		new Vector3( bodyHalfW * 0.94, yLow + ( yBelt - yLow ) * 0.30, z ), // sill tuck
+		new Vector3( bodyHalfW, yLow + ( yBelt - yLow ) * 0.72, z ), // door barrel
+		new Vector3( bodyHalfW * 0.985, yBelt, z ), // shoulder crease under the glass
+		new Vector3( ( bodyHalfW + roofHalfW ) * 0.5, yBelt + ( yRoof - yBelt ) * 0.20, z ),
 		new Vector3( roofHalfW, yBelt + ( yRoof - yBelt ) * 0.62, z ),
-		new Vector3( roofHalfW * 0.95, yRoof - ( yRoof - yBelt ) * 0.06, z ),
+		new Vector3( roofHalfW * 0.96, yRoof - ( yRoof - yBelt ) * 0.05, z ),
 		new Vector3( roofHalfW * 0.55, yRoof, z )
 	];
 
@@ -146,95 +227,246 @@ function carSection( z, bodyHalfW, roofHalfW, yLow, yBelt, yRoof ) {
 
 }
 
-function buildCarGeometry( p ) {
-
-	const w = p.width, r = p.wheelRadius;
-	const halfW = w / 2;
-
-	// stations from nose ( +Z ) to tail ( -Z ): z, bodyHalfW, roofHalfW, yLow, yBelt, yRoof.
-	// the greenhouse only rises over the middle rows; bumper rows pinch in and drop low
-	const stations = [
-		[ 2.20, halfW * 0.80, halfW * 0.80, 0.42, 0.66, 0.74 ], // front fascia
-		[ 1.95, halfW * 0.97, halfW * 0.94, 0.39, 0.76, 0.86 ],
-		[ 1.45, halfW, halfW * 0.98, 0.37, 0.84, 0.96 ],
-		[ 1.00, halfW, halfW * 0.73, 0.36, 0.92, 1.06 ], // cowl, windshield base
-		[ 0.45, halfW, halfW * 0.69, 0.35, 0.95, 1.43 ], // roof front, windshield top
-		[ - 0.55, halfW, halfW * 0.69, 0.35, 0.95, 1.45 ], // roof
-		[ - 1.05, halfW, halfW * 0.71, 0.36, 0.93, 1.40 ], // roof rear, backlight top
-		[ - 1.55, halfW, halfW * 0.95, 0.37, 0.86, 0.98 ], // decklid, backlight base
-		[ - 1.95, halfW * 0.97, halfW * 0.94, 0.39, 0.78, 0.88 ],
-		[ - 2.20, halfW * 0.82, halfW * 0.82, 0.42, 0.68, 0.76 ] // rear fascia
-	];
+function buildCarGeometry( spec ) {
 
-	const sections = stations.map( s => carSection( ...s ) );
+	const sections = spec.stations.map( s => carSection( ...s ) );
 	const body = new LoftGeometry( sections, { closed: true, capStart: true, capEnd: true } );
 
-	// fat round tyres tucked under the fenders, each faced with an outward chrome
-	// hubcap disc ( only the outer face is ever seen, so a flat disc is enough )
+	// each wheel is one lathed profile: tread with rounded shoulders, a bulged
+	// sidewall stepping down to the rim lip, and the spoke face recessed behind
+	// it. the material splits tyre from alloy by radial distance, so the whole
+	// wheel needs no seams and no separate hub disc
+	const r = spec.wheelRadius;
+	const rim = spec.hubRadius;
+	const w = r * 0.68;
+
+	const wheelProfile = [
+		new Vector2( r * 0.5, - w * 0.5 ), // inner opening, hidden in the arch shadow
+		new Vector2( r * 0.94, - w * 0.44 ),
+		new Vector2( r, - w * 0.16 ), // tread
+		new Vector2( r, w * 0.16 ),
+		new Vector2( r * 0.94, w * 0.4 ), // outer shoulder
+		new Vector2( rim + 0.02, w * 0.46 ), // sidewall bulge down to the rim
+		new Vector2( rim, w * 0.32 ), // rim lip, stepping inward
+		new Vector2( 0.02, w * 0.32 ) // recessed spoke face
+	];
+
 	const wheels = [];
-	const hubcaps = [];
-	const wheelX = halfW - 0.06;
-	for ( const x of [ - wheelX, wheelX ] ) {
+	for ( const x of [ - spec.wheelX, spec.wheelX ] ) {
 
 		const side = Math.sign( x );
-		for ( const z of [ - 1.45, 1.45 ] ) {
+		for ( const z of [ - spec.wheelZ, spec.wheelZ ] ) {
 
-			wheels.push( new CylinderGeometry( r, r, 0.22, 12 ).rotateZ( Math.PI / 2 ).translate( x, r, z ) );
-			hubcaps.push( new CircleGeometry( 0.15, 12 ).rotateY( side * Math.PI / 2 ).translate( x + side * 0.12, r, z ) );
+			wheels.push( new LatheGeometry( wheelProfile, 14 ).rotateZ( - side * Math.PI / 2 ).translate( x, r, z ) );
 
 		}
 
 	}
 
-	// chrome bumpers wrap each end and a matte grille fills the nose between the lamps;
-	// each pod is sunk into the fascia so its back never floats and only its face shows
-	const frontBumper = new BoxGeometry( w * 0.94, 0.2, 0.18 ).translate( 0, 0.40, 2.18 );
-	const rearBumper = new BoxGeometry( w * 0.94, 0.2, 0.18 ).translate( 0, 0.40, - 2.18 );
-	const grille = new BoxGeometry( 0.5, 0.13, 0.08 ).translate( 0, 0.60, 2.23 );
+	// slim chrome bumpers wrap each end, a matte grille fills the nose between the
+	// lamps, and each lamp pod is sunk into the fascia so only its face shows
+	const frontBumper = new BoxGeometry( 1.52, 0.16, 0.18 ).translate( 0, spec.bumperY, spec.nose - 0.06 );
+	const rearBumper = new BoxGeometry( 1.52, 0.16, 0.18 ).translate( 0, spec.bumperY, spec.tail + 0.06 );
+	const grille = new BoxGeometry( 0.64, 0.16, 0.08 ).translate( 0, spec.lampY - 0.02, spec.nose + 0.01 );
 
 	const headlights = mergeGeometries( [
-		new BoxGeometry( 0.36, 0.12, 0.08 ).translate( - 0.56, 0.62, 2.22 ),
-		new BoxGeometry( 0.36, 0.12, 0.08 ).translate( 0.56, 0.62, 2.22 )
+		new BoxGeometry( 0.34, 0.12, 0.08 ).translate( - 0.48, spec.lampY, spec.nose ),
+		new BoxGeometry( 0.34, 0.12, 0.08 ).translate( 0.48, spec.lampY, spec.nose )
 	] );
 
 	const taillights = mergeGeometries( [
-		new BoxGeometry( 0.42, 0.14, 0.08 ).translate( - 0.56, 0.64, - 2.22 ),
-		new BoxGeometry( 0.42, 0.14, 0.08 ).translate( 0.56, 0.64, - 2.22 )
+		new BoxGeometry( 0.36, 0.13, 0.08 ).translate( - 0.46, spec.tailLampY, spec.tail ),
+		new BoxGeometry( 0.36, 0.13, 0.08 ).translate( 0.46, spec.tailLampY, spec.tail )
 	] );
 
-	return mergeGeometries( [
-		part( body, BODY ),
-		part( mergeGeometries( [ ...wheels, grille ] ), WHEEL ),
-		part( mergeGeometries( [ ...hubcaps, frontBumper, rearBumper ] ), TRIM ),
-		part( headlights, HEADLIGHT ),
-		part( taillights, TAILLIGHT )
+	// wing mirrors at the base of the A-pillars, angled slightly into the wind
+	const mirrorHalfW = spec.stations[ 0 ][ 1 ] < 0.8 ? 0.92 : 0.94;
+	const mirrors = mergeGeometries( [
+		new BoxGeometry( 0.16, 0.09, 0.06 ).rotateY( - 0.25 ).translate( - ( mirrorHalfW + 0.06 ), spec.mirror[ 0 ], spec.mirror[ 1 ] ),
+		new BoxGeometry( 0.16, 0.09, 0.06 ).rotateY( 0.25 ).translate( mirrorHalfW + 0.06, spec.mirror[ 0 ], spec.mirror[ 1 ] )
 	] );
 
+	// licence plates centred on the bumper faces
+	const plates = mergeGeometries( [
+		new BoxGeometry( 0.32, 0.12, 0.02 ).translate( 0, spec.bumperY + 0.02, spec.nose + 0.02 ),
+		new BoxGeometry( 0.32, 0.12, 0.02 ).translate( 0, spec.bumperY + 0.02, spec.tail - 0.02 )
+	] );
+
+	const darkParts = [ ...wheels, grille ];
+
+	// low roof rails along an SUV's crown
+	if ( spec.rails ) {
+
+		darkParts.push(
+			new BoxGeometry( 0.05, 0.045, 1.0 ).translate( - 0.55, 1.645, - 0.32 ),
+			new BoxGeometry( 0.05, 0.045, 1.0 ).translate( 0.55, 1.645, - 0.32 )
+		);
+
+	}
+
+	const parts = [
+		part( mergeGeometries( [ body, mirrors ] ), BODY ),
+		part( mergeGeometries( darkParts ), WHEEL ),
+		part( mergeGeometries( [ frontBumper, rearBumper ] ), TRIM ),
+		part( headlights, HEADLIGHT ),
+		part( taillights, TAILLIGHT ),
+		part( plates, PLATE )
+	];
+
+	// the taxi's lit roof sign
+	if ( spec.sign ) parts.push( part( new BoxGeometry( 0.36, 0.1, 0.14 ).translate( 0, 1.46, - 0.1 ), SIGN ) );
+
+	return mergeGeometries( parts );
+
 }
 
-function createCarMaterial( paint ) {
+function createCarMaterial( paintColor, spec ) {
+
+	// every spec constant enters the shader as a uniform, so all paints and body
+	// types share one compiled pipeline: the whole fleet costs a single shader
+	// compile instead of one per paint/type pairing
+
+	const paint = uniform( new Color( paintColor ) );
+	const glassY = uniform( spec.glassY );
+	const roofCapY = uniform( spec.roofCapY );
+	const cabinMin = uniform( spec.cabin[ 0 ] );
+	const cabinMax = uniform( spec.cabin[ 1 ] );
+	const wheelR = uniform( spec.wheelRadius );
+	const wheelZ = uniform( spec.wheelZ );
+	const hubRadius = uniform( spec.hubRadius );
+	const nose = uniform( spec.nose );
+	const screenFrontZ = uniform( spec.screens[ 0 ] );
+	const screenRearZ = uniform( spec.screens[ 1 ] );
+	const handleY = uniform( spec.handleY );
+	const handleZ = uniform( new Vector2( ...spec.handles ) );
+	const seamZ = uniform( new Vector3( ...spec.seams ) );
+
+	// pad to a fixed pillar count so the unrolled loop is identical for all types
+	const pillarBands = spec.pillars.slice();
+	while ( pillarBands.length < 4 ) pillarBands.push( [ 9, 9.001 ] );
+	const pillars = uniformArray( pillarBands.map( ( band ) => new Vector2( ...band ) ) );
 
 	const partId = varying( attribute( 'partId', 'float' ) ).setInterpolation( InterpolationSamplingType.FLAT, InterpolationSamplingMode.EITHER );
+	const isBody = partId.equal( BODY );
 	const isWheel = partId.equal( WHEEL );
 	const isTrim = partId.equal( TRIM );
 	const isHeadlight = partId.equal( HEADLIGHT );
 	const isTaillight = partId.equal( TAILLIGHT );
+	const isPlate = partId.equal( PLATE );
+	const isSign = partId.equal( SIGN );
 
-	const material = new MeshStandardNodeMaterial();
+	// all the masks below carve the shared shell in canonical model space, so
+	// their constants come from the same spec that placed the loft stations
 
-	// painted shell, matte tyres and grille, chrome trim, and two lit lamps
-	material.colorNode = select( isWheel, color( 0x141414 ),
-		select( isTrim, color( 0xc2c6ca ),
-			select( isHeadlight, color( 0xf6f4e0 ),
-				select( isTaillight, color( 0x6a0a0a ), color( paint ) ) ) ) );
+	const p = positionGeometry;
+
+	// smoked glazing: the greenhouse above the beltline over the cabin span,
+	// interrupted by painted pillar bands and a painted roof cap
+	const onScreens = p.z.greaterThan( screenFrontZ ).or( p.z.lessThan( screenRearZ ) );
+	const paneY = select( onScreens, glassY.sub( 0.09 ), glassY );
+	const overBelt = p.y.greaterThan( paneY ).and( p.y.lessThan( roofCapY ) );
+	const inCabin = p.z.greaterThan( cabinMin ).and( p.z.lessThan( cabinMax ) );
+	let inPillar = null;
+	for ( let i = 0; i < 4; i ++ ) {
 
-	material.roughnessNode = select( isWheel, float( 0.85 ),
-		select( isTrim, float( 0.25 ), float( 0.5 ) ) );
+		const band = pillars.element( i );
+		const inBand = p.z.greaterThan( band.x ).and( p.z.lessThan( band.y ) );
+		inPillar = inPillar === null ? inBand : inPillar.or( inBand );
 
-	material.metalnessNode = select( isTrim, float( 0.9 ), float( 0.1 ) ); // chrome trim, dielectric clear-coat paint
+	}
+
+	const isGlass = isBody.and( overBelt ).and( inCabin ).and( inPillar.not() );
+
+	// tinted glass as a dark mirror: a rubber reveal frames each pane, the tint
+	// deepens toward the belt where the cabin sits behind it, and metal-style
+	// reflectance turns the panes into sky mirrors even under a dim environment
+	const paneEdge = ( a, b ) => smoothstep( 0.05, 0.016, a ).max( smoothstep( 0.05, 0.016, b ) );
+	let reveal = paneEdge( p.y.sub( paneY ), roofCapY.sub( p.y ) ).max( paneEdge( p.z.sub( cabinMin ), cabinMax.sub( p.z ) ) );
+	for ( let i = 0; i < 4; i ++ ) {
+
+		const band = pillars.element( i );
+		reveal = reveal.max( smoothstep( 0.045, 0.015, p.z.sub( band.x ).abs().min( p.z.sub( band.y ).abs() ) ) );
+
+	}
+
+	const glassT = p.y.sub( paneY ).div( roofCapY.sub( paneY ) ).clamp();
+	const glassColor = color( 0x2a323b ).mul( glassT.mul( 0.55 ).add( 0.7 ) ).mul( reveal.mul( 0.7 ).oneMinus() );
+
+	// baked contact shading: the flanks fall into shadow around each wheel arch
+	// and along the rocker panel, and the underbody drops to near black
+	const arch = ( z ) => smoothstep( wheelR.add( 0.02 ), wheelR.add( 0.14 ), vec2( p.z.sub( z ), p.y.sub( wheelR ) ).length() );
+	const onFlank = smoothstep( 0.55, 0.8, p.x.abs() );
+	const arches = arch( wheelZ ).mul( arch( wheelZ.negate() ) ).oneMinus().mul( onFlank );
+	const rocker = smoothstep( 0.55, 0.36, p.y );
+	const shading = arches.max( rocker.mul( 0.85 ) ).clamp( 0, 0.9 ).oneMinus();
+
+	// door seams: thin dark cuts across the flank, with a handle dash behind
+	// the leading edge of each door
+	let seams = float( 0 );
+	for ( const z of [ seamZ.x, seamZ.y, seamZ.z ] ) seams = seams.max( smoothstep( 0.028, 0.008, p.z.sub( z ).abs() ) );
+	for ( const z of [ handleZ.x, handleZ.y ] ) seams = seams.max( smoothstep( 0.016, 0.008, p.y.sub( handleY ).abs() ).mul( smoothstep( 0.055, 0.04, p.z.sub( z ).abs() ) ).mul( 0.7 ) );
+	const seamBand = smoothstep( 0.4, 0.46, p.y ).mul( smoothstep( glassY, glassY.sub( 0.05 ), p.y ) );
+	const seamMask = seams.mul( onFlank ).mul( seamBand ).mul( 0.55 );
+
+	// dark plastic valance across the lower fascias
+	const valance = smoothstep( 0.56, 0.5, p.y ).mul( smoothstep( nose.sub( 0.34 ), nose.sub( 0.22 ), p.z.abs() ) );
+
+	// window gaskets: a dark rubber line where the glazing meets the paint,
+	// running the length of the cabin at the belt and the roof edge
+	const cabinGate = smoothstep( cabinMin.sub( 0.04 ), cabinMin.add( 0.08 ), p.z ).mul( smoothstep( cabinMax.add( 0.04 ), cabinMax.sub( 0.08 ), p.z ) );
+	const gasketLines = smoothstep( 0.022, 0.008, p.y.sub( glassY ).abs() ).max( smoothstep( 0.022, 0.008, p.y.sub( roofCapY ).abs() ) );
+	const gasket = gasketLines.mul( cabinGate ).mul( 0.6 );
+
+	const paintShaded = paint.mul( shading ).mul( seamMask.max( gasket ).oneMinus() );
+	const bodyColor = mix( paintShaded, color( 0x17181a ), valance );
+
+	// the wheel splits by radial distance from its axle: the recessed face
+	// inside the rim lip is the alloy ( five spokes between a rim ring and a
+	// centre cap, gaps falling to the brake shadow ), everything outside is
+	// tyre rubber with a faint raised-lettering band on the sidewall
+	const wheelPlane = vec2( p.z.abs().sub( wheelZ ), p.y.sub( wheelR ) );
+	const wheelDist = wheelPlane.length();
+	const spokeAngle = atan( wheelPlane.y, wheelPlane.x ).mul( 5 / ( Math.PI * 2 ) );
+	const spokes = smoothstep( 0.62, 0.42, spokeAngle.fract().sub( 0.5 ).abs().mul( 2 ) );
+	const rimRing = smoothstep( hubRadius.sub( 0.045 ), hubRadius.sub( 0.02 ), wheelDist );
+	const hubCap = smoothstep( 0.055, 0.04, wheelDist );
+	const alloy = spokes.max( rimRing ).max( hubCap );
+	const alloyColor = mix( color( 0x0a0a0c ), color( 0x9ea3a8 ), alloy );
+
+	const isAlloy = wheelDist.lessThan( hubRadius );
+	const sidewall = smoothstep( 0.05, 0.015, wheelDist.sub( wheelR.mul( 0.8 ) ).abs() );
+	const tireColor = color( 0x131315 ).mul( sidewall.mul( 0.5 ).add( 1 ) );
+
+	// the grille reads as dark horizontal slats
+	const slats = p.y.mul( 30 ).fract().step( 0.5 ).mul( 0.4 ).oneMinus();
+	const grilleColor = color( 0x1b1c1e ).mul( slats );
+	const wheelColor = select( p.z.greaterThan( nose.sub( 0.2 ) ), grilleColor, select( isAlloy, alloyColor, tireColor ) );
 
-	material.emissiveNode = select( isHeadlight, color( 0xf6f4e0 ).mul( 1.6 ),
-		select( isTaillight, color( 0xff1a0a ).mul( 2.4 ), color( 0x000000 ) ) ); // lit lamps
+	const material = new MeshStandardNodeMaterial();
+
+	material.colorNode = select( isWheel, wheelColor,
+		select( isTrim, color( 0xc2c6ca ),
+			select( isHeadlight, color( 0xd8d8d2 ),
+				select( isTaillight, color( 0x5a0a0a ),
+					select( isPlate, color( 0xd8d4c4 ),
+						select( isSign, color( 0xf2efe0 ),
+							select( isGlass, glassColor, bodyColor ) ) ) ) ) ) );
+
+	material.roughnessNode = select( isWheel, select( isAlloy, mix( float( 0.55 ), float( 0.3 ), alloy ), float( 0.85 ) ),
+		select( isTrim, float( 0.25 ),
+			select( isPlate.or( isSign ), float( 0.6 ),
+				select( isGlass, float( 0.06 ), mix( float( 0.32 ), float( 0.65 ), valance ) ) ) ) );
+
+	material.metalnessNode = select( isGlass, float( 0.85 ),
+		select( isTrim, float( 0.9 ),
+			select( isWheel.and( isAlloy ), float( 0.8 ),
+				select( isBody, smoothstep( 0.5, 0.56, p.y ).mul( 0.2 ), float( 0 ) ) ) ) ); // mirror glass, chrome, alloy, metallic paint above the valance
+
+	// lamp lenses: parked cars run dim marker lights, so they read at dusk
+	// without blowing out in daylight ( sunlit white renders near 32 here )
+	material.emissiveNode = select( isHeadlight, color( 0xfff2d8 ).mul( 10 ),
+		select( isTaillight, color( 0xff2211 ).mul( 4 ),
+			select( isSign, color( 0xfff6d8 ).mul( 12 ), color( 0x000000 ) ) ) );
 
 	return material;
 

+ 19 - 4
examples/jsm/generators/city/HydrantGenerator.js

@@ -8,7 +8,7 @@ import {
 } from 'three';
 
 import { MeshStandardNodeMaterial } from 'three/webgpu';
-import { attribute, color, float, select, varying } from 'three/tsl';
+import { attribute, color, float, mix, mx_fractal_noise_float, positionGeometry, select, smoothstep, varying } from 'three/tsl';
 
 import { mergeGeometries } from '../../utils/BufferGeometryUtils.js';
 
@@ -94,6 +94,10 @@ function buildHydrantGeometry( p ) {
 	const dome = new SphereGeometry( r * 0.65, 10, 4, 0, Math.PI * 2, 0, Math.PI / 2 ).translate( 0, 0.75, 0 );
 	const nut = new CylinderGeometry( 0.05, 0.05, 0.07, 6 ).translate( 0, 0.87, 0 ); // hex operating nut on top
 
+	// bolted flange rings where the castings meet: at the bonnet and above the footing
+	const bonnetFlange = new CylinderGeometry( r * 1.18, r * 1.18, 0.035, 12 ).translate( 0, 0.645, 0 );
+	const baseFlange = new CylinderGeometry( r * 1.28, r * 1.28, 0.04, 12 ).translate( 0, 0.11, 0 );
+
 	// left and right outlet nozzles, stubs finished with a bare cap, set just below mid-barrel
 	const nozzleL = new CylinderGeometry( 0.06, 0.06, 0.12, 8 ).rotateZ( Math.PI / 2 ).translate( - ( r + 0.03 ), 0.45, 0 );
 	const capL = new CylinderGeometry( 0.07, 0.07, 0.025, 8 ).rotateZ( Math.PI / 2 ).translate( - ( r + 0.102 ), 0.45, 0 );
@@ -106,6 +110,7 @@ function buildHydrantGeometry( p ) {
 
 	return mergeGeometries( [
 		part( footing, BODY ), part( barrel, BODY ), part( shoulder, BODY ), part( dome, BODY ),
+		part( bonnetFlange, BODY ), part( baseFlange, BODY ),
 		part( nozzleL, BODY ), part( nozzleR, BODY ), part( pumper, BODY ),
 		part( capL, CAP ), part( capR, CAP ), part( pumperCap, CAP ), part( nut, CAP )
 	] );
@@ -117,10 +122,20 @@ function createHydrantMaterial() {
 	const partId = varying( attribute( 'partId', 'float' ) ).setInterpolation( InterpolationSamplingType.FLAT, InterpolationSamplingMode.EITHER );
 	const isCap = partId.equal( CAP );
 
+	const p = positionGeometry;
+
+	// decades of repaints: the red fades patchily, and rust blooms up from the
+	// footing and wherever the paint has flaked through
+	const wear = mx_fractal_noise_float( p.mul( 9 ), 3 ).mul( 0.5 ).add( 0.5 );
+	const faded = mix( color( 0x8f2f1e ), color( 0xb85a38 ), wear.mul( 0.6 ) );
+
+	const rustMask = smoothstep( 0.28, 0.02, p.y ).max( smoothstep( 0.55, 0.85, wear ).mul( 0.5 ) );
+	const body = mix( faded, color( 0x4a2c1a ), rustMask.mul( 0.8 ) );
+
 	const material = new MeshStandardNodeMaterial();
-	material.colorNode = select( isCap, color( 0x9a9a92 ), color( 0x9a3320 ) ); // bare metal caps, weathered hydrant red
-	material.roughnessNode = select( isCap, float( 0.4 ), float( 0.55 ) );
-	material.metalnessNode = select( isCap, float( 0.8 ), float( 0.2 ) );
+	material.colorNode = select( isCap, color( 0x8a8a80 ), body ); // bare metal caps over the weathered red
+	material.roughnessNode = select( isCap, float( 0.45 ), wear.mul( 0.3 ).add( rustMask.mul( 0.25 ) ).add( 0.5 ) );
+	material.metalnessNode = select( isCap, float( 0.8 ), float( 0.15 ) );
 
 	return material;
 

+ 271 - 49
examples/jsm/generators/city/PersonGenerator.js

@@ -1,25 +1,40 @@
 import {
 	BoxGeometry,
-	Color,
+	BufferAttribute,
 	CylinderGeometry,
 	InstancedMesh,
-	SphereGeometry
+	InterpolationSamplingMode,
+	InterpolationSamplingType,
+	Group,
+	LatheGeometry,
+	Matrix4,
+	SphereGeometry,
+	Vector2,
+	Vector3
 } from 'three';
 
 import { MeshStandardNodeMaterial } from 'three/webgpu';
-import { color } from 'three/tsl';
+import { array, attribute, color, float, fract, instanceIndex, mix, positionGeometry, select, sin, smoothstep, varying } from 'three/tsl';
 
 import { mergeGeometries } from '../../utils/BufferGeometryUtils.js';
+import { LoftGeometry } from '../../geometries/LoftGeometry.js';
 
 /**
- * A low-poly pedestrian: a sphere head over a tapered coat, with boxy legs and arms
- * down the sides. Built once and instanced across a list of placements so a whole
- * crowd shares one geometry and one material. Clothing variety comes from a
- * per-instance coat colour picked from a muted palette, which the engine multiplies
- * into the white base, so no per-vertex zones are needed.
+ * A low-poly pedestrian crowd: each figure is a lathed coat with lofted limbs,
+ * a skin head and hands under a cap of hair. Every limb is a single loft swept
+ * through its joints, so sleeves bend at the elbow, legs flex at the knee and
+ * shoes round off from heel to toe without any joint seams. Figures come in two
+ * shared poses, mid-stride and standing ( the stander carries a bag ), and every
+ * placement is assigned a pose, a height and its clothing deterministically, so
+ * a crowd walks out of two instanced draws with no per-instance attributes.
  *
- * The canonical figure stands on `y = 0`, centred in X / Z and ~1.75 m tall, facing
- * `+Z` so a placement turns it to face the road.
+ * The material splits the figure into zones on a baked `partId` and hashes
+ * `instanceIndex` per zone, drawing coat, shirt, trousers, skin, hair and shoes
+ * from small palettes; canonical-space masks add the collar, the coat's front
+ * opening and a shirt V at the chest.
+ *
+ * The canonical figure stands on `y = 0`, centred in X / Z and ~1.75 m tall,
+ * facing `+Z` so a placement turns it to face the road.
  *
  * ```js
  * const people = new PersonGenerator();
@@ -33,7 +48,7 @@ class PersonGenerator {
 		this.parameters = Object.assign( {}, PersonGenerator.defaults, parameters );
 
 		this.material = null;
-		this.geometry = null;
+		this.geometries = null;
 		this.mesh = null;
 
 	}
@@ -43,36 +58,64 @@ class PersonGenerator {
 		this.dispose();
 
 		if ( this.material === null ) this.material = createPersonMaterial();
-		if ( this.geometry === null ) this.geometry = buildPersonGeometry( this.parameters );
+		if ( this.geometries === null ) {
+
+			this.geometries = {
+				walk: buildPersonGeometry( this.parameters, 'walk' ),
+				stand: buildPersonGeometry( this.parameters, 'stand' )
+			};
+
+		}
 
-		const mesh = new InstancedMesh( this.geometry, this.material, placements.length );
+		// deal each placement a pose and a height from its index, so the crowd
+		// varies but rebuilding the same city deals the same crowd
+		const buckets = { walk: [], stand: [] };
+		const matrix = new Matrix4();
 
-		const c = new Color();
 		for ( let i = 0; i < placements.length; i ++ ) {
 
-			mesh.setMatrixAt( i, placements[ i ] );
+			const h1 = ( ( i * 2654435761 ) >>> 0 ) % 1000 / 1000;
+			const h2 = ( ( i * 1597334677 ) >>> 0 ) % 1000 / 1000;
 
-			// deterministic coat colour from a tiny hash of the instance index
-			const swatch = COATS[ ( ( i * 2654435761 ) >>> 0 ) % COATS.length ];
-			mesh.setColorAt( i, c.setHex( swatch ) );
+			const scale = 0.92 + h2 * 0.15; // ~1.6 m to ~1.9 m
+			const posed = placements[ i ].clone().multiply( matrix.makeScale( scale, scale, scale ) );
+
+			buckets[ h1 < 0.65 ? 'walk' : 'stand' ].push( posed );
 
 		}
 
-		if ( mesh.instanceColor ) mesh.instanceColor.needsUpdate = true;
+		const group = new Group();
+		group.name = 'People';
+
+		for ( const pose of [ 'walk', 'stand' ] ) {
+
+			const matrices = buckets[ pose ];
+			if ( matrices.length === 0 ) continue;
 
-		mesh.castShadow = true;
-		mesh.name = 'People';
+			const mesh = new InstancedMesh( this.geometries[ pose ], this.material, matrices.length );
+			for ( let i = 0; i < matrices.length; i ++ ) mesh.setMatrixAt( i, matrices[ i ] );
+			mesh.castShadow = mesh.receiveShadow = true;
+			mesh.name = 'People';
+			group.add( mesh );
 
-		this.mesh = mesh;
+		}
+
+		this.mesh = group;
 
-		return mesh;
+		return group;
 
 	}
 
 	dispose() {
 
-		if ( this.geometry ) this.geometry.dispose();
-		this.geometry = null;
+		if ( this.geometries ) {
+
+			this.geometries.walk.dispose();
+			this.geometries.stand.dispose();
+
+		}
+
+		this.geometries = null;
 		this.mesh = null;
 
 	}
@@ -83,36 +126,178 @@ PersonGenerator.defaults = {
 	height: 1.75 // overall standing height
 };
 
-// muted coat colours, picked per instance for crowd variety
-const COATS = [ 0x2b3a52, 0x33332f, 0x8a6a44, 0x5a2f2f, 0x4f5238, 0x6a6a66, 0x222024 ];
+// material-zone codes baked per vertex
+const SKIN = 0, HAIR = 1, COAT = 2, LEGS = 3, SHOES = 4, BAG = 5;
 
-function buildPersonGeometry( p ) {
+// small palettes the material indexes per instance
+const COAT_COLORS = [ 0x2b3a52, 0x33332f, 0x8a6a44, 0x5a2f2f, 0x4f5238, 0x6a6a66, 0x222024, 0x7d3f22 ];
+const SHIRT_COLORS = [ 0xe8e6e0, 0xb8c4d8, 0xcfc8b8, 0x9aa7b8 ];
+const LEG_COLORS = [ 0x22242c, 0x3a3f4a, 0x2e2a26, 0x4a4640, 0x1d1d20 ];
+const SKIN_COLORS = [ 0xc68863, 0xa96f4c, 0x8a5535, 0x6b3d24, 0xd9a077 ];
+const HAIR_COLORS = [ 0x1a1512, 0x3a2a1a, 0x584022, 0x6e6862, 0x2a2624 ];
 
-	// rounded head and a short neck rising from the collar
-	const head = new SphereGeometry( 0.105, 10, 8 ).translate( 0, 1.62, 0 );
-	const neck = new CylinderGeometry( 0.05, 0.05, 0.08, 6 ).translate( 0, 1.52, 0 );
+// tag a geometry with a flat partId and normalize to non-indexed for merging
+function part( geometry, id ) {
 
-	// coat: broad at the shoulders, drawn in at the waist, with a slight skirt flare
-	const torso = new CylinderGeometry( 0.2, 0.155, 0.5, 8 ).translate( 0, 1.24, 0 );
-	const skirt = new CylinderGeometry( 0.17, 0.19, 0.22, 8 ).translate( 0, 0.95, 0 );
+	const g = geometry.index ? geometry.toNonIndexed() : geometry;
+	g.deleteAttribute( 'uv' ); // the material works in canonical space, so drop uvs for a clean merge
+	g.setAttribute( 'partId', new BufferAttribute( new Float32Array( g.attributes.position.count ).fill( id ), 1 ) );
+	return g;
 
-	// tapered arms hanging just outside the shoulders, splayed out a touch
-	const armL = new CylinderGeometry( 0.055, 0.042, 0.62, 6 ).rotateZ( - 0.08 ).translate( - 0.21, 1.18, 0 );
-	const armR = new CylinderGeometry( 0.055, 0.042, 0.62, 6 ).rotateZ( 0.08 ).translate( 0.21, 1.18, 0 );
+}
 
-	// rounded hands capping the arms
-	const handL = new SphereGeometry( 0.045, 6, 3 ).translate( - 0.235, 0.87, 0 );
-	const handR = new SphereGeometry( 0.045, 6, 3 ).translate( 0.235, 0.87, 0 );
+const _rotation = new Matrix4();
+const _rotationZ = new Matrix4();
 
-	// legs standing close, tapering from thigh to ankle
-	const legL = new CylinderGeometry( 0.085, 0.062, 0.9, 6 ).translate( - 0.1, 0.45, 0 );
-	const legR = new CylinderGeometry( 0.085, 0.062, 0.9, 6 ).translate( 0.1, 0.45, 0 );
+// offsets a joint from its parent: `v` swung about the parent by rotX / rotZ
+function swingJoint( parent, v, rotX, rotZ ) {
 
-	// flattened feet nudged forward so the toes lead
-	const footL = new BoxGeometry( 0.1, 0.05, 0.22 ).translate( - 0.1, 0.025, 0.04 );
-	const footR = new BoxGeometry( 0.1, 0.05, 0.22 ).translate( 0.1, 0.025, 0.04 );
+	return v.clone()
+		.applyMatrix4( _rotation.makeRotationX( rotX ).multiply( _rotationZ.makeRotationZ( rotZ ) ) )
+		.add( parent );
+
+}
+
+// a horizontal 6-point ring for limb lofts, wound for a downward sweep
+function ring( center, radius ) {
+
+	const points = [];
+	for ( let i = 0; i < 6; i ++ ) {
+
+		const a = i / 6 * Math.PI * 2;
+		points.push( new Vector3( center.x + Math.cos( a ) * radius, center.y, center.z + Math.sin( a ) * radius ) );
+
+	}
+
+	return points;
+
+}
+
+function buildPersonGeometry( p, pose ) {
+
+	const walking = pose === 'walk';
+
+	// rounded head and a short open-ended neck rising from the collar
+	const head = new SphereGeometry( 0.105, 8, 5 ).translate( 0, 1.62, 0 );
+	const neck = new CylinderGeometry( 0.05, 0.055, 0.09, 5, 1, true ).translate( 0, 1.51, 0 );
+
+	// a cap of hair hugging the top and back of the skull, leaving the face open
+	const hair = new SphereGeometry( 0.115, 8, 4, 0, Math.PI * 2, 0, Math.PI * 0.55 )
+		.rotateX( - 0.42 )
+		.translate( 0, 1.615, - 0.012 );
+
+	// the coat, lathed as one organic profile: hem, hip, waist, chest, shoulder,
+	// collar. flattened front-to-back so the torso is not a tube
+	const coatProfile = [
+		new Vector2( 0.17, 0.82 ),
+		new Vector2( 0.185, 0.9 ),
+		new Vector2( 0.16, 1.06 ),
+		new Vector2( 0.175, 1.24 ),
+		new Vector2( 0.19, 1.38 ),
+		new Vector2( 0.165, 1.445 ),
+		new Vector2( 0.085, 1.5 ),
+		new Vector2( 0.02, 1.52 )
+	];
+	const coat = new LatheGeometry( coatProfile, 8 ).scale( 1, 1, 0.74 );
+
+	// arms swing from the shoulders; each sleeve is one loft bending through the
+	// elbow, with a small lofted hand chained from the wrist
+	const armSwing = walking ? 0.32 : 0.05;
+	const coatParts = [ coat ];
+	const skinParts = [ head ];
+
+	for ( const side of [ - 1, 1 ] ) {
+
+		const swing = walking ? - side * armSwing : armSwing; // walkers counter-swing opposite their stride, standers hang
+		const shoulder = new Vector3( side * 0.19, 1.42, 0 );
+		const deltoid = swingJoint( shoulder, new Vector3( 0, - 0.07, 0 ), swing, side * 0.05 );
+		const elbow = swingJoint( shoulder, new Vector3( 0, - 0.3, 0 ), swing, side * 0.05 );
+		const wrist = swingJoint( elbow, new Vector3( 0, - 0.26, 0 ), swing - 0.25, side * 0.03 );
+
+		// the sleeve pinches to a tip tucked inside the coat's shoulder slope,
+		// so it emerges like a raglan seam instead of ending in a flat cap
+		coatParts.push( new LoftGeometry( [
+			ring( new Vector3( side * 0.165, 1.455, 0 ), 0.018 ),
+			ring( deltoid, 0.056 ),
+			ring( elbow, 0.047 ),
+			ring( wrist, 0.038 )
+		], { capStart: true, capEnd: true } ) );
+
+		const along = wrist.clone().sub( elbow ).normalize();
+		skinParts.push( new LoftGeometry( [
+			ring( wrist, 0.03 ),
+			ring( wrist.clone().addScaledVector( along, 0.055 ), 0.041 ),
+			ring( wrist.clone().addScaledVector( along, 0.1 ), 0.018 )
+		], { capStart: true, capEnd: true } ) );
+
+	}
 
-	const geometry = mergeGeometries( [ head, neck, torso, skirt, armL, armR, handL, handR, legL, legR, footL, footR ] );
+	// legs stride from the hips as single lofts, the knee ring pushed forward
+	// where the gait flexes it; shoes loft from heel to toe under each ankle
+	const legSwing = walking ? 0.21 : 0;
+	const legParts = [];
+	const shoeParts = [];
+
+	for ( const side of [ - 1, 1 ] ) {
+
+		const swing = side * legSwing;
+		const hip = new Vector3( side * 0.1, 0.92, 0 );
+		const ankle = swingJoint( hip, new Vector3( 0, - 0.84, 0 ), swing, 0 );
+
+		// the trailing, pushing leg flexes most; a straight stack still gets a
+		// hint of knee so the silhouette never reads as a rigid post
+		const kneeBend = walking ? ( swing > 0 ? 0.06 : 0.02 ) : 0.015;
+		const knee = hip.clone().lerp( ankle, 0.52 ).add( new Vector3( 0, 0, kneeBend ) );
+
+		legParts.push( new LoftGeometry( [
+			ring( hip, 0.08 ),
+			ring( knee, 0.063 ),
+			ring( ankle, 0.048 )
+		], { capStart: true, capEnd: true } ) );
+
+		// foot rings run heel to toe in the shoe's own frame, then take the
+		// stride pitch, so the front foot lands on its heel and the back one
+		// pushes off its toe
+		const footRing = ( y, z, rx, ry ) => {
+
+			const points = [];
+			for ( let i = 0; i < 6; i ++ ) {
+
+				const a = i / 6 * Math.PI * 2;
+				points.push( new Vector3( Math.cos( a ) * rx, y + Math.sin( a ) * ry, z ) );
+
+			}
+
+			return points;
+
+		};
+
+		const shoe = new LoftGeometry( [
+			footRing( - 0.037, - 0.06, 0.045, 0.042 ),
+			footRing( - 0.039, 0.06, 0.05, 0.04 ),
+			footRing( - 0.049, 0.15, 0.036, 0.026 )
+		], { capStart: true, capEnd: true } )
+			.rotateY( walking ? 0 : side * 0.12 ) // standers rest toe-out
+			.rotateX( swing )
+			.translate( ankle.x, ankle.y, ankle.z );
+
+		shoeParts.push( shoe );
+
+	}
+
+	const parts = [
+		part( mergeGeometries( skinParts ), SKIN ),
+		part( neck, SKIN ),
+		part( hair, HAIR ),
+		part( mergeGeometries( coatParts ), COAT ),
+		part( mergeGeometries( legParts ), LEGS ),
+		part( mergeGeometries( shoeParts ), SHOES )
+	];
+
+	// the stander carries a bag at their right hand
+	if ( ! walking ) parts.push( part( new BoxGeometry( 0.07, 0.2, 0.16 ).translate( 0.27, 0.72, 0.05 ), BAG ) );
+
+	const geometry = mergeGeometries( parts );
 
 	// scale the canonical 1.75 m figure to the requested height, feet stay on y = 0
 	const s = p.height / 1.75;
@@ -124,9 +309,46 @@ function buildPersonGeometry( p ) {
 
 function createPersonMaterial() {
 
+	const partId = varying( attribute( 'partId', 'float' ) ).setInterpolation( InterpolationSamplingType.FLAT, InterpolationSamplingMode.EITHER );
+	const isSkin = partId.equal( SKIN );
+	const isHair = partId.equal( HAIR );
+	const isCoat = partId.equal( COAT );
+	const isLegs = partId.equal( LEGS );
+	const isShoes = partId.equal( SHOES );
+	const isBag = partId.equal( BAG );
+
+	// per-instance palette picks, each from its own hash lane so coat, shirt,
+	// trousers, skin and hair combine freely across the crowd
+	const lane = ( salt ) => fract( sin( float( instanceIndex ).add( salt ).mul( 12.9898 ) ).mul( 43758.5453 ) );
+	const pick = ( colors, salt ) => array( colors.map( c => color( c ) ) ).element( lane( salt ).mul( colors.length ).floor().min( colors.length - 1 ) );
+
+	const q = positionGeometry;
+
+	// tailoring, drawn in the canonical figure's space: a darker collar band, the
+	// coat falling open down the front, and a shirt V at the chest
+	const collar = smoothstep( 1.43, 1.45, q.y ).mul( smoothstep( 1.52, 1.5, q.y ) );
+	const placket = smoothstep( 0.02, 0.008, q.x.abs() ).mul( smoothstep( 0.02, 0.06, q.z ) ).mul( smoothstep( 1.37, 1.33, q.y ) ).mul( smoothstep( 0.8, 0.86, q.y ) );
+	const shirtV = smoothstep( 0.05, 0.06, q.z ).mul( smoothstep( 1.32, 1.36, q.y ) ).mul( smoothstep( 1.49, 1.46, q.y ) ).mul( smoothstep( 0.006, 0.0, q.x.abs().sub( q.y.sub( 1.32 ).mul( 0.42 ) ) ) );
+
+	const coatBase = pick( COAT_COLORS, 29.0 );
+	const coatTailored = mix( coatBase.mul( collar.mul( 0.25 ).add( placket.mul( 0.5 ) ).oneMinus().clamp( 0.4, 1 ) ), pick( SHIRT_COLORS, 71.0 ), shirtV );
+
+	// shoes split between black and brown leather
+	const shoes = mix( color( 0x1c1a18 ), color( 0x4a3524 ), lane( 5.0 ).step( 0.45 ) );
+
 	const material = new MeshStandardNodeMaterial();
-	material.colorNode = color( 0xffffff ); // white base, the per-instance coat colour multiplies in
-	material.roughness = 0.85; // matte cloth
+
+	material.colorNode = select( isSkin, pick( SKIN_COLORS, 17.0 ),
+		select( isHair, pick( HAIR_COLORS, 3.0 ),
+			select( isCoat, coatTailored,
+				select( isLegs, pick( LEG_COLORS, 47.0 ),
+					select( isShoes, shoes,
+						select( isBag, color( 0x3a2c20 ), color( 0xffffff ) ) ) ) ) ) );
+
+	material.roughnessNode = select( isSkin, float( 0.55 ),
+		select( isHair, float( 0.65 ),
+			select( isShoes.or( isBag ), float( 0.5 ), float( 0.85 ) ) ) ); // leather sheen under matte cloth
+
 	material.metalness = 0;
 
 	return material;

+ 136 - 23
examples/jsm/generators/city/StreetTreeGenerator.js

@@ -4,20 +4,24 @@ import {
 	IcosahedronGeometry,
 	InstancedMesh,
 	InterpolationSamplingMode,
-	InterpolationSamplingType
+	InterpolationSamplingType,
+	RingGeometry,
+	Vector3
 } from 'three';
 
 import { MeshStandardNodeMaterial } from 'three/webgpu';
-import { attribute, color, float, mix, normalWorldGeometry, select, varying } from 'three/tsl';
+import { attribute, color, float, fract, mix, mx_fractal_noise_float, normalWorldGeometry, positionGeometry, positionWorld, select, sin, smoothstep, varying, vec3 } from 'three/tsl';
 
-import { mergeGeometries } from '../../utils/BufferGeometryUtils.js';
+import { mergeGeometries, mergeVertices } from '../../utils/BufferGeometryUtils.js';
 
 /**
- * A young street tree set in a curbside pit: a tapered bark trunk rising from the
- * soil into a canopy of a few overlapping squashed leaf blobs, offset from each
- * other so the crown reads irregular rather than as a single ball. Built once and
- * instanced across a list of placements, dressed with one cheap material that
- * branches on a baked `partId` ( brown bark, green foliage ).
+ * A young street tree set in a curbside pit: a flared bark trunk rising through a
+ * cast-iron pit grate, branching into a canopy of vertex-jittered leaf clumps. The
+ * clump normals are blended toward a shared crown sphere so the clumps shade as one
+ * irregular crown rather than separate balls, and the material adds interior
+ * self-shadowing and patchy colour so the foliage reads as leaf mass. Built once
+ * and instanced across a list of placements, dressed with one cheap material that
+ * branches on a baked `partId`.
  *
  * The canopy is near rotationally symmetric, so the canonical model stands on
  * `y = 0`, centred in X / Z, with its slight lean toward `+Z` matching the other
@@ -73,7 +77,12 @@ StreetTreeGenerator.defaults = {
 	trunkRadius: 0.18 // bark radius at the base
 };
 
-const TRUNK = 0, LEAF = 1;
+const TRUNK = 0, LEAF = 1, GRATE = 2;
+
+// the crown sphere the clump normals blend toward; the material reuses it for
+// the interior-shadow falloff
+const CROWN_CENTER = new Vector3( 0, 3.9, 0.1 );
+const CROWN_RADIUS = 3.1;
 
 function part( geometry, id ) {
 
@@ -83,22 +92,102 @@ function part( geometry, id ) {
 
 }
 
+// deterministic position hash for the clump jitter
+function hash( x, y, z ) {
+
+	const s = Math.sin( x * 127.1 + y * 311.7 + z * 74.7 ) * 43758.5453;
+	return s - Math.floor( s );
+
+}
+
+// a leaf clump: an icosahedron with its vertices pushed in and out radially so the
+// silhouette breaks into lobes, its normals pulled toward the shared crown sphere
+function leafClump( radius, x, y, z ) {
+
+	// polyhedron geometries come non-indexed; weld them so the jittered surface
+	// gets smooth vertex normals instead of hard facets
+	const g = mergeVertices( new IcosahedronGeometry( radius, 1 ).scale( 1, 0.82, 1 ) );
+
+	const position = g.attributes.position;
+	const v = new Vector3();
+
+	for ( let i = 0; i < position.count; i ++ ) {
+
+		v.fromBufferAttribute( position, i );
+		const n = hash( v.x, v.y, v.z );
+		v.multiplyScalar( 0.86 + n * 0.3 );
+		position.setXYZ( i, v.x, v.y, v.z );
+
+	}
+
+	g.translate( x, y, z );
+	g.computeVertexNormals();
+
+	// blend the faceted normals toward the crown sphere so all the clumps light
+	// as one continuous canopy instead of a cluster of separate balls
+	const normal = g.attributes.normal;
+
+	for ( let i = 0; i < normal.count; i ++ ) {
+
+		v.fromBufferAttribute( position, i ).sub( CROWN_CENTER ).normalize();
+		normal.setXYZ(
+			i,
+			normal.getX( i ) * 0.45 + v.x * 0.55,
+			normal.getY( i ) * 0.45 + v.y * 0.55,
+			normal.getZ( i ) * 0.45 + v.z * 0.55
+		);
+
+	}
+
+	g.normalizeNormals();
+
+	return g;
+
+}
+
 function buildStreetTreeGeometry( p ) {
 
 	const r = p.trunkRadius, h = p.trunkHeight;
 
-	const soil = new CylinderGeometry( 0.5, 0.55, 0.06, 12 ).translate( 0, 0.03, 0 ); // tree-pit soil, proud of the pavement
-	const trunk = new CylinderGeometry( r * 0.55, r, h, 8 ).translate( 0, h / 2, 0 ); // tapering toward the crown
+	// tree pit: dark soil sunk inside a cast-iron grate ring flush with the pavement
+	const soil = new CylinderGeometry( 0.24, 0.3, 0.05, 10 ).translate( 0, 0.025, 0 );
+	const grate = new RingGeometry( 0.24, 0.62, 16, 4 ).rotateX( - Math.PI / 2 ).translate( 0, 0.045, 0 );
+
+	// the trunk flares at the root collar and tapers toward the crown
+	const rootFlare = new CylinderGeometry( r * 1.15, r * 1.6, 0.22, 8 ).translate( 0, 0.11, 0 );
+	const trunk = new CylinderGeometry( r * 0.55, r * 1.1, h, 8 ).translate( 0, h / 2, 0 );
+
+	// a few bare limbs fan from the trunk top into the clumps, so the canopy
+	// visibly grows out of the tree instead of hovering over it
+	const limbs = [];
+	for ( const [ rx, rz, len ] of [ [ 0.5, 0.2, 1.5 ], [ - 0.45, - 0.3, 1.4 ], [ 0.1, - 0.55, 1.2 ], [ - 0.15, 0.5, 1.3 ] ] ) {
 
-	// a few squashed blobs, offset in X / Z so the crown is lumpy rather than a single ball
-	const blobs = [
-		new IcosahedronGeometry( 2.4, 1 ).scale( 1, 0.8, 1 ).translate( 0, 3.8, 0 ),
-		new IcosahedronGeometry( 2, 1 ).scale( 1, 0.8, 1 ).translate( 1, 4.3, 0.4 ),
-		new IcosahedronGeometry( 1.9, 1 ).scale( 1, 0.8, 1 ).translate( - 0.9, 3.6, - 0.4 ),
-		new IcosahedronGeometry( 1.8, 1 ).scale( 1, 0.8, 1 ).translate( 0.2, 4.5, 0.7 )
+		limbs.push(
+			new CylinderGeometry( 0.03, 0.07, len, 5 )
+				.translate( 0, len / 2, 0 )
+				.rotateX( rx ).rotateZ( rz )
+				.translate( 0, h - 0.15, 0 )
+		);
+
+	}
+
+	// overlapping jittered clumps, one dominant mass with satellites tucked
+	// around it so the crown reads irregular rather than as a single ball
+	const clumps = [
+		leafClump( 2.15, 0, 3.9, 0.1 ),
+		leafClump( 1.5, 1.15, 4.35, 0.5 ),
+		leafClump( 1.45, - 1.05, 3.55, - 0.5 ),
+		leafClump( 1.3, 0.35, 4.75, - 0.65 ),
+		leafClump( 1.25, - 0.35, 4.5, 0.9 ),
+		leafClump( 1.1, 0.9, 3.3, - 0.85 )
 	];
 
-	return mergeGeometries( [ part( soil, TRUNK ), part( trunk, TRUNK ), ...blobs.map( b => part( b, LEAF ) ) ] );
+	return mergeGeometries( [
+		part( soil, TRUNK ),
+		part( grate, GRATE ),
+		part( mergeGeometries( [ rootFlare, trunk, ...limbs ] ), TRUNK ),
+		...clumps.map( c => part( c, LEAF ) )
+	] );
 
 }
 
@@ -106,14 +195,38 @@ function createStreetTreeMaterial() {
 
 	const partId = varying( attribute( 'partId', 'float' ) ).setInterpolation( InterpolationSamplingType.FLAT, InterpolationSamplingMode.EITHER );
 	const isLeaf = partId.equal( LEAF );
+	const isGrate = partId.equal( GRATE );
+
+	const p = positionGeometry;
+
+	// smooth world-space noise: large patches shift the green so no two trees
+	// ( or two sides of one crown ) read as the same flat colour
+	const patch = mx_fractal_noise_float( positionWorld.mul( 0.55 ), 2 ).mul( 0.5 ).add( 0.5 );
+
+	// interior shadow: leaves darken toward the crown core, where a real canopy
+	// swallows the light, and brighten toward the sun-fed rim
+	const crownDist = p.sub( vec3( CROWN_CENTER.x, CROWN_CENTER.y, CROWN_CENTER.z ) ).length().div( CROWN_RADIUS );
+	const interiorAO = smoothstep( 0.1, 1.05, crownDist ).mul( 0.5 ).add( 0.5 );
+
+	// sky-facing lift plus patchy hue drift between deep and warm greens
+	const skyLift = normalWorldGeometry.y.mul( 0.5 ).add( 0.5 );
+	const leafBase = mix( color( 0x33511f ), color( 0x5d7c33 ), skyLift );
+	const leafVaried = mix( leafBase, color( 0x77822f ), patch.mul( 0.55 ) );
+	const leaf = leafVaried.mul( interiorAO );
+
+	// bark: vertical streaks of lighter dead bark over the brown
+	const barkStreak = fract( sin( p.x.mul( 37.7 ).add( p.z.mul( 51.3 ) ) ).mul( 289.31 ).add( p.y.mul( 0.7 ) ) );
+	const bark = mix( color( 0x4a3a2a ), color( 0x6a5a44 ), smoothstep( 0.55, 0.95, barkStreak ).mul( 0.6 ) );
 
-	// lift the green toward the sky-facing side so the crown gets shape for free
-	const leaf = mix( color( 0x3c5a2c ), color( 0x577a38 ), normalWorldGeometry.y.mul( 0.5 ).add( 0.5 ) );
+	// cast-iron pit grate: concentric slots cut by a radial stripe pattern
+	const gr = p.xz.length();
+	const slots = smoothstep( 0.35, 0.5, fract( gr.mul( 11.0 ) ) );
+	const grate = mix( color( 0x121110 ), color( 0x3a3835 ), slots );
 
 	const material = new MeshStandardNodeMaterial();
-	material.colorNode = select( isLeaf, leaf, color( 0x4a3a2a ) ); // foliage green over bark brown
-	material.roughnessNode = float( 0.9 );
-	material.metalnessNode = float( 0 );
+	material.colorNode = select( isLeaf, leaf, select( isGrate, grate, bark ) );
+	material.roughnessNode = select( isGrate, float( 0.7 ), float( 0.9 ) );
+	material.metalnessNode = select( isGrate, float( 0.4 ), float( 0 ) );
 
 	return material;
 

+ 1 - 1
examples/jsm/generators/city/StreetlightGenerator.js

@@ -129,7 +129,7 @@ function createStreetlightMaterial() {
 	material.colorNode = select( isLens, color( 0xfff0cc ), color( 0x40433d ) ); // warm lit lens, dark olive-grey metal
 	material.roughnessNode = select( isLens, float( 0.3 ), float( 0.5 ) );
 	material.metalnessNode = select( isLens, float( 0 ), float( 0.7 ) );
-	material.emissiveNode = select( isLens, color( 0xffe2a6 ).mul( 3 ), color( 0x000000 ) ); // the lamp glows
+	material.emissiveNode = select( isLens, color( 0xffe2a6 ).mul( 60 ), color( 0x000000 ) ); // luminaire lens at ~2x a sunlit white wall, as a real cobra head reads
 
 	return material;
 

+ 1 - 1
examples/jsm/generators/city/TrafficlightGenerator.js

@@ -135,7 +135,7 @@ function createTrafficlightMaterial() {
 	material.colorNode = select( partId.equal( RED ), color( 0x3a0a06 ), select( partId.equal( AMBER ), color( 0x402608 ), select( isGreen, color( 0x2bd24b ), color( 0x233029 ) ) ) ); // dark glass, lit green, dark olive metal
 	material.roughnessNode = select( isLens, float( 0.3 ), float( 0.5 ) );
 	material.metalnessNode = select( isLens, float( 0 ), float( 0.7 ) );
-	material.emissiveNode = select( isGreen, color( 0x2bd24b ).mul( 4.5 ), color( 0x000000 ) ); // only the go signal glows
+	material.emissiveNode = select( isGreen, color( 0x2bd24b ).mul( 18 ), color( 0x000000 ) ); // only the go signal glows, at LED-aspect radiance that still reads in full sun
 
 	return material;
 

+ 24 - 11
examples/jsm/generators/city/TrashcanGenerator.js

@@ -1,13 +1,14 @@
 import {
 	BufferAttribute,
 	CylinderGeometry,
+	IcosahedronGeometry,
 	InstancedMesh,
 	InterpolationSamplingMode,
 	InterpolationSamplingType
 } from 'three';
 
 import { MeshStandardNodeMaterial } from 'three/webgpu';
-import { attribute, color, float, Fn, fract, mix, select, step, uv, varying } from 'three/tsl';
+import { attribute, color, float, Fn, fract, mix, mx_fractal_noise_float, positionGeometry, select, smoothstep, step, uv, varying } from 'three/tsl';
 
 import { mergeGeometries } from '../../utils/BufferGeometryUtils.js';
 
@@ -87,7 +88,10 @@ function buildTrashcanGeometry( p ) {
 	const foot = new CylinderGeometry( r * 0.92, r * 0.86, 0.06, 16 ).translate( 0, 0.03, 0 );
 	const bag = new CylinderGeometry( r * 0.86, r * 0.7, h * 0.9, 12 ).translate( 0, h * 0.5, 0 ); // dark refuse, just inside
 
-	return mergeGeometries( [ part( drum, MESH ), part( rim, RIM ), part( foot, RIM ), part( bag, TRASH ) ] );
+	// the basket never quite closes: a lumpy mound of refuse crests over the rim
+	const mound = new IcosahedronGeometry( r * 0.82, 1 ).scale( 1, 0.55, 1 ).translate( 0.02, h + 0.02, - 0.01 );
+
+	return mergeGeometries( [ part( drum, MESH ), part( rim, RIM ), part( foot, RIM ), part( bag, TRASH ), part( mound, TRASH ) ] );
 
 }
 
@@ -97,23 +101,32 @@ function createTrashcanMaterial() {
 	const isRim = partId.equal( RIM );
 	const isTrash = partId.equal( TRASH );
 
-	// procedural diamond weave on the drum, drawn from its UV so the mesh reads as
-	// open without alpha: dark gaps between bright-green wire, faded out by row
+	// expanded-metal weave on the drum, drawn from its UV so the mesh reads as
+	// open without alpha: thin crossing diagonal wires around dark diamond holes
 	const weave = Fn( () => {
 
 		const u = uv().x.mul( 26 );
-		const v = uv().y.mul( 14 );
-		const a = step( 0.5, fract( u.add( v ) ) );
-		const b = step( 0.5, fract( u.sub( v ) ) );
-		return a.add( b ).mul( 0.5 ); // 0 in the holes, 1 on the crossing wires
+		const v = uv().y.mul( 9 );
+		const a = fract( u.add( v ) ).sub( 0.5 ).abs();
+		const b = fract( u.sub( v ) ).sub( 0.5 ).abs();
+		return smoothstep( 0.3, 0.2, a.min( b ) ); // 1 on the wires, 0 in the diamond holes
 
 	} )();
 
-	const green = color( 0x2f4a32 );
-	const drumColor = mix( green.mul( 0.25 ), green, weave ); // holes go near-black
+	const p = positionGeometry;
+
+	// street grime creeps up from the foot of the basket
+	const grime = smoothstep( 0.45, 0.05, p.y ).mul( 0.5 ).oneMinus();
+
+	const green = color( 0x39543a ).mul( grime );
+	const drumColor = mix( color( 0x101210 ), green, weave ); // holes fall into the dark interior
+
+	// the mound of refuse: dark bags flecked with scraps of paper and litter
+	const fleck = step( 0.82, mx_fractal_noise_float( p.mul( 34 ), 2 ).mul( 0.5 ).add( 0.5 ) );
+	const trash = mix( color( 0x24221d ), color( 0xa89e88 ), fleck.mul( smoothstep( 0.6, 0.75, p.y ) ) );
 
 	const material = new MeshStandardNodeMaterial();
-	material.colorNode = select( isTrash, color( 0x1b1b18 ), select( isRim, green.mul( 1.1 ), drumColor ) );
+	material.colorNode = select( isTrash, trash, select( isRim, green.mul( 1.15 ), drumColor ) );
 	material.roughnessNode = select( isTrash, float( 0.9 ), float( 0.55 ) );
 	material.metalnessNode = select( isTrash, float( 0 ), float( 0.5 ) );
 

BIN
examples/screenshots/webgpu_generator_city.jpg


+ 1 - 24
examples/webgpu_generator_city.html

@@ -39,13 +39,12 @@
 		<script type="module">
 
 			import * as THREE from 'three/webgpu';
-			import { pass, mrt, normalView, screenUV, sample, packNormalToRGB, unpackRGBToNormal, builtinAOContext } from 'three/tsl';
+			import { pass } from 'three/tsl';
 
 			import { Inspector } from 'three/addons/inspector/Inspector.js';
 			import { FirstPersonControls } from 'three/addons/controls/FirstPersonControls.js';
 			import { SkyMesh } from 'three/addons/objects/SkyMesh.js';
 			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';
@@ -154,31 +153,9 @@
 
 				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
-				// then darkens only the sky/IBL fill in the crevices, never the direct sun
-
 				renderPipeline = new THREE.RenderPipeline( renderer );
 
-				const prePass = pass( scene, camera );
-				prePass.transparent = false;
-				prePass.setMRT( mrt( { output: packNormalToRGB( normalView ) } ) );
-				prePass.getTexture( 'output' ).type = THREE.UnsignedByteType; // normals fit in 8 bits
-
-				const prePassNormal = sample( ( uv ) => unpackRGBToNormal( prePass.getTextureNode().sample( uv ) ) );
-				const prePassDepth = prePass.getTextureNode( 'depth' );
-
-				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;
-
 				const scenePass = pass( scene, camera );
-				scenePass.contextNode = builtinAOContext( aoPass.getTextureNode().sample( screenUV ).r );
-
 				const scenePassColor = scenePass.getTextureNode();
 
 				// bloom: a soft glow on the brightest areas, at quarter-res since the

粤ICP备19079148号