Accueil / npm / three@0.186.1 / examples / jsm / generators / city / StreetTreeGenerator.js

StreetTreeGenerator.js

import {
	CylinderGeometry,
	IcosahedronGeometry,
	InterpolationSamplingMode,
	InterpolationSamplingType,
	RingGeometry,
	Vector3
} from 'three';

import { MeshStandardNodeMaterial } from 'three/webgpu';
import { attribute, color, float, fract, instanceIndex, mix, mx_fractal_noise_float, mx_noise_float, normalView, normalWorldGeometry, positionGeometry, positionLocal, positionViewDirection, positionWorld, select, sin, smoothstep, time, varying, vec3 } from 'three/tsl';

import { mergeGeometries, mergeVertices } from '../../utils/BufferGeometryUtils.js';

import { InstancedMeshGenerator } from './InstancedMeshGenerator.js';
import { part, bumpNormal } from './CityGeneratorUtils.js';

/**
 * A young street tree set in a curbside pit: a flared bark trunk rising through a
 * cast-iron pit grate, branching into a canopy of vertex-jittered leaf clumps. The
 * clump normals are blended toward a shared crown sphere so the clumps shade as one
 * irregular crown rather than separate balls, and the material adds interior
 * self-shadowing and patchy colour so the foliage reads as leaf mass. Built once
 * and instanced across a list of placements, dressed with one cheap material that
 * branches on a baked `partId`.
 *
 * The canopy is near rotationally symmetric, so the canonical model stands on
 * `y = 0`, centred in X / Z, with its slight lean toward `+Z` matching the other
 * pieces of street furniture.
 *
 * ```js
 * const trees = new StreetTreeGenerator();
 * scene.add( trees.build( placements ) ); // placements: Matrix4[]
 * ```
 */
class StreetTreeGenerator extends InstancedMeshGenerator {

	constructor( parameters = {} ) {

		super( parameters, buildStreetTreeGeometry, createStreetTreeMaterial, 'StreetTrees' );

	}

}

StreetTreeGenerator.defaults = {
	trunkHeight: 2.6, // clear trunk below the canopy
	trunkRadius: 0.18 // bark radius at the base
};

const TRUNK = 0, LEAF = 1, GRATE = 2;

// the crown sphere the clump normals blend toward; the material reuses it for
// the interior-shadow falloff
const CROWN_CENTER = new Vector3( 0, 3.9, 0.1 );
const CROWN_RADIUS = 3.1;

// deterministic position hash for the clump jitter
function hash( x, y, z ) {

	const s = Math.sin( x * 127.1 + y * 311.7 + z * 74.7 ) * 43758.5453;
	return s - Math.floor( s );

}

// a leaf clump: an icosahedron with its vertices pushed in and out radially so the
// silhouette breaks into lobes, its normals pulled toward the shared crown sphere
function leafClump( radius, x, y, z ) {

	// polyhedron geometries come non-indexed; weld them so the jittered surface
	// gets smooth vertex normals instead of hard facets
	const g = mergeVertices( new IcosahedronGeometry( radius, 1 ).scale( 1, 0.82, 1 ) );

	const position = g.attributes.position;
	const v = new Vector3();

	for ( let i = 0; i < position.count; i ++ ) {

		v.fromBufferAttribute( position, i );
		const n = hash( v.x, v.y, v.z );
		v.multiplyScalar( 0.86 + n * 0.3 );
		position.setXYZ( i, v.x, v.y, v.z );

	}

	g.translate( x, y, z );
	g.computeVertexNormals();

	// blend the faceted normals toward the crown sphere so all the clumps light
	// as one continuous canopy instead of a cluster of separate balls
	const normal = g.attributes.normal;

	for ( let i = 0; i < normal.count; i ++ ) {

		v.fromBufferAttribute( position, i ).sub( CROWN_CENTER ).normalize();
		normal.setXYZ(
			i,
			normal.getX( i ) * 0.45 + v.x * 0.55,
			normal.getY( i ) * 0.45 + v.y * 0.55,
			normal.getZ( i ) * 0.45 + v.z * 0.55
		);

	}

	g.normalizeNormals();

	return g;

}

function buildStreetTreeGeometry( p ) {

	const r = p.trunkRadius, h = p.trunkHeight;

	// tree pit: dark soil sunk inside a cast-iron grate ring flush with the pavement
	const soil = new CylinderGeometry( 0.24, 0.3, 0.05, 10 ).translate( 0, 0.025, 0 );
	const grate = new RingGeometry( 0.24, 0.62, 16, 4 ).rotateX( - Math.PI / 2 ).translate( 0, 0.045, 0 );

	// the trunk flares at the root collar and tapers toward the crown
	const rootFlare = new CylinderGeometry( r * 1.15, r * 1.6, 0.22, 8 ).translate( 0, 0.11, 0 );
	const trunk = new CylinderGeometry( r * 0.55, r * 1.1, h, 8 ).translate( 0, h / 2, 0 );

	// a few bare limbs fan from the trunk top into the clumps, so the canopy
	// visibly grows out of the tree instead of hovering over it
	const limbs = [];
	for ( const [ rx, rz, len ] of [[ 0.5, 0.2, 1.5 ], [ - 0.45, - 0.3, 1.4 ], [ 0.1, - 0.55, 1.2 ], [ - 0.15, 0.5, 1.3 ]] ) {

		limbs.push(
			new CylinderGeometry( 0.03, 0.07, len, 5 )
				.translate( 0, len / 2, 0 )
				.rotateX( rx ).rotateZ( rz )
				.translate( 0, h - 0.15, 0 )
		);

	}

	// overlapping jittered clumps, one dominant mass with satellites tucked
	// around it so the crown reads irregular rather than as a single ball
	const clumps = [
		leafClump( 2.15, 0, 3.9, 0.1 ),
		leafClump( 1.5, 1.15, 4.35, 0.5 ),
		leafClump( 1.45, - 1.05, 3.55, - 0.5 ),
		leafClump( 1.3, 0.35, 4.75, - 0.65 ),
		leafClump( 1.25, - 0.35, 4.5, 0.9 ),
		leafClump( 1.1, 0.9, 3.3, - 0.85 )
	];

	return mergeGeometries( [
		part( soil, TRUNK ),
		part( grate, GRATE ),
		part( mergeGeometries( [ rootFlare, trunk, ...limbs ] ), TRUNK ),
		...clumps.map( c => part( c, LEAF ) )
	] );

}

function createStreetTreeMaterial() {

	const partId = varying( attribute( 'partId', 'float' ) ).setInterpolation( InterpolationSamplingType.FLAT, InterpolationSamplingMode.EITHER );
	const isLeaf = partId.equal( LEAF );
	const isGrate = partId.equal( GRATE );

	const p = positionGeometry;

	// smooth world-space noise: large patches shift the green so no two trees
	// ( or two sides of one crown ) read as the same flat colour
	const patch = mx_fractal_noise_float( positionWorld.mul( 0.55 ), 2 ).mul( 0.5 ).add( 0.5 );

	// leaf detail field: tuft-scale clumps and a leaf-scale sparkle. the same
	// heights drive the colour break-up and the derivative bump, so the crown
	// catches light like packed foliage instead of a smooth shell
	const tufts = mx_fractal_noise_float( positionWorld.mul( 2.2 ), 2 ).mul( 0.5 ).add( 0.5 );
	const sparkle = mx_noise_float( positionWorld.mul( 9 ) ).mul( 0.5 ).add( 0.5 );

	// interior shadow: leaves darken toward the crown core, where a real canopy
	// swallows the light, and brighten toward the sun-fed rim
	const crownDist = p.sub( vec3( CROWN_CENTER.x, CROWN_CENTER.y, CROWN_CENTER.z ) ).length().div( CROWN_RADIUS );
	const interiorAO = smoothstep( 0.1, 1.05, crownDist ).mul( 0.5 ).add( 0.5 );

	// every tree draws its own tint: some paler, some warm as if turning early
	const treeLane = fract( sin( float( instanceIndex ).mul( 12.9898 ) ).mul( 43758.5453 ) );

	// sky-facing lift plus patchy hue drift between deep and warm greens
	const skyLift = normalWorldGeometry.y.mul( 0.5 ).add( 0.5 );
	const leafBase = mix( color( 0x33511f ), color( 0x5d7c33 ), skyLift );
	const leafVaried = mix( leafBase, color( 0x77822f ), patch.mul( 0.55 ) );
	const leafTinted = mix( leafVaried, color( 0x7d7c2c ), treeLane.mul( 0.3 ) );
	const leafLit = leafTinted.mul( tufts.mul( 0.35 ).add( 0.75 ) ).mul( sparkle.mul( 0.24 ).add( 0.88 ) );
	const leaf = leafLit.mul( interiorAO );

	// bark: ridges running up the trunk, their crevices darker and rougher
	const ridge = mx_fractal_noise_float( vec3( p.x.mul( 9 ), p.y.mul( 1.4 ), p.z.mul( 9 ) ), 2 ).abs().oneMinus();
	const bark = mix( color( 0x4a3a2a ), color( 0x6f5e46 ), smoothstep( 0.55, 1.0, ridge ).mul( 0.7 ) );

	// cast-iron pit grate: concentric slots cut by a radial stripe pattern
	const gr = p.xz.length();
	const slots = smoothstep( 0.35, 0.5, fract( gr.mul( 11.0 ) ) );
	const grate = mix( color( 0x121110 ), color( 0x3a3835 ), slots );

	const material = new MeshStandardNodeMaterial();
	material.colorNode = select( isLeaf, leaf, select( isGrate, grate, bark ) );
	material.roughnessNode = select( isGrate, float( 0.7 ), select( isLeaf, sparkle.mul( 0.15 ).add( 0.8 ), ridge.mul( 0.12 ).add( 0.82 ) ) );
	material.metalnessNode = select( isGrate, float( 0.4 ), float( 0 ) );

	// the same detail heights feed the screen-space bump, so tufts and bark
	// ridges self-shade under the sun
	// tufts only: leaf-scale noise in the bump turns to moire at grazing light,
	// and the bump itself fades out at grazing view angles for the same reason
	const grazingFade = smoothstep( 0.08, 0.35, normalView.dot( positionViewDirection ).abs() );
	const detailHeight = select( isLeaf, tufts.mul( 0.07 ), select( isGrate, float( 0 ), ridge.mul( 0.02 ) ) ).mul( grazingFade );
	material.normalNode = bumpNormal( detailHeight );

	// a gentle breeze: the canopy leans and flutters, rising with height so the
	// trunk stays planted. per-instance phase keeps neighbouring trees out of step
	const isLeafVertex = attribute( 'partId', 'float' ).equal( LEAF );
	const phase = float( instanceIndex ).mul( 1.7 );
	const heightGate = smoothstep( 2.0, 5.0, p.y );
	const lean = vec3(
		sin( time.mul( 0.8 ).add( phase ) ).mul( 0.05 ),
		0,
		sin( time.mul( 0.61 ).add( phase.mul( 1.3 ) ) ).mul( 0.04 )
	);
	const flutter = sin( time.mul( 3.1 ).add( p.y.mul( 2.4 ) ).add( p.x.mul( 1.9 ) ) ).mul( 0.015 );
	const sway = lean.add( vec3( flutter, flutter.negate(), 0 ) ).mul( heightGate );
	material.positionNode = positionLocal.add( select( isLeafVertex, sway, vec3( 0 ) ) ); // on top of positionLocal, which already carries the instance transform

	return material;

}

export { StreetTreeGenerator };