TerrainGenerator.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. import {
  2. BufferGeometry,
  3. Float32BufferAttribute,
  4. Group,
  5. Mesh
  6. } from 'three';
  7. import { MeshStandardNodeMaterial } from 'three/webgpu';
  8. import { cameraPosition, color, float, Fn, If, mix, mx_noise_float, normalView, normalWorld, positionView, positionWorld, saturation, smoothstep, uniform } from 'three/tsl';
  9. import { ImprovedNoise } from '../math/ImprovedNoise.js';
  10. /**
  11. * Bakes a procedural mountain range into a single {@link THREE.BufferGeometry} and
  12. * returns a `THREE.Group` ready to add to a scene.
  13. *
  14. * The heightfield is a derivative-damped fractal sum ( Quilez's fake erosion ): each
  15. * octave is suppressed where the running slope is already steep, concentrating detail
  16. * into weathered ridgelines, and a low-frequency domain warp makes those ridges
  17. * meander. A few passes of thermal ( talus ) erosion then relax any slope past the
  18. * angle of repose, settling the fractal's needle-spikes into real crests.
  19. *
  20. * The grid is triangulated with alternating quad diagonals ( a diamond pattern ), so a
  21. * coarse mesh holds its silhouette without a one-way grain. The surface shades itself
  22. * from altitude and slope in TSL — grass, forest, rock, scree and snow, with detail
  23. * normals and aerial perspective — so no material or textures are needed.
  24. *
  25. * The baked height grid is exposed through {@link TerrainGenerator#sampleHeight} so a
  26. * scattered forest ( or anything else ) can sit exactly on the surface.
  27. *
  28. * ```js
  29. * const terrain = new TerrainGenerator( { seed: 1 } );
  30. * scene.add( terrain.build() );
  31. * ```
  32. */
  33. class TerrainGenerator {
  34. constructor( parameters = {} ) {
  35. this.parameters = Object.assign( {}, TerrainGenerator.defaults, parameters );
  36. // baked altitude range, fed to the shader so the colour bands track the real
  37. // valley floor and peaks
  38. this.minHeight = uniform( 0 );
  39. this.maxHeight = uniform( 1 );
  40. this.material = terrainMaterial( this.minHeight, this.maxHeight );
  41. this.geometry = null;
  42. this.group = null;
  43. }
  44. build() {
  45. this.dispose();
  46. const p = this.parameters;
  47. const N = p.segments + 1;
  48. const half = p.size / 2;
  49. // world coordinate of each grid line, shared by the bake and layout below
  50. const coord = new Array( N );
  51. for ( let i = 0; i < N; i ++ ) coord[ i ] = i / p.segments * p.size - half;
  52. // bake the height grid; kept around so the surface can be sampled ( bilinearly )
  53. // afterwards — e.g. to sit a scattered forest on it
  54. const height = heightField( p );
  55. const heights = new Float32Array( N * N );
  56. for ( let iz = 0; iz < N; iz ++ ) {
  57. for ( let ix = 0; ix < N; ix ++ ) {
  58. heights[ iz * N + ix ] = height( coord[ ix ], coord[ iz ] );
  59. }
  60. }
  61. // relax slopes past the angle of repose, shedding the fractal's needle-spikes
  62. if ( p.talusPasses > 0 ) thermalErode( heights, N, p.size / p.segments, p.talus, p.talusPasses );
  63. // lay the grid out flat in the XZ plane ( Y-up ) and find the height range
  64. const positions = new Float32Array( N * N * 3 );
  65. let min = Infinity, max = - Infinity;
  66. for ( let iz = 0; iz < N; iz ++ ) {
  67. for ( let ix = 0; ix < N; ix ++ ) {
  68. const o = iz * N + ix;
  69. const y = heights[ o ];
  70. positions[ o * 3 ] = coord[ ix ];
  71. positions[ o * 3 + 1 ] = y;
  72. positions[ o * 3 + 2 ] = coord[ iz ];
  73. if ( y < min ) min = y;
  74. if ( y > max ) max = y;
  75. }
  76. }
  77. // flip the quad diagonal on every other quad, so the mesh reads as diamonds
  78. // rather than a one-way grain
  79. const indices = [];
  80. for ( let iz = 0; iz < p.segments; iz ++ ) {
  81. for ( let ix = 0; ix < p.segments; ix ++ ) {
  82. const a = iz * N + ix, b = a + 1, c = a + N, d = c + 1;
  83. if ( ( ix + iz ) % 2 === 0 ) indices.push( a, c, b, b, c, d );
  84. else indices.push( a, c, d, a, d, b );
  85. }
  86. }
  87. const geometry = new BufferGeometry();
  88. geometry.setAttribute( 'position', new Float32BufferAttribute( positions, 3 ) );
  89. geometry.setIndex( indices );
  90. geometry.computeVertexNormals();
  91. this.heights = heights;
  92. this.gridSize = N;
  93. this.minY = min;
  94. this.maxY = max;
  95. this.minHeight.value = min;
  96. this.maxHeight.value = max;
  97. const mesh = new Mesh( geometry, this.material );
  98. mesh.castShadow = mesh.receiveShadow = true;
  99. const group = new Group();
  100. group.name = 'Terrain';
  101. group.add( mesh );
  102. this.geometry = geometry;
  103. this.group = group;
  104. return group;
  105. }
  106. // world-space height at ( x, z ), bilinearly interpolated from the baked grid
  107. sampleHeight( x, z ) {
  108. const p = this.parameters;
  109. const N = this.gridSize;
  110. const half = p.size / 2;
  111. const fx = Math.max( 0, Math.min( p.segments, ( x + half ) / p.size * p.segments ) );
  112. const fz = Math.max( 0, Math.min( p.segments, ( z + half ) / p.size * p.segments ) );
  113. const ix = Math.min( N - 2, Math.floor( fx ) );
  114. const iz = Math.min( N - 2, Math.floor( fz ) );
  115. const tx = fx - ix;
  116. const tz = fz - iz;
  117. const h = this.heights;
  118. const h00 = h[ iz * N + ix ];
  119. const h10 = h[ iz * N + ix + 1 ];
  120. const h01 = h[ ( iz + 1 ) * N + ix ];
  121. const h11 = h[ ( iz + 1 ) * N + ix + 1 ];
  122. return ( h00 * ( 1 - tx ) + h10 * tx ) * ( 1 - tz ) + ( h01 * ( 1 - tx ) + h11 * tx ) * tz;
  123. }
  124. // surface flatness at ( x, z ): the normal's y component ( 1 on the flat, → 0 on a
  125. // cliff ). allocation-free, for cheaply testing many candidate forest positions.
  126. sampleSlope( x, z ) {
  127. const e = this.parameters.size / this.parameters.segments;
  128. const hx = this.sampleHeight( x + e, z ) - this.sampleHeight( x - e, z );
  129. const hz = this.sampleHeight( x, z + e ) - this.sampleHeight( x, z - e );
  130. return 2 * e / Math.sqrt( hx * hx + 4 * e * e + hz * hz );
  131. }
  132. dispose() {
  133. if ( this.geometry ) this.geometry.dispose();
  134. this.geometry = null;
  135. this.group = null;
  136. }
  137. }
  138. TerrainGenerator.defaults = {
  139. seed: 1,
  140. size: 200, // world units across the square patch
  141. segments: 192, // grid quads per side; vertices = ( segments + 1 )²
  142. heightScale: 65, // peak-to-valley exaggeration, in world units
  143. frequency: 0.01, // base noise frequency ( the footprint of a mountain )
  144. octaves: 5,
  145. lacunarity: 1.97, // per-octave frequency step; off 2 so octaves don't grid-lock
  146. gain: 0.5, // per-octave amplitude step ( persistence )
  147. erosion: 0.7, // derivative damping: higher flattens valleys and sharpens ridges
  148. warp: 0.35, // domain-warp strength ( noise units ): bends ridges and valleys
  149. valleyBias: 1.2, // power curve over the height, to flatten the mist floor
  150. seaLevel: 0.15, // 0..1, subtracted before scaling so the valley floor sinks below y = 0
  151. talus: 1, // thermal-erosion angle of repose ( rise / run ): lower settles flatter
  152. talusPasses: 12 // thermal-erosion iterations ( 0 = off )
  153. };
  154. // deterministic PRNG ( mulberry32 ), so a seed always bakes the same terrain
  155. function createRandom( seed ) {
  156. let s = ( seed >>> 0 ) || 1;
  157. return function () {
  158. s = ( s + 0x6D2B79F5 ) | 0;
  159. let t = Math.imul( s ^ ( s >>> 15 ), 1 | s );
  160. t = ( t + Math.imul( t ^ ( t >>> 7 ), 61 | t ) ) ^ t;
  161. return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296;
  162. };
  163. }
  164. // builds the height( worldX, worldZ ) function for one seed
  165. function heightField( p ) {
  166. const perlin = new ImprovedNoise();
  167. const random = createRandom( p.seed );
  168. // ImprovedNoise's permutation is fixed, so a seed can only shift the sample window:
  169. // a translation and a per-octave z-slice, drawn from the PRNG to decorrelate seeds
  170. const offsetX = random() * 256;
  171. const offsetZ = random() * 256;
  172. const slice = random() * 256;
  173. const { frequency, octaves, lacunarity, gain, erosion, warp, valleyBias, seaLevel, heightScale } = p;
  174. // low-frequency fractal sum that warps the sample position
  175. function warpField( x, z, zr ) {
  176. let freq = 1, amp = 1, sum = 0, norm = 0;
  177. for ( let i = 0; i < 2; i ++ ) {
  178. sum += amp * perlin.noise( x * freq + offsetX, z * freq + offsetZ, zr + i * 1.7 );
  179. norm += amp; freq *= lacunarity; amp *= gain;
  180. }
  181. return sum / norm;
  182. }
  183. // derivative-damped fractal sum: each octave is divided down where the running
  184. // gradient is already steep, keeping ridges crisp and valleys smooth. the domain
  185. // rotates between octaves to break the noise's axis-aligned grid.
  186. function eroded( x, z ) {
  187. let sum = 0, amp = 1, dX = 0, dZ = 0, px = x, pz = z, freq = 1;
  188. const e = 0.004; // finite-difference step, in noise units
  189. for ( let i = 0; i < octaves; i ++ ) {
  190. const zr = slice + i * 1.7;
  191. const bx = px * freq + offsetX, bz = pz * freq + offsetZ;
  192. const n = perlin.noise( bx, bz, zr );
  193. const nx = perlin.noise( bx + e, bz, zr );
  194. const nz = perlin.noise( bx, bz + e, zr );
  195. // this octave's world-space gradient ( chain rule: × freq )
  196. dX += ( nx - n ) / e * freq;
  197. dZ += ( nz - n ) / e * freq;
  198. sum += amp * n / ( 1 + erosion * ( dX * dX + dZ * dZ ) );
  199. // rotate the domain ~37° ( the matrix [ 0.8 -0.6 ; 0.6 0.8 ] )
  200. const rx = 0.8 * px - 0.6 * pz;
  201. pz = 0.6 * px + 0.8 * pz;
  202. px = rx;
  203. freq *= lacunarity; amp *= gain;
  204. }
  205. return sum * 0.5 + 0.5;
  206. }
  207. return function ( worldX, worldZ ) {
  208. const x = worldX * frequency, z = worldZ * frequency;
  209. // warp the sample so ridges and valleys meander instead of running straight
  210. const wx = x + warp * warpField( x + 1.3, z + 7.2, slice + 40 );
  211. const wz = z + warp * warpField( x + 5.2, z + 1.3, slice + 70 );
  212. // power curve that settles the low ground into a flat mist bed
  213. const h = Math.pow( Math.min( eroded( wx, wz ) * 1.1, 1 ), valleyBias );
  214. return ( h - seaLevel ) * heightScale;
  215. };
  216. }
  217. // thermal ( talus ) erosion on the baked height grid: a cell overhanging a neighbour
  218. // by more than the talus drop sheds the excess downhill, so over a few passes slopes
  219. // relax to the angle of repose. spikes — steep on every side — bleed off fastest;
  220. // broad one-sided faces keep their shape. material is conserved through a delta buffer,
  221. // so the result is independent of cell order.
  222. function thermalErode( h, N, cellSize, talus, passes ) {
  223. const drop = talus * cellSize; // max height step a slope can hold between two cells
  224. const carry = 0.5; // fraction of the steepest overhang moved per pass ( <= 0.5 = stable )
  225. const delta = new Float32Array( N * N );
  226. const ex = [ 0, 0, 0, 0 ];
  227. const off = [ - 1, 1, - N, N ];
  228. for ( let p = 0; p < passes; p ++ ) {
  229. delta.fill( 0 );
  230. for ( let z = 0; z < N; z ++ ) {
  231. for ( let x = 0; x < N; x ++ ) {
  232. const i = z * N + x;
  233. const hi = h[ i ];
  234. // overhang past the talus drop toward each of the 4 neighbours
  235. ex[ 0 ] = x > 0 ? hi - h[ i - 1 ] - drop : 0;
  236. ex[ 1 ] = x < N - 1 ? hi - h[ i + 1 ] - drop : 0;
  237. ex[ 2 ] = z > 0 ? hi - h[ i - N ] - drop : 0;
  238. ex[ 3 ] = z < N - 1 ? hi - h[ i + N ] - drop : 0;
  239. let sum = 0, peak = 0;
  240. for ( let k = 0; k < 4; k ++ ) {
  241. const d = ex[ k ];
  242. if ( d <= 0 ) {
  243. ex[ k ] = 0;
  244. continue;
  245. }
  246. sum += d;
  247. if ( d > peak ) peak = d;
  248. }
  249. if ( sum <= 0 ) continue;
  250. // move a slice of the steepest overhang, split across the downhill
  251. // neighbours in proportion to how far each sits below the talus line
  252. const move = carry * peak;
  253. delta[ i ] -= move;
  254. for ( let k = 0; k < 4; k ++ ) {
  255. if ( ex[ k ] > 0 ) delta[ i + off[ k ] ] += move * ex[ k ] / sum;
  256. }
  257. }
  258. }
  259. for ( let k = 0; k < N * N; k ++ ) h[ k ] += delta[ k ];
  260. }
  261. }
  262. // --- shading -------------------------------------------------------------
  263. // perturbs the normal by a world-space height field using Mikkelsen's surface-gradient
  264. // method. the built-in bumpMap reads height by offsetting the UV — a no-op for a
  265. // world-keyed height — so the height's screen-space derivatives are fed in directly.
  266. // returns a view-space normal.
  267. function bumpNormal( height ) {
  268. const dpdx = positionView.dFdx();
  269. const dpdy = positionView.dFdy();
  270. const r1 = dpdy.cross( normalView );
  271. const r2 = normalView.cross( dpdx );
  272. const det = dpdx.dot( r1 );
  273. const grad = det.sign().mul( height.dFdx().mul( r1 ).add( height.dFdy().mul( r2 ) ) );
  274. return det.abs().mul( normalView ).sub( grad ).normalize();
  275. }
  276. // altitude- and slope-based shading, all in TSL ( no textures ). only the colour,
  277. // roughness and detail normal are authored here; the lighting ( sun, sky fill, the
  278. // snow's warm/cool cast ) comes from the scene's lights and environment.
  279. function terrainMaterial( minHeight, maxHeight ) {
  280. const material = new MeshStandardNodeMaterial();
  281. material.metalness = 0;
  282. const distance = positionWorld.distance( cameraPosition );
  283. // the two drivers: normalised altitude ( valley 0 → peak 1 ) and surface flatness
  284. const altitude = positionWorld.y.sub( minHeight ).div( maxHeight.sub( minHeight ) ).clamp();
  285. const flatness = normalWorld.y.clamp(); // 1 on level ground, 0 on a vertical cliff
  286. const steep = flatness.oneMinus();
  287. // three reused noise scales: fine band-edge jitter, grain ( ~5u patches ) and macro
  288. const detail = mx_noise_float( positionWorld.xz.mul( 0.05 ) );
  289. const grain = mx_noise_float( positionWorld.xz.mul( 0.18 ) );
  290. const macro = mx_noise_float( positionWorld.xz.mul( 0.012 ) );
  291. const grass = color( 0x6e7253 ); // dry sage-olive meadow ( not video-game green )
  292. const dryGrass = color( 0x8a8550 );
  293. const forest = color( 0x39402f ); // dark forested mid-slope band, under the trees
  294. const rock = color( 0x736a5f ); // warm grey-brown rock
  295. const scree = color( 0x837a6f ); // brighter broken rock below the cliffs
  296. const lichen = color( 0x6c7355 ); // muted green-grey, patched onto lower rock
  297. const snow = color( 0xe9ecf0 ); // fresh snow; warm-sun / cool-sky cast is from the lighting
  298. const snowDeep = color( 0xccd6e2 ); // cooler wind-packed snow, drifted into patches
  299. // two band frequencies of lighter / darker stone, wobbled by noise, so cliff faces
  300. // read as layered bedding instead of flat grey
  301. const bandA = positionWorld.y.mul( 0.5 ).add( detail.mul( 3 ) ).add( macro.mul( 4 ) ).sin();
  302. const bandB = positionWorld.y.mul( 1.4 ).add( grain.mul( 2 ) ).sin();
  303. const strata = bandA.mul( 0.6 ).add( bandB.mul( 0.4 ) ).mul( 0.5 ).add( 0.5 );
  304. // lichen creeps onto the lower, gentler rock; cliffs and high ground stay bare grey
  305. const lichenMask = smoothstep( 0.45, 0.72, grain ).mul( smoothstep( 0.62, 0.32, steep ) ).mul( smoothstep( 0.66, 0.34, altitude ) );
  306. const rockShade = mix( rock, lichen, lichenMask.mul( 0.45 ) ).mul( strata.mul( 0.36 ).add( 0.8 ) );
  307. // meadow, drifting to dry grass in macro-noise patches over a mid band
  308. let surface = mix( grass, dryGrass, smoothstep( 0.15, 0.75, macro ).mul( smoothstep( 0.22, 0.5, altitude ) ) );
  309. // dark forested band on the gentle mid-slopes ( where the instanced trees live )
  310. surface = mix( surface, forest, smoothstep( 0.16, 0.34, altitude ).mul( smoothstep( 0.5, 0.72, flatness ) ).mul( 0.75 ) );
  311. // rock by altitude, and on every steep face regardless of height
  312. surface = mix( surface, rockShade, smoothstep( 0.46, 0.64, altitude.add( detail.mul( 0.06 ) ) ) );
  313. surface = mix( surface, rockShade, smoothstep( 0.34, 0.62, steep ) );
  314. // scree on the medium-steep ground below the cliffs, broken up by noise
  315. const screeMask = smoothstep( 0.42, 0.7, steep ).mul( smoothstep( 0.35, 0.7, flatness ) ).mul( detail.mul( 0.5 ).add( 0.5 ) );
  316. surface = mix( surface, scree, screeMask.mul( 0.5 ) );
  317. // snow on high, flat ground; the grain noise breaks the line so rock pokes through
  318. // near the snowline instead of stopping on a clean contour
  319. 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 ) );
  320. const snowColor = mix( snow, snowDeep, smoothstep( 0.2, 0.7, grain ).mul( 0.6 ) ); // patchy, not a flat sheet
  321. surface = mix( surface, snowColor, snowMask );
  322. // dark, damp ground pooling in the low flat creases ( cheap moisture proxy )
  323. const cavity = smoothstep( 0.24, 0.06, altitude ).mul( flatness );
  324. surface = surface.mul( cavity.mul( 0.32 ).oneMinus() );
  325. // macro drift then a fine grain mottle, so no band is a flat colour
  326. surface = surface.mul( macro.mul( 0.5 ).add( 0.5 ).mul( 0.3 ).add( 0.84 ) );
  327. surface = surface.mul( grain.mul( 0.5 ).add( 0.5 ).mul( 0.12 ).add( 0.94 ) );
  328. // aerial perspective: desaturate and lift distant ground toward a cool haze, so
  329. // depth reads and the range recedes into the mist
  330. const aerial = smoothstep( 180, 820, distance );
  331. surface = saturation( surface, aerial.oneMinus().mul( 0.5 ).add( 0.5 ) );
  332. surface = mix( surface, color( 0xcfc8ba ), aerial.mul( 0.62 ) ); // far ridges dissolve into the sky
  333. material.colorNode = surface;
  334. material.roughnessNode = mix( float( 0.95 ), float( 0.72 ), snowMask );
  335. // detail normals: three octaves of world-space relief, faded out with distance so
  336. // they can't alias into fireflies in the haze. gating the noise behind the fade ( a
  337. // real branch ) lets the far majority of this fragment-bound terrain skip the taps.
  338. const detailFade = smoothstep( 420, 60, distance );
  339. const reliefStrength = mix( float( 0.25 ), float( 0.55 ), steep ); // more on rock, less on grass
  340. const relief = Fn( () => {
  341. const r = float( 0 ).toVar();
  342. If( detailFade.greaterThan( 0.01 ), () => {
  343. r.assign( mx_noise_float( positionWorld.xz.mul( 0.6 ) )
  344. .add( mx_noise_float( positionWorld.xz.mul( 1.7 ) ).mul( 0.5 ) )
  345. .add( mx_noise_float( positionWorld.xz.mul( 4.0 ) ).mul( 0.25 ) )
  346. .mul( reliefStrength ).mul( detailFade ).mul( 0.25 ) );
  347. } );
  348. return r;
  349. } )();
  350. material.normalNode = bumpNormal( relief );
  351. return material;
  352. }
  353. export { TerrainGenerator };
粤ICP备19079148号