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

Examples: Improve webgpu_custom_fog with terrain and forest generators. (#33873)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mrdoob 2 недель назад
Родитель
Сommit
fc5a1a3ada

+ 347 - 0
examples/jsm/generators/ForestGenerator.js

@@ -0,0 +1,347 @@
+import {
+	BufferAttribute,
+	Group,
+	IcosahedronGeometry,
+	InstancedBufferAttribute,
+	InstancedMesh,
+	Object3D,
+	Vector3
+} from 'three';
+
+import { MeshStandardNodeMaterial } from 'three/webgpu';
+import { attribute, color, float, Fn, If, mix, mx_noise_float, normalView, positionLocal, positionView, positionWorld, smoothstep, step, uniform } from 'three/tsl';
+
+import { ImprovedNoise } from '../math/ImprovedNoise.js';
+import { mergeVertices } from '../utils/BufferGeometryUtils.js';
+
+/**
+ * Carpets a {@link TerrainGenerator} ( or anything exposing `sampleHeight`,
+ * `sampleSlope`, `minY`, `maxY` and `parameters.size` ) with a forest of hundreds
+ * of thousands of trees in a single draw call.
+ *
+ * Each tree is the cheapest thing that still reads as a tree: a ~20-face icosphere
+ * squashed into a tapered teardrop and lumped with a little noise, carrying a baked
+ * dark-base / bright-top gradient. Tens of triangles each, so a single
+ * {@link THREE.InstancedMesh} of half a million of them costs one draw call. Trees
+ * are placed by rejection sampling against ecological rules — a min/max altitude
+ * band ( above the mist floor, below the snowline ), a slope limit ( none on
+ * cliffs ) and a low-frequency density mask that opens clearings — then jittered in
+ * yaw, lean and ( squared-biased ) scale so the stand never reads as copies.
+ *
+ * ```js
+ * const forest = new ForestGenerator( { count: 500000 } );
+ * scene.add( forest.build( terrain ) );
+ * ```
+ */
+class ForestGenerator {
+
+	constructor( parameters = {} ) {
+
+		this.parameters = Object.assign( {}, ForestGenerator.defaults, parameters );
+
+		// stochastic distance cull ( THREE.Fog-style near / far ): drawn within `from`, gone
+		// past `to`, the band between thinned by a baked random. live-tunable uniforms.
+		this.from = uniform( this.parameters.from );
+		this.to = uniform( this.parameters.to );
+
+		// main-camera position ( set via setCameraPosition ). NOT the TSL cameraPosition node:
+		// in the shadow pass that resolves to the light, which would cull the wrong trees.
+		this._cameraPosition = uniform( new Vector3() );
+
+		this.material = createForestMaterial( this.from, this.to, this._cameraPosition );
+		this.mesh = null;
+		this.group = null;
+
+	}
+
+	build( terrain ) {
+
+		this.dispose();
+
+		const p = this.parameters;
+		const geometry = blobGeometry( p );
+
+		const size = terrain.parameters.size;
+		const minY = terrain.minY;
+		const span = terrain.maxY - terrain.minY;
+
+		const random = createRandom( p.seed );
+
+		// a low-frequency field that breaks the forest into patches and clearings
+		const perlin = new ImprovedNoise();
+		const dOffX = random() * 256, dOffZ = random() * 256, dSlice = random() * 256;
+		const densityAt = ( x, z ) => smoothBlend( - 0.12, 0.22, perlin.noise( x * p.densityFrequency + dOffX, z * p.densityFrequency + dOffZ, dSlice ) );
+
+		const mesh = new InstancedMesh( geometry, this.material, p.count );
+		mesh.castShadow = mesh.receiveShadow = p.castShadow; // honoured on every rebuild
+
+		// per-instance cull data: xyz = tree position ( for its distance to the camera ),
+		// w = a threshold jitter from a separate PRNG, so it doesn't disturb placement
+		const cullData = new Float32Array( p.count * 4 );
+		const cullRandom = createRandom( ( p.seed ^ 0x9e3779b9 ) >>> 0 );
+
+		// per-instance regional colour drift, baked here so the vertex-bound shader taps no
+		// noise. offsets come from the cull PRNG, so placement is untouched.
+		const regionData = new Float32Array( p.count );
+		const rOffX = cullRandom() * 256, rOffZ = cullRandom() * 256, rSlice = cullRandom() * 256;
+
+		const dummy = new Object3D();
+		let placed = 0;
+		let attempts = 0;
+		const maxAttempts = p.count * 14; // give up rather than hang if the band is too small
+
+		while ( placed < p.count && attempts < maxAttempts ) {
+
+			attempts ++;
+
+			const x = ( random() - 0.5 ) * size;
+			const z = ( random() - 0.5 ) * size;
+
+			const y = terrain.sampleHeight( x, z );
+			const altitude = ( y - minY ) / span;
+			if ( altitude < p.altitudeMin || altitude > p.altitudeMax ) continue;
+
+			if ( terrain.sampleSlope( x, z ) < p.minSlope ) continue;
+
+			// density mask, feathered out at the top so the treeline scatters, not a clean line
+			let density = densityAt( x, z );
+			density *= smoothBlend( p.altitudeMax, p.altitudeMax - 0.14, altitude );
+			if ( random() >= density ) continue;
+
+			dummy.position.set( x, y - p.sink, z ); // sink the base point into the ground
+			dummy.rotation.set( ( random() - 0.5 ) * 0.12, random() * Math.PI * 2, ( random() - 0.5 ) * 0.12 ); // small lean + free yaw, trunk ~vertical
+
+			const s = p.minScale + random() * random() * ( p.maxScale - p.minScale ); // squared bias: mostly small, rare giants
+			dummy.scale.set( s * ( 0.85 + random() * 0.3 ), s, s * ( 0.85 + random() * 0.3 ) );
+
+			dummy.updateMatrix();
+			mesh.setMatrixAt( placed, dummy.matrix );
+
+			const c = placed * 4;
+			cullData[ c ] = x;
+			cullData[ c + 1 ] = dummy.position.y; // the sunk y, matching the drawn position
+			cullData[ c + 2 ] = z;
+			cullData[ c + 3 ] = cullRandom();
+
+			regionData[ placed ] = Math.min( 1, Math.max( 0, perlin.noise( x * 0.02 + rOffX, z * 0.02 + rOffZ, rSlice ) * 0.6 + 0.5 ) );
+
+			placed ++;
+
+		}
+
+		mesh.count = placed; // only what got planted
+		mesh.instanceMatrix.needsUpdate = true;
+		geometry.setAttribute( 'cull', new InstancedBufferAttribute( cullData, 4 ) );
+		geometry.setAttribute( 'region', new InstancedBufferAttribute( regionData, 1 ) );
+
+		const group = new Group();
+		group.name = 'Forest';
+		group.add( mesh );
+
+		this.mesh = mesh;
+		this.group = group;
+
+		return group;
+
+	}
+
+	// call each frame so the distance cull tracks the camera
+	setCameraPosition( position ) {
+
+		this._cameraPosition.value.copy( position );
+
+	}
+
+	dispose() {
+
+		if ( this.mesh ) this.mesh.geometry.dispose();
+		this.mesh = null;
+		this.group = null;
+
+	}
+
+}
+
+ForestGenerator.defaults = {
+	seed: 1,
+	count: 500000, // number of trees to plant ( a single instanced draw call )
+	detail: 0, // icosphere subdivision ( 0 = 20 faces, welds to 12 verts )
+	radius: 1.3, // base half-width of a tree blob, in world units
+	height: 4, // base height of a tree blob
+	distortion: 0.5, // lumpiness of the blob hull ( a rough conifer, not a smooth egg )
+	sink: 0.4, // how far the base point is pushed under the surface, to hide it
+	altitudeMin: 0.12, // normalised altitude band the forest occupies: above the mist floor...
+	altitudeMax: 0.46, // ...and safely below the snowline
+	minSlope: 0.55, // minimum surface flatness ( normal.y ); steeper ground stays bare rock
+	densityFrequency: 0.012, // patch / clearing scale ( world units )
+	minScale: 0.7,
+	maxScale: 1.8,
+	from: 300, // distance ( like THREE.Fog ) within which every tree is drawn...
+	to: 620, // ...past which none are; the band between thins out stochastically
+	castShadow: false // whether the canopy casts + receives shadows ( 500k casters is a real cost — opt in )
+};
+
+// deterministic PRNG ( mulberry32 ), matching the other generators
+function createRandom( seed ) {
+
+	let s = ( seed >>> 0 ) || 1;
+
+	return function () {
+
+		s = ( s + 0x6D2B79F5 ) | 0;
+		let t = Math.imul( s ^ ( s >>> 15 ), 1 | s );
+		t = ( t + Math.imul( t ^ ( t >>> 7 ), 61 | t ) ) ^ t;
+		return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296;
+
+	};
+
+}
+
+function smoothBlend( edge0, edge1, x ) {
+
+	const t = Math.max( 0, Math.min( 1, ( x - edge0 ) / ( edge1 - edge0 ) ) );
+	return t * t * ( 3 - 2 * t );
+
+}
+
+// smooth low-frequency lump over the unit sphere, so the blob hull is bumpy not spiky
+function blobNoise( x, y, z ) {
+
+	return Math.sin( x * 3.1 ) * Math.sin( y * 2.7 + 1.3 ) * Math.sin( z * 3.5 + 2.1 );
+
+}
+
+// one tree blob: an icosphere squashed into a lumpy, tapered teardrop, base at y = 0.
+// normals are re-pointed up-and-out so it shades as a soft canopy volume; a baked `ao`
+// ( 0 base → 1 crown ) drives the dark-underside / bright-crown gradient.
+function blobGeometry( p ) {
+
+	// IcosahedronGeometry is non-indexed ( 60 verts ); deleting uv + normal lets mergeVertices
+	// weld by position to 12 verts — ~5× fewer vertex-shader runs. normals are rebuilt below.
+	let geometry = new IcosahedronGeometry( 1, p.detail );
+	geometry.deleteAttribute( 'uv' );
+	geometry.deleteAttribute( 'normal' );
+	geometry = mergeVertices( geometry );
+
+	const position = geometry.attributes.position;
+	const count = position.count;
+
+	const normals = new Float32Array( count * 3 );
+	const ao = new Float32Array( count );
+
+	for ( let i = 0; i < count; i ++ ) {
+
+		const ux = position.getX( i );
+		const uy = position.getY( i );
+		const uz = position.getZ( i ); // a point on the unit sphere
+
+		const h = ( uy + 1 ) / 2; // 0 at the base, 1 at the top
+		const taper = 1 - 0.62 * h; // narrower toward a pointier crown
+		const lump = 1 + p.distortion * blobNoise( ux, uy, uz );
+		const r = taper * lump;
+
+		position.setXYZ( i, ux * r * p.radius, h * p.height, uz * r * p.radius );
+
+		// up-and-outward normal: a soft, dome-lit canopy rather than faceted rock
+		const inv = 1 / Math.hypot( ux, 0.55, uz );
+		normals[ i * 3 ] = ux * inv;
+		normals[ i * 3 + 1 ] = 0.55 * inv;
+		normals[ i * 3 + 2 ] = uz * inv;
+
+		ao[ i ] = h;
+
+	}
+
+	position.needsUpdate = true;
+	geometry.setAttribute( 'normal', new BufferAttribute( normals, 3 ) );
+	geometry.setAttribute( 'ao', new BufferAttribute( ao, 1 ) );
+	geometry.computeBoundingSphere();
+
+	return geometry;
+
+}
+
+// derivative-based bump ( surface-gradient method ): perturbs the view normal from a
+// procedural height field, so the canopy reads as clustered foliage, not a smooth shell
+function bumpNormal( height ) {
+
+	const dpdx = positionView.dFdx();
+	const dpdy = positionView.dFdy();
+	const r1 = dpdy.cross( normalView );
+	const r2 = normalView.cross( dpdx );
+	const det = dpdx.dot( r1 );
+	const grad = det.sign().mul( height.dFdx().mul( r1 ).add( height.dFdy().mul( r2 ) ) );
+
+	return det.abs().mul( normalView ).sub( grad ).normalize();
+
+}
+
+/**
+ * The single material shared by every tree in a {@link ForestGenerator}. A plain
+ * MeshStandardNodeMaterial lit by the scene — only the surface is authored: deep
+ * shadowed green in the recesses rising to a bright, yellow-green sunlit crown,
+ * mottled into needle clumps by 3D noise, with a matching bump so the clumps catch
+ * the light. Half a million instanced blobs makes this mesh vertex-bound, so the
+ * regional colour drift is baked to a per-instance attribute ( no shader noise for it ),
+ * and the costly clump noise + bump are **gated by distance** — full detail on the near
+ * trees ( where it reads ), skipped on the far canopy ( where it is sub-pixel ).
+ *
+ * @param {Node} from - distance within which every tree is drawn.
+ * @param {Node} to - distance past which no tree is drawn.
+ * @return {MeshStandardNodeMaterial}
+ */
+function createForestMaterial( from, to, camPos ) {
+
+	const material = new MeshStandardNodeMaterial();
+	material.metalness = 0;
+	material.roughness = 0.88;
+
+	const cull = attribute( 'cull', 'vec4' ); // xyz = tree position, w = random 0..1
+	const d = cull.xyz.distance( camPos ); // per-tree distance to the ( main ) camera
+
+	// stochastic distance cull: past its jittered `from`→`to` threshold a tree collapses to a
+	// point, dropping the far canopy. `positionLocal` is already WORLD space here ( the instance
+	// transform runs before positionNode ), so the ×0 lands the whole blob on the origin.
+	const t = d.sub( from ).div( to.sub( from ) );
+	material.positionNode = positionLocal.mul( step( t, cull.w ) ); // keep where random ≥ t
+
+	const ao = attribute( 'ao', 'float' ); // 0 at the blob base, 1 at the crown
+
+	// regional drift, baked per tree ( see build ) so no stage taps a noise; a blob is small
+	// enough that one value per tree reads as a smooth field across the canopy
+	const region = attribute( 'region', 'float' );
+	const deep = mix( color( 0x1d3318 ), color( 0x2e4420 ), region ); // shadowed interior
+	const bright = mix( color( 0x4c6a2e ), color( 0x6e8a40 ), region ); // sunlit tips ( muted green, not neon )
+
+	// one 3D noise field ( coarse + fine ), shared by the colour and bump, near canopy only
+	const detailFade = smoothstep( 280, 25, positionWorld.distance( camPos ) );
+
+	// gated by an If ( which must sit inside an Fn ) so the far canopy skips the noise
+	const clump = Fn( () => {
+
+		const c = float( 0 ).toVar();
+
+		If( detailFade.greaterThan( 0.01 ), () => {
+
+			c.assign( mx_noise_float( positionWorld.mul( 0.9 ) )
+				.add( mx_noise_float( positionWorld.mul( 3.1 ) ).mul( 0.5 ) )
+				.mul( detailFade ) );
+
+		} );
+
+		return c;
+
+	} )();
+
+	// deep recesses → bright clumps / crown
+	const lit = ao.mul( 0.5 ).add( 0.32 ).add( clump.mul( 0.18 ) ).clamp();
+	material.colorNode = mix( deep, bright, lit );
+
+	// clumps catch the light ( clump is 0 far away, so the bump flattens there )
+	material.normalNode = bumpNormal( clump.mul( 0.22 ) );
+
+	return material;
+
+}
+
+export { ForestGenerator, createForestMaterial };

+ 504 - 0
examples/jsm/generators/TerrainGenerator.js

@@ -0,0 +1,504 @@
+import {
+	BufferGeometry,
+	Float32BufferAttribute,
+	Group,
+	Mesh
+} from 'three';
+
+import { MeshStandardNodeMaterial } from 'three/webgpu';
+import { cameraPosition, color, float, Fn, If, mix, mx_noise_float, normalView, normalWorld, positionView, positionWorld, saturation, smoothstep, uniform } from 'three/tsl';
+
+import { ImprovedNoise } from '../math/ImprovedNoise.js';
+
+/**
+ * Bakes a procedural mountain range into a single {@link THREE.BufferGeometry} and
+ * returns a `THREE.Group` ready to add to a scene.
+ *
+ * The heightfield is a derivative-damped fractal sum ( Quilez's fake erosion ): each
+ * octave is suppressed where the running slope is already steep, concentrating detail
+ * into weathered ridgelines, and a low-frequency domain warp makes those ridges
+ * meander. A few passes of thermal ( talus ) erosion then relax any slope past the
+ * angle of repose, settling the fractal's needle-spikes into real crests.
+ *
+ * The grid is triangulated with alternating quad diagonals ( a diamond pattern ), so a
+ * coarse mesh holds its silhouette without a one-way grain. The surface shades itself
+ * from altitude and slope in TSL — grass, forest, rock, scree and snow, with detail
+ * normals and aerial perspective — so no material or textures are needed.
+ *
+ * The baked height grid is exposed through {@link TerrainGenerator#sampleHeight} so a
+ * scattered forest ( or anything else ) can sit exactly on the surface.
+ *
+ * ```js
+ * const terrain = new TerrainGenerator( { seed: 1 } );
+ * scene.add( terrain.build() );
+ * ```
+ */
+class TerrainGenerator {
+
+	constructor( parameters = {} ) {
+
+		this.parameters = Object.assign( {}, TerrainGenerator.defaults, parameters );
+
+		// baked altitude range, fed to the shader so the colour bands track the real
+		// valley floor and peaks
+		this.minHeight = uniform( 0 );
+		this.maxHeight = uniform( 1 );
+
+		this.material = terrainMaterial( this.minHeight, this.maxHeight );
+		this.geometry = null;
+		this.group = null;
+
+	}
+
+	build() {
+
+		this.dispose();
+
+		const p = this.parameters;
+		const N = p.segments + 1;
+		const half = p.size / 2;
+
+		// world coordinate of each grid line, shared by the bake and layout below
+		const coord = new Array( N );
+		for ( let i = 0; i < N; i ++ ) coord[ i ] = i / p.segments * p.size - half;
+
+		// bake the height grid; kept around so the surface can be sampled ( bilinearly )
+		// afterwards — e.g. to sit a scattered forest on it
+		const height = heightField( p );
+		const heights = new Float32Array( N * N );
+
+		for ( let iz = 0; iz < N; iz ++ ) {
+
+			for ( let ix = 0; ix < N; ix ++ ) {
+
+				heights[ iz * N + ix ] = height( coord[ ix ], coord[ iz ] );
+
+			}
+
+		}
+
+		// relax slopes past the angle of repose, shedding the fractal's needle-spikes
+		if ( p.talusPasses > 0 ) thermalErode( heights, N, p.size / p.segments, p.talus, p.talusPasses );
+
+		// lay the grid out flat in the XZ plane ( Y-up ) and find the height range
+		const positions = new Float32Array( N * N * 3 );
+		let min = Infinity, max = - Infinity;
+
+		for ( let iz = 0; iz < N; iz ++ ) {
+
+			for ( let ix = 0; ix < N; ix ++ ) {
+
+				const o = iz * N + ix;
+				const y = heights[ o ];
+
+				positions[ o * 3 ] = coord[ ix ];
+				positions[ o * 3 + 1 ] = y;
+				positions[ o * 3 + 2 ] = coord[ iz ];
+
+				if ( y < min ) min = y;
+				if ( y > max ) max = y;
+
+			}
+
+		}
+
+		// flip the quad diagonal on every other quad, so the mesh reads as diamonds
+		// rather than a one-way grain
+		const indices = [];
+
+		for ( let iz = 0; iz < p.segments; iz ++ ) {
+
+			for ( let ix = 0; ix < p.segments; ix ++ ) {
+
+				const a = iz * N + ix, b = a + 1, c = a + N, d = c + 1;
+
+				if ( ( ix + iz ) % 2 === 0 ) indices.push( a, c, b, b, c, d );
+				else indices.push( a, c, d, a, d, b );
+
+			}
+
+		}
+
+		const geometry = new BufferGeometry();
+		geometry.setAttribute( 'position', new Float32BufferAttribute( positions, 3 ) );
+		geometry.setIndex( indices );
+		geometry.computeVertexNormals();
+
+		this.heights = heights;
+		this.gridSize = N;
+		this.minY = min;
+		this.maxY = max;
+		this.minHeight.value = min;
+		this.maxHeight.value = max;
+
+		const mesh = new Mesh( geometry, this.material );
+		mesh.castShadow = mesh.receiveShadow = true;
+
+		const group = new Group();
+		group.name = 'Terrain';
+		group.add( mesh );
+
+		this.geometry = geometry;
+		this.group = group;
+
+		return group;
+
+	}
+
+	// world-space height at ( x, z ), bilinearly interpolated from the baked grid
+	sampleHeight( x, z ) {
+
+		const p = this.parameters;
+		const N = this.gridSize;
+		const half = p.size / 2;
+
+		const fx = Math.max( 0, Math.min( p.segments, ( x + half ) / p.size * p.segments ) );
+		const fz = Math.max( 0, Math.min( p.segments, ( z + half ) / p.size * p.segments ) );
+
+		const ix = Math.min( N - 2, Math.floor( fx ) );
+		const iz = Math.min( N - 2, Math.floor( fz ) );
+		const tx = fx - ix;
+		const tz = fz - iz;
+
+		const h = this.heights;
+		const h00 = h[ iz * N + ix ];
+		const h10 = h[ iz * N + ix + 1 ];
+		const h01 = h[ ( iz + 1 ) * N + ix ];
+		const h11 = h[ ( iz + 1 ) * N + ix + 1 ];
+
+		return ( h00 * ( 1 - tx ) + h10 * tx ) * ( 1 - tz ) + ( h01 * ( 1 - tx ) + h11 * tx ) * tz;
+
+	}
+
+	// surface flatness at ( x, z ): the normal's y component ( 1 on the flat, → 0 on a
+	// cliff ). allocation-free, for cheaply testing many candidate forest positions.
+	sampleSlope( x, z ) {
+
+		const e = this.parameters.size / this.parameters.segments;
+		const hx = this.sampleHeight( x + e, z ) - this.sampleHeight( x - e, z );
+		const hz = this.sampleHeight( x, z + e ) - this.sampleHeight( x, z - e );
+
+		return 2 * e / Math.sqrt( hx * hx + 4 * e * e + hz * hz );
+
+	}
+
+	dispose() {
+
+		if ( this.geometry ) this.geometry.dispose();
+		this.geometry = null;
+		this.group = null;
+
+	}
+
+}
+
+TerrainGenerator.defaults = {
+	seed: 1,
+	size: 200, // world units across the square patch
+	segments: 192, // grid quads per side; vertices = ( segments + 1 )²
+	heightScale: 65, // peak-to-valley exaggeration, in world units
+	frequency: 0.01, // base noise frequency ( the footprint of a mountain )
+	octaves: 5,
+	lacunarity: 1.97, // per-octave frequency step; off 2 so octaves don't grid-lock
+	gain: 0.5, // per-octave amplitude step ( persistence )
+	erosion: 0.7, // derivative damping: higher flattens valleys and sharpens ridges
+	warp: 0.35, // domain-warp strength ( noise units ): bends ridges and valleys
+	valleyBias: 1.2, // power curve over the height, to flatten the mist floor
+	seaLevel: 0.15, // 0..1, subtracted before scaling so the valley floor sinks below y = 0
+	talus: 1, // thermal-erosion angle of repose ( rise / run ): lower settles flatter
+	talusPasses: 12 // thermal-erosion iterations ( 0 = off )
+};
+
+// deterministic PRNG ( mulberry32 ), so a seed always bakes the same terrain
+function createRandom( seed ) {
+
+	let s = ( seed >>> 0 ) || 1;
+
+	return function () {
+
+		s = ( s + 0x6D2B79F5 ) | 0;
+		let t = Math.imul( s ^ ( s >>> 15 ), 1 | s );
+		t = ( t + Math.imul( t ^ ( t >>> 7 ), 61 | t ) ) ^ t;
+		return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296;
+
+	};
+
+}
+
+// builds the height( worldX, worldZ ) function for one seed
+function heightField( p ) {
+
+	const perlin = new ImprovedNoise();
+	const random = createRandom( p.seed );
+
+	// ImprovedNoise's permutation is fixed, so a seed can only shift the sample window:
+	// a translation and a per-octave z-slice, drawn from the PRNG to decorrelate seeds
+	const offsetX = random() * 256;
+	const offsetZ = random() * 256;
+	const slice = random() * 256;
+
+	const { frequency, octaves, lacunarity, gain, erosion, warp, valleyBias, seaLevel, heightScale } = p;
+
+	// low-frequency fractal sum that warps the sample position
+	function warpField( x, z, zr ) {
+
+		let freq = 1, amp = 1, sum = 0, norm = 0;
+
+		for ( let i = 0; i < 2; i ++ ) {
+
+			sum += amp * perlin.noise( x * freq + offsetX, z * freq + offsetZ, zr + i * 1.7 );
+			norm += amp; freq *= lacunarity; amp *= gain;
+
+		}
+
+		return sum / norm;
+
+	}
+
+	// derivative-damped fractal sum: each octave is divided down where the running
+	// gradient is already steep, keeping ridges crisp and valleys smooth. the domain
+	// rotates between octaves to break the noise's axis-aligned grid.
+	function eroded( x, z ) {
+
+		let sum = 0, amp = 1, dX = 0, dZ = 0, px = x, pz = z, freq = 1;
+		const e = 0.004; // finite-difference step, in noise units
+
+		for ( let i = 0; i < octaves; i ++ ) {
+
+			const zr = slice + i * 1.7;
+			const bx = px * freq + offsetX, bz = pz * freq + offsetZ;
+			const n = perlin.noise( bx, bz, zr );
+			const nx = perlin.noise( bx + e, bz, zr );
+			const nz = perlin.noise( bx, bz + e, zr );
+
+			// this octave's world-space gradient ( chain rule: × freq )
+			dX += ( nx - n ) / e * freq;
+			dZ += ( nz - n ) / e * freq;
+
+			sum += amp * n / ( 1 + erosion * ( dX * dX + dZ * dZ ) );
+
+			// rotate the domain ~37° ( the matrix [ 0.8 -0.6 ; 0.6 0.8 ] )
+			const rx = 0.8 * px - 0.6 * pz;
+			pz = 0.6 * px + 0.8 * pz;
+			px = rx;
+
+			freq *= lacunarity; amp *= gain;
+
+		}
+
+		return sum * 0.5 + 0.5;
+
+	}
+
+	return function ( worldX, worldZ ) {
+
+		const x = worldX * frequency, z = worldZ * frequency;
+
+		// warp the sample so ridges and valleys meander instead of running straight
+		const wx = x + warp * warpField( x + 1.3, z + 7.2, slice + 40 );
+		const wz = z + warp * warpField( x + 5.2, z + 1.3, slice + 70 );
+
+		// power curve that settles the low ground into a flat mist bed
+		const h = Math.pow( Math.min( eroded( wx, wz ) * 1.1, 1 ), valleyBias );
+
+		return ( h - seaLevel ) * heightScale;
+
+	};
+
+}
+
+// thermal ( talus ) erosion on the baked height grid: a cell overhanging a neighbour
+// by more than the talus drop sheds the excess downhill, so over a few passes slopes
+// relax to the angle of repose. spikes — steep on every side — bleed off fastest;
+// broad one-sided faces keep their shape. material is conserved through a delta buffer,
+// so the result is independent of cell order.
+function thermalErode( h, N, cellSize, talus, passes ) {
+
+	const drop = talus * cellSize; // max height step a slope can hold between two cells
+	const carry = 0.5; // fraction of the steepest overhang moved per pass ( <= 0.5 = stable )
+	const delta = new Float32Array( N * N );
+	const ex = [ 0, 0, 0, 0 ];
+	const off = [ - 1, 1, - N, N ];
+
+	for ( let p = 0; p < passes; p ++ ) {
+
+		delta.fill( 0 );
+
+		for ( let z = 0; z < N; z ++ ) {
+
+			for ( let x = 0; x < N; x ++ ) {
+
+				const i = z * N + x;
+				const hi = h[ i ];
+
+				// overhang past the talus drop toward each of the 4 neighbours
+				ex[ 0 ] = x > 0 ? hi - h[ i - 1 ] - drop : 0;
+				ex[ 1 ] = x < N - 1 ? hi - h[ i + 1 ] - drop : 0;
+				ex[ 2 ] = z > 0 ? hi - h[ i - N ] - drop : 0;
+				ex[ 3 ] = z < N - 1 ? hi - h[ i + N ] - drop : 0;
+
+				let sum = 0, peak = 0;
+
+				for ( let k = 0; k < 4; k ++ ) {
+
+					const d = ex[ k ];
+
+					if ( d <= 0 ) {
+
+						ex[ k ] = 0;
+						continue;
+
+					}
+
+					sum += d;
+					if ( d > peak ) peak = d;
+
+				}
+
+				if ( sum <= 0 ) continue;
+
+				// move a slice of the steepest overhang, split across the downhill
+				// neighbours in proportion to how far each sits below the talus line
+				const move = carry * peak;
+				delta[ i ] -= move;
+
+				for ( let k = 0; k < 4; k ++ ) {
+
+					if ( ex[ k ] > 0 ) delta[ i + off[ k ] ] += move * ex[ k ] / sum;
+
+				}
+
+			}
+
+		}
+
+		for ( let k = 0; k < N * N; k ++ ) h[ k ] += delta[ k ];
+
+	}
+
+}
+
+// --- shading -------------------------------------------------------------
+
+// perturbs the normal by a world-space height field using Mikkelsen's surface-gradient
+// method. the built-in bumpMap reads height by offsetting the UV — a no-op for a
+// world-keyed height — so the height's screen-space derivatives are fed in directly.
+// returns a view-space normal.
+function bumpNormal( height ) {
+
+	const dpdx = positionView.dFdx();
+	const dpdy = positionView.dFdy();
+	const r1 = dpdy.cross( normalView );
+	const r2 = normalView.cross( dpdx );
+	const det = dpdx.dot( r1 );
+	const grad = det.sign().mul( height.dFdx().mul( r1 ).add( height.dFdy().mul( r2 ) ) );
+
+	return det.abs().mul( normalView ).sub( grad ).normalize();
+
+}
+
+// altitude- and slope-based shading, all in TSL ( no textures ). only the colour,
+// roughness and detail normal are authored here; the lighting ( sun, sky fill, the
+// snow's warm/cool cast ) comes from the scene's lights and environment.
+function terrainMaterial( minHeight, maxHeight ) {
+
+	const material = new MeshStandardNodeMaterial();
+	material.metalness = 0;
+
+	const distance = positionWorld.distance( cameraPosition );
+
+	// the two drivers: normalised altitude ( valley 0 → peak 1 ) and surface flatness
+	const altitude = positionWorld.y.sub( minHeight ).div( maxHeight.sub( minHeight ) ).clamp();
+	const flatness = normalWorld.y.clamp(); // 1 on level ground, 0 on a vertical cliff
+	const steep = flatness.oneMinus();
+
+	// three reused noise scales: fine band-edge jitter, grain ( ~5u patches ) and macro
+	const detail = mx_noise_float( positionWorld.xz.mul( 0.05 ) );
+	const grain = mx_noise_float( positionWorld.xz.mul( 0.18 ) );
+	const macro = mx_noise_float( positionWorld.xz.mul( 0.012 ) );
+
+	const grass = color( 0x6e7253 ); // dry sage-olive meadow ( not video-game green )
+	const dryGrass = color( 0x8a8550 );
+	const forest = color( 0x39402f ); // dark forested mid-slope band, under the trees
+	const rock = color( 0x736a5f ); // warm grey-brown rock
+	const scree = color( 0x837a6f ); // brighter broken rock below the cliffs
+	const lichen = color( 0x6c7355 ); // muted green-grey, patched onto lower rock
+	const snow = color( 0xe9ecf0 ); // fresh snow; warm-sun / cool-sky cast is from the lighting
+	const snowDeep = color( 0xccd6e2 ); // cooler wind-packed snow, drifted into patches
+
+	// two band frequencies of lighter / darker stone, wobbled by noise, so cliff faces
+	// read as layered bedding instead of flat grey
+	const bandA = positionWorld.y.mul( 0.5 ).add( detail.mul( 3 ) ).add( macro.mul( 4 ) ).sin();
+	const bandB = positionWorld.y.mul( 1.4 ).add( grain.mul( 2 ) ).sin();
+	const strata = bandA.mul( 0.6 ).add( bandB.mul( 0.4 ) ).mul( 0.5 ).add( 0.5 );
+
+	// lichen creeps onto the lower, gentler rock; cliffs and high ground stay bare grey
+	const lichenMask = smoothstep( 0.45, 0.72, grain ).mul( smoothstep( 0.62, 0.32, steep ) ).mul( smoothstep( 0.66, 0.34, altitude ) );
+	const rockShade = mix( rock, lichen, lichenMask.mul( 0.45 ) ).mul( strata.mul( 0.36 ).add( 0.8 ) );
+
+	// meadow, drifting to dry grass in macro-noise patches over a mid band
+	let surface = mix( grass, dryGrass, smoothstep( 0.15, 0.75, macro ).mul( smoothstep( 0.22, 0.5, altitude ) ) );
+
+	// dark forested band on the gentle mid-slopes ( where the instanced trees live )
+	surface = mix( surface, forest, smoothstep( 0.16, 0.34, altitude ).mul( smoothstep( 0.5, 0.72, flatness ) ).mul( 0.75 ) );
+
+	// rock by altitude, and on every steep face regardless of height
+	surface = mix( surface, rockShade, smoothstep( 0.46, 0.64, altitude.add( detail.mul( 0.06 ) ) ) );
+	surface = mix( surface, rockShade, smoothstep( 0.34, 0.62, steep ) );
+
+	// scree on the medium-steep ground below the cliffs, broken up by noise
+	const screeMask = smoothstep( 0.42, 0.7, steep ).mul( smoothstep( 0.35, 0.7, flatness ) ).mul( detail.mul( 0.5 ).add( 0.5 ) );
+	surface = mix( surface, scree, screeMask.mul( 0.5 ) );
+
+	// snow on high, flat ground; the grain noise breaks the line so rock pokes through
+	// near the snowline instead of stopping on a clean contour
+	const snowMask = smoothstep( 0.56, 0.78, altitude.add( detail.mul( 0.08 ) ).add( grain.mul( 0.05 ) ) ).mul( smoothstep( 0.3, 0.6, flatness ) );
+	const snowColor = mix( snow, snowDeep, smoothstep( 0.2, 0.7, grain ).mul( 0.6 ) ); // patchy, not a flat sheet
+	surface = mix( surface, snowColor, snowMask );
+
+	// dark, damp ground pooling in the low flat creases ( cheap moisture proxy )
+	const cavity = smoothstep( 0.24, 0.06, altitude ).mul( flatness );
+	surface = surface.mul( cavity.mul( 0.32 ).oneMinus() );
+
+	// macro drift then a fine grain mottle, so no band is a flat colour
+	surface = surface.mul( macro.mul( 0.5 ).add( 0.5 ).mul( 0.3 ).add( 0.84 ) );
+	surface = surface.mul( grain.mul( 0.5 ).add( 0.5 ).mul( 0.12 ).add( 0.94 ) );
+
+	// aerial perspective: desaturate and lift distant ground toward a cool haze, so
+	// depth reads and the range recedes into the mist
+	const aerial = smoothstep( 180, 820, distance );
+	surface = saturation( surface, aerial.oneMinus().mul( 0.5 ).add( 0.5 ) );
+	surface = mix( surface, color( 0xcfc8ba ), aerial.mul( 0.62 ) ); // far ridges dissolve into the sky
+
+	material.colorNode = surface;
+	material.roughnessNode = mix( float( 0.95 ), float( 0.72 ), snowMask );
+
+	// detail normals: three octaves of world-space relief, faded out with distance so
+	// they can't alias into fireflies in the haze. gating the noise behind the fade ( a
+	// real branch ) lets the far majority of this fragment-bound terrain skip the taps.
+	const detailFade = smoothstep( 420, 60, distance );
+	const reliefStrength = mix( float( 0.25 ), float( 0.55 ), steep ); // more on rock, less on grass
+	const relief = Fn( () => {
+
+		const r = float( 0 ).toVar();
+
+		If( detailFade.greaterThan( 0.01 ), () => {
+
+			r.assign( mx_noise_float( positionWorld.xz.mul( 0.6 ) )
+				.add( mx_noise_float( positionWorld.xz.mul( 1.7 ) ).mul( 0.5 ) )
+				.add( mx_noise_float( positionWorld.xz.mul( 4.0 ) ).mul( 0.25 ) )
+				.mul( reliefStrength ).mul( detailFade ).mul( 0.25 ) );
+
+		} );
+
+		return r;
+
+	} )();
+
+	material.normalNode = bumpNormal( relief );
+
+	return material;
+
+}
+
+export { TerrainGenerator };

BIN
examples/screenshots/webgpu_custom_fog.jpg


+ 175 - 72
examples/webgpu_custom_fog.html

@@ -9,6 +9,9 @@
 		<meta property="og:url" content="https://threejs.org/examples/webgpu_custom_fog.html">
 		<meta property="og:image" content="https://threejs.org/examples/screenshots/webgpu_custom_fog.jpg">
 		<link type="text/css" rel="stylesheet" href="example.css">
+		<style>
+			body { background-color: #d0dee7; } /* match the scene's background grey */
+		</style>
 	</head>
 	<body>
 
@@ -20,7 +23,7 @@
 			</div>
 
 			<small>
-				Custom Fog via TSL.
+				Custom height fog via TSL, pooling in a procedural alpine valley forested with 500,000 instanced trees.
 			</small>
 		</div>
 
@@ -38,24 +41,67 @@
 		<script type="module">
 
 			import * as THREE from 'three/webgpu';
-			import { color, fog, float, positionWorld, triNoise3D, positionView, normalWorld, uniform } from 'three/tsl';
+			import { color, fog, positionWorld, triNoise3D, normalWorld, uniform, densityFogFactor } from 'three/tsl';
 
-			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+			import { FirstPersonControls } from 'three/addons/controls/FirstPersonControls.js';
 			import { Inspector } from 'three/addons/inspector/Inspector.js';
+			import { SkyMesh } from 'three/addons/objects/SkyMesh.js';
+			import { TerrainGenerator } from 'three/addons/generators/TerrainGenerator.js';
+			import { ForestGenerator } from 'three/addons/generators/ForestGenerator.js';
 
-			let camera, scene, renderer;
-			let controls;
+			let camera, scene, renderer, controls, timer;
+			let terrain, forest, terrainGroup, forestGroup;
+			let sky, sun, sunLight, pmremGenerator, envScene;
+
+			const parameters = {
+				elevation: 11, // sun height above the horizon, in degrees ( low = golden hour )
+				azimuth: 150 // sun compass direction, in degrees
+			};
 
 			init();
 
-			function init() {
+			async function init() {
+
+				renderer = new THREE.WebGPURenderer( { antialias: true } );
+				renderer.setPixelRatio( window.devicePixelRatio );
+				renderer.setSize( window.innerWidth, window.innerHeight );
+				renderer.setAnimationLoop( animate );
+				renderer.toneMapping = THREE.ACESFilmicToneMapping;
+				renderer.toneMappingExposure = 0.62;
+				renderer.shadowMap.enabled = true;
+				renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+				renderer.inspector = new Inspector();
+				document.body.appendChild( renderer.domElement );
+
+				await renderer.init();
+
+				pmremGenerator = new THREE.PMREMGenerator( renderer );
 
-				camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 1, 600 );
-				camera.position.set( 30, 15, 30 );
+				camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 1, 20000 );
+				camera.position.set( - 50, 88, 230 );
 
 				scene = new THREE.Scene();
+				scene.environmentIntensity = 0.16; // a dim, cool sky fill so the warm sun stays the key and shadows read
 
-				// custom fog
+				// a physical sky, used only to bake the image-based lighting ( it is not
+				// the visible background — the fog gradient below is ); elevation / azimuth move the sun
+
+				sky = new SkyMesh();
+				sky.scale.setScalar( 10000 );
+				sky.turbidity.value = 12;
+				sky.rayleigh.value = 2;
+				sky.mieCoefficient.value = 0.005;
+				sky.mieDirectionalG.value = 0.88;
+
+				sun = new THREE.Vector3();
+				envScene = new THREE.Scene();
+
+				// custom fog. an animated, two-octave triNoise3D haze that settles into
+				// the valley as a level band: solid below `fogBase` ( down under the
+				// mountains ) and fading out by `fogTop` ( around mid-height ), so the
+				// peaks rise clear above it. the band sits at a fixed altitude whatever
+				// the distance, and the noise wobbles its top edge and drifts it through
+				// world space, so it reads as slow-moving cloud. ( the peaks reach ~130 )
 
 				const skyColorValue = 0xf0f5f5;
 				const groundColorValue = 0xd0dee7;
@@ -63,99 +109,152 @@
 				const skyColor = color( skyColorValue );
 				const groundColor = color( groundColorValue );
 
-				const fogNoiseDistance = positionView.z.negate().smoothstep( 0, camera.far - 300 );
-
-				const distance = fogNoiseDistance.mul( 20 ).max( 4 );
-				const alpha = .98;
-				const groundFogArea = float( distance ).sub( positionWorld.y ).div( distance ).pow( 3 ).saturate().mul( alpha );
+				const fogBase = uniform( - 20 ); // world-y the fog is solid below ( the valley floor )
+				const fogTop = uniform( 55 ); // world-y the fog fades out by ( mid-mountain )
+				const haze = uniform( 0.0012 ); // distance haze, so the far peaks dissolve into the same grey
 
 				// a alternative way to create a TimerNode
-				const timer = uniform( 0 ).onFrameUpdate( ( frame ) => frame.time );
+				const time = uniform( 0 ).onFrameUpdate( ( frame ) => frame.time );
 
-				const fogNoiseA = triNoise3D( positionWorld.mul( .005 ), 0.2, timer );
-				const fogNoiseB = triNoise3D( positionWorld.mul( .01 ), 0.2, timer.mul( 1.2 ) );
+				const fogNoiseA = triNoise3D( positionWorld.mul( .005 ), 0.2, time );
+				const fogNoiseB = triNoise3D( positionWorld.mul( .01 ), 0.2, time.mul( 1.2 ) );
 
-				const fogNoise = fogNoiseA.add( fogNoiseB ).mul( groundColor );
+				const fogNoise = fogNoiseA.add( fogNoiseB );
 
-				// apply custom fog
+				// the noise lifts and drops the top of the band so it breaks into wisps
+				const top = fogTop.add( fogNoise.sub( 0.7 ).mul( 22 ) );
+				const groundFogArea = top.sub( positionWorld.y ).div( top.sub( fogBase ) ).saturate().mul( .98 );
 
-				scene.fogNode = fog( fogNoiseDistance.oneMinus().mix( groundColor, fogNoise ), groundFogArea );
-				scene.backgroundNode = normalWorld.y.max( 0 ).mix( groundColor, skyColor );
+				// the valley band plus a distance haze, so the far peaks dissolve into the grey too
+				const fogArea = groundFogArea.oneMinus().mul( densityFogFactor( haze ).oneMinus() ).oneMinus();
 
-				// builds
+				scene.fogNode = fog( groundColor, fogArea );
+				scene.backgroundNode = normalWorld.y.max( 0 ).mix( groundColor, skyColor );
 
-				const buildWindows = positionWorld.y.mul( 10 ).floor().mod( 4 ).sign().mix( color( 0x000066 ).add( fogNoiseDistance ), color( 0xffffff ) );
+				// terrain + forest
 
-				const buildGeometry = new THREE.BoxGeometry( 1, 1, 1 );
-				const buildMaterial = new THREE.MeshPhongNodeMaterial( {
-					colorNode: buildWindows
+				terrain = new TerrainGenerator( {
+					seed: 1,
+					size: 900,
+					segments: 512,
+					frequency: 0.0065,
+					heightScale: 150,
+					erosion: 0.7,
+					valleyBias: 1.2
 				} );
 
-				const buildMesh = new THREE.InstancedMesh( buildGeometry, buildMaterial, 4000 );
-				scene.add( buildMesh );
+				forest = new ForestGenerator( { count: 500000, castShadow: true } );
 
-				const dummy = new THREE.Object3D();
-				const center = new THREE.Vector3();
+				generate();
 
-				for ( let i = 0; i < buildMesh.count; i ++ ) {
+				// a single directional key aligned with the sky's sun, for the crisp relief
+				// shadows the sky fill alone can't give. its colour, intensity and position —
+				// and the baked environment — are set by updateSun()
 
-					const scaleY = Math.random() * 7 + .5;
+				sunLight = new THREE.DirectionalLight();
+				sunLight.castShadow = true;
+				sunLight.shadow.camera.left = - 420;
+				sunLight.shadow.camera.right = 420;
+				sunLight.shadow.camera.top = 420;
+				sunLight.shadow.camera.bottom = - 420;
+				sunLight.shadow.camera.near = 200;
+				sunLight.shadow.camera.far = 1800;
+				sunLight.shadow.mapSize.set( 4096, 4096 );
+				sunLight.shadow.bias = - 0.0004;
+				sunLight.shadow.normalBias = 0.15;
+				sunLight.shadow.autoUpdate = false; // the scene is static — re-render the shadow map only when the sun moves ( see updateSun ), not every frame
+				scene.add( sunLight );
 
-					dummy.position.x = Math.random() * 600 - 300;
-					dummy.position.z = Math.random() * 600 - 300;
+				updateSun();
 
-					const distance = Math.max( dummy.position.distanceTo( center ) * .012, 1 );
+				// gui
 
-					dummy.position.y = .5 * scaleY * distance;
+				const gui = renderer.inspector.createParameters( 'Settings' );
 
-					dummy.scale.x = dummy.scale.z = Math.random() * 3 + .5;
-					dummy.scale.y = scaleY * distance;
+				const skyFolder = gui.addFolder( 'Sun' );
+				skyFolder.add( parameters, 'elevation', 1, 40 ).step( 0.5 ).name( 'elevation' ).onChange( updateSun );
+				skyFolder.add( parameters, 'azimuth', 0, 360 ).step( 1 ).name( 'azimuth' ).onChange( updateSun );
 
-					dummy.updateMatrix();
+				const fogFolder = gui.addFolder( 'Fog' );
+				fogFolder.add( fogBase, 'value', - 40, 20 ).step( 1 ).name( 'base' );
+				fogFolder.add( fogTop, 'value', 0, 130 ).step( 1 ).name( 'top' );
+				fogFolder.add( haze, 'value', 0, 0.005 ).step( 0.0001 ).name( 'haze' );
 
-					buildMesh.setMatrixAt( i, dummy.matrix );
+				const forestFolder = gui.addFolder( 'Forest' );
+				forestFolder.add( forest.from, 'value', 50, 1000 ).step( 10 ).name( 'cull from' );
+				forestFolder.add( forest.to, 'value', 100, 1400 ).step( 10 ).name( 'cull to' );
 
-				}
+				const terrainFolder = gui.addFolder( 'Terrain' );
+				terrainFolder.add( terrain.parameters, 'erosion', 0, 1.5 ).step( 0.05 ).name( 'erosion' );
+				terrainFolder.add( terrain.parameters, 'valleyBias', 1, 3 ).step( 0.1 ).name( 'valley bias' );
+				terrainFolder.add( { newSeed }, 'newSeed' ).name( 'regenerate' );
 
-				// lights
+				// controls
 
-				scene.add( new THREE.HemisphereLight( skyColorValue, groundColorValue, 0.5 ) );
+				timer = new THREE.Timer();
 
-				// geometry
+				controls = new FirstPersonControls( camera, renderer.domElement );
+				controls.movementSpeed = 20;
+				controls.lookSpeed = 0.1;
+				controls.lookAt( 0, 5, - 120 ); // face across the valley
 
-				const planeGeometry = new THREE.PlaneGeometry( 200, 200 );
-				const planeMaterial = new THREE.MeshPhongMaterial( {
-					color: 0x999999
-				} );
+				window.addEventListener( 'resize', resize );
 
-				const ground = new THREE.Mesh( planeGeometry, planeMaterial );
-				ground.rotation.x = - Math.PI / 2;
-				ground.scale.multiplyScalar( 3 );
-				ground.castShadow = true;
-				ground.receiveShadow = true;
-				scene.add( ground );
+			}
 
-				// renderer
+			// walks the sun: aligns the sky and the key light ( warm and dim near the
+			// horizon ), then re-bakes the sky into the IBL environment
+			function updateSun() {
+
+				const elevation = parameters.elevation;
+
+				sun.setFromSphericalCoords(
+					1,
+					THREE.MathUtils.degToRad( 90 - elevation ),
+					THREE.MathUtils.degToRad( parameters.azimuth )
+				);
+				sky.sunPosition.value.copy( sun );
+
+				// the longer air path near the horizon dims and warms the sun. it stays
+				// far brighter than the sky fill, so it reads as the key and casts firm shadows
+				const transmittance = Math.sqrt( Math.max( Math.sin( THREE.MathUtils.degToRad( elevation ) ), 0 ) );
+				sunLight.color.set( 0xff7a2f ).lerp( new THREE.Color( 0xfff2e0 ), transmittance ); // deep orange → warm white
+				sunLight.intensity = 11 * transmittance + 0.3;
+				sunLight.position.copy( sun ).multiplyScalar( 900 );
+				sunLight.shadow.needsUpdate = true; // the sun moved, so the on-demand shadow map needs one refresh
+
+				// re-bake the sky ( without the sun disc ) into the environment map for IBL.
+				// the sky lives only in envScene; it is never added to the visible scene
+				sky.showSunDisc.value = false;
+				envScene.add( sky );
+				const env = pmremGenerator.fromScene( envScene ).texture;
+				if ( scene.environment ) scene.environment.dispose();
+				scene.environment = env;
 
-				renderer = new THREE.WebGPURenderer( { antialias: true } );
-				renderer.setPixelRatio( window.devicePixelRatio );
-				renderer.setSize( window.innerWidth, window.innerHeight );
-				renderer.setAnimationLoop( animate );
-				renderer.inspector = new Inspector();
-				document.body.appendChild( renderer.domElement );
+			}
 
-				// controls
+			// a new seed ( and whatever erosion / valley bias is dialled in ) rebuilds
+			// the terrain, and with it the forest that sits on top
+			function newSeed() {
 
-				controls = new OrbitControls( camera, renderer.domElement );
-				controls.target.set( 0, 2, 0 );
-				controls.minDistance = 7;
-				controls.maxDistance = 100;
-				controls.maxPolarAngle = Math.PI / 2;
-				controls.autoRotate = true;
-				controls.autoRotateSpeed = .1;
-				controls.update();
+				terrain.parameters.seed ++;
+				generate();
 
-				window.addEventListener( 'resize', resize );
+			}
+
+			// the forest sits on the terrain, so a new terrain means a new forest
+			function generate() {
+
+				if ( terrainGroup ) scene.remove( terrainGroup );
+				if ( forestGroup ) scene.remove( forestGroup );
+
+				terrainGroup = terrain.build();
+				forestGroup = forest.build( terrain );
+
+				scene.add( terrainGroup );
+				scene.add( forestGroup );
+
+				if ( sunLight ) sunLight.shadow.needsUpdate = true; // rebuilt geometry ⇒ re-render the shadow map ( skipped on the first build, before the light exists; updateSun covers it )
 
 			}
 
@@ -170,7 +269,11 @@
 
 			function animate() {
 
-				controls.update();
+				timer.update();
+
+				controls.update( timer.getDelta() );
+
+				forest.setCameraPosition( camera.position ); // drives the forest cull
 
 				renderer.render( scene, camera );
 

粤ICP备19079148号