ForestGenerator.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. import {
  2. BufferAttribute,
  3. Group,
  4. IcosahedronGeometry,
  5. InstancedBufferAttribute,
  6. InstancedMesh,
  7. Object3D,
  8. Vector3
  9. } from 'three';
  10. import { MeshStandardNodeMaterial } from 'three/webgpu';
  11. import { attribute, color, float, Fn, If, mix, mx_noise_float, normalView, positionLocal, positionView, positionWorld, smoothstep, step, uniform } from 'three/tsl';
  12. import { ImprovedNoise } from '../math/ImprovedNoise.js';
  13. import { mergeVertices } from '../utils/BufferGeometryUtils.js';
  14. /**
  15. * Carpets a {@link TerrainGenerator} ( or anything exposing `sampleHeight`,
  16. * `sampleSlope`, `minY`, `maxY` and `parameters.size` ) with a forest of hundreds
  17. * of thousands of trees in a single draw call.
  18. *
  19. * Each tree is the cheapest thing that still reads as a tree: a ~20-face icosphere
  20. * squashed into a tapered teardrop and lumped with a little noise, carrying a baked
  21. * dark-base / bright-top gradient. Tens of triangles each, so a single
  22. * {@link THREE.InstancedMesh} of half a million of them costs one draw call. Trees
  23. * are placed by rejection sampling against ecological rules — a min/max altitude
  24. * band ( above the mist floor, below the snowline ), a slope limit ( none on
  25. * cliffs ) and a low-frequency density mask that opens clearings — then jittered in
  26. * yaw, lean and ( squared-biased ) scale so the stand never reads as copies.
  27. *
  28. * ```js
  29. * const forest = new ForestGenerator( { count: 500000 } );
  30. * scene.add( forest.build( terrain ) );
  31. * ```
  32. */
  33. class ForestGenerator {
  34. constructor( parameters = {} ) {
  35. this.parameters = Object.assign( {}, ForestGenerator.defaults, parameters );
  36. // stochastic distance cull ( THREE.Fog-style near / far ): drawn within `from`, gone
  37. // past `to`, the band between thinned by a baked random. live-tunable uniforms.
  38. this.from = uniform( this.parameters.from );
  39. this.to = uniform( this.parameters.to );
  40. // main-camera position ( set via setCameraPosition ). NOT the TSL cameraPosition node:
  41. // in the shadow pass that resolves to the light, which would cull the wrong trees.
  42. this._cameraPosition = uniform( new Vector3() );
  43. this.material = createForestMaterial( this.from, this.to, this._cameraPosition );
  44. this.mesh = null;
  45. this.group = null;
  46. }
  47. build( terrain ) {
  48. this.dispose();
  49. const p = this.parameters;
  50. const geometry = blobGeometry( p );
  51. const size = terrain.parameters.size;
  52. const minY = terrain.minY;
  53. const span = terrain.maxY - terrain.minY;
  54. const random = createRandom( p.seed );
  55. // a low-frequency field that breaks the forest into patches and clearings
  56. const perlin = new ImprovedNoise();
  57. const dOffX = random() * 256, dOffZ = random() * 256, dSlice = random() * 256;
  58. const densityAt = ( x, z ) => smoothBlend( - 0.12, 0.22, perlin.noise( x * p.densityFrequency + dOffX, z * p.densityFrequency + dOffZ, dSlice ) );
  59. const mesh = new InstancedMesh( geometry, this.material, p.count );
  60. mesh.castShadow = mesh.receiveShadow = p.castShadow; // honoured on every rebuild
  61. // per-instance cull data: xyz = tree position ( for its distance to the camera ),
  62. // w = a threshold jitter from a separate PRNG, so it doesn't disturb placement
  63. const cullData = new Float32Array( p.count * 4 );
  64. const cullRandom = createRandom( ( p.seed ^ 0x9e3779b9 ) >>> 0 );
  65. // per-instance regional colour drift, baked here so the vertex-bound shader taps no
  66. // noise. offsets come from the cull PRNG, so placement is untouched.
  67. const regionData = new Float32Array( p.count );
  68. const rOffX = cullRandom() * 256, rOffZ = cullRandom() * 256, rSlice = cullRandom() * 256;
  69. const dummy = new Object3D();
  70. let placed = 0;
  71. let attempts = 0;
  72. const maxAttempts = p.count * 14; // give up rather than hang if the band is too small
  73. while ( placed < p.count && attempts < maxAttempts ) {
  74. attempts ++;
  75. const x = ( random() - 0.5 ) * size;
  76. const z = ( random() - 0.5 ) * size;
  77. const y = terrain.sampleHeight( x, z );
  78. const altitude = ( y - minY ) / span;
  79. if ( altitude < p.altitudeMin || altitude > p.altitudeMax ) continue;
  80. if ( terrain.sampleSlope( x, z ) < p.minSlope ) continue;
  81. // density mask, feathered out at the top so the treeline scatters, not a clean line
  82. let density = densityAt( x, z );
  83. density *= smoothBlend( p.altitudeMax, p.altitudeMax - 0.14, altitude );
  84. if ( random() >= density ) continue;
  85. dummy.position.set( x, y - p.sink, z ); // sink the base point into the ground
  86. dummy.rotation.set( ( random() - 0.5 ) * 0.12, random() * Math.PI * 2, ( random() - 0.5 ) * 0.12 ); // small lean + free yaw, trunk ~vertical
  87. const s = p.minScale + random() * random() * ( p.maxScale - p.minScale ); // squared bias: mostly small, rare giants
  88. dummy.scale.set( s * ( 0.85 + random() * 0.3 ), s, s * ( 0.85 + random() * 0.3 ) );
  89. dummy.updateMatrix();
  90. mesh.setMatrixAt( placed, dummy.matrix );
  91. const c = placed * 4;
  92. cullData[ c ] = x;
  93. cullData[ c + 1 ] = dummy.position.y; // the sunk y, matching the drawn position
  94. cullData[ c + 2 ] = z;
  95. cullData[ c + 3 ] = cullRandom();
  96. regionData[ placed ] = Math.min( 1, Math.max( 0, perlin.noise( x * 0.02 + rOffX, z * 0.02 + rOffZ, rSlice ) * 0.6 + 0.5 ) );
  97. placed ++;
  98. }
  99. mesh.count = placed; // only what got planted
  100. mesh.instanceMatrix.needsUpdate = true;
  101. geometry.setAttribute( 'cull', new InstancedBufferAttribute( cullData, 4 ) );
  102. geometry.setAttribute( 'region', new InstancedBufferAttribute( regionData, 1 ) );
  103. const group = new Group();
  104. group.name = 'Forest';
  105. group.add( mesh );
  106. this.mesh = mesh;
  107. this.group = group;
  108. return group;
  109. }
  110. // call each frame so the distance cull tracks the camera
  111. setCameraPosition( position ) {
  112. this._cameraPosition.value.copy( position );
  113. }
  114. dispose() {
  115. if ( this.mesh ) this.mesh.geometry.dispose();
  116. this.mesh = null;
  117. this.group = null;
  118. }
  119. }
  120. ForestGenerator.defaults = {
  121. seed: 1,
  122. count: 500000, // number of trees to plant ( a single instanced draw call )
  123. detail: 0, // icosphere subdivision ( 0 = 20 faces, welds to 12 verts )
  124. radius: 1.3, // base half-width of a tree blob, in world units
  125. height: 4, // base height of a tree blob
  126. distortion: 0.5, // lumpiness of the blob hull ( a rough conifer, not a smooth egg )
  127. sink: 0.4, // how far the base point is pushed under the surface, to hide it
  128. altitudeMin: 0.12, // normalised altitude band the forest occupies: above the mist floor...
  129. altitudeMax: 0.46, // ...and safely below the snowline
  130. minSlope: 0.55, // minimum surface flatness ( normal.y ); steeper ground stays bare rock
  131. densityFrequency: 0.012, // patch / clearing scale ( world units )
  132. minScale: 0.7,
  133. maxScale: 1.8,
  134. from: 300, // distance ( like THREE.Fog ) within which every tree is drawn...
  135. to: 620, // ...past which none are; the band between thins out stochastically
  136. castShadow: false // whether the canopy casts + receives shadows ( 500k casters is a real cost — opt in )
  137. };
  138. // deterministic PRNG ( mulberry32 ), matching the other generators
  139. function createRandom( seed ) {
  140. let s = ( seed >>> 0 ) || 1;
  141. return function () {
  142. s = ( s + 0x6D2B79F5 ) | 0;
  143. let t = Math.imul( s ^ ( s >>> 15 ), 1 | s );
  144. t = ( t + Math.imul( t ^ ( t >>> 7 ), 61 | t ) ) ^ t;
  145. return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296;
  146. };
  147. }
  148. function smoothBlend( edge0, edge1, x ) {
  149. const t = Math.max( 0, Math.min( 1, ( x - edge0 ) / ( edge1 - edge0 ) ) );
  150. return t * t * ( 3 - 2 * t );
  151. }
  152. // smooth low-frequency lump over the unit sphere, so the blob hull is bumpy not spiky
  153. function blobNoise( x, y, z ) {
  154. return Math.sin( x * 3.1 ) * Math.sin( y * 2.7 + 1.3 ) * Math.sin( z * 3.5 + 2.1 );
  155. }
  156. // one tree blob: an icosphere squashed into a lumpy, tapered teardrop, base at y = 0.
  157. // normals are re-pointed up-and-out so it shades as a soft canopy volume; a baked `ao`
  158. // ( 0 base → 1 crown ) drives the dark-underside / bright-crown gradient.
  159. function blobGeometry( p ) {
  160. // IcosahedronGeometry is non-indexed ( 60 verts ); deleting uv + normal lets mergeVertices
  161. // weld by position to 12 verts — ~5× fewer vertex-shader runs. normals are rebuilt below.
  162. let geometry = new IcosahedronGeometry( 1, p.detail );
  163. geometry.deleteAttribute( 'uv' );
  164. geometry.deleteAttribute( 'normal' );
  165. geometry = mergeVertices( geometry );
  166. const position = geometry.attributes.position;
  167. const count = position.count;
  168. const normals = new Float32Array( count * 3 );
  169. const ao = new Float32Array( count );
  170. for ( let i = 0; i < count; i ++ ) {
  171. const ux = position.getX( i );
  172. const uy = position.getY( i );
  173. const uz = position.getZ( i ); // a point on the unit sphere
  174. const h = ( uy + 1 ) / 2; // 0 at the base, 1 at the top
  175. const taper = 1 - 0.62 * h; // narrower toward a pointier crown
  176. const lump = 1 + p.distortion * blobNoise( ux, uy, uz );
  177. const r = taper * lump;
  178. position.setXYZ( i, ux * r * p.radius, h * p.height, uz * r * p.radius );
  179. // up-and-outward normal: a soft, dome-lit canopy rather than faceted rock
  180. const inv = 1 / Math.hypot( ux, 0.55, uz );
  181. normals[ i * 3 ] = ux * inv;
  182. normals[ i * 3 + 1 ] = 0.55 * inv;
  183. normals[ i * 3 + 2 ] = uz * inv;
  184. ao[ i ] = h;
  185. }
  186. position.needsUpdate = true;
  187. geometry.setAttribute( 'normal', new BufferAttribute( normals, 3 ) );
  188. geometry.setAttribute( 'ao', new BufferAttribute( ao, 1 ) );
  189. geometry.computeBoundingSphere();
  190. return geometry;
  191. }
  192. // derivative-based bump ( surface-gradient method ): perturbs the view normal from a
  193. // procedural height field, so the canopy reads as clustered foliage, not a smooth shell
  194. function bumpNormal( height ) {
  195. const dpdx = positionView.dFdx();
  196. const dpdy = positionView.dFdy();
  197. const r1 = dpdy.cross( normalView );
  198. const r2 = normalView.cross( dpdx );
  199. const det = dpdx.dot( r1 );
  200. const grad = det.sign().mul( height.dFdx().mul( r1 ).add( height.dFdy().mul( r2 ) ) );
  201. return det.abs().mul( normalView ).sub( grad ).normalize();
  202. }
  203. /**
  204. * The single material shared by every tree in a {@link ForestGenerator}. A plain
  205. * MeshStandardNodeMaterial lit by the scene — only the surface is authored: deep
  206. * shadowed green in the recesses rising to a bright, yellow-green sunlit crown,
  207. * mottled into needle clumps by 3D noise, with a matching bump so the clumps catch
  208. * the light. Half a million instanced blobs makes this mesh vertex-bound, so the
  209. * regional colour drift is baked to a per-instance attribute ( no shader noise for it ),
  210. * and the costly clump noise + bump are **gated by distance** — full detail on the near
  211. * trees ( where it reads ), skipped on the far canopy ( where it is sub-pixel ).
  212. *
  213. * @param {Node} from - distance within which every tree is drawn.
  214. * @param {Node} to - distance past which no tree is drawn.
  215. * @return {MeshStandardNodeMaterial}
  216. */
  217. function createForestMaterial( from, to, camPos ) {
  218. const material = new MeshStandardNodeMaterial();
  219. material.metalness = 0;
  220. material.roughness = 0.88;
  221. const cull = attribute( 'cull', 'vec4' ); // xyz = tree position, w = random 0..1
  222. const d = cull.xyz.distance( camPos ); // per-tree distance to the ( main ) camera
  223. // stochastic distance cull: past its jittered `from`→`to` threshold a tree collapses to a
  224. // point, dropping the far canopy. `positionLocal` is already WORLD space here ( the instance
  225. // transform runs before positionNode ), so the ×0 lands the whole blob on the origin.
  226. const t = d.sub( from ).div( to.sub( from ) );
  227. material.positionNode = positionLocal.mul( step( t, cull.w ) ); // keep where random ≥ t
  228. const ao = attribute( 'ao', 'float' ); // 0 at the blob base, 1 at the crown
  229. // regional drift, baked per tree ( see build ) so no stage taps a noise; a blob is small
  230. // enough that one value per tree reads as a smooth field across the canopy
  231. const region = attribute( 'region', 'float' );
  232. const deep = mix( color( 0x1d3318 ), color( 0x2e4420 ), region ); // shadowed interior
  233. const bright = mix( color( 0x4c6a2e ), color( 0x6e8a40 ), region ); // sunlit tips ( muted green, not neon )
  234. // one 3D noise field ( coarse + fine ), shared by the colour and bump, near canopy only
  235. const detailFade = smoothstep( 280, 25, positionWorld.distance( camPos ) );
  236. // gated by an If ( which must sit inside an Fn ) so the far canopy skips the noise
  237. const clump = Fn( () => {
  238. const c = float( 0 ).toVar();
  239. If( detailFade.greaterThan( 0.01 ), () => {
  240. c.assign( mx_noise_float( positionWorld.mul( 0.9 ) )
  241. .add( mx_noise_float( positionWorld.mul( 3.1 ) ).mul( 0.5 ) )
  242. .mul( detailFade ) );
  243. } );
  244. return c;
  245. } )();
  246. // deep recesses → bright clumps / crown
  247. const lit = ao.mul( 0.5 ).add( 0.32 ).add( clump.mul( 0.18 ) ).clamp();
  248. material.colorNode = mix( deep, bright, lit );
  249. // clumps catch the light ( clump is 0 far away, so the bump flattens there )
  250. material.normalNode = bumpNormal( clump.mul( 0.22 ) );
  251. return material;
  252. }
  253. export { ForestGenerator, createForestMaterial };
粤ICP备19079148号