Files
Aislo/node_modules/three/examples/jsm/generators/TerrainGenerator.js
T
2026-07-05 14:05:22 +09:00

505 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };