UNPKG

three

Version:

JavaScript 3D library

348 lines (253 loc) 12.9 kB
import { BufferAttribute, Group, IcosahedronGeometry, InstancedBufferAttribute, InstancedMesh, Object3D, Vector3 } from 'three'; import { MeshStandardNodeMaterial } from 'three/webgpu'; import { attribute, color, float, Fn, If, mix, mx_noise_float, normalView, positionLocal, positionView, positionWorld, smoothstep, step, uniform } from 'three/tsl'; import { ImprovedNoise } from '../math/ImprovedNoise.js'; import { mergeVertices } from '../utils/BufferGeometryUtils.js'; /** * Carpets a {@link TerrainGenerator} ( or anything exposing `sampleHeight`, * `sampleSlope`, `minY`, `maxY` and `parameters.size` ) with a forest of hundreds * of thousands of trees in a single draw call. * * Each tree is the cheapest thing that still reads as a tree: a ~20-face icosphere * squashed into a tapered teardrop and lumped with a little noise, carrying a baked * dark-base / bright-top gradient. Tens of triangles each, so a single * {@link THREE.InstancedMesh} of half a million of them costs one draw call. Trees * are placed by rejection sampling against ecological rules — a min/max altitude * band ( above the mist floor, below the snowline ), a slope limit ( none on * cliffs ) and a low-frequency density mask that opens clearings — then jittered in * yaw, lean and ( squared-biased ) scale so the stand never reads as copies. * * ```js * const forest = new ForestGenerator( { count: 500000 } ); * scene.add( forest.build( terrain ) ); * ``` */ class ForestGenerator { constructor( parameters = {} ) { this.parameters = Object.assign( {}, ForestGenerator.defaults, parameters ); // stochastic distance cull ( THREE.Fog-style near / far ): drawn within `from`, gone // past `to`, the band between thinned by a baked random. live-tunable uniforms. this.from = uniform( this.parameters.from ); this.to = uniform( this.parameters.to ); // main-camera position ( set via setCameraPosition ). NOT the TSL cameraPosition node: // in the shadow pass that resolves to the light, which would cull the wrong trees. this._cameraPosition = uniform( new Vector3() ); this.material = createForestMaterial( this.from, this.to, this._cameraPosition ); this.mesh = null; this.group = null; } build( terrain ) { this.dispose(); const p = this.parameters; const geometry = blobGeometry( p ); const size = terrain.parameters.size; const minY = terrain.minY; const span = terrain.maxY - terrain.minY; const random = createRandom( p.seed ); // a low-frequency field that breaks the forest into patches and clearings const perlin = new ImprovedNoise(); const dOffX = random() * 256, dOffZ = random() * 256, dSlice = random() * 256; const densityAt = ( x, z ) => smoothBlend( - 0.12, 0.22, perlin.noise( x * p.densityFrequency + dOffX, z * p.densityFrequency + dOffZ, dSlice ) ); const mesh = new InstancedMesh( geometry, this.material, p.count ); mesh.castShadow = mesh.receiveShadow = p.castShadow; // honoured on every rebuild // per-instance cull data: xyz = tree position ( for its distance to the camera ), // w = a threshold jitter from a separate PRNG, so it doesn't disturb placement const cullData = new Float32Array( p.count * 4 ); const cullRandom = createRandom( ( p.seed ^ 0x9e3779b9 ) >>> 0 ); // per-instance regional colour drift, baked here so the vertex-bound shader taps no // noise. offsets come from the cull PRNG, so placement is untouched. const regionData = new Float32Array( p.count ); const rOffX = cullRandom() * 256, rOffZ = cullRandom() * 256, rSlice = cullRandom() * 256; const dummy = new Object3D(); let placed = 0; let attempts = 0; const maxAttempts = p.count * 14; // give up rather than hang if the band is too small while ( placed < p.count && attempts < maxAttempts ) { attempts ++; const x = ( random() - 0.5 ) * size; const z = ( random() - 0.5 ) * size; const y = terrain.sampleHeight( x, z ); const altitude = ( y - minY ) / span; if ( altitude < p.altitudeMin || altitude > p.altitudeMax ) continue; if ( terrain.sampleSlope( x, z ) < p.minSlope ) continue; // density mask, feathered out at the top so the treeline scatters, not a clean line let density = densityAt( x, z ); density *= smoothBlend( p.altitudeMax, p.altitudeMax - 0.14, altitude ); if ( random() >= density ) continue; dummy.position.set( x, y - p.sink, z ); // sink the base point into the ground dummy.rotation.set( ( random() - 0.5 ) * 0.12, random() * Math.PI * 2, ( random() - 0.5 ) * 0.12 ); // small lean + free yaw, trunk ~vertical const s = p.minScale + random() * random() * ( p.maxScale - p.minScale ); // squared bias: mostly small, rare giants dummy.scale.set( s * ( 0.85 + random() * 0.3 ), s, s * ( 0.85 + random() * 0.3 ) ); dummy.updateMatrix(); mesh.setMatrixAt( placed, dummy.matrix ); const c = placed * 4; cullData[ c ] = x; cullData[ c + 1 ] = dummy.position.y; // the sunk y, matching the drawn position cullData[ c + 2 ] = z; cullData[ c + 3 ] = cullRandom(); regionData[ placed ] = Math.min( 1, Math.max( 0, perlin.noise( x * 0.02 + rOffX, z * 0.02 + rOffZ, rSlice ) * 0.6 + 0.5 ) ); placed ++; } mesh.count = placed; // only what got planted mesh.instanceMatrix.needsUpdate = true; geometry.setAttribute( 'cull', new InstancedBufferAttribute( cullData, 4 ) ); geometry.setAttribute( 'region', new InstancedBufferAttribute( regionData, 1 ) ); const group = new Group(); group.name = 'Forest'; group.add( mesh ); this.mesh = mesh; this.group = group; return group; } // call each frame so the distance cull tracks the camera setCameraPosition( position ) { this._cameraPosition.value.copy( position ); } dispose() { if ( this.mesh ) this.mesh.geometry.dispose(); this.mesh = null; this.group = null; } } ForestGenerator.defaults = { seed: 1, count: 500000, // number of trees to plant ( a single instanced draw call ) detail: 0, // icosphere subdivision ( 0 = 20 faces, welds to 12 verts ) radius: 1.3, // base half-width of a tree blob, in world units height: 4, // base height of a tree blob distortion: 0.5, // lumpiness of the blob hull ( a rough conifer, not a smooth egg ) sink: 0.4, // how far the base point is pushed under the surface, to hide it altitudeMin: 0.12, // normalised altitude band the forest occupies: above the mist floor... altitudeMax: 0.46, // ...and safely below the snowline minSlope: 0.55, // minimum surface flatness ( normal.y ); steeper ground stays bare rock densityFrequency: 0.012, // patch / clearing scale ( world units ) minScale: 0.7, maxScale: 1.8, from: 300, // distance ( like THREE.Fog ) within which every tree is drawn... to: 620, // ...past which none are; the band between thins out stochastically castShadow: false // whether the canopy casts + receives shadows ( 500k casters is a real cost — opt in ) }; // deterministic PRNG ( mulberry32 ), matching the other generators 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; }; } function smoothBlend( edge0, edge1, x ) { const t = Math.max( 0, Math.min( 1, ( x - edge0 ) / ( edge1 - edge0 ) ) ); return t * t * ( 3 - 2 * t ); } // smooth low-frequency lump over the unit sphere, so the blob hull is bumpy not spiky function blobNoise( x, y, z ) { return Math.sin( x * 3.1 ) * Math.sin( y * 2.7 + 1.3 ) * Math.sin( z * 3.5 + 2.1 ); } // one tree blob: an icosphere squashed into a lumpy, tapered teardrop, base at y = 0. // normals are re-pointed up-and-out so it shades as a soft canopy volume; a baked `ao` // ( 0 base → 1 crown ) drives the dark-underside / bright-crown gradient. function blobGeometry( p ) { // IcosahedronGeometry is non-indexed ( 60 verts ); deleting uv + normal lets mergeVertices // weld by position to 12 verts — ~5× fewer vertex-shader runs. normals are rebuilt below. let geometry = new IcosahedronGeometry( 1, p.detail ); geometry.deleteAttribute( 'uv' ); geometry.deleteAttribute( 'normal' ); geometry = mergeVertices( geometry ); const position = geometry.attributes.position; const count = position.count; const normals = new Float32Array( count * 3 ); const ao = new Float32Array( count ); for ( let i = 0; i < count; i ++ ) { const ux = position.getX( i ); const uy = position.getY( i ); const uz = position.getZ( i ); // a point on the unit sphere const h = ( uy + 1 ) / 2; // 0 at the base, 1 at the top const taper = 1 - 0.62 * h; // narrower toward a pointier crown const lump = 1 + p.distortion * blobNoise( ux, uy, uz ); const r = taper * lump; position.setXYZ( i, ux * r * p.radius, h * p.height, uz * r * p.radius ); // up-and-outward normal: a soft, dome-lit canopy rather than faceted rock const inv = 1 / Math.hypot( ux, 0.55, uz ); normals[ i * 3 ] = ux * inv; normals[ i * 3 + 1 ] = 0.55 * inv; normals[ i * 3 + 2 ] = uz * inv; ao[ i ] = h; } position.needsUpdate = true; geometry.setAttribute( 'normal', new BufferAttribute( normals, 3 ) ); geometry.setAttribute( 'ao', new BufferAttribute( ao, 1 ) ); geometry.computeBoundingSphere(); return geometry; } // derivative-based bump ( surface-gradient method ): perturbs the view normal from a // procedural height field, so the canopy reads as clustered foliage, not a smooth shell 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(); } /** * The single material shared by every tree in a {@link ForestGenerator}. A plain * MeshStandardNodeMaterial lit by the scene — only the surface is authored: deep * shadowed green in the recesses rising to a bright, yellow-green sunlit crown, * mottled into needle clumps by 3D noise, with a matching bump so the clumps catch * the light. Half a million instanced blobs makes this mesh vertex-bound, so the * regional colour drift is baked to a per-instance attribute ( no shader noise for it ), * and the costly clump noise + bump are **gated by distance** — full detail on the near * trees ( where it reads ), skipped on the far canopy ( where it is sub-pixel ). * * @param {Node} from - distance within which every tree is drawn. * @param {Node} to - distance past which no tree is drawn. * @return {MeshStandardNodeMaterial} */ function createForestMaterial( from, to, camPos ) { const material = new MeshStandardNodeMaterial(); material.metalness = 0; material.roughness = 0.88; const cull = attribute( 'cull', 'vec4' ); // xyz = tree position, w = random 0..1 const d = cull.xyz.distance( camPos ); // per-tree distance to the ( main ) camera // stochastic distance cull: past its jittered `from`→`to` threshold a tree collapses to a // point, dropping the far canopy. `positionLocal` is already WORLD space here ( the instance // transform runs before positionNode ), so the ×0 lands the whole blob on the origin. const t = d.sub( from ).div( to.sub( from ) ); material.positionNode = positionLocal.mul( step( t, cull.w ) ); // keep where random ≥ t const ao = attribute( 'ao', 'float' ); // 0 at the blob base, 1 at the crown // regional drift, baked per tree ( see build ) so no stage taps a noise; a blob is small // enough that one value per tree reads as a smooth field across the canopy const region = attribute( 'region', 'float' ); const deep = mix( color( 0x1d3318 ), color( 0x2e4420 ), region ); // shadowed interior const bright = mix( color( 0x4c6a2e ), color( 0x6e8a40 ), region ); // sunlit tips ( muted green, not neon ) // one 3D noise field ( coarse + fine ), shared by the colour and bump, near canopy only const detailFade = smoothstep( 280, 25, positionWorld.distance( camPos ) ); // gated by an If ( which must sit inside an Fn ) so the far canopy skips the noise const clump = Fn( () => { const c = float( 0 ).toVar(); If( detailFade.greaterThan( 0.01 ), () => { c.assign( mx_noise_float( positionWorld.mul( 0.9 ) ) .add( mx_noise_float( positionWorld.mul( 3.1 ) ).mul( 0.5 ) ) .mul( detailFade ) ); } ); return c; } )(); // deep recesses → bright clumps / crown const lit = ao.mul( 0.5 ).add( 0.32 ).add( clump.mul( 0.18 ) ).clamp(); material.colorNode = mix( deep, bright, lit ); // clumps catch the light ( clump is 0 far away, so the bump flattens there ) material.normalNode = bumpNormal( clump.mul( 0.22 ) ); return material; } export { ForestGenerator, createForestMaterial };