537 lines
18 KiB
JavaScript
537 lines
18 KiB
JavaScript
import * as THREE from 'three';
|
|
import { Water } from 'three/addons/objects/Water.js';
|
|
import { Sky } from 'three/addons/objects/Sky.js';
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
|
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
|
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
|
import { TerrainGenerator } from './TerrainGenerator.js';
|
|
import { VegetationSystem } from './VegetationSystem.js';
|
|
|
|
export class OceanScene {
|
|
constructor(container) {
|
|
this.container = container;
|
|
this.scene = null;
|
|
this.camera = null;
|
|
this.renderer = null;
|
|
this.controls = null;
|
|
this.water = null;
|
|
this.sky = null;
|
|
this.sun = new THREE.Vector3();
|
|
this.terrain = null;
|
|
this.vegetation = null;
|
|
this.vegetationSystem = null;
|
|
this.pmremGenerator = null;
|
|
this.renderTarget = null;
|
|
this.sunLight = null;
|
|
this.vegetationFillLight = null;
|
|
this.composer = null;
|
|
this.bloomPass = null;
|
|
this.cloudGroup = null;
|
|
this.cloudMaterials = [];
|
|
this.cloudLayers = [];
|
|
|
|
this.params = {
|
|
elevation: 2,
|
|
azimuth: 180,
|
|
exposure: 0.1,
|
|
turbidity: 10,
|
|
rayleigh: 2,
|
|
bloomStrength: 0.1,
|
|
bloomRadius: 0,
|
|
bloomThreshold: 0,
|
|
cloudCoverage: 0.4,
|
|
cloudDensity: 0.5,
|
|
cloudElevation: 0.5,
|
|
mieCoefficient: 0.005,
|
|
mieDirectionalG: 0.8
|
|
};
|
|
|
|
this.clock = new THREE.Clock();
|
|
this.frameCount = 0;
|
|
this.lastTime = performance.now();
|
|
}
|
|
|
|
async init() {
|
|
this.initRenderer();
|
|
this.initScene();
|
|
this.initCamera();
|
|
this.initControls();
|
|
this.initLighting();
|
|
this.initPostProcessing();
|
|
await this.initSky();
|
|
this.initClouds();
|
|
await this.initWater();
|
|
await this.initTerrain();
|
|
await this.initVegetation();
|
|
this.initSunPosition();
|
|
this.initEventListeners();
|
|
}
|
|
|
|
initRenderer() {
|
|
this.renderer = new THREE.WebGLRenderer({
|
|
antialias: true,
|
|
powerPreference: 'high-performance'
|
|
});
|
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
|
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
this.renderer.toneMappingExposure = this.params.exposure;
|
|
this.renderer.shadowMap.enabled = true;
|
|
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
this.container.appendChild(this.renderer.domElement);
|
|
}
|
|
|
|
initScene() {
|
|
this.scene = new THREE.Scene();
|
|
this.scene.fog = new THREE.FogExp2(0x8cb8d4, 0.0008);
|
|
}
|
|
|
|
initCamera() {
|
|
this.camera = new THREE.PerspectiveCamera(
|
|
60,
|
|
window.innerWidth / window.innerHeight,
|
|
1,
|
|
20000
|
|
);
|
|
this.camera.position.set(100, 50, 200);
|
|
}
|
|
|
|
initControls() {
|
|
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
|
this.controls.enableDamping = true;
|
|
this.controls.dampingFactor = 0.05;
|
|
this.controls.maxPolarAngle = Math.PI * 0.48;
|
|
this.controls.minDistance = 30;
|
|
this.controls.maxDistance = 1000;
|
|
this.controls.target.set(0, 10, 0);
|
|
this.controls.update();
|
|
}
|
|
|
|
initLighting() {
|
|
const ambientLight = new THREE.AmbientLight(0x555555);
|
|
this.scene.add(ambientLight);
|
|
|
|
this.sunLight = new THREE.DirectionalLight(0xffffff, 1.5);
|
|
this.sunLight.castShadow = true;
|
|
this.sunLight.shadow.mapSize.width = 2048;
|
|
this.sunLight.shadow.mapSize.height = 2048;
|
|
this.sunLight.shadow.camera.near = 0.5;
|
|
this.sunLight.shadow.camera.far = 500;
|
|
this.sunLight.shadow.camera.left = -100;
|
|
this.sunLight.shadow.camera.right = 100;
|
|
this.sunLight.shadow.camera.top = 100;
|
|
this.sunLight.shadow.camera.bottom = -100;
|
|
this.scene.add(this.sunLight);
|
|
|
|
this.vegetationFillLight = new THREE.DirectionalLight(0xffb06a, 0.95);
|
|
this.vegetationFillLight.castShadow = false;
|
|
this.scene.add(this.vegetationFillLight);
|
|
}
|
|
|
|
initPostProcessing() {
|
|
this.composer = new EffectComposer(this.renderer);
|
|
this.composer.addPass(new RenderPass(this.scene, this.camera));
|
|
|
|
this.bloomPass = new UnrealBloomPass(
|
|
new THREE.Vector2(window.innerWidth, window.innerHeight),
|
|
this.params.bloomStrength,
|
|
this.params.bloomRadius,
|
|
this.params.bloomThreshold
|
|
);
|
|
this.composer.addPass(this.bloomPass);
|
|
}
|
|
|
|
async initSky() {
|
|
this.sky = new Sky();
|
|
this.sky.scale.setScalar(10000);
|
|
this.scene.add(this.sky);
|
|
|
|
const skyUniforms = this.sky.material.uniforms;
|
|
skyUniforms['turbidity'].value = this.params.turbidity;
|
|
skyUniforms['rayleigh'].value = this.params.rayleigh;
|
|
skyUniforms['mieCoefficient'].value = this.params.mieCoefficient;
|
|
skyUniforms['mieDirectionalG'].value = this.params.mieDirectionalG;
|
|
|
|
this.pmremGenerator = new THREE.PMREMGenerator(this.renderer);
|
|
}
|
|
|
|
initClouds() {
|
|
const cloudTexture = this.createCloudTexture();
|
|
this.cloudGroup = new THREE.Group();
|
|
this.cloudGroup.position.y = -120;
|
|
this.addCloudDomeLayer(cloudTexture, {
|
|
radius: 4200,
|
|
opacity: 0.42,
|
|
repeatX: 5.5,
|
|
repeatY: 2.4,
|
|
speedX: 0.00045,
|
|
speedY: 0.00012
|
|
});
|
|
this.addCloudDomeLayer(cloudTexture, {
|
|
radius: 3900,
|
|
opacity: 0.28,
|
|
repeatX: 7.5,
|
|
repeatY: 3.2,
|
|
speedX: -0.00032,
|
|
speedY: 0.00018
|
|
});
|
|
|
|
this.scene.add(this.cloudGroup);
|
|
this.setCloudElevation(this.params.cloudElevation);
|
|
this.setCloudCoverage(this.params.cloudCoverage);
|
|
this.updateClouds();
|
|
}
|
|
|
|
addCloudDomeLayer(texture, config) {
|
|
const layerTexture = texture.clone();
|
|
layerTexture.wrapS = THREE.RepeatWrapping;
|
|
layerTexture.wrapT = THREE.RepeatWrapping;
|
|
layerTexture.repeat.set(config.repeatX, config.repeatY);
|
|
layerTexture.needsUpdate = true;
|
|
|
|
const material = new THREE.MeshBasicMaterial({
|
|
map: layerTexture,
|
|
alphaMap: layerTexture,
|
|
color: 0xffffff,
|
|
transparent: true,
|
|
opacity: config.opacity,
|
|
side: THREE.BackSide,
|
|
depthWrite: false,
|
|
alphaTest: 0.08,
|
|
fog: false
|
|
});
|
|
|
|
const geometry = new THREE.SphereGeometry(
|
|
config.radius,
|
|
32,
|
|
20,
|
|
0,
|
|
Math.PI * 2,
|
|
0,
|
|
Math.PI * 0.48
|
|
);
|
|
|
|
const mesh = new THREE.Mesh(geometry, material);
|
|
mesh.rotation.y = Math.random() * Math.PI * 2;
|
|
this.cloudMaterials.push(material);
|
|
this.cloudLayers.push({
|
|
mesh,
|
|
texture: layerTexture,
|
|
speedX: config.speedX,
|
|
speedY: config.speedY,
|
|
baseOpacity: config.opacity
|
|
});
|
|
this.cloudGroup.add(mesh);
|
|
}
|
|
|
|
createCloudTexture() {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 256;
|
|
canvas.height = 256;
|
|
|
|
const context = canvas.getContext('2d');
|
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
for (let i = 0; i < 28; i++) {
|
|
const x = 28 + Math.random() * 200;
|
|
const y = 52 + Math.random() * 152;
|
|
const radius = 28 + Math.random() * 44;
|
|
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
|
|
|
|
gradient.addColorStop(0, 'rgba(255,255,255,0.92)');
|
|
gradient.addColorStop(0.28, 'rgba(255,255,255,0.78)');
|
|
gradient.addColorStop(0.62, 'rgba(255,255,255,0.22)');
|
|
gradient.addColorStop(1, 'rgba(255,255,255,0)');
|
|
|
|
context.fillStyle = gradient;
|
|
context.fillRect(x - radius, y - radius, radius * 2, radius * 2);
|
|
}
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
texture.needsUpdate = true;
|
|
|
|
return texture;
|
|
}
|
|
|
|
async initWater() {
|
|
const waterGeometry = new THREE.PlaneGeometry(10000, 10000, 128, 128);
|
|
|
|
const waterNormals = await new Promise((resolve) => {
|
|
new THREE.TextureLoader().load(
|
|
'/textures/waternormals.jpg',
|
|
(texture) => {
|
|
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
|
|
resolve(texture);
|
|
}
|
|
);
|
|
});
|
|
|
|
this.water = new Water(waterGeometry, {
|
|
textureWidth: 512,
|
|
textureHeight: 512,
|
|
waterNormals: waterNormals,
|
|
sunDirection: new THREE.Vector3(),
|
|
sunColor: 0xffffff,
|
|
waterColor: 0x001e0f,
|
|
distortionScale: 3.7,
|
|
fog: true
|
|
});
|
|
|
|
this.water.rotation.x = -Math.PI / 2;
|
|
this.water.position.y = -0.15;
|
|
this.scene.add(this.water);
|
|
}
|
|
|
|
async initTerrain() {
|
|
const terrainGen = new TerrainGenerator({
|
|
size: 1200, // 地形平面尺寸
|
|
segments: 200, // 网格细分数,越高细节越多
|
|
maxHeight: 34, // 海面以上地形的最大起伏高度
|
|
waterLevel: 0, // 海平面基准高度
|
|
underwaterDepthBias: 4.5, // 整体压低海面以下地形,避免浅滩露出
|
|
landBias: 0.2, // 整体抬升陆地倾向,增大陆地露出面积
|
|
falloffStartRatio: 0.22, // 从中心向外开始下沉的起始比例
|
|
maxLandRatio: 0.46, // 大陆海岸线的大致外缘比例
|
|
edgeDepth: 12, // 海岸外侧向海底下沉的强度
|
|
coreRadiusRatio: 0.24, // 大陆核心高地区域半径比例
|
|
continentLift: 0.55, // 核心大陆的额外抬升强度
|
|
coastVariance: 0.05, // 海岸线形状起伏幅度,越大越不规则
|
|
outerShelfDepth: 4, // 大陆外侧陆架的额外下沉深度
|
|
seed: 42 // 固定随机种子,保证地形稳定复现
|
|
});
|
|
|
|
this.terrain = terrainGen.generate();
|
|
this.scene.add(this.terrain);
|
|
|
|
this.terrainGenerator = terrainGen;
|
|
}
|
|
|
|
async initVegetation() {
|
|
const vegSystem = new VegetationSystem(this.terrainGenerator, {
|
|
grassCount: 28000, // 草簇数量,越大地表覆盖越密
|
|
shrubCount: 12, // 灌木和低矮植物数量
|
|
treeCount: 9, // 树木数量
|
|
terrainSize: 1200, // 植被允许分布的地形范围
|
|
waterLevel: 1 // 植被生成时参考的水位,避免贴近海边
|
|
});
|
|
|
|
this.vegetation = vegSystem.generate();
|
|
this.vegetationSystem = vegSystem;
|
|
vegSystem.addToScene(this.scene);
|
|
}
|
|
|
|
initSunPosition() {
|
|
this.updateSun();
|
|
}
|
|
|
|
updateSun() {
|
|
const phi = THREE.MathUtils.degToRad(90 - this.params.elevation);
|
|
const theta = THREE.MathUtils.degToRad(this.params.azimuth);
|
|
|
|
this.sun.setFromSphericalCoords(1, phi, theta);
|
|
|
|
this.sky.material.uniforms['sunPosition'].value.copy(this.sun);
|
|
this.water.material.uniforms['sunDirection'].value.copy(this.sun).normalize();
|
|
|
|
if (this.sunLight) {
|
|
const sunDistance = 100;
|
|
this.sunLight.position.set(
|
|
this.sun.x * sunDistance,
|
|
this.sun.y * sunDistance,
|
|
this.sun.z * sunDistance
|
|
);
|
|
}
|
|
|
|
if (this.vegetationFillLight) {
|
|
const fillDistance = 90;
|
|
this.vegetationFillLight.position.set(
|
|
-this.sun.x * fillDistance * 0.45 + 35,
|
|
Math.max(18, this.sun.y * fillDistance * 0.28 + 24),
|
|
-this.sun.z * fillDistance * 0.35 + 28
|
|
);
|
|
this.vegetationFillLight.intensity = THREE.MathUtils.lerp(0.7, 1.15, THREE.MathUtils.clamp((this.sun.y + 0.2) / 0.9, 0, 1));
|
|
}
|
|
|
|
if (this.renderTarget) {
|
|
this.renderTarget.dispose();
|
|
}
|
|
|
|
const sceneEnv = new THREE.Scene();
|
|
sceneEnv.add(this.sky);
|
|
this.renderTarget = this.pmremGenerator.fromScene(sceneEnv);
|
|
this.scene.environment = this.renderTarget.texture;
|
|
this.scene.add(this.sky);
|
|
|
|
this.scene.fog.color.setHex(this.getFogColor());
|
|
this.updateClouds();
|
|
}
|
|
|
|
getFogColor() {
|
|
const elevation = this.params.elevation;
|
|
|
|
if (elevation < 0) {
|
|
return 0x1a2a3a;
|
|
} else if (elevation < 10) {
|
|
return 0x4a5a6a;
|
|
} else if (elevation < 20) {
|
|
return 0x8cb8d4;
|
|
} else if (elevation < 45) {
|
|
return 0x9ec5db;
|
|
} else if (elevation < 70) {
|
|
return 0xb8d4e8;
|
|
} else {
|
|
return 0xd4e8f4;
|
|
}
|
|
}
|
|
|
|
initEventListeners() {
|
|
window.addEventListener('resize', () => this.onWindowResize());
|
|
}
|
|
|
|
onWindowResize() {
|
|
this.camera.aspect = window.innerWidth / window.innerHeight;
|
|
this.camera.updateProjectionMatrix();
|
|
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
|
if (this.composer) {
|
|
this.composer.setSize(window.innerWidth, window.innerHeight);
|
|
}
|
|
}
|
|
|
|
setSunElevation(value) {
|
|
this.params.elevation = value;
|
|
this.updateSun();
|
|
}
|
|
|
|
setSunAzimuth(value) {
|
|
this.params.azimuth = value;
|
|
this.updateSun();
|
|
}
|
|
|
|
setExposure(value) {
|
|
this.params.exposure = value;
|
|
this.renderer.toneMappingExposure = value;
|
|
}
|
|
|
|
setTurbidity(value) {
|
|
this.params.turbidity = value;
|
|
this.sky.material.uniforms['turbidity'].value = value;
|
|
this.updateSun();
|
|
}
|
|
|
|
setRayleigh(value) {
|
|
this.params.rayleigh = value;
|
|
this.sky.material.uniforms['rayleigh'].value = value;
|
|
this.updateSun();
|
|
}
|
|
|
|
setBloomStrength(value) {
|
|
this.params.bloomStrength = value;
|
|
if (this.bloomPass) {
|
|
this.bloomPass.strength = value;
|
|
}
|
|
}
|
|
|
|
setBloomRadius(value) {
|
|
this.params.bloomRadius = value;
|
|
if (this.bloomPass) {
|
|
this.bloomPass.radius = value;
|
|
}
|
|
}
|
|
|
|
setBloomThreshold(value) {
|
|
this.params.bloomThreshold = value;
|
|
if (this.bloomPass) {
|
|
this.bloomPass.threshold = value;
|
|
}
|
|
}
|
|
|
|
setCloudCoverage(value) {
|
|
this.params.cloudCoverage = value;
|
|
this.updateClouds();
|
|
}
|
|
|
|
setCloudDensity(value) {
|
|
this.params.cloudDensity = value;
|
|
this.updateClouds();
|
|
}
|
|
|
|
setCloudElevation(value) {
|
|
this.params.cloudElevation = value;
|
|
if (this.cloudGroup) {
|
|
this.cloudGroup.position.y = THREE.MathUtils.lerp(-380, 260, value);
|
|
}
|
|
}
|
|
|
|
updateClouds() {
|
|
if (!this.cloudGroup) return;
|
|
|
|
const sunMix = THREE.MathUtils.clamp((this.params.elevation + 10) / 100, 0, 1);
|
|
const warmCloud = new THREE.Color(0xdab188);
|
|
const dayCloud = new THREE.Color(0xd1dbe6);
|
|
const cloudColor = warmCloud.lerp(dayCloud, sunMix);
|
|
|
|
for (const layer of this.cloudLayers) {
|
|
const coverageFactor = 0.15 + this.params.cloudCoverage * 1.15;
|
|
const densityFactor = 0.2 + this.params.cloudDensity * 1.35;
|
|
const opacity = layer.baseOpacity * coverageFactor * densityFactor;
|
|
layer.mesh.material.opacity = opacity;
|
|
layer.mesh.material.color.copy(cloudColor);
|
|
layer.mesh.visible = opacity > 0.015;
|
|
}
|
|
}
|
|
|
|
animate() {
|
|
requestAnimationFrame(() => this.animate());
|
|
|
|
const time = this.clock.getElapsedTime();
|
|
|
|
if (this.water) {
|
|
this.water.material.uniforms['time'].value += 1.0 / 60.0;
|
|
}
|
|
|
|
if (this.cloudGroup) {
|
|
this.cloudGroup.rotation.y = time * 0.004;
|
|
this.cloudLayers.forEach((layer) => {
|
|
layer.texture.offset.x = time * layer.speedX;
|
|
layer.texture.offset.y = time * layer.speedY;
|
|
});
|
|
}
|
|
|
|
if (this.vegetationSystem) {
|
|
this.vegetationSystem.update(time);
|
|
}
|
|
|
|
this.controls.update();
|
|
if (this.composer) {
|
|
this.composer.render();
|
|
} else {
|
|
this.renderer.render(this.scene, this.camera);
|
|
}
|
|
|
|
this.frameCount++;
|
|
const currentTime = performance.now();
|
|
if (currentTime - this.lastTime >= 1000) {
|
|
const fps = Math.round(this.frameCount * 1000 / (currentTime - this.lastTime));
|
|
const fpsElement = document.getElementById('fps');
|
|
if (fpsElement) {
|
|
fpsElement.textContent = fps;
|
|
}
|
|
this.frameCount = 0;
|
|
this.lastTime = currentTime;
|
|
}
|
|
}
|
|
|
|
hideLoading() {
|
|
const loading = document.getElementById('loading');
|
|
if (loading) {
|
|
loading.style.opacity = '0';
|
|
loading.style.transition = 'opacity 0.5s ease';
|
|
setTimeout(() => {
|
|
loading.style.display = 'none';
|
|
}, 500);
|
|
}
|
|
}
|
|
}
|