import * as THREE from 'three'; import { Tree } from '@dgreenheck/ez-tree'; import { SimplexNoise } from './utils/SimplexNoise.js'; export class VegetationSystem { constructor(terrain, options = {}) { this.terrain = terrain; this.options = { grassCount: options.grassCount ?? 18000, shrubCount: options.shrubCount ?? 420, lowPlantCount: options.lowPlantCount ?? 260, treeCount: options.treeCount ?? 120, terrainSize: options.terrainSize ?? 1000, waterLevel: options.waterLevel ?? 0, treePlacements: options.treePlacements ?? [], shrubPlacements: options.shrubPlacements ?? [], lowPlantPlacements: options.lowPlantPlacements ?? [], grassAreas: options.grassAreas ?? [] }; this.noise = new SimplexNoise(12345); this.group = new THREE.Group(); this.grass = null; this.plants = []; this.animatedPlants = []; this.treePositions = []; this.occupiedPlantPositions = []; } generate() { this.group.clear(); this.plants = []; this.animatedPlants = []; this.treePositions = []; this.occupiedPlantPositions = []; this.generateGrass(); this.generateTrees(); this.generateShrubs(); this.generateLowPlants(); if (this.grass) { this.group.add(this.grass); } this.plants.forEach((plant) => this.group.add(plant)); return this.group; } generateGrass() { const placements = []; if (this.options.grassAreas.length > 0) { this.options.grassAreas.forEach((area, areaIndex) => { const areaCount = area.count ?? 2400; placements.push(...this.collectAreaPlacements(areaCount, { centerX: area.centerX ?? 0, centerZ: area.centerZ ?? 0, width: area.width ?? 120, depth: area.depth ?? 120, minHeight: area.minHeight ?? this.options.waterLevel + 1.2, maxHeight: area.maxHeight ?? this.options.waterLevel + 12, maxSlope: area.maxSlope ?? 1.35, densityScale: area.densityScale ?? 0.02, densityThreshold: area.densityThreshold ?? -0.18, jitterSeed: areaIndex * 13.17 })); }); } if (this.options.grassCount > 0) { placements.push(...this.collectPlacements(this.options.grassCount, { areaRatio: 0.78, minHeight: this.options.waterLevel + 1.2, maxHeight: this.options.waterLevel + 12, maxSlope: 1.35, densityScale: 0.02, densityThreshold: -0.18 })); } if (placements.length === 0) { this.grass = null; return; } const grassGeometries = [ this.createGrassBladeGeometry(0), this.createGrassBladeGeometry(Math.PI / 3), this.createGrassBladeGeometry(-Math.PI / 3) ]; const material = new THREE.MeshStandardMaterial({ color: 0x7aa35a, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }); const grassGroup = new THREE.Group(); const dummy = new THREE.Object3D(); grassGeometries.forEach((geometry, layerIndex) => { const mesh = new THREE.InstancedMesh(geometry, material, placements.length); mesh.castShadow = false; mesh.receiveShadow = true; placements.forEach((placement, index) => { dummy.position.set(placement.x, placement.y, placement.z); dummy.rotation.set(0, placement.rotation + layerIndex * 0.35, placement.tilt); dummy.scale.setScalar(placement.scale); dummy.updateMatrix(); mesh.setMatrixAt(index, dummy.matrix); const color = new THREE.Color().setHSL( THREE.MathUtils.lerp(0.22, 0.31, placement.colorMix), THREE.MathUtils.lerp(0.34, 0.52, placement.colorMix), THREE.MathUtils.lerp(0.24, 0.4, placement.colorMix) ); mesh.setColorAt(index, color); }); mesh.instanceMatrix.needsUpdate = true; if (mesh.instanceColor) { mesh.instanceColor.needsUpdate = true; } grassGroup.add(mesh); }); this.grass = grassGroup; } generateTrees() { const prototypes = [ this.createPlantPrototype('Pine Small', 3001, 1.05), this.createPlantPrototype('Aspen Small', 3002, 1.0), this.createPlantPrototype('Oak Small', 3003, 1.15), this.createPlantPrototype('Pine Medium', 3004, 1.25) ]; const placements = [ ...this.normalizeManualPlacements(this.options.treePlacements, { defaultScaleRange: [1.15, 1.85] }), ...this.collectPlacements(this.options.treeCount, { areaRatio: 0.66, minHeight: this.options.waterLevel + 2.5, maxHeight: this.options.waterLevel + 18, maxSlope: 0.95, minSpacing: 15, densityScale: 0.0042, densityThreshold: 0.14 }) ]; placements.forEach((placement, index) => { const tree = this.instantiatePlant(prototypes[index % prototypes.length], placement, 1.15, 1.9); this.plants.push(tree); this.animatedPlants.push(tree); this.treePositions.push({ x: placement.x, z: placement.z }); this.occupiedPlantPositions.push({ x: placement.x, z: placement.z }); }); } generateShrubs() { this.generateBushLayer({ placements: this.options.shrubPlacements, count: this.options.shrubCount, placementScaleRange: [0.85, 1.35], instantiateScaleRange: [0.75, 1.35], config: { areaRatio: 0.72, minHeight: this.options.waterLevel + 1.5, maxHeight: this.options.waterLevel + 13, maxSlope: 1.1, minSpacing: 6, densityScale: 0.006, densityThreshold: 0.05 }, seedBase: 2001, tintJitterBase: 0.85 }); } generateLowPlants() { this.generateBushLayer({ placements: this.options.lowPlantPlacements, count: this.options.lowPlantCount, placementScaleRange: [0.42, 0.7], instantiateScaleRange: [0.42, 0.72], config: { areaRatio: 0.74, minHeight: this.options.waterLevel + 1.3, maxHeight: this.options.waterLevel + 10, maxSlope: 1.2, minSpacing: 4, densityScale: 0.008, densityThreshold: -0.02 }, seedBase: 4001, tintJitterBase: 0.7 }); } generateBushLayer(layerOptions) { const prototypes = [ this.createPlantPrototype('Bush 1', layerOptions.seedBase, layerOptions.tintJitterBase), this.createPlantPrototype('Bush 2', layerOptions.seedBase + 1, layerOptions.tintJitterBase + 0.08), this.createPlantPrototype('Bush 3', layerOptions.seedBase + 2, layerOptions.tintJitterBase + 0.02) ]; const placements = [ ...this.normalizeManualPlacements(layerOptions.placements, { defaultScaleRange: layerOptions.placementScaleRange }), ...this.collectPlacements(layerOptions.count, layerOptions.config) ]; placements.forEach((placement, index) => { const plant = this.instantiatePlant( prototypes[index % prototypes.length], placement, layerOptions.instantiateScaleRange[0], layerOptions.instantiateScaleRange[1] ); this.plants.push(plant); this.animatedPlants.push(plant); this.occupiedPlantPositions.push({ x: placement.x, z: placement.z }); }); } instantiatePlant(prototype, placement, minScale, maxScale) { const plant = prototype.clone(true); plant.position.set(placement.x, placement.y, placement.z); plant.rotation.y = placement.rotation; plant.scale.setScalar(placement.scale ?? THREE.MathUtils.lerp(minScale, maxScale, placement.scaleMix ?? Math.random())); plant.traverse((child) => { child.castShadow = true; child.receiveShadow = true; }); return plant; } normalizeManualPlacements(placements, config = {}) { return placements.map((placement, index) => { const y = placement.y ?? this.terrain.getHeightAt(placement.x, placement.z); const randomScale = THREE.MathUtils.lerp( config.defaultScaleRange?.[0] ?? 1, config.defaultScaleRange?.[1] ?? 1, ((index * 37) % 100) / 100 ); return { x: placement.x, y, z: placement.z, rotation: placement.rotation ?? (index * Math.PI * 0.37) % (Math.PI * 2), tilt: placement.tilt ?? 0, scale: placement.scale ?? randomScale, scaleMix: placement.scaleMix ?? (((index * 53) % 100) / 100), colorMix: placement.colorMix ?? 0.6 }; }); } createPlantPrototype(presetName, seed, tintJitter) { const plant = new Tree(); plant.loadPreset(presetName); plant.options.seed = seed; plant.options.bark.tint = this.jitterColor(plant.options.bark.tint, tintJitter * 0.04); plant.options.leaves.tint = this.jitterColor(plant.options.leaves.tint, tintJitter * 0.08); plant.generate(); this.tunePlantMaterials(plant); return plant; } tunePlantMaterials(plant) { plant.traverse((child) => { if (!child.isMesh || !child.material) return; const materials = Array.isArray(child.material) ? child.material : [child.material]; materials.forEach((material) => { if (material.name === 'branches') { material.color.multiplyScalar(1.6); material.emissive = new THREE.Color(0x6a4a34); material.emissiveIntensity = 0.38; material.shininess = 26; } if (material.name === 'leaves') { material.color.multiplyScalar(1.2); material.emissive = new THREE.Color(0x355326); material.emissiveIntensity = 0.18; material.alphaTest = 0.42; } material.needsUpdate = true; }); if (child.material?.name === 'branches') { child.receiveShadow = false; } }); } createGrassBladeGeometry(rotationY) { const width = 0.24; const height = 1.4; const lean = 0.18; const geometry = new THREE.PlaneGeometry(width, height, 1, 3); geometry.translate(0, height * 0.5, 0); const position = geometry.attributes.position; for (let i = 0; i < position.count; i++) { const y = position.getY(i); const bend = (y / height) ** 1.8; const taper = THREE.MathUtils.lerp(1, 0.18, y / height); position.setX(i, position.getX(i) * taper + lean * bend); position.setZ(i, 0.06 * bend); } position.needsUpdate = true; geometry.computeVertexNormals(); geometry.rotateY(rotationY); return geometry; } collectPlacements(targetCount, config) { if (!targetCount) return []; const placements = []; const maxAttempts = targetCount * 14; for (let i = 0; i < maxAttempts && placements.length < targetCount; i++) { const x = (Math.random() - 0.5) * this.options.terrainSize * config.areaRatio; const z = (Math.random() - 0.5) * this.options.terrainSize * config.areaRatio; const placement = this.buildPlacementFromPoint(x, z, config); if (!placement) continue; if (config.minSpacing && !this.isFarEnoughFromPlants(placements, x, z, config.minSpacing)) continue; if (config.minSpacing && !this.isFarEnoughFromPlants(this.treePositions, x, z, config.minSpacing * 0.8)) continue; if (config.minSpacing && !this.isFarEnoughFromPlants(this.occupiedPlantPositions, x, z, config.minSpacing * 0.7)) continue; placements.push(placement); } return placements; } collectAreaPlacements(targetCount, config) { const placements = []; const maxAttempts = targetCount * 10; for (let i = 0; i < maxAttempts && placements.length < targetCount; i++) { const x = config.centerX + (Math.random() - 0.5) * config.width; const z = config.centerZ + (Math.random() - 0.5) * config.depth; const placement = this.buildPlacementFromPoint(x, z, config, config.jitterSeed); if (placement) { placements.push(placement); } } return placements; } buildPlacementFromPoint(x, z, config, jitterSeed = 0) { const y = this.terrain.getHeightAt(x, z); if (y < config.minHeight || y > config.maxHeight) return null; const slope = this.getSlopeAt(x, z); if (slope > config.maxSlope) return null; const densityNoise = this.noise.noise2D(x * config.densityScale + jitterSeed, z * config.densityScale - jitterSeed); if (densityNoise < config.densityThreshold) return null; const colorMix = THREE.MathUtils.clamp((densityNoise + 1) * 0.5, 0, 1); return { x, y, z, rotation: Math.random() * Math.PI * 2, tilt: this.noise.noise2D(x * 0.03, z * 0.03) * 0.08, scale: THREE.MathUtils.lerp(0.75, 1.45, Math.random()), scaleMix: Math.random(), colorMix }; } getSlopeAt(x, z) { const sample = 2.5; const left = this.terrain.getHeightAt(x - sample, z); const right = this.terrain.getHeightAt(x + sample, z); const down = this.terrain.getHeightAt(x, z - sample); const up = this.terrain.getHeightAt(x, z + sample); return Math.hypot((right - left) / (sample * 2), (up - down) / (sample * 2)); } isFarEnoughFromPlants(positions, x, z, minSpacing) { const minSpacingSq = minSpacing * minSpacing; for (const pos of positions) { const dx = pos.x - x; const dz = pos.z - z; if (dx * dx + dz * dz < minSpacingSq) { return false; } } return true; } jitterColor(hex, amount) { const color = new THREE.Color(hex); const hsl = {}; color.getHSL(hsl); hsl.h = (hsl.h + this.noise.noise2D(hex * 0.0001, amount * 100) * amount + 1) % 1; hsl.s = THREE.MathUtils.clamp(hsl.s + amount * 0.6, 0, 1); hsl.l = THREE.MathUtils.clamp(hsl.l + amount * 0.3, 0, 1); color.setHSL(hsl.h, hsl.s, hsl.l); return color.getHex(); } update(elapsedTime) { this.animatedPlants.forEach((plant) => { if (typeof plant.update === 'function') { plant.update(elapsedTime); } }); } addToScene(scene) { scene.add(this.group); } }