From 3491447cfda3121a9ccb9c16926ebf0a26b68762 Mon Sep 17 00:00:00 2001 From: como Date: Thu, 26 Mar 2026 10:29:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=8D=A2=E6=96=B0=E7=9A=84=E6=A4=8D?= =?UTF-8?q?=E8=A2=AB=E7=94=9F=E6=88=90=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 10 + package.json | 1 + src/OceanScene.js | 15 +- src/VegetationSystem.js | 426 ++++++++++++++++++++++++---------------- 4 files changed, 278 insertions(+), 174 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0c2f1a9..24a2c71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 75ea386..92799b8 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "preview": "vite preview" }, "dependencies": { + "@dgreenheck/ez-tree": "^1.1.0", "three": "^0.171.0" }, "devDependencies": { diff --git a/src/OceanScene.js b/src/OceanScene.js index 3af48b1..7c79778 100644 --- a/src/OceanScene.js +++ b/src/OceanScene.js @@ -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); } @@ -480,6 +483,10 @@ export class OceanScene { layer.texture.offset.y = time * layer.speedY; }); } + + if (this.vegetationSystem) { + this.vegetationSystem.update(time); + } this.controls.update(); if (this.composer) { diff --git a/src/VegetationSystem.js b/src/VegetationSystem.js index 5264845..738667c 100644 --- a/src/VegetationSystem.js +++ b/src/VegetationSystem.js @@ -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(); - - return { - grass: this.grass, - trees: this.trees - }; + this.generateShrubs(); + + 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 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 + }); + + const grassGeometries = [ + this.createGrassBladeGeometry(0), + this.createGrassBladeGeometry(Math.PI / 3), + this.createGrassBladeGeometry(-Math.PI / 3) + ]; + const material = new THREE.MeshStandardMaterial({ - vertexColors: true, - side: THREE.DoubleSide, - roughness: 0.9, - metalness: 0.0 + color: 0x7aa35a, + roughness: 0.96, + metalness: 0, + side: THREE.DoubleSide }); - - this.grass = new THREE.Mesh(geometry, material); - this.grass.castShadow = true; - this.grass.receiveShadow = true; - } - - generateTrees() { - const treePositions = []; - - for (let i = 0; i < this.options.treeCount * 10; i++) { - if (treePositions.length >= this.options.treeCount) break; - - 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); - } - } - - createTree(pos) { - const tree = new THREE.Group(); - - const trunkHeight = 5 + Math.random() * 5; - const trunkRadius = 0.3 + Math.random() * 0.2; - - 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 foliageLayers = 3 + Math.floor(Math.random() * 2); - let foliageY = trunkHeight; - - for (let i = 0; i < foliageLayers; i++) { - const foliageRadius = (2.5 - i * 0.4) + Math.random() * 0.5; - const foliageHeight = 2 + Math.random() * 1; - - const foliageGeometry = new THREE.ConeGeometry( - foliageRadius, - foliageHeight, - 8 - ); - - 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 + + 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); }); - - 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); - - const scale = 0.8 + Math.random() * 0.4; - tree.scale.setScalar(scale); - - tree.rotation.y = Math.random() * Math.PI * 2; - - return tree; + + 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 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.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 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)); + }); + } + + 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; + } + + 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) { + 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 y = this.terrain.getHeightAt(x, z); + + 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 + }); + } + + return placements; + } + + 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 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); } }