Compare commits

...

10 Commits

Author SHA1 Message Date
c0ca5b51c4 添加风机 2026-03-27 18:11:27 +08:00
4a9410802d 修正夜空 2026-03-27 16:20:05 +08:00
36965a7dc3 黑夜场景未完成 2026-03-26 19:14:24 +08:00
562d031171 调整雾 2026-03-26 18:26:13 +08:00
2b0893b913 天气预设调整 2026-03-26 18:05:59 +08:00
92e3dd7792 调整云 2026-03-26 17:29:15 +08:00
6b44ebf599 预设天气 2026-03-26 16:44:33 +08:00
77664bce52 预设 2026-03-26 16:13:49 +08:00
e7d4267f60 加入导出功能 2026-03-26 14:06:08 +08:00
f3581443d6 重写控制面板 2026-03-26 13:42:03 +08:00
9 changed files with 1469 additions and 518 deletions

View File

@@ -60,142 +60,6 @@
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
#info {
position: fixed;
top: 20px;
left: 20px;
color: white;
background: rgba(0, 0, 0, 0.6);
padding: 15px 20px;
border-radius: 10px;
font-size: 14px;
z-index: 100;
backdrop-filter: blur(10px);
max-width: 300px;
}
#info h2 {
margin-bottom: 10px;
font-size: 16px;
}
#info p {
margin: 5px 0;
opacity: 0.9;
}
#sun-controls {
position: fixed;
top: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.7);
padding: 15px;
border-radius: 10px;
color: white;
z-index: 100;
backdrop-filter: blur(10px);
min-width: 280px;
max-height: calc(100vh - 40px);
overflow-y: auto;
}
#sun-controls h3 {
margin-bottom: 12px;
font-size: 14px;
border-bottom: 1px solid rgba(255,255,255,0.2);
padding-bottom: 8px;
}
.control-section {
margin-bottom: 10px;
}
.control-section-title {
font-size: 11px;
opacity: 0.6;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.control-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 12px;
}
.control-group {
margin-bottom: 0;
}
.control-group.full-width {
grid-column: 1 / -1;
}
.control-group label {
display: block;
margin-bottom: 3px;
font-size: 11px;
opacity: 0.85;
}
.control-group input[type="range"] {
width: 100%;
height: 4px;
border-radius: 2px;
background: rgba(255,255,255,0.2);
outline: none;
-webkit-appearance: none;
}
.control-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #4a9eff;
cursor: pointer;
}
.control-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255,255,255,0.06);
margin-bottom: 8px;
}
.control-toggle label {
font-size: 12px;
opacity: 0.9;
}
.control-toggle input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #5da9ff;
}
.control-value {
text-align: right;
font-size: 10px;
opacity: 0.6;
margin-top: 1px;
}
#stats {
position: fixed;
bottom: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.7);
padding: 10px 15px;
border-radius: 8px;
color: white;
font-size: 12px;
z-index: 100;
}
</style> </style>
</head> </head>
<body> <body>
@@ -207,185 +71,6 @@
<div id="container"></div> <div id="container"></div>
<div id="info">
<h2>🌊 写实无尽海洋</h2>
<p>🖱️ 左键拖动旋转视角</p>
<p>🖱️ 右键拖动平移</p>
<p>🖱️ 滚轮缩放</p>
<p>☀️ 使用右上角控制太阳</p>
</div>
<div id="sun-controls">
<h3>☀️ 场景控制</h3>
<div class="control-section">
<div class="control-section-title">太阳 & 光照</div>
<div class="control-grid">
<div class="control-group">
<label>太阳高度</label>
<input type="range" id="sun-elevation" min="0" max="90" value="2" step="0.1">
<div class="control-value" id="sun-elevation-value">2.0°</div>
</div>
<div class="control-group">
<label>太阳方位</label>
<input type="range" id="sun-azimuth" min="-180" max="180" value="180" step="0.1">
<div class="control-value" id="sun-azimuth-value">180.0°</div>
</div>
<div class="control-group">
<label>曝光度</label>
<input type="range" id="exposure" min="0" max="1" value="0.1" step="0.01">
<div class="control-value" id="exposure-value">0.10</div>
</div>
<div class="control-group">
<label>浑浊度</label>
<input type="range" id="turbidity" min="1" max="20" value="10" step="0.1">
<div class="control-value" id="turbidity-value">10.0</div>
</div>
<div class="control-group">
<label>瑞利散射</label>
<input type="range" id="rayleigh" min="0" max="4" value="2" step="0.01">
<div class="control-value" id="rayleigh-value">2.00</div>
</div>
</div>
</div>
<div class="control-section">
<div class="control-section-title">Bloom 效果</div>
<div class="control-grid">
<div class="control-group">
<label>强度</label>
<input type="range" id="bloom-strength" min="0" max="3" value="0.1" step="0.01">
<div class="control-value" id="bloom-strength-value">0.10</div>
</div>
<div class="control-group">
<label>扩散</label>
<input type="range" id="bloom-radius" min="0" max="1" value="0" step="0.01">
<div class="control-value" id="bloom-radius-value">0.00</div>
</div>
</div>
</div>
<div class="control-section">
<div class="control-section-title">云层</div>
<div class="control-grid">
<div class="control-group">
<label>覆盖度</label>
<input type="range" id="cloud-coverage" min="0" max="1" value="0.4" step="0.01">
<div class="control-value" id="cloud-coverage-value">0.40</div>
</div>
<div class="control-group">
<label>密度</label>
<input type="range" id="cloud-density" min="0" max="1" value="0.5" step="0.01">
<div class="control-value" id="cloud-density-value">0.50</div>
</div>
<div class="control-group">
<label>高度</label>
<input type="range" id="cloud-elevation" min="0" max="1" value="0.5" step="0.01">
<div class="control-value" id="cloud-elevation-value">0.50</div>
</div>
</div>
</div>
<div class="control-section">
<div class="control-section-title">雾气</div>
<div class="control-grid">
<div class="control-group">
<label>浓度</label>
<input type="range" id="fog-density" min="0" max="1" value="0.42" step="0.01">
<div class="control-value" id="fog-density-value">0.42</div>
</div>
<div class="control-group">
<label>高度</label>
<input type="range" id="fog-height" min="0" max="1" value="0.32" step="0.01">
<div class="control-value" id="fog-height-value">0.32</div>
</div>
<div class="control-group">
<label>范围</label>
<input type="range" id="fog-range" min="0" max="1" value="0.55" step="0.01">
<div class="control-value" id="fog-range-value">0.55</div>
</div>
</div>
</div>
<div class="control-section">
<div class="control-section-title">雨效</div>
<div class="control-toggle">
<label for="rain-enabled">启用镜头雨幕</label>
<input type="checkbox" id="rain-enabled">
</div>
<div class="control-grid">
<div class="control-group">
<label>屏幕雨滴</label>
<input type="range" id="rain-screen-intensity" min="0" max="1.5" value="0.41" step="0.01">
<div class="control-value" id="rain-screen-intensity-value">0.41</div>
</div>
<div class="control-group">
<label>雨线强度</label>
<input type="range" id="rain-veil-intensity" min="0" max="1.5" value="1.15" step="0.01">
<div class="control-value" id="rain-veil-intensity-value">1.15</div>
</div>
<div class="control-group">
<label>雨滴尺寸</label>
<input type="range" id="rain-drop-size" min="0.4" max="1.8" value="1.00" step="0.01">
<div class="control-value" id="rain-drop-size-value">1.00</div>
</div>
<div class="control-group">
<label>速度</label>
<input type="range" id="rain-speed" min="0.2" max="2.5" value="1.00" step="0.01">
<div class="control-value" id="rain-speed-value">1.00</div>
</div>
</div>
<div class="control-toggle" style="margin-top: 8px;">
<label for="rain-audio-enabled">启用雨声</label>
<input type="checkbox" id="rain-audio-enabled" checked>
</div>
<div class="control-grid">
<div class="control-group full-width">
<label>雨声音量</label>
<input type="range" id="rain-audio-volume" min="0" max="1" value="0.35" step="0.01">
<div class="control-value" id="rain-audio-volume-value">0.35</div>
</div>
</div>
</div>
<div class="control-section">
<div class="control-section-title">雪效</div>
<div class="control-toggle">
<label for="snow-enabled">启用降雪</label>
<input type="checkbox" id="snow-enabled">
</div>
<div class="control-grid">
<div class="control-group">
<label>雪量</label>
<input type="range" id="snow-intensity" min="0" max="1.5" value="0.65" step="0.01">
<div class="control-value" id="snow-intensity-value">0.65</div>
</div>
<div class="control-group">
<label>速度</label>
<input type="range" id="snow-speed" min="0.2" max="2.2" value="0.85" step="0.01">
<div class="control-value" id="snow-speed-value">0.85</div>
</div>
</div>
</div>
<div class="control-section">
<div class="control-section-title">雷闪</div>
<div class="control-toggle">
<label for="lightning-enabled">启用雷闪</label>
<input type="checkbox" id="lightning-enabled" checked>
</div>
<div class="control-grid">
<div class="control-group full-width">
<label>雷闪强度</label>
<input type="range" id="lightning-intensity" min="0" max="1.5" value="0.75" step="0.01">
<div class="control-value" id="lightning-intensity-value">0.75</div>
</div>
</div>
</div>
</div>
<div id="stats">FPS: <span id="fps">60</span></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

18
public/models/README.md Normal file
View File

@@ -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

Binary file not shown.

View File

@@ -1 +1 @@
codex resume 019d2462-a1f1-7a72-947b-70470e482854 codex resume 019d284e-bc41-78f0-a961-32ce95bfa96a

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
import { OceanScene } from './OceanScene.js'; import { OceanScene } from './OceanScene.js';
import { WEATHER_PRESETS } from './weatherPresets.js';
async function main() { async function main() {
const container = document.getElementById('container'); const container = document.getElementById('container');
const oceanScene = new OceanScene(container); const oceanScene = new OceanScene(container);
try { try {
await oceanScene.init(); await oceanScene.init();
@@ -30,52 +31,198 @@ async function main() {
} }
function setupControls(oceanScene) { function setupControls(oceanScene) {
const bindSlider = (id, formatter, setter) => { const gui = new GUI({ title: '场景控制' });
const slider = document.getElementById(id); gui.domElement.style.marginTop = '12px';
const valueLabel = document.getElementById(`${id}-value`); gui.domElement.style.marginRight = '12px';
if (!slider || !valueLabel) return; gui.domElement.style.zIndex = '120';
slider.addEventListener('input', (e) => { const params = oceanScene.params;
const value = parseFloat(e.target.value); const controllers = [];
setter(value); const presetState = { 当前天气: 'default' };
valueLabel.textContent = formatter(value); const presetOptions = Object.fromEntries(
}); Object.entries(WEATHER_PRESETS).map(([key, preset]) => [preset.label, key])
);
const presetKeys = [
'elevation',
'azimuth',
'exposure',
'turbidity',
'rayleigh',
'bloomStrength',
'bloomRadius',
'waterColor',
'cloudCoverage',
'cloudDensity',
'cloudElevation',
'fogDensity',
'fogHeight',
'fogRange',
'rainEnabled',
'rainScreenIntensity',
'rainVeilIntensity',
'rainDropSize',
'rainSpeed',
'rainAudioEnabled',
'rainAudioVolume',
'snowEnabled',
'snowIntensity',
'snowSpeed',
'starEnabled',
'starIntensity',
'lightningEnabled',
'lightningIntensity'
];
const presetComments = {
elevation: '太阳高度角',
azimuth: '太阳方位角',
exposure: '场景曝光度',
turbidity: '天空浑浊度',
rayleigh: '瑞利散射强度',
bloomStrength: '泛光强度',
bloomRadius: '泛光扩散范围',
waterColor: '海水颜色',
cloudCoverage: '云层覆盖度',
cloudDensity: '云层密度',
cloudElevation: '云层高度',
fogDensity: '雾气浓度',
fogHeight: '雾气高度',
fogRange: '雾气范围',
rainEnabled: '是否启用雨效',
rainScreenIntensity: '屏幕雨滴强度',
rainVeilIntensity: '雨线强度',
rainDropSize: '雨滴尺寸',
rainSpeed: '雨效速度',
rainAudioEnabled: '是否启用雨声',
rainAudioVolume: '雨声音量',
snowEnabled: '是否启用降雪',
snowIntensity: '雪量',
snowSpeed: '降雪速度',
starEnabled: '是否启用星空',
starIntensity: '星空强度',
lightningEnabled: '是否启用雷闪',
lightningIntensity: '雷闪强度'
}; };
const bindCheckbox = (id, setter) => { const exportActions = {
const checkbox = document.getElementById(id); 导出预设: () => {
if (!checkbox) return; const preset = {
meta: {
version: 1,
comment: '场景预设导出文件',
exportedAt: new Date().toISOString()
},
comments: Object.fromEntries(
presetKeys.map((key) => [key, presetComments[key]])
),
params: Object.fromEntries(
presetKeys.map((key) => [key, params[key]])
)
};
checkbox.addEventListener('change', (e) => { const blob = new Blob([JSON.stringify(preset, null, 2)], { type: 'application/json' });
setter(e.target.checked); const url = URL.createObjectURL(blob);
}); const link = document.createElement('a');
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
link.href = url;
link.download = `scene-preset-${stamp}.json`;
link.click();
URL.revokeObjectURL(url);
}
}; };
bindSlider('sun-elevation', (value) => `${value.toFixed(1)}°`, (value) => oceanScene.setSunElevation(value)); const markPresetCustom = () => {
bindSlider('sun-azimuth', (value) => `${value.toFixed(1)}°`, (value) => oceanScene.setSunAzimuth(value)); if (presetState.当前天气 !== 'custom') {
bindSlider('exposure', (value) => value.toFixed(2), (value) => oceanScene.setExposure(value)); presetState.当前天气 = 'custom';
bindSlider('turbidity', (value) => value.toFixed(1), (value) => oceanScene.setTurbidity(value)); presetController.updateDisplay();
bindSlider('rayleigh', (value) => value.toFixed(2), (value) => oceanScene.setRayleigh(value)); }
bindSlider('bloom-strength', (value) => value.toFixed(2), (value) => oceanScene.setBloomStrength(value)); };
bindSlider('bloom-radius', (value) => value.toFixed(2), (value) => oceanScene.setBloomRadius(value)); const refreshControllers = () => {
bindSlider('cloud-coverage', (value) => value.toFixed(2), (value) => oceanScene.setCloudCoverage(value)); controllers.forEach((controller) => controller.updateDisplay());
bindSlider('cloud-density', (value) => value.toFixed(2), (value) => oceanScene.setCloudDensity(value)); updateStarControllerState();
bindSlider('cloud-elevation', (value) => value.toFixed(2), (value) => oceanScene.setCloudElevation(value)); };
bindSlider('fog-density', (value) => value.toFixed(2), (value) => oceanScene.setFogDensity(value)); const setControllerEnabled = (controller, enabled) => {
bindSlider('fog-height', (value) => value.toFixed(2), (value) => oceanScene.setFogHeight(value)); controller.domElement.style.opacity = enabled ? '1' : '0.45';
bindSlider('fog-range', (value) => value.toFixed(2), (value) => oceanScene.setFogRange(value)); controller.domElement.style.pointerEvents = enabled ? 'auto' : 'none';
bindSlider('rain-screen-intensity', (value) => value.toFixed(2), (value) => oceanScene.setRainScreenIntensity(value)); controller.enable?.();
bindSlider('rain-veil-intensity', (value) => value.toFixed(2), (value) => oceanScene.setRainVeilIntensity(value)); if (!enabled) {
bindSlider('rain-drop-size', (value) => value.toFixed(2), (value) => oceanScene.setRainDropSize(value)); controller.disable?.();
bindSlider('rain-speed', (value) => value.toFixed(2), (value) => oceanScene.setRainSpeed(value)); }
bindSlider('rain-audio-volume', (value) => value.toFixed(2), (value) => oceanScene.setRainAudioVolume(value)); controller.domElement.querySelectorAll('input, select, button').forEach((element) => {
bindSlider('snow-intensity', (value) => value.toFixed(2), (value) => oceanScene.setSnowIntensity(value)); element.disabled = !enabled;
bindSlider('snow-speed', (value) => value.toFixed(2), (value) => oceanScene.setSnowSpeed(value)); });
bindSlider('lightning-intensity', (value) => value.toFixed(2), (value) => oceanScene.setLightningIntensity(value)); };
bindCheckbox('rain-enabled', (value) => oceanScene.setRainEnabled(value)); const bindController = (controller, applyValue) => {
bindCheckbox('rain-audio-enabled', (value) => oceanScene.setRainAudioEnabled(value)); controllers.push(controller);
bindCheckbox('snow-enabled', (value) => oceanScene.setSnowEnabled(value)); controller.onChange((value) => {
bindCheckbox('lightning-enabled', (value) => oceanScene.setLightningEnabled(value)); applyValue(value);
markPresetCustom();
updateStarControllerState();
});
return controller;
};
const presetController = gui.add(presetState, '当前天气', { ...presetOptions, 自定义: 'custom' }).name('天气预设');
presetController.onChange((presetKey) => {
if (presetKey === 'custom') return;
oceanScene.applyParams(WEATHER_PRESETS[presetKey].params);
refreshControllers();
});
gui.add(exportActions, '导出预设');
const skyFolder = gui.addFolder('天空');
bindController(skyFolder.add(params, 'elevation', -12, 90, 0.1).name('太阳高度'), (value) => oceanScene.setSunElevation(value));
bindController(skyFolder.add(params, 'azimuth', -180, 180, 0.1).name('太阳方位'), (value) => oceanScene.setSunAzimuth(value));
bindController(skyFolder.add(params, 'exposure', 0, 1, 0.01).name('曝光度'), (value) => oceanScene.setExposure(value));
bindController(skyFolder.add(params, 'turbidity', 1, 20, 0.1).name('浑浊度'), (value) => oceanScene.setTurbidity(value));
bindController(skyFolder.add(params, 'rayleigh', 0, 4, 0.01).name('瑞利散射'), (value) => oceanScene.setRayleigh(value));
const starEnabledController = bindController(skyFolder.add(params, 'starEnabled').name('启用星空'), (value) => oceanScene.setStarEnabled(value));
const starIntensityController = bindController(skyFolder.add(params, 'starIntensity', 0, 1.5, 0.01).name('星空强度'), (value) => oceanScene.setStarIntensity(value));
const updateStarControllerState = () => {
const canUseStars = params.elevation < -1.0;
if (!canUseStars && params.starEnabled) {
oceanScene.setStarEnabled(false);
}
setControllerEnabled(starEnabledController, canUseStars);
setControllerEnabled(starIntensityController, canUseStars);
starEnabledController.updateDisplay();
starIntensityController.updateDisplay();
};
const bloomFolder = gui.addFolder('泛光');
bindController(bloomFolder.add(params, 'bloomStrength', 0, 1, 0.01).name('强度'), (value) => oceanScene.setBloomStrength(value));
bindController(bloomFolder.add(params, 'bloomRadius', 0, 3, 0.01).name('扩散'), (value) => oceanScene.setBloomRadius(value));
const waterFolder = gui.addFolder('海水');
bindController(waterFolder.addColor(params, 'waterColor').name('颜色'), (value) => oceanScene.setWaterColor(value));
const cloudFolder = gui.addFolder('云层');
bindController(cloudFolder.add(params, 'cloudCoverage', 0, 1, 0.01).name('覆盖度'), (value) => oceanScene.setCloudCoverage(value));
bindController(cloudFolder.add(params, 'cloudDensity', 0, 1, 0.01).name('密度'), (value) => oceanScene.setCloudDensity(value));
bindController(cloudFolder.add(params, 'cloudElevation', 0, 1, 0.01).name('高度'), (value) => oceanScene.setCloudElevation(value));
const fogFolder = gui.addFolder('雾气');
bindController(fogFolder.add(params, 'fogDensity', 0, 1, 0.01).name('浓度'), (value) => oceanScene.setFogDensity(value));
bindController(fogFolder.add(params, 'fogHeight', 0, 1, 0.01).name('高度'), (value) => oceanScene.setFogHeight(value));
bindController(fogFolder.add(params, 'fogRange', 0, 1, 0.01).name('范围'), (value) => oceanScene.setFogRange(value));
const rainFolder = gui.addFolder('雨效');
bindController(rainFolder.add(params, 'rainEnabled').name('启用雨效'), (value) => oceanScene.setRainEnabled(value));
bindController(rainFolder.add(params, 'rainScreenIntensity', 0, 1.5, 0.01).name('屏幕雨滴'), (value) => oceanScene.setRainScreenIntensity(value));
bindController(rainFolder.add(params, 'rainVeilIntensity', 0.5, 2.5, 0.01).name('雨线强度'), (value) => oceanScene.setRainVeilIntensity(value));
bindController(rainFolder.add(params, 'rainDropSize', 0.4, 1.8, 0.01).name('雨滴尺寸'), (value) => oceanScene.setRainDropSize(value));
bindController(rainFolder.add(params, 'rainSpeed', 0.2, 2.5, 0.01).name('速度'), (value) => oceanScene.setRainSpeed(value));
bindController(rainFolder.add(params, 'rainAudioEnabled').name('启用雨声'), (value) => oceanScene.setRainAudioEnabled(value));
bindController(rainFolder.add(params, 'rainAudioVolume', 0, 1, 0.01).name('雨声音量'), (value) => oceanScene.setRainAudioVolume(value));
bindController(rainFolder.add(params, 'lightningEnabled').name('启用雷闪'), (value) => oceanScene.setLightningEnabled(value));
bindController(rainFolder.add(params, 'lightningIntensity', 0, 1.5, 0.01).name('雷闪强度'), (value) => oceanScene.setLightningIntensity(value));
const snowFolder = gui.addFolder('雪效');
bindController(snowFolder.add(params, 'snowEnabled').name('启用降雪'), (value) => oceanScene.setSnowEnabled(value));
bindController(snowFolder.add(params, 'snowIntensity', 0, 1.5, 0.01).name('雪量'), (value) => oceanScene.setSnowIntensity(value));
bindController(snowFolder.add(params, 'snowSpeed', 0.2, 2.2, 0.01).name('速度'), (value) => oceanScene.setSnowSpeed(value));
gui.close();
updateStarControllerState();
} }
main().catch(console.error); main().catch(console.error);

View File

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

161
src/objects/WindTurbine.js Normal file
View File

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

190
src/weatherPresets.js Normal file
View File

@@ -0,0 +1,190 @@
export const DEFAULT_SCENE_PARAMS = {
elevation: 1.5,
azimuth: -180,
exposure: 0.16,
turbidity: 2.7,
rayleigh: 4.0,
bloomStrength: 0.16,
bloomRadius: 0,
bloomThreshold: 0,
waterColor: '#18465a',
cloudCoverage: 0.26,
cloudDensity: 0.38,
cloudElevation: 0.66,
fogDensity: 0.16,
fogHeight: 0.26,
fogRange: 0.38,
rainEnabled: false,
rainScreenIntensity: 0.41,
rainVeilIntensity: 1.15,
rainDropSize: 1.0,
rainSpeed: 1.0,
rainAudioEnabled: true,
rainAudioVolume: 0.35,
snowEnabled: false,
snowIntensity: 0.65,
snowSpeed: 0.85,
starEnabled: true,
starIntensity: 0.7,
lightningEnabled: true,
lightningIntensity: 0.75,
mieCoefficient: 0.005,
mieDirectionalG: 0.8
};
export const WEATHER_PRESETS = {
default: {
label: '夕阳',
params: {
...DEFAULT_SCENE_PARAMS
}
},
clear: {
label: '日出',
params: {
...DEFAULT_SCENE_PARAMS,
elevation: 2.5,
azimuth: 0,
exposure: 0.28,
turbidity: 1.0,
rayleigh: 2.18,
bloomStrength: 0.22,
bloomRadius: 0.38,
waterColor: '#2d6f74',
cloudCoverage: 0.34,
cloudDensity: 0.24,
cloudElevation: 0.9,
fogDensity: 0.12,
fogHeight: 0.58,
fogRange: 0.28,
starIntensity: 0.22,
lightningEnabled: false
}
},
noon: {
label: '正午',
params: {
...DEFAULT_SCENE_PARAMS,
elevation: 72,
azimuth: 180,
exposure: 0.27,
turbidity: 8.2,
rayleigh: 2.1,
bloomStrength: 0.14,
bloomRadius: 0.06,
waterColor: '#2b78a4',
cloudCoverage: 0.18,
cloudDensity: 0.28,
cloudElevation: 0.7,
fogDensity: 0.1,
fogHeight: 0.2,
fogRange: 0.24,
starIntensity: 0.0,
lightningEnabled: false
}
},
night: {
label: '黑夜',
params: {
...DEFAULT_SCENE_PARAMS,
elevation: -8,
azimuth: 205,
exposure: 0.2,
turbidity: 1.8,
rayleigh: 0.2,
bloomStrength: 0.12,
bloomRadius: 0.08,
waterColor: '#07131f',
cloudCoverage: 0.03,
cloudDensity: 0.08,
cloudElevation: 0.78,
fogDensity: 0.08,
fogHeight: 0.22,
fogRange: 0.2,
rainEnabled: false,
snowEnabled: false,
starEnabled: true,
starIntensity: 0.4,
lightningEnabled: false
}
},
overcast: {
label: '阴天',
params: {
...DEFAULT_SCENE_PARAMS,
elevation: 24,
azimuth: 168,
exposure: 0.11,
turbidity: 15.8,
rayleigh: 1.2,
bloomStrength: 0.08,
bloomRadius: 0.18,
waterColor: '#315565',
cloudCoverage: 0.84,
cloudDensity: 0.88,
cloudElevation: 0.34,
fogDensity: 0.38,
fogHeight: 0.36,
fogRange: 0.62,
starIntensity: 0.04,
lightningEnabled: false
}
},
rainy: {
label: '下雨',
params: {
...DEFAULT_SCENE_PARAMS,
elevation: 45,
azimuth: 170,
exposure: 0.085,
turbidity: 3.5,
rayleigh: 0,
bloomStrength: 0,
bloomRadius: 0,
waterColor: '#223d48',
cloudCoverage: 0.48,
cloudDensity: 0.92,
cloudElevation: 0.82,
fogDensity: 0.62,
fogHeight: 0.34,
fogRange: 0.78,
rainEnabled: true,
rainScreenIntensity: 0.38,
rainVeilIntensity: 1.4,
rainDropSize: 0.4,
rainSpeed: 1.16,
rainAudioEnabled: true,
rainAudioVolume: 0.38,
starIntensity: 0.02,
lightningEnabled: true,
lightningIntensity: 0.68,
snowEnabled: false
}
},
snow: {
label: '降雪',
params: {
...DEFAULT_SCENE_PARAMS,
elevation: 20,
azimuth: 172,
exposure: 0.1,
turbidity: 14.5,
rayleigh: 1.4,
bloomStrength: 0.06,
bloomRadius: 0.12,
waterColor: '#355160',
cloudCoverage: 0.88,
cloudDensity: 0.76,
cloudElevation: 0.38,
fogDensity: 0.56,
fogHeight: 0.42,
fogRange: 0.74,
starIntensity: 0.16,
rainEnabled: false,
lightningEnabled: false,
snowEnabled: true,
snowIntensity: 1.18,
snowSpeed: 1.28
}
}
};