init
This commit is contained in:
1
.claude/skills/threejs
Submodule
1
.claude/skills/threejs
Submodule
Submodule .claude/skills/threejs added at b1c623076c
1
.claude/skills/webgpu
Submodule
1
.claude/skills/webgpu
Submodule
Submodule .claude/skills/webgpu added at 4adcfe8ef2
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
212
README.md
Normal file
212
README.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# 写实无尽海洋场景
|
||||||
|
|
||||||
|
一个基于 Three.js 的写实海洋场景,包含程序化生成的地形、植被、写实的水面和天空效果。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 🌊 写实海洋
|
||||||
|
- 使用 Three.js 官方 Water shader
|
||||||
|
- 动态水面波纹和反射
|
||||||
|
- 基于法线贴图的真实水面效果
|
||||||
|
- 支持天空颜色反射
|
||||||
|
|
||||||
|
### 🏔️ 程序化地形
|
||||||
|
- 使用 Simplex Noise 和 FBM(分形布朗运动)生成
|
||||||
|
- 多层噪声叠加创建自然起伏
|
||||||
|
- 基于高度的颜色渐变(深海、浅滩、沙滩、草地、岩石、雪山)
|
||||||
|
- 平滑的海岸线过渡
|
||||||
|
|
||||||
|
### 🌲 植被系统
|
||||||
|
- 40,000+ 片草地(实例化渲染)
|
||||||
|
- 400+ 棵程序化生成的树木
|
||||||
|
- 根据地形高度智能分布
|
||||||
|
- 逼真的树木几何形状(树干 + 多层树冠)
|
||||||
|
|
||||||
|
### ☀️ 天空与光影
|
||||||
|
- Three.js Sky shader(大气散射)
|
||||||
|
- 真实的太阳位置计算
|
||||||
|
- 动态雾效随太阳高度变化
|
||||||
|
- ACES Filmic 色调映射
|
||||||
|
- 高质量阴影
|
||||||
|
|
||||||
|
### 🎮 交互控制
|
||||||
|
- 轨道相机控制(拖拽旋转、右键平移、滚轮缩放)
|
||||||
|
- 太阳高度角控制(-10° 到 90°)
|
||||||
|
- 太阳方位角控制(-180° 到 180°)
|
||||||
|
- 曝光度调节
|
||||||
|
- 大气浑浊度控制
|
||||||
|
- 瑞利散射强度控制
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **Three.js r171** - 3D 渲染引擎
|
||||||
|
- **Vite** - 现代化构建工具
|
||||||
|
- **ES Modules** - 模块化代码组织
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── index.html # HTML 入口
|
||||||
|
├── package.json # 项目配置
|
||||||
|
├── vite.config.js # Vite 配置
|
||||||
|
└── src/
|
||||||
|
├── main.js # 应用入口
|
||||||
|
├── OceanScene.js # 主场景管理器
|
||||||
|
├── TerrainGenerator.js # 地形生成器
|
||||||
|
├── VegetationSystem.js # 植被系统
|
||||||
|
└── utils/
|
||||||
|
└── SimplexNoise.js # Simplex Noise 实现
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装与运行
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动开发服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 http://localhost:3000
|
||||||
|
|
||||||
|
### 构建生产版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
### 相机控制
|
||||||
|
- **左键拖动**: 旋转视角
|
||||||
|
- **右键拖动**: 平移相机
|
||||||
|
- **滚轮**: 缩放
|
||||||
|
|
||||||
|
### 太阳控制面板
|
||||||
|
|
||||||
|
右上角提供完整的太阳和大气控制:
|
||||||
|
|
||||||
|
1. **太阳高度角 (Elevation)**
|
||||||
|
- 范围: -10° 到 90°
|
||||||
|
- 控制太阳在天空中的高度
|
||||||
|
- 日出/日落效果在 0-15°
|
||||||
|
- 正午效果在 70-90°
|
||||||
|
|
||||||
|
2. **太阳方位角 (Azimuth)**
|
||||||
|
- 范围: -180° 到 180°
|
||||||
|
- 控制太阳的水平位置
|
||||||
|
- 180° = 正南方向
|
||||||
|
|
||||||
|
3. **曝光度 (Exposure)**
|
||||||
|
- 范围: 0.1 到 2.0
|
||||||
|
- 调节整体亮度
|
||||||
|
- 默认 0.5 适合大多数场景
|
||||||
|
|
||||||
|
4. **大气浑浊度 (Turbidity)**
|
||||||
|
- 范围: 1 到 20
|
||||||
|
- 较低值: 清澈蓝天
|
||||||
|
- 较高值: 雾霾/阴天效果
|
||||||
|
|
||||||
|
5. **瑞利散射 (Rayleigh)**
|
||||||
|
- 范围: 0 到 4
|
||||||
|
- 影响天空蓝色强度
|
||||||
|
- 较高值: 更蓝的天空
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
- 实例化渲染草地(减少 draw calls)
|
||||||
|
- 程序化生成(无需加载外部模型)
|
||||||
|
- 自适应像素比(限制最大 2x)
|
||||||
|
- PCF 软阴影
|
||||||
|
- 雾效减少远处渲染负担
|
||||||
|
|
||||||
|
## 浏览器要求
|
||||||
|
|
||||||
|
- Chrome 90+
|
||||||
|
- Firefox 88+
|
||||||
|
- Safari 14+
|
||||||
|
- Edge 90+
|
||||||
|
|
||||||
|
需要支持 WebGL 2.0
|
||||||
|
|
||||||
|
## 参数调优建议
|
||||||
|
|
||||||
|
### 日出场景
|
||||||
|
```
|
||||||
|
Elevation: 5-15
|
||||||
|
Azimuth: 90-180
|
||||||
|
Exposure: 0.4-0.6
|
||||||
|
Turbidity: 8-12
|
||||||
|
Rayleigh: 2-3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 正午场景
|
||||||
|
```
|
||||||
|
Elevation: 70-85
|
||||||
|
Azimuth: 180
|
||||||
|
Exposure: 0.5-0.7
|
||||||
|
Turbidity: 6-10
|
||||||
|
Rayleigh: 1.5-2.5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 黄昏场景
|
||||||
|
```
|
||||||
|
Elevation: 0-10
|
||||||
|
Azimuth: 270
|
||||||
|
Exposure: 0.3-0.5
|
||||||
|
Turbidity: 12-16
|
||||||
|
Rayleigh: 2-4
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自定义修改
|
||||||
|
|
||||||
|
### 调整地形大小
|
||||||
|
编辑 `src/OceanScene.js` 中的 `initTerrain()`:
|
||||||
|
```javascript
|
||||||
|
const terrainGen = new TerrainGenerator({
|
||||||
|
size: 1000, // 地形尺寸
|
||||||
|
segments: 256, // 细分程度(影响细节)
|
||||||
|
maxHeight: 50, // 最大高度
|
||||||
|
waterLevel: 0, // 水面高度
|
||||||
|
seed: 42 // 随机种子
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 调整植被密度
|
||||||
|
编辑 `src/OceanScene.js` 中的 `initVegetation()`:
|
||||||
|
```javascript
|
||||||
|
const vegSystem = new VegetationSystem(this.terrainGenerator, {
|
||||||
|
grassCount: 40000, // 草地数量
|
||||||
|
treeCount: 400, // 树木数量
|
||||||
|
terrainSize: 1000,
|
||||||
|
waterLevel: 0
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 调整水面效果
|
||||||
|
编辑 `src/OceanScene.js` 中的 `initWater()`:
|
||||||
|
```javascript
|
||||||
|
this.water = new Water(waterGeometry, {
|
||||||
|
textureWidth: 512,
|
||||||
|
textureHeight: 512,
|
||||||
|
waterColor: 0x001e0f, // 水面颜色
|
||||||
|
distortionScale: 3.7, // 波纹扭曲强度
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## 参考资源
|
||||||
|
|
||||||
|
- [Three.js Ocean Example](https://threejs.org/examples/webgl_shaders_ocean.html)
|
||||||
|
- [Three.js Sky Shader](https://threejs.org/docs/#api/en/objects/Sky)
|
||||||
|
- [Simplex Noise Algorithm](https://en.wikipedia.org/wiki/Simplex_noise)
|
||||||
202
index.html
Normal file
202
index.html
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>写实无尽海洋场景</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#container {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #1e3a5f 0%, #2d5a87 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border: 4px solid rgba(255,255,255,0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
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: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: white;
|
||||||
|
z-index: 100;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sun-controls h3 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 16px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.2);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4a9eff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-value {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="loading">
|
||||||
|
<h1>写实海洋场景</h1>
|
||||||
|
<div class="loader"></div>
|
||||||
|
<p style="margin-top: 20px; opacity: 0.8;">正在加载场景...</p>
|
||||||
|
</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-group">
|
||||||
|
<label>太阳高度角 (Elevation)</label>
|
||||||
|
<input type="range" id="sun-elevation" min="-10" max="90" value="15" step="0.1">
|
||||||
|
<div class="control-value" id="elevation-value">15.0°</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>太阳方位角 (Azimuth)</label>
|
||||||
|
<input type="range" id="sun-azimuth" min="-180" max="180" value="180" step="0.1">
|
||||||
|
<div class="control-value" id="azimuth-value">180.0°</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>曝光度 (Exposure)</label>
|
||||||
|
<input type="range" id="exposure" min="0.1" max="2" value="0.5" step="0.01">
|
||||||
|
<div class="control-value" id="exposure-value">0.50</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>大气浑浊度 (Turbidity)</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>瑞利散射 (Rayleigh)</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 id="stats">FPS: <span id="fps">60</span></div>
|
||||||
|
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1152
package-lock.json
generated
Normal file
1152
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
Normal file
16
package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "realistic-ocean-scene",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"three": "^0.171.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
305
src/OceanScene.js
Normal file
305
src/OceanScene.js
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
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 { 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.pmremGenerator = null;
|
||||||
|
this.renderTarget = null;
|
||||||
|
this.sunLight = null;
|
||||||
|
|
||||||
|
this.params = {
|
||||||
|
elevation: 2,
|
||||||
|
azimuth: 180,
|
||||||
|
exposure: 0.5,
|
||||||
|
turbidity: 10,
|
||||||
|
rayleigh: 2,
|
||||||
|
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();
|
||||||
|
await this.initSky();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initWater() {
|
||||||
|
const waterGeometry = new THREE.PlaneGeometry(10000, 10000, 128, 128);
|
||||||
|
|
||||||
|
const waterNormals = await new Promise((resolve) => {
|
||||||
|
new THREE.TextureLoader().load(
|
||||||
|
'https://threejs.org/examples/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;
|
||||||
|
this.scene.add(this.water);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initTerrain() {
|
||||||
|
const terrainGen = new TerrainGenerator({
|
||||||
|
size: 1200,
|
||||||
|
segments: 200,
|
||||||
|
maxHeight: 30,
|
||||||
|
waterLevel: 0,
|
||||||
|
seed: 42
|
||||||
|
});
|
||||||
|
|
||||||
|
this.terrain = terrainGen.generate();
|
||||||
|
this.scene.add(this.terrain);
|
||||||
|
|
||||||
|
this.terrainGenerator = terrainGen;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initVegetation() {
|
||||||
|
const vegSystem = new VegetationSystem(this.terrainGenerator, {
|
||||||
|
grassCount: 30000,
|
||||||
|
treeCount: 300,
|
||||||
|
terrainSize: 1200,
|
||||||
|
waterLevel: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
this.vegetation = vegSystem.generate();
|
||||||
|
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.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());
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
animate() {
|
||||||
|
requestAnimationFrame(() => this.animate());
|
||||||
|
|
||||||
|
const time = this.clock.getElapsedTime();
|
||||||
|
|
||||||
|
if (this.water) {
|
||||||
|
this.water.material.uniforms['time'].value += 1.0 / 60.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controls.update();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/TerrainGenerator.js
Normal file
142
src/TerrainGenerator.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { SimplexNoise } from './utils/SimplexNoise.js';
|
||||||
|
|
||||||
|
export class TerrainGenerator {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.size = options.size || 1000;
|
||||||
|
this.segments = options.segments || 256;
|
||||||
|
this.maxHeight = options.maxHeight || 50;
|
||||||
|
this.waterLevel = options.waterLevel || 0;
|
||||||
|
this.beachWidth = options.beachWidth || 20;
|
||||||
|
|
||||||
|
this.noise = new SimplexNoise(options.seed || 42);
|
||||||
|
this.terrain = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
generate() {
|
||||||
|
const geometry = new THREE.PlaneGeometry(
|
||||||
|
this.size,
|
||||||
|
this.size,
|
||||||
|
this.segments,
|
||||||
|
this.segments
|
||||||
|
);
|
||||||
|
|
||||||
|
const positions = geometry.attributes.position.array;
|
||||||
|
const colors = new Float32Array(positions.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < positions.length; i += 3) {
|
||||||
|
const x = positions[i];
|
||||||
|
const y = positions[i + 1];
|
||||||
|
|
||||||
|
let height = this.getHeight(x, y);
|
||||||
|
positions[i + 2] = height;
|
||||||
|
|
||||||
|
const color = this.getTerrainColor(x, y, height);
|
||||||
|
colors[i] = color.r;
|
||||||
|
colors[i + 1] = color.g;
|
||||||
|
colors[i + 2] = color.b;
|
||||||
|
}
|
||||||
|
|
||||||
|
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
geometry.rotateX(-Math.PI / 2);
|
||||||
|
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
vertexColors: true,
|
||||||
|
roughness: 0.85,
|
||||||
|
metalness: 0.0,
|
||||||
|
flatShading: false
|
||||||
|
});
|
||||||
|
|
||||||
|
this.terrain = new THREE.Mesh(geometry, material);
|
||||||
|
this.terrain.receiveShadow = true;
|
||||||
|
this.terrain.castShadow = true;
|
||||||
|
|
||||||
|
return this.terrain;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHeight(x, y) {
|
||||||
|
const scale = 0.0008;
|
||||||
|
|
||||||
|
let height = this.noise.fbm(x * scale, y * scale, 3, 2.0, 0.5);
|
||||||
|
const detail = this.noise.fbm(x * scale * 3, y * scale * 3, 2, 2.0, 0.5) * 0.2;
|
||||||
|
height += detail;
|
||||||
|
|
||||||
|
const distFromCenter = Math.sqrt(x * x + y * y);
|
||||||
|
const maxLandDist = this.size * 0.45;
|
||||||
|
const falloffStart = this.size * 0.25;
|
||||||
|
|
||||||
|
let continentMask = 1.0;
|
||||||
|
if (distFromCenter > falloffStart) {
|
||||||
|
const t = (distFromCenter - falloffStart) / (maxLandDist - falloffStart);
|
||||||
|
continentMask = Math.max(0, 1 - Math.pow(t, 1.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
height = height * continentMask;
|
||||||
|
height = height * this.maxHeight * 0.4;
|
||||||
|
|
||||||
|
const beachZone = 2;
|
||||||
|
if (height > this.waterLevel && height < this.waterLevel + beachZone) {
|
||||||
|
const blend = Math.max(0, Math.min(1, (height - this.waterLevel) / beachZone));
|
||||||
|
height = this.waterLevel + Math.pow(blend, 0.5) * beachZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTerrainColor(x, y, height) {
|
||||||
|
const normalizedHeight = (height - this.waterLevel) / this.maxHeight;
|
||||||
|
|
||||||
|
const deepWater = new THREE.Color(0x1a3d5c);
|
||||||
|
const shallowWater = new THREE.Color(0x2d6b8a);
|
||||||
|
const beach = new THREE.Color(0xc2b280);
|
||||||
|
const grass = new THREE.Color(0x3d6b3d);
|
||||||
|
const darkGrass = new THREE.Color(0x2d4a2d);
|
||||||
|
const rock = new THREE.Color(0x5a5a5a);
|
||||||
|
const snow = new THREE.Color(0xe8e8e8);
|
||||||
|
|
||||||
|
let color = new THREE.Color();
|
||||||
|
|
||||||
|
if (normalizedHeight < -0.1) {
|
||||||
|
color.copy(deepWater);
|
||||||
|
} else if (normalizedHeight < 0) {
|
||||||
|
color.lerpColors(deepWater, shallowWater, (normalizedHeight + 0.1) / 0.1);
|
||||||
|
} else if (normalizedHeight < 0.05) {
|
||||||
|
const beachBlend = Math.min(1, normalizedHeight / 0.05);
|
||||||
|
color.lerpColors(shallowWater, beach, beachBlend);
|
||||||
|
} else if (normalizedHeight < 0.15) {
|
||||||
|
const sandToGrass = (normalizedHeight - 0.05) / 0.1;
|
||||||
|
color.lerpColors(beach, grass, sandToGrass);
|
||||||
|
} else if (normalizedHeight < 0.4) {
|
||||||
|
const grassBlend = (normalizedHeight - 0.15) / 0.25;
|
||||||
|
color.lerpColors(grass, darkGrass, grassBlend);
|
||||||
|
} else if (normalizedHeight < 0.6) {
|
||||||
|
const rockBlend = (normalizedHeight - 0.4) / 0.2;
|
||||||
|
color.lerpColors(darkGrass, rock, rockBlend);
|
||||||
|
} else if (normalizedHeight < 0.8) {
|
||||||
|
color.copy(rock);
|
||||||
|
} else {
|
||||||
|
const snowBlend = (normalizedHeight - 0.8) / 0.2;
|
||||||
|
color.lerpColors(rock, snow, snowBlend);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noiseVariation = this.noise.noise2D(x * 0.05, y * 0.05) * 0.1;
|
||||||
|
color.r = Math.max(0, Math.min(1, color.r + noiseVariation));
|
||||||
|
color.g = Math.max(0, Math.min(1, color.g + noiseVariation));
|
||||||
|
color.b = Math.max(0, Math.min(1, color.b + noiseVariation));
|
||||||
|
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTerrain() {
|
||||||
|
return this.terrain;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHeightAt(x, z) {
|
||||||
|
return this.getHeight(x, -z);
|
||||||
|
}
|
||||||
|
|
||||||
|
isLand(x, z) {
|
||||||
|
return this.getHeight(x, z) > this.waterLevel;
|
||||||
|
}
|
||||||
|
}
|
||||||
195
src/VegetationSystem.js
Normal file
195
src/VegetationSystem.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
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,
|
||||||
|
terrainSize: options.terrainSize || 1000,
|
||||||
|
waterLevel: options.waterLevel || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
this.noise = new SimplexNoise(12345);
|
||||||
|
this.grass = null;
|
||||||
|
this.trees = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
generate() {
|
||||||
|
this.generateGrass();
|
||||||
|
this.generateTrees();
|
||||||
|
|
||||||
|
return {
|
||||||
|
grass: this.grass,
|
||||||
|
trees: this.trees
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
addToScene(scene) {
|
||||||
|
if (this.grass) scene.add(this.grass);
|
||||||
|
this.trees.forEach(tree => scene.add(tree));
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/main.js
Normal file
76
src/main.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { OceanScene } from './OceanScene.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const container = document.getElementById('container');
|
||||||
|
|
||||||
|
const oceanScene = new OceanScene(container);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await oceanScene.init();
|
||||||
|
|
||||||
|
setupControls(oceanScene);
|
||||||
|
|
||||||
|
oceanScene.hideLoading();
|
||||||
|
|
||||||
|
oceanScene.animate();
|
||||||
|
|
||||||
|
console.log('写实海洋场景加载完成!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('场景加载失败:', error);
|
||||||
|
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
if (loading) {
|
||||||
|
loading.innerHTML = `
|
||||||
|
<h1 style="color: #ff6b6b;">加载失败</h1>
|
||||||
|
<p style="margin-top: 20px; opacity: 0.8;">${error.message}</p>
|
||||||
|
<p style="margin-top: 10px; opacity: 0.6;">请刷新页面重试</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupControls(oceanScene) {
|
||||||
|
const elevationSlider = document.getElementById('sun-elevation');
|
||||||
|
const azimuthSlider = document.getElementById('sun-azimuth');
|
||||||
|
const exposureSlider = document.getElementById('exposure');
|
||||||
|
const turbiditySlider = document.getElementById('turbidity');
|
||||||
|
const rayleighSlider = document.getElementById('rayleigh');
|
||||||
|
|
||||||
|
const elevationValue = document.getElementById('elevation-value');
|
||||||
|
const azimuthValue = document.getElementById('azimuth-value');
|
||||||
|
const exposureValue = document.getElementById('exposure-value');
|
||||||
|
const turbidityValue = document.getElementById('turbidity-value');
|
||||||
|
const rayleighValue = document.getElementById('rayleigh-value');
|
||||||
|
|
||||||
|
elevationSlider.addEventListener('input', (e) => {
|
||||||
|
const value = parseFloat(e.target.value);
|
||||||
|
oceanScene.setSunElevation(value);
|
||||||
|
elevationValue.textContent = value.toFixed(1) + '°';
|
||||||
|
});
|
||||||
|
|
||||||
|
azimuthSlider.addEventListener('input', (e) => {
|
||||||
|
const value = parseFloat(e.target.value);
|
||||||
|
oceanScene.setSunAzimuth(value);
|
||||||
|
azimuthValue.textContent = value.toFixed(1) + '°';
|
||||||
|
});
|
||||||
|
|
||||||
|
exposureSlider.addEventListener('input', (e) => {
|
||||||
|
const value = parseFloat(e.target.value);
|
||||||
|
oceanScene.setExposure(value);
|
||||||
|
exposureValue.textContent = value.toFixed(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
turbiditySlider.addEventListener('input', (e) => {
|
||||||
|
const value = parseFloat(e.target.value);
|
||||||
|
oceanScene.setTurbidity(value);
|
||||||
|
turbidityValue.textContent = value.toFixed(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
rayleighSlider.addEventListener('input', (e) => {
|
||||||
|
const value = parseFloat(e.target.value);
|
||||||
|
oceanScene.setRayleigh(value);
|
||||||
|
rayleighValue.textContent = value.toFixed(2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
131
src/utils/SimplexNoise.js
Normal file
131
src/utils/SimplexNoise.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
export class SimplexNoise {
|
||||||
|
constructor(seed = Math.random()) {
|
||||||
|
this.p = new Uint8Array(256);
|
||||||
|
this.perm = new Uint8Array(512);
|
||||||
|
this.permMod12 = new Uint8Array(512);
|
||||||
|
|
||||||
|
const random = this.seededRandom(seed);
|
||||||
|
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
this.p[i] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 255; i > 0; i--) {
|
||||||
|
const j = Math.floor(random() * (i + 1));
|
||||||
|
[this.p[i], this.p[j]] = [this.p[j], this.p[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 512; i++) {
|
||||||
|
this.perm[i] = this.p[i & 255];
|
||||||
|
this.permMod12[i] = this.perm[i] % 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.grad3 = new Float32Array([
|
||||||
|
1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0,
|
||||||
|
1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1,
|
||||||
|
0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
seededRandom(seed) {
|
||||||
|
return function() {
|
||||||
|
seed = (seed * 9301 + 49297) % 233280;
|
||||||
|
return seed / 233280;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
dot2(g, x, y) {
|
||||||
|
return g[0] * x + g[1] * y;
|
||||||
|
}
|
||||||
|
|
||||||
|
noise2D(xin, yin) {
|
||||||
|
const F2 = 0.5 * (Math.sqrt(3) - 1);
|
||||||
|
const G2 = (3 - Math.sqrt(3)) / 6;
|
||||||
|
|
||||||
|
const s = (xin + yin) * F2;
|
||||||
|
const i = Math.floor(xin + s);
|
||||||
|
const j = Math.floor(yin + s);
|
||||||
|
|
||||||
|
const t = (i + j) * G2;
|
||||||
|
const X0 = i - t;
|
||||||
|
const Y0 = j - t;
|
||||||
|
const x0 = xin - X0;
|
||||||
|
const y0 = yin - Y0;
|
||||||
|
|
||||||
|
let i1, j1;
|
||||||
|
if (x0 > y0) {
|
||||||
|
i1 = 1;
|
||||||
|
j1 = 0;
|
||||||
|
} else {
|
||||||
|
i1 = 0;
|
||||||
|
j1 = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const x1 = x0 - i1 + G2;
|
||||||
|
const y1 = y0 - j1 + G2;
|
||||||
|
const x2 = x0 - 1 + 2 * G2;
|
||||||
|
const y2 = y0 - 1 + 2 * G2;
|
||||||
|
|
||||||
|
const ii = i & 255;
|
||||||
|
const jj = j & 255;
|
||||||
|
|
||||||
|
let n0 = 0, n1 = 0, n2 = 0;
|
||||||
|
|
||||||
|
let t0 = 0.5 - x0 * x0 - y0 * y0;
|
||||||
|
if (t0 >= 0) {
|
||||||
|
const gi0 = this.permMod12[ii + this.perm[jj]] * 3;
|
||||||
|
t0 *= t0;
|
||||||
|
n0 = t0 * t0 * this.dot2([this.grad3[gi0], this.grad3[gi0 + 1]], x0, y0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let t1 = 0.5 - x1 * x1 - y1 * y1;
|
||||||
|
if (t1 >= 0) {
|
||||||
|
const gi1 = this.permMod12[ii + i1 + this.perm[jj + j1]] * 3;
|
||||||
|
t1 *= t1;
|
||||||
|
n1 = t1 * t1 * this.dot2([this.grad3[gi1], this.grad3[gi1 + 1]], x1, y1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let t2 = 0.5 - x2 * x2 - y2 * y2;
|
||||||
|
if (t2 >= 0) {
|
||||||
|
const gi2 = this.permMod12[ii + 1 + this.perm[jj + 1]] * 3;
|
||||||
|
t2 *= t2;
|
||||||
|
n2 = t2 * t2 * this.dot2([this.grad3[gi2], this.grad3[gi2 + 1]], x2, y2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 70 * (n0 + n1 + n2);
|
||||||
|
}
|
||||||
|
|
||||||
|
fbm(x, y, octaves = 6, lacunarity = 2, persistence = 0.5) {
|
||||||
|
let value = 0;
|
||||||
|
let amplitude = 1;
|
||||||
|
let frequency = 1;
|
||||||
|
let maxValue = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < octaves; i++) {
|
||||||
|
value += amplitude * this.noise2D(x * frequency, y * frequency);
|
||||||
|
maxValue += amplitude;
|
||||||
|
amplitude *= persistence;
|
||||||
|
frequency *= lacunarity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value / maxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ridgedNoise(x, y, octaves = 6, lacunarity = 2, persistence = 0.5) {
|
||||||
|
let value = 0;
|
||||||
|
let amplitude = 1;
|
||||||
|
let frequency = 1;
|
||||||
|
let maxValue = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < octaves; i++) {
|
||||||
|
let noise = 1 - Math.abs(this.noise2D(x * frequency, y * frequency));
|
||||||
|
noise = noise * noise;
|
||||||
|
value += amplitude * noise;
|
||||||
|
maxValue += amplitude;
|
||||||
|
amplitude *= persistence;
|
||||||
|
frequency *= lacunarity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value / maxValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
vite.config.js
Normal file
8
vite.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 3000
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user