Files
three-offshore-vibe/src/VegetationSystem.js

439 lines
16 KiB
JavaScript

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);
}
}