three
Version:
JavaScript 3D library
505 lines (353 loc) • 17.5 kB
JavaScript
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 };