UNPKG

three

Version:

JavaScript 3D library

505 lines (353 loc) 17.5 kB
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 };