UNPKG

three

Version:

JavaScript 3D library

372 lines (269 loc) 13 kB
import { BufferAttribute, BufferGeometry, Mesh, Vector3 } from 'three'; import { MeshStandardNodeMaterial } from 'three/webgpu'; import { color, float, mx_fractal_noise_float, positionLocal, vec3 } from 'three/tsl'; // the golden angle ( 137.5° ): rolling each sibling branch by this much around the // parent axis spreads them like a real stem, so they never line up const GOLDEN_ANGLE = Math.PI * ( 3 - Math.sqrt( 5 ) ); const DEG2RAD = Math.PI / 180; const TAU = Math.PI * 2; const UP = /*@__PURE__*/ new Vector3( 0, 1, 0 ); const _axis = /*@__PURE__*/ new Vector3(); /** * Grows a procedural tree skeleton — trunk, branches and twigs, each swept as a tapered * tube — and bakes it into one indexed {@link BufferGeometry} (position and normal * only), ready to instance into a forest. It produces *branches only*; add foliage as a * separate layer. * * The branching is deterministic for a given `seed`: a recursive sweep lays down gently * curved tubes with a parallel-transport frame (so they never twist), forking by the * pipe model (each child much thinner than its parent), spreading children along the * upper part of each branch with a golden-angle roll, and pulling them back up toward * the light. A flared root, non-linear taper and gravity droop fill in the character. * * Parameters are set with a fluent builder: a `set<Param>()` exists for every default * ( `setSeed`, `setLevels`, `setChildren`, … ), each returning `this` for chaining. * * Each `build()` returns a fresh, independent mesh that the caller owns, so one * generator can be re-parametrized and built repeatedly to grow a varied stand: * * ```js * const generator = new TreeGenerator( material ); * const oak = generator.setSeed( 1 ).setLevels( 4 ).build(); * const pine = generator.setSeed( 2 ).setLevels( 5 ).build(); * ``` */ class TreeGenerator { constructor( material = null ) { this.material = material; this.parameters = {}; // overrides; defaults fill the rest at build time } build() { const p = Object.assign( {}, TreeGenerator.defaults, this.parameters ); const random = createRandom( p.seed ); const tubes = []; growBranch( tubes, new Vector3(), UP, p.trunkLength, p.trunkRadius, 0, p, random ); const geometry = createGeometry( tubes ); const mesh = new Mesh( geometry, this.material || createTreeMaterial() ); mesh.name = 'Tree'; return mesh; } } TreeGenerator.defaults = { seed: 1, levels: 4, // recursion depth: trunk, branch, twig, sub-twig children: [ 3, 12, 8 ], // sub-branches per level; density comes from many children spread along each parent, not depth branchAngle: [ 38, 50, 58 ], // degrees a child tilts off its parent axis, per level angleVariance: 14, // degrees of random jitter on the branch angle, breaks fractal regularity lengthRatio: 0.62, // child length / parent length lengthVariance: 0, // fractional variation in child length branchLengthFalloff: 0, // shorten children toward the tip of their parent trunkLength: 9, // trunk length in world units; sets the tree's height trunkRadius: 0.42, // base radius of the trunk taper: 0.55, // a branch thins to ( 1 - taper ) of its base radius along its own length taperCurve: 0.7, // < 1 keeps the bole full then tapers ( real trees ), 1 = straight cone rootFlare: 0.6, // how much the trunk swells at the very base flareFrac: 0.18, // fraction of the trunk over which the flare acts radiusExponent: 2.3, // pipe model ( da Vinci ): childBase = parentBase × ( 1 / children )^( 1 / radiusExponent ) minRadius: 0.05, // hair-thin floor so twigs don't taper to a sliver minLength: 0.6, // branches shorter than this stop recursing droop: 0.05, // gravity sag per branch ( ≈ droop × length ); the trunk stays upright upPull: 0.3, // phototropism: 0 = a bare spread cone ( many branches aim down ), 1 = straight up gnarl: [ 0.05, 0.16, 0.26, 0.32 ], // per-level random wobble on each tube segment radialSegments: 6, // ring vertices around a tube ( drops by one per level for thin twigs ) sectionLength: 1.3, // world units per tube segment, so a tall trunk stays smooth childStart: 0.12, // fraction up a sub-branch before children appear trunkClear: 0.25 // fraction of the trunk kept bare before the crown ( raise for a tall clean bole ) }; // a fluent setter for every default — setSeed(), setLevels(), setChildren(), … — each // storing its value and returning `this`, so the API stays in sync with the parameters for ( const key of Object.keys( TreeGenerator.defaults ) ) { TreeGenerator.prototype[ 'set' + key[ 0 ].toUpperCase() + key.slice( 1 ) ] = function ( value ) { this.parameters[ key ] = value; return this; }; } // --- skeleton ------------------------------------------------------------ // Grows one branch as a gently curved, tapered tube and recurses, collecting the tube // into `tubes`. The tube is swept with a parallel-transport frame ( rotated by the same // rotation that bends the tangent each step ) so it never twists, unlike a naive Frenet // frame. Children fork off the upper part of the branch by the pipe model. function growBranch( tubes, base, dir, length, baseRadius, level, p, random ) { const sections = Math.max( 3, Math.min( 24, Math.round( length / p.sectionLength ) ) ); // ring count tracks length const radial = Math.max( 3, p.radialSegments - level ); const step = length / sections; const gnarl = p.gnarl[ Math.min( level, p.gnarl.length - 1 ) ]; const start = level === 0 ? p.trunkClear : p.childStart; // the trunk carries a clean bole below its crown let tangent = dir.clone().normalize(); const normal = perpendicular( tangent ); const rings = []; const pos = base.clone(); for ( let s = 0; s <= sections; s ++ ) { const t = s / sections; // non-linear taper down to ( 1 - taper ) of the base, with a flared root on the trunk let radius = baseRadius * ( ( 1 - p.taper ) + p.taper * Math.pow( 1 - t, p.taperCurve ) ); if ( level === 0 && p.rootFlare > 0 ) { const flare = Math.max( 0, ( p.flareFrac - t ) / p.flareFrac ); radius *= 1 + p.rootFlare * flare * flare * flare; // sharp knee, confined to the base } rings.push( { pos: pos.clone(), tangent: tangent.clone(), normal: normal.clone(), radius } ); if ( s < sections ) { const next = tangent.clone(); next.x += ( random() * 2 - 1 ) * gnarl; next.y += ( random() * 2 - 1 ) * gnarl; next.z += ( random() * 2 - 1 ) * gnarl; if ( level > 0 ) next.y -= p.droop * step; // branches sag; the trunk stays vertical next.normalize(); transport( tangent, next, normal ); // keep the frame torsion-free pos.addScaledVector( next, step ); tangent = next; } } tubes.push( { rings, radial } ); if ( level >= p.levels - 1 || length < p.minLength ) return; // fork: children spread along the upper branch, each tilted off the local tangent, // rolled by the golden angle, and much thinner than the parent ( pipe model ) const n = p.children[ Math.min( level, p.children.length - 1 ) ]; const angle = p.branchAngle[ Math.min( level, p.branchAngle.length - 1 ) ]; const pipeDrop = Math.pow( 1 / n, 1 / p.radiusExponent ); for ( let i = 0; i < n; i ++ ) { const t = start + ( i + 0.5 + ( random() - 0.5 ) * 0.6 ) / n * ( 1 - start ); const ring = ringAt( rings, t ); const tilt = ( angle + ( random() * 2 - 1 ) * p.angleVariance ) * DEG2RAD; const roll = i * GOLDEN_ANGLE + ( random() * 2 - 1 ) * 0.4; // tilt off a perpendicular axis FIRST, then roll about the parent axis, then pull // back toward the light ( else the roll sends half the children downward ) const childDir = ring.tangent.clone() .applyAxisAngle( ring.normal, tilt ) .applyAxisAngle( ring.tangent, roll ); if ( p.upPull > 0 ) childDir.lerp( UP, p.upPull ).normalize(); // the pipe-model drop, but never fatter than the wood it leaves nor below the floor const childBase = Math.max( p.minRadius, Math.min( baseRadius * pipeDrop, ring.radius ) ); let childLength = length * p.lengthRatio * ( 1 - p.branchLengthFalloff * t ); if ( p.lengthVariance > 0 ) childLength *= 1 + ( random() * 2 - 1 ) * p.lengthVariance; growBranch( tubes, ring.pos, childDir, childLength, childBase, level + 1, p, random ); } } // a unit vector perpendicular to v ( cross with the least-aligned axis ) function perpendicular( v ) { const a = Math.abs( v.x ) < 0.9 ? _axis.set( 1, 0, 0 ) : _axis.set( 0, 1, 0 ); return new Vector3().crossVectors( v, a ).normalize(); } // rotate frame vector n by the rotation that maps tangent t0 onto t1 function transport( t0, t1, n ) { _axis.crossVectors( t0, t1 ); const sin = _axis.length(); if ( sin < 1e-6 ) return; // already parallel _axis.divideScalar( sin ); n.applyAxisAngle( _axis, Math.atan2( sin, t0.dot( t1 ) ) ); } // sample the branch frame at fraction t ( 0..1 ) for spawning a child function ringAt( rings, t ) { const f = Math.max( 0, Math.min( 0.999, t ) ) * ( rings.length - 1 ); const i = Math.floor( f ); const frac = f - i; const a = rings[ i ]; const b = rings[ i + 1 ]; return { pos: a.pos.clone().lerp( b.pos, frac ), tangent: a.tangent.clone().lerp( b.tangent, frac ).normalize(), normal: a.normal.clone().lerp( b.normal, frac ).normalize(), radius: a.radius + ( b.radius - a.radius ) * frac }; } // --- geometry ------------------------------------------------------------ // Write each ring vertex once, then join adjacent rings with indexed triangles. function createGeometry( tubes ) { let vertexCount = 0; let indexCount = 0; for ( const { rings, radial } of tubes ) { vertexCount += rings.length * radial; indexCount += ( rings.length - 1 ) * radial * 6; } const positions = new Float32Array( vertexCount * 3 ); const normals = new Float32Array( vertexCount * 3 ); const indices = new ( vertexCount > 65535 ? Uint32Array : Uint16Array )( indexCount ); const binormal = new Vector3(); let vertexOffset = 0; let indexOffset = 0; for ( const { rings, radial } of tubes ) { let offset = vertexOffset * 3; for ( const { pos, tangent, normal, radius } of rings ) { binormal.crossVectors( tangent, normal ); for ( let j = 0; j < radial; j ++ ) { const angle = j / radial * TAU; const c = Math.cos( angle ); const s = Math.sin( angle ); const nx = c * normal.x + s * binormal.x; const ny = c * normal.y + s * binormal.y; const nz = c * normal.z + s * binormal.z; positions[ offset ] = pos.x + nx * radius; positions[ offset + 1 ] = pos.y + ny * radius; positions[ offset + 2 ] = pos.z + nz * radius; normals[ offset ] = nx; normals[ offset + 1 ] = ny; normals[ offset + 2 ] = nz; offset += 3; } } for ( let r = 0; r < rings.length - 1; r ++ ) { const a = vertexOffset + r * radial; const b = a + radial; for ( let j = 0; j < radial; j ++ ) { const next = ( j + 1 ) % radial; // wrap the seam back to the first vertex indices[ indexOffset ++ ] = a + j; indices[ indexOffset ++ ] = b + next; indices[ indexOffset ++ ] = b + j; indices[ indexOffset ++ ] = a + j; indices[ indexOffset ++ ] = a + next; indices[ indexOffset ++ ] = b + next; } } vertexOffset += rings.length * radial; } const geometry = new BufferGeometry(); geometry.setAttribute( 'position', new BufferAttribute( positions, 3 ) ); geometry.setAttribute( 'normal', new BufferAttribute( normals, 3 ) ); geometry.setIndex( new BufferAttribute( indices, 1 ) ); geometry.computeBoundingSphere(); return geometry; } // --- deterministic PRNG ( mulberry32 ) ----------------------------------- 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; }; } // --- material ------------------------------------------------------------ /** * A simple bark material for a {@link TreeGenerator} mesh: a low-saturation brown with a * faint, vertically-stretched grain, so trunks read near-black against bright fog. * * @param {Object} [parameters] - `barkColor` ( a hex, THREE.Color or TSL node ) and `barkScale` ( a THREE.Vector3 ). * @return {MeshStandardNodeMaterial} */ function createTreeMaterial( parameters = {} ) { const c = parameters.barkColor; const barkColor = c === undefined ? color( 0x4b3a2b ) : ( c.isColor || typeof c === 'number' ? color( c ) : c ); const material = new MeshStandardNodeMaterial(); const barkScale = parameters.barkScale ? vec3( parameters.barkScale ) : vec3( 2.5, 0.4, 2.5 ); const grain = mx_fractal_noise_float( positionLocal.mul( barkScale ), 3 ).mul( 0.18 ); material.colorNode = barkColor.mul( grain.add( 0.9 ) ); material.roughnessNode = float( 0.95 ); material.metalnessNode = float( 0 ); return material; } export { TreeGenerator, createTreeMaterial };