Accueil / npm / three@0.186.1 / examples / jsm / generators / ForestGenerator.js

ForestGenerator.js

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} with trees in a single instanced draw call.
 * Each tree is a distorted icosphere with a baked crown gradient. Altitude, slope
 * and a density mask control placement; rotation and scale vary between trees.
 *
 * Compatible terrain objects expose `sampleHeight`, `sampleSlope`, `minY`, `maxY`
 * and `parameters.size`.
 *
 * ```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.dispose();
			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 };

Exemple