diff --git a/public/models/README.md b/public/models/README.md new file mode 100644 index 0000000..356e3ee --- /dev/null +++ b/public/models/README.md @@ -0,0 +1,18 @@ +将你选中的海上风机资产导出为 GLB 文件,并放到下面这个路径: + +`public/models/offshore-wind-turbine.glb` + +当前代码会优先加载这个文件: + +- 存在该文件:使用真实 GLB 风机资产 +- 文件不存在或加载失败:自动回退到程序化风机占位模型 + +建议优先选择包含完整塔架、机舱、叶片的海上风机模型。 + +当前选定来源: + +- Sketchfab: Generic Wind Turbine (V136 125.5h 145d) +- 链接: https://sketchfab.com/3d-models/generic-wind-turbine-v136-1255h-145d-90ad27be20c541d1a0e4818d4e501679 +- 发布时间: 2020-06-11 +- 许可: CC Attribution +- 规模: 约 2.1k triangles diff --git a/public/models/offshore-wind-turbine.glb b/public/models/offshore-wind-turbine.glb new file mode 100644 index 0000000..2e22cc3 Binary files /dev/null and b/public/models/offshore-wind-turbine.glb differ diff --git a/src/OceanScene.js b/src/OceanScene.js index f43ded4..0489b9a 100644 --- a/src/OceanScene.js +++ b/src/OceanScene.js @@ -10,6 +10,7 @@ import Stats from 'three/addons/libs/stats.module.js'; import { TerrainGenerator } from './TerrainGenerator.js'; import { VegetationSystem } from './VegetationSystem.js'; import { DEFAULT_SCENE_PARAMS } from './weatherPresets.js'; +import { OffshoreWindTurbineAsset } from './objects/OffshoreWindTurbineAsset.js'; const RAIN_AUDIO_URL = '/audio/rain-calming.mp3'; const THUNDER_AUDIO_URL = '/audio/thunder-distant.mp3'; @@ -25,8 +26,10 @@ export class OceanScene { this.sky = null; this.starField = null; this.moonSprite = null; + this.moonGlowSprite = null; this.galaxyBand = null; this.sun = new THREE.Vector3(); + this.initialSun = new THREE.Vector3(); this.terrain = null; this.vegetation = null; this.vegetationSystem = null; @@ -50,6 +53,7 @@ export class OceanScene { this.fogLayers = []; this.horizonFog = null; this.skyHazeBand = null; + this.windTurbine = null; this.rainAudioPool = []; this.rainAudioActiveIndex = 0; this.rainAudioIsPlaying = false; @@ -85,6 +89,7 @@ export class OceanScene { this.initFog(); await this.initWater(); await this.initTerrain(); + await this.initWindTurbine(); await this.initVegetation(); this.initSunPosition(); this.initEventListeners(); @@ -645,6 +650,20 @@ export class OceanScene { this.moonSprite.scale.setScalar(490); this.scene.add(this.moonSprite); + const moonGlowTexture = this.createMoonGlowTexture(); + const moonGlowMaterial = new THREE.SpriteMaterial({ + map: moonGlowTexture, + color: 0xc6dbff, + transparent: true, + opacity: 0, + depthWrite: false, + depthTest: true, + blending: THREE.AdditiveBlending + }); + this.moonGlowSprite = new THREE.Sprite(moonGlowMaterial); + this.moonGlowSprite.scale.setScalar(980); + this.scene.add(this.moonGlowSprite); + const galaxyTexture = this.createGalaxyTexture(); const galaxyMaterial = new THREE.SpriteMaterial({ map: galaxyTexture, @@ -704,6 +723,28 @@ export class OceanScene { return texture; } + createMoonGlowTexture() { + const canvas = document.createElement('canvas'); + canvas.width = 512; + canvas.height = 512; + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const glow = ctx.createRadialGradient(256, 256, 0, 256, 256, 256); + glow.addColorStop(0, 'rgba(255,255,255,0.62)'); + glow.addColorStop(0.12, 'rgba(236,242,255,0.46)'); + glow.addColorStop(0.28, 'rgba(198,216,255,0.26)'); + glow.addColorStop(0.52, 'rgba(148,184,255,0.11)'); + glow.addColorStop(0.78, 'rgba(120,160,255,0.05)'); + glow.addColorStop(1, 'rgba(120,160,255,0)'); + ctx.fillStyle = glow; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + return texture; + } + createGalaxyTexture() { const canvas = document.createElement('canvas'); canvas.width = 1024; @@ -1259,15 +1300,36 @@ export class OceanScene { const baseWaterOnBeforeRender = this.water.onBeforeRender.bind(this.water); this.water.onBeforeRender = (...args) => { const starsWereVisible = this.starField?.visible ?? false; + const moonWasVisible = this.moonSprite?.visible ?? false; + const moonGlowWasVisible = this.moonGlowSprite?.visible ?? false; + const galaxyWasVisible = this.galaxyBand?.visible ?? false; if (this.starField) { this.starField.visible = false; } + if (this.moonSprite) { + this.moonSprite.visible = false; + } + if (this.moonGlowSprite) { + this.moonGlowSprite.visible = false; + } + if (this.galaxyBand) { + this.galaxyBand.visible = false; + } try { baseWaterOnBeforeRender(...args); } finally { if (this.starField) { this.starField.visible = starsWereVisible; } + if (this.moonSprite) { + this.moonSprite.visible = moonWasVisible; + } + if (this.moonGlowSprite) { + this.moonGlowSprite.visible = moonGlowWasVisible; + } + if (this.galaxyBand) { + this.galaxyBand.visible = galaxyWasVisible; + } } }; @@ -1299,6 +1361,18 @@ export class OceanScene { this.terrainGenerator = terrainGen; } + + async initWindTurbine() { + this.windTurbine = new OffshoreWindTurbineAsset({ + position: new THREE.Vector3(280, 0, -2350), + yaw: 0, + scale: 0.68, + rotorSpeed: 0.24 + }); + await this.windTurbine.load(); + this.windTurbine.addToScene(this.scene); + this.windTurbine.faceDirection(this.sun); + } async initVegetation() { const vegSystem = new VegetationSystem(this.terrainGenerator, { @@ -1344,6 +1418,7 @@ export class OceanScene { } initSunPosition() { + this.initialSun.copy(this.sun); this.updateSun(); } @@ -1352,6 +1427,9 @@ export class OceanScene { const theta = THREE.MathUtils.degToRad(this.params.azimuth); this.sun.setFromSphericalCoords(1, phi, theta); + if (this.initialSun.lengthSq() === 0) { + this.initialSun.copy(this.sun); + } this.sky.material.uniforms['sunPosition'].value.copy(this.sun); this.water.material.uniforms['sunDirection'].value.copy(this.sun).normalize(); @@ -1965,7 +2043,7 @@ export class OceanScene { this.starField.material.uniforms.intensity.value = opacity; if (this.moonSprite) { - const moonAngle = THREE.MathUtils.degToRad(this.params.azimuth - 42); + const moonAngle = Math.atan2(this.initialSun.z, this.initialSun.x); const moonDistance = 3200; this.moonSprite.position.set( Math.cos(moonAngle) * moonDistance, @@ -1977,6 +2055,13 @@ export class OceanScene { this.moonSprite.material.opacity = THREE.MathUtils.lerp(0.0, 2.4, moonGlow); const moonScale = THREE.MathUtils.lerp(410, 730, moonGlow); this.moonSprite.scale.setScalar(moonScale); + + if (this.moonGlowSprite) { + this.moonGlowSprite.position.copy(this.moonSprite.position); + this.moonGlowSprite.visible = this.moonSprite.visible; + this.moonGlowSprite.material.opacity = moonGlow * 1.18; + this.moonGlowSprite.scale.setScalar(THREE.MathUtils.lerp(980, 1680, moonGlow)); + } } if (this.galaxyBand) { @@ -2070,7 +2155,8 @@ export class OceanScene { requestAnimationFrame(() => this.animate()); this.stats?.begin(); - const time = this.clock.getElapsedTime(); + const delta = this.clock.getDelta(); + const time = this.clock.elapsedTime; this.updateLightning(time); this.updateThunder(time); this.updateRainAudioLoop(); @@ -2107,6 +2193,11 @@ export class OceanScene { this.vegetationSystem.update(time); } + if (this.windTurbine) { + this.windTurbine.faceDirection(this.sun); + this.windTurbine.update(time, delta); + } + if (this.lightningFlash > 0.001) { this.updateClouds(); this.updateFog(); diff --git a/src/main.js b/src/main.js index e1b75c4..04c808e 100644 --- a/src/main.js +++ b/src/main.js @@ -6,7 +6,6 @@ async function main() { const container = document.getElementById('container'); const oceanScene = new OceanScene(container); - try { await oceanScene.init(); diff --git a/src/objects/OffshoreWindTurbineAsset.js b/src/objects/OffshoreWindTurbineAsset.js new file mode 100644 index 0000000..641ac28 --- /dev/null +++ b/src/objects/OffshoreWindTurbineAsset.js @@ -0,0 +1,133 @@ +import * as THREE from 'three'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +import { WindTurbine } from './WindTurbine.js'; + +const DEFAULT_MODEL_URL = '/models/offshore-wind-turbine.glb'; + +export class OffshoreWindTurbineAsset { + constructor({ + modelUrl = DEFAULT_MODEL_URL, + position = new THREE.Vector3(360, 0, -260), + yaw = -Math.PI * 0.18, + scale = 1, + rotorSpeed = 0.34 + } = {}) { + this.modelUrl = modelUrl; + this.position = position.clone(); + this.yaw = yaw; + this.scale = scale; + this.rotorSpeed = rotorSpeed; + + this.group = new THREE.Group(); + this.group.position.copy(this.position); + this.group.rotation.y = this.yaw; + this.group.scale.setScalar(this.scale); + + this.rotors = []; + this.mixer = null; + this.model = null; + this.usingFallback = false; + } + + async load() { + try { + const gltf = await this.loadGltf(this.modelUrl); + this.attachModel(gltf); + } catch (error) { + console.warn(`风机资产加载失败,回退到程序化风机: ${this.modelUrl}`, error); + this.attachFallback(); + } + + return this; + } + + loadGltf(url) { + const loader = new GLTFLoader(); + return new Promise((resolve, reject) => { + loader.load(url, resolve, undefined, reject); + }); + } + + attachModel(gltf) { + const model = gltf.scene; + this.fitModelToTargetHeight(model); + this.prepareModel(model); + this.alignModelToWaterline(model); + this.group.add(model); + this.model = model; + + if (gltf.animations?.length) { + this.mixer = new THREE.AnimationMixer(model); + gltf.animations.forEach((clip) => { + this.mixer.clipAction(clip).play(); + }); + } + } + + attachFallback() { + const fallback = new WindTurbine({ + position: new THREE.Vector3(0, 0, 0), + yaw: 0, + rotorSpeed: this.rotorSpeed + }); + this.group.add(fallback.group); + this.rotors = fallback.rotors; + this.usingFallback = true; + } + + prepareModel(model) { + model.traverse((child) => { + if (!child.isMesh) return; + child.castShadow = true; + child.receiveShadow = true; + + const materials = Array.isArray(child.material) ? child.material : [child.material]; + materials.forEach((material) => { + if (!material) return; + if ('color' in material) { + material.color.set(0xe8edf3); + } + if ('metalness' in material && material.metalness < 0.05) { + material.metalness = 0.18; + } + if ('roughness' in material && material.roughness > 0.92) { + material.roughness = 0.82; + } + }); + }); + + } + + fitModelToTargetHeight(model, targetHeight = 340) { + const box = new THREE.Box3().setFromObject(model); + const size = box.getSize(new THREE.Vector3()); + if (size.y <= 0.0001) return; + + const scaleFactor = targetHeight / size.y; + model.scale.multiplyScalar(scaleFactor); + } + + alignModelToWaterline(model) { + const box = new THREE.Box3().setFromObject(model); + model.position.y -= box.min.y; + } + + addToScene(scene) { + scene.add(this.group); + } + + faceDirection(direction) { + const flatDirection = new THREE.Vector3(direction.x, 0, direction.z); + if (flatDirection.lengthSq() < 0.0001) return; + + flatDirection.normalize(); + const yaw = Math.atan2(flatDirection.x, flatDirection.z); + this.group.rotation.y = yaw; + } + + update(time, delta = 1 / 60) { + if (this.mixer) { + this.mixer.update(delta); + } + } +} diff --git a/src/objects/WindTurbine.js b/src/objects/WindTurbine.js new file mode 100644 index 0000000..84ae343 --- /dev/null +++ b/src/objects/WindTurbine.js @@ -0,0 +1,161 @@ +import * as THREE from 'three'; + +export class WindTurbine { + constructor({ + position = new THREE.Vector3(360, 0, -260), + yaw = -Math.PI * 0.18, + rotorSpeed = 0.34 + } = {}) { + this.group = new THREE.Group(); + this.group.position.copy(position); + this.group.rotation.y = yaw; + + this.rotors = []; + this.rotorSpeed = rotorSpeed; + + this.init(); + } + + init() { + const towerMaterial = new THREE.MeshStandardMaterial({ + color: 0xe7edf2, + metalness: 0.28, + roughness: 0.55 + }); + const accentMaterial = new THREE.MeshStandardMaterial({ + color: 0xc4d0da, + metalness: 0.35, + roughness: 0.48 + }); + const bladeMaterial = new THREE.MeshStandardMaterial({ + color: 0xf7fafc, + metalness: 0.16, + roughness: 0.62 + }); + const foundationMaterial = new THREE.MeshStandardMaterial({ + color: 0x71808f, + metalness: 0.24, + roughness: 0.82 + }); + const platformMaterial = new THREE.MeshStandardMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.16, + roughness: 0.95, + metalness: 0 + }); + const ringMaterial = new THREE.MeshStandardMaterial({ + color: 0x91a1ae, + metalness: 0.38, + roughness: 0.42 + }); + + const foundation = new THREE.Mesh( + new THREE.CylinderGeometry(8.2, 10.6, 36, 24), + foundationMaterial + ); + foundation.position.y = -17.8; + this.group.add(foundation); + + const transition = new THREE.Mesh( + new THREE.CylinderGeometry(5.2, 6.8, 9, 24), + accentMaterial + ); + transition.position.y = 4.2; + this.group.add(transition); + + const tower = new THREE.Mesh( + new THREE.CylinderGeometry(2.9, 4.8, 92, 28), + towerMaterial + ); + tower.position.y = 54; + this.group.add(tower); + + const nacelle = new THREE.Mesh( + new THREE.CapsuleGeometry(3.2, 11, 8, 16), + towerMaterial + ); + nacelle.rotation.z = Math.PI / 2; + nacelle.position.set(0, 102, 0); + this.group.add(nacelle); + + const tailFin = new THREE.Mesh( + new THREE.BoxGeometry(1.1, 5.5, 2.8), + accentMaterial + ); + tailFin.position.set(-8.5, 102.5, 0); + this.group.add(tailFin); + + const rotor = this.createRotor(bladeMaterial, accentMaterial, ringMaterial); + rotor.position.set(6.8, 102, 0); + rotor.userData.rotationSpeed = this.rotorSpeed; + this.group.add(rotor); + this.rotors.push(rotor); + + const platform = new THREE.Mesh( + new THREE.CylinderGeometry(7.6, 9.2, 0.5, 32), + platformMaterial + ); + platform.position.y = 0.12; + this.group.add(platform); + + this.group.traverse((child) => { + if (!child.isMesh) return; + child.castShadow = true; + child.receiveShadow = true; + }); + } + + createRotor(bladeMaterial, hubMaterial, ringMaterial) { + const rotor = new THREE.Group(); + + const hub = new THREE.Mesh( + new THREE.SphereGeometry(2.2, 20, 20), + hubMaterial + ); + rotor.add(hub); + + const bladeShape = new THREE.Shape(); + bladeShape.moveTo(-0.3, 0); + bladeShape.quadraticCurveTo(1.2, 2.6, 0.7, 22); + bladeShape.quadraticCurveTo(0.2, 31.5, -0.55, 38.5); + bladeShape.quadraticCurveTo(-1.0, 31, -0.9, 21); + bladeShape.quadraticCurveTo(-0.82, 6.8, -0.3, 0); + + const bladeGeometry = new THREE.ExtrudeGeometry(bladeShape, { + depth: 0.22, + bevelEnabled: false, + curveSegments: 20, + steps: 1 + }); + bladeGeometry.translate(0, -2.2, -0.11); + + for (let i = 0; i < 3; i++) { + const blade = new THREE.Mesh(bladeGeometry, bladeMaterial); + blade.rotation.x = Math.PI / 2; + blade.rotation.z = (i / 3) * Math.PI * 2; + blade.position.x = 0.55; + rotor.add(blade); + } + + const ring = new THREE.Mesh( + new THREE.TorusGeometry(2.65, 0.16, 10, 40), + ringMaterial + ); + ring.rotation.y = Math.PI / 2; + rotor.add(ring); + + return rotor; + } + + addToScene(scene) { + scene.add(this.group); + } + + update(time, delta = 1 / 60) { + this.rotors.forEach((rotor, index) => { + const gust = 1.0 + Math.sin(time * 0.28 + index * 1.7) * 0.08; + rotor.rotation.x += rotor.userData.rotationSpeed * gust * delta; + }); + } +}