更换新的植被生成系统

This commit is contained in:
2026-03-26 10:29:51 +08:00
parent dc417a1f1a
commit 3491447cfd
4 changed files with 278 additions and 174 deletions

10
package-lock.json generated
View File

@@ -8,12 +8,22 @@
"name": "realistic-ocean-scene",
"version": "1.0.0",
"dependencies": {
"@dgreenheck/ez-tree": "^1.1.0",
"three": "^0.171.0"
},
"devDependencies": {
"vite": "^6.0.0"
}
},
"node_modules/@dgreenheck/ez-tree": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@dgreenheck/ez-tree/-/ez-tree-1.1.0.tgz",
"integrity": "sha512-6pvS6hD6B6h00dm0SnkgYeT4ABU5Y1Z9M44p1tXiV5C0eKrQy2sKECXshoaUv0qAOqYVL68w/PwadUxDFDiHUg==",
"license": "MIT",
"peerDependencies": {
"three": ">=0.167"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",

View File

@@ -8,6 +8,7 @@
"preview": "vite preview"
},
"dependencies": {
"@dgreenheck/ez-tree": "^1.1.0",
"three": "^0.171.0"
},
"devDependencies": {

View File

@@ -20,6 +20,7 @@ export class OceanScene {
this.sun = new THREE.Vector3();
this.terrain = null;
this.vegetation = null;
this.vegetationSystem = null;
this.pmremGenerator = null;
this.renderTarget = null;
this.sunLight = null;
@@ -304,13 +305,15 @@ export class OceanScene {
async initVegetation() {
const vegSystem = new VegetationSystem(this.terrainGenerator, {
grassCount: 30000,
treeCount: 300,
terrainSize: 1200,
waterLevel: 1
grassCount: 140000, // 草簇数量,越大地表覆盖越密
shrubCount: 12, // 灌木和低矮植物数量
treeCount: 9, // 树木数量
terrainSize: 1200, // 植被允许分布的地形范围
waterLevel: 1 // 植被生成时参考的水位,避免贴近海边
});
this.vegetation = vegSystem.generate();
this.vegetationSystem = vegSystem;
vegSystem.addToScene(this.scene);
}
@@ -481,6 +484,10 @@ export class OceanScene {
});
}
if (this.vegetationSystem) {
this.vegetationSystem.update(time);
}
this.controls.update();
if (this.composer) {
this.composer.render();

View File

@@ -1,195 +1,281 @@
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 || 50000,
treeCount: options.treeCount || 500,
grassCount: options.grassCount || 18000,
shrubCount: options.shrubCount || 420,
treeCount: options.treeCount || 120,
terrainSize: options.terrainSize || 1000,
waterLevel: options.waterLevel || 0
};
this.noise = new SimplexNoise(12345);
this.group = new THREE.Group();
this.grass = null;
this.trees = [];
this.plants = [];
this.animatedPlants = [];
this.treePositions = [];
}
generate() {
this.group.clear();
this.plants = [];
this.animatedPlants = [];
this.treePositions = [];
this.generateGrass();
this.generateTrees();
this.generateShrubs();
return {
grass: this.grass,
trees: this.trees
};
if (this.grass) {
this.group.add(this.grass);
}
this.plants.forEach((plant) => this.group.add(plant));
return this.group;
}
generateGrass() {
const geometry = new THREE.BufferGeometry();
const positions = [];
const colors = [];
const uvs = [];
const indices = [];
const grassBladeHeight = 0.8;
const grassBladeWidth = 0.1;
let vertexIndex = 0;
for (let i = 0; i < this.options.grassCount; i++) {
const x = (Math.random() - 0.5) * this.options.terrainSize * 0.7;
const z = (Math.random() - 0.5) * this.options.terrainSize * 0.7;
const y = this.terrain.getHeightAt(x, z);
if (y < this.options.waterLevel + 0.5 || y > this.options.waterLevel + 10) continue;
const bendX = this.noise.noise2D(x * 0.1, z * 0.1) * 0.3;
const bendZ = this.noise.noise2D(x * 0.1 + 100, z * 0.1) * 0.3;
positions.push(
x - grassBladeWidth / 2, y, z,
x + grassBladeWidth / 2, y, z,
x + bendX + grassBladeWidth / 4, y + grassBladeHeight, z + bendZ
);
const greenVariation = 0.7 + Math.random() * 0.3;
const baseColor = new THREE.Color().setHSL(0.3, 0.6 * greenVariation, 0.25 * greenVariation);
colors.push(
baseColor.r, baseColor.g, baseColor.b,
baseColor.r * 0.9, baseColor.g * 0.9, baseColor.b * 0.9,
baseColor.r * 0.8, baseColor.g * 0.8, baseColor.b * 0.8
);
uvs.push(0, 0, 1, 0, 0.5, 1);
indices.push(
vertexIndex, vertexIndex + 1, vertexIndex + 2,
vertexIndex + 2, vertexIndex + 1, vertexIndex
);
vertexIndex += 3;
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.setIndex(indices);
geometry.computeVertexNormals();
const material = new THREE.MeshStandardMaterial({
vertexColors: true,
side: THREE.DoubleSide,
roughness: 0.9,
metalness: 0.0
const placements = 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
});
this.grass = new THREE.Mesh(geometry, material);
this.grass.castShadow = true;
this.grass.receiveShadow = true;
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;
}
generateShrubs() {
const prototypes = [
this.createPlantPrototype('Bush 1', 2001, 0.85),
this.createPlantPrototype('Bush 2', 2002, 0.95),
this.createPlantPrototype('Bush 3', 2003, 1.05)
];
const placements = this.collectPlacements(this.options.shrubCount, {
areaRatio: 0.72,
minHeight: this.options.waterLevel + 1.5,
maxHeight: this.options.waterLevel + 13,
maxSlope: 1.1,
minSpacing: 5,
densityScale: 0.006,
densityThreshold: 0.05
});
placements.forEach((placement, index) => {
const prototype = prototypes[index % prototypes.length];
const shrub = prototype.clone(true);
shrub.position.set(placement.x, placement.y, placement.z);
shrub.rotation.y = placement.rotation;
shrub.scale.setScalar(THREE.MathUtils.lerp(0.75, 1.35, placement.scaleMix));
shrub.traverse((child) => {
child.castShadow = true;
child.receiveShadow = true;
});
this.plants.push(shrub);
this.animatedPlants.push(shrub);
});
}
generateTrees() {
const treePositions = [];
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)
];
for (let i = 0; i < this.options.treeCount * 10; i++) {
if (treePositions.length >= this.options.treeCount) break;
const placements = 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
});
const x = (Math.random() - 0.5) * this.options.terrainSize * 0.6;
const z = (Math.random() - 0.5) * this.options.terrainSize * 0.6;
const y = this.terrain.getHeightAt(x, z);
if (y < this.options.waterLevel + 1 || y > this.options.waterLevel + 10) continue;
const densityNoise = this.noise.noise2D(x * 0.005, z * 0.005);
if (densityNoise < 0.3) continue;
let tooClose = false;
for (const pos of treePositions) {
const dist = Math.sqrt((pos.x - x) ** 2 + (pos.z - z) ** 2);
if (dist < 10) {
tooClose = true;
break;
}
}
if (!tooClose) {
treePositions.push({ x, y, z });
}
}
for (const pos of treePositions) {
const tree = this.createTree(pos);
this.trees.push(tree);
}
placements.forEach((placement, index) => {
const prototype = prototypes[index % prototypes.length];
const tree = prototype.clone(true);
tree.position.set(placement.x, placement.y, placement.z);
tree.rotation.y = placement.rotation;
tree.scale.setScalar(THREE.MathUtils.lerp(1.1, 1.9, placement.scaleMix));
tree.traverse((child) => {
child.castShadow = true;
child.receiveShadow = true;
});
this.plants.push(tree);
this.animatedPlants.push(tree);
this.treePositions.push(new THREE.Vector2(placement.x, placement.z));
});
}
createTree(pos) {
const tree = new THREE.Group();
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();
return plant;
}
const trunkHeight = 5 + Math.random() * 5;
const trunkRadius = 0.3 + Math.random() * 0.2;
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 trunkGeometry = new THREE.CylinderGeometry(
trunkRadius * 0.7,
trunkRadius,
trunkHeight,
8
);
const trunkMaterial = new THREE.MeshStandardMaterial({
color: 0x4a3728,
roughness: 0.9,
metalness: 0.0
});
const trunk = new THREE.Mesh(trunkGeometry, trunkMaterial);
trunk.position.y = trunkHeight / 2;
trunk.castShadow = true;
trunk.receiveShadow = true;
tree.add(trunk);
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);
const foliageLayers = 3 + Math.floor(Math.random() * 2);
let foliageY = trunkHeight;
return geometry;
}
for (let i = 0; i < foliageLayers; i++) {
const foliageRadius = (2.5 - i * 0.4) + Math.random() * 0.5;
const foliageHeight = 2 + Math.random() * 1;
collectPlacements(targetCount, config) {
const placements = [];
const maxAttempts = targetCount * 14;
const foliageGeometry = new THREE.ConeGeometry(
foliageRadius,
foliageHeight,
8
);
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 y = this.terrain.getHeightAt(x, z);
const greenVariation = 0.8 + Math.random() * 0.2;
const foliageMaterial = new THREE.MeshStandardMaterial({
color: new THREE.Color().setHSL(0.28 + Math.random() * 0.05, 0.5 * greenVariation, 0.2 * greenVariation),
roughness: 0.8,
metalness: 0.0
if (y < config.minHeight || y > config.maxHeight) continue;
const slope = this.getSlopeAt(x, z);
if (slope > config.maxSlope) continue;
const densityNoise = this.noise.noise2D(x * config.densityScale, z * config.densityScale);
if (densityNoise < config.densityThreshold) 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;
const colorMix = THREE.MathUtils.clamp((densityNoise + 1) * 0.5, 0, 1);
placements.push({
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
});
const foliage = new THREE.Mesh(foliageGeometry, foliageMaterial);
foliage.position.y = foliageY;
foliage.castShadow = true;
foliage.receiveShadow = true;
tree.add(foliage);
foliageY += foliageHeight * 0.6;
}
tree.position.set(pos.x, pos.y, pos.z);
return placements;
}
const scale = 0.8 + Math.random() * 0.4;
tree.scale.setScalar(scale);
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);
tree.rotation.y = Math.random() * Math.PI * 2;
return Math.hypot((right - left) / (sample * 2), (up - down) / (sample * 2));
}
return tree;
isFarEnoughFromPlants(positions, x, z, minSpacing) {
const minSpacingSq = minSpacing * minSpacing;
for (const pos of positions) {
const px = pos.x;
const pz = pos.z ?? pos.y;
const dx = px - x;
const dz = pz - 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) {
if (this.grass) scene.add(this.grass);
this.trees.forEach(tree => scene.add(tree));
scene.add(this.group);
}
}