Accueil / npm / three@0.186.1 / examples / jsm / generators / TerrainGenerator.js

TerrainGenerator.js

import {
	BufferAttribute,
	BufferGeometry,
	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 mesh, returned in a `THREE.Group`.
 * Domain-warped, derivative-damped noise shapes the ridges; thermal erosion relaxes
 * steep slopes. A TSL material shades grass, rock and snow from altitude and slope.
 *
 * The baked grid is available through {@link TerrainGenerator#sampleHeight} for
 * placing a forest or other objects on the terrain.
 *
 * ```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 );
		const normals = new Float32Array( N * N * 3 );
		const cellSize = p.size / p.segments;
		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 ];

				// Sample the grid's gradient so alternating triangle fans have equal weight.
				const left = Math.max( 0, ix - 1 ), right = Math.min( N - 1, ix + 1 );
				const back = Math.max( 0, iz - 1 ), front = Math.min( N - 1, iz + 1 );
				const nx = ( heights[ iz * N + left ] - heights[ iz * N + right ] ) / ( ( right - left ) * cellSize );
				const nz = ( heights[ back * N + ix ] - heights[ front * N + ix ] ) / ( ( front - back ) * cellSize );
				const length = Math.hypot( nx, 1, nz );
				normals[ o * 3 ] = nx / length;
				normals[ o * 3 + 1 ] = 1 / length;
				normals[ o * 3 + 2 ] = nz / length;

				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 IndexArray = N * N > 65535 ? Uint32Array : Uint16Array;
		const indices = new IndexArray( p.segments * p.segments * 6 );
		let index = 0;

		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;
				const even = ( ix + iz ) % 2 === 0;

				indices[ index ++ ] = a;
				indices[ index ++ ] = c;
				indices[ index ++ ] = even ? b : d;
				indices[ index ++ ] = even ? b : a;
				indices[ index ++ ] = even ? c : d;
				indices[ index ++ ] = even ? d : b;

			}

		}

		const geometry = new BufferGeometry();
		geometry.setAttribute( 'position', new BufferAttribute( positions, 3 ) );
		geometry.setAttribute( 'normal', new BufferAttribute( normals, 3 ) );
		geometry.setIndex( new BufferAttribute( indices, 1 ) );

		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, strength ) {

	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.mul( strength ) ).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( 0x716e68 ); // weathered 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( positionWorld.x.mul( 0.08 ) ).add( positionWorld.z.mul( 0.05 ) ).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 ) ).mul( grain.mul( 0.18 ).add( 1 ) );

	// 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.45, 0.75, 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 );

	// Volumetric noise keeps relief consistent on cliffs. Skip fine grain in the distance.
	const detailFade = smoothstep( 420, 60, distance );
	const grainFade = smoothstep( 180, 40, distance );
	const reliefStrength = mix( mix( float( 0.25 ), float( 0.55 ), steep ), float( 0.04 ), snowMask );
	const relief = Fn( () => {

		const r = float( 0 ).toVar();

		If( detailFade.greaterThan( 0.01 ), () => {

			r.assign( mx_noise_float( positionWorld.mul( 0.6 ) ) );

			If( grainFade.greaterThan( 0.01 ), () => {

				r.addAssign( mx_noise_float( positionWorld.mul( 1.7 ) ).mul( 0.5 ).mul( grainFade ) );

			} );

		} );

		return r;

	} )();

	// Apply strength after differentiating the noise; slope derivatives reveal triangles.
	material.normalNode = bumpNormal( relief, reliefStrength.mul( detailFade ).mul( 0.25 ) );

	return material;

}

export { TerrainGenerator };

Exemple