本文拆解一个完整的 Three.js 场景:程序化生成的岛屿、自定义着色器海面、手工拼装的飞艇,以及一套手感顺滑的飞行控制。所有代码均可直接运行,无需外部资源。


一、整体架构:从天空到海底的层次
场景采用经典的三层结构,每一层独立更新,互不干扰:
text
Scene
├── sky 天空球(跟随相机)
├── sun 平行光 + 阴影
├── ocean 自定义着色器海面
├── island 程序化地形 + 植被/建筑
├── clouds 飘动的云团
└── airship 飞艇(玩家)
相机使用透视投影,视场角 58°,远平面 3000,配合 Fog 让远处海面自然融入天空。渲染器开启 ACESFilmicToneMapping,让高光过渡更柔和。
js
scene.fog = new THREE.Fog(0xcfe8ee, 140, 560);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
二、海面:用着色器制造"活"的波浪
平面几何体(190×190 分段)承担了全部海洋。如果放在 CPU 上逐顶点计算,帧率会直接崩掉,所以波浪位移完全交给顶点着色器:
glsl
float waveH(vec2 p, float t){
float h = 0.0;
h += sin(p.x * 0.130 + t * 0.85) * 0.42; // 长波涌浪
h += sin(p.y * 0.170 - t * 0.65) * 0.34; // 横向长波
h += sin((p.x*0.60 + p.y*0.80) * 0.090 + t*1.15) * 0.28; // 斜向交叉波
h += sin(p.x * 0.310 - t * 1.90) * 0.11; // 高频细波
h += sin(p.y * 0.270 + t * 1.55) * 0.09;
return h;
}
五组正弦波叠加,频率和方向各不相同,避免出现规律的"搓板"感。顶点位移后,把世界坐标和高度传给片元着色器。
片元着色器负责四件事:
-
深浅渐变:根据波高在深蓝与青绿之间插值;
-
浪尖白花 :
smoothstep(0.42, 0.84, vH)提取高波峰,混入白色; -
细碎波纹:两组高频正弦相乘,叠加极微弱的亮度扰动;
-
菲涅尔反射:用视线方向计算边缘反射强度,远处混入天空色。
glsl
float fres = pow(1.0 - clamp(viewDir.y, 0.0, 1.0), 3.5);
col = mix(col, uSky, fres * 0.62);
float dist = length(cameraPosition - vWorld);
col = mix(col, uSky, smoothstep(160.0, 620.0, dist));
远处混入雾色,近处保留水体质感,视觉上自然过渡到地平线。
三、岛屿:极坐标网格 + 多层噪声
岛屿不是导入的模型,而是每帧初始化时一次性生成的极坐标网格。
3.1 海岸线形状
海岸半径由多个正弦叠加决定,保证轮廓自然、首尾闭合:
js
function coastR(a) {
return ISLAND_R * (
1
+ 0.100 * Math.sin(a * 3 + 1.2)
+ 0.065 * Math.cos(a * 5 - 0.7)
+ 0.040 * Math.sin(a * 7 + 2.1)
);
}
三组不同频率的波叠加,形成既有大湾又有小岬的轮廓。
3.2 高度剖面
高度随归一化半径 t 变化,用余弦幂函数塑造山丘:
js
function islandProfile(t) {
return 5.4 * Math.pow(Math.cos(t * Math.PI / 2), 1.55);
}
中心最高约 5.4 单位,边缘趋近于 0。再叠加两层小尺度扰动,让山脊不完全对称:
js
y += 0.45 * Math.sin(x * 0.55) * Math.cos(z * 0.48) * (1 - t);
y += 0.22 * Math.sin(x * 1.30 + z * 0.90) * (1 - t * 0.6);
if (t > 0.90) y -= (t - 0.90) * 3.0; // 岸边压入水下,形成沙滩
3.3 顶点着色
用顶点颜色(vertexColors)代替贴图,从沙滩黄过渡到草地绿,再到高处深绿:
js
const g = 1 - smoothstep(t, 0.56, 0.86);
const c = sandCol.clone().lerp(grassCol, g);
c.lerp(darkCol, Math.max(0, (y - 3.2) / 3.0) * 0.45);
最后乘一个随坐标变化的微扰因子,避免色带过于均匀。
3.4 植被与建筑
棕榈树用 CatmullRomCurve3 生成弯曲的树干,再用 TubeGeometry 转成实体;叶片是压扁的球体,绕顶点均匀分布。灯塔、小屋、岩石都是基础几何体的组合。所有物体都通过 sampleIslandHeight(x, z) 精确贴地。
四、飞艇:手工拼装的卡通造型
飞艇没有用复杂模型,而是由几个基础体组合而成:
| 部件 | 几何体 | 说明 |
|---|---|---|
| 气囊 | CapsuleGeometry |
旋转 90° 变成水平椭球 |
| 环带 | TorusGeometry |
三圈红色装饰环 |
| 尾鳍 | BoxGeometry |
垂直 + 两片水平 |
| 吊舱 | BoxGeometry |
底部驾驶舱 |
| 螺旋桨 | BoxGeometry × 3 |
绕 Z 轴旋转 |
| 首部装饰 | SphereGeometry |
金色球体 |
js
const envGeo = new THREE.CapsuleGeometry(1.25, 3.7, 10, 28);
envGeo.rotateX(Math.PI / 2);
材质分工明确:envMat 米白哑光、strMat 红漆、goldMat 金属、metalMat 高金属度。开启 castShadow 后,飞艇在岛屿和海面上都有投影。
五、飞行控制:三轴独立,手感优先
控制系统的核心是把"操作"和"姿态"解耦------玩家输入直接改变的是目标值,实际姿态通过阻尼插值逼近目标,避免生硬。
5.1 状态量
js
const ship = {
yaw: 0, // 偏航角
speed: 0, // 前进速度
vy: 0, // 垂直速度
pitch: 0, // 俯仰(视觉)
bank: 0, // 侧倾(视觉)
pos: new THREE.Vector3(0, 9, 46)
};
5.2 转向与油门
转向速度随前进速度变化------低速时转向迟钝,高速时灵敏,符合直觉:
js
const turnFactor = 0.45 + 0.55 * Math.min(1, Math.abs(ship.speed) / 14);
ship.yaw += turn * 1.25 * turnFactor * dt;
油门松开后不是立刻停止,而是指数衰减模拟空气阻力:
js
ship.speed *= Math.pow(0.32, dt);
5.3 地形避让
每次更新都采样脚下地形高度,保证飞艇不会撞山:
js
const groundH = Math.max(0, sampleIslandHeight(ship.pos.x, ship.pos.z));
const minY = groundH + MIN_CLEAR; // MIN_CLEAR = 2.9
if (ship.pos.y < minY) {
ship.pos.y = minY;
if (ship.vy < 0) ship.vy = 0;
}
海面高度为 0,岛屿高度由采样函数给出,两者取较大值即可。
5.4 姿态插值
俯仰和侧倾不直接由按键决定,而是根据速度、垂直运动和转向量计算目标值,再阻尼逼近:
js
const bankTarget = turn * 0.34 * (0.6 + 0.4 * Math.min(1, Math.abs(ship.speed) / 18));
const pitchTarget = clamp(ship.vy * 0.030, -0.22, 0.22)
+ clamp(-ship.speed * 0.004, -0.10, 0.10);
ship.bank += (bankTarget - ship.bank) * Math.min(1, 5 * dt);
ship.pitch += (pitchTarget - ship.pitch) * Math.min(1, 5 * dt);
最终用四元数组合三轴旋转:
js
qYaw.setFromAxisAngle(AXIS_Y, ship.yaw);
qPitch.setFromAxisAngle(AXIS_X, ship.pitch);
qBank.setFromAxisAngle(AXIS_Z, ship.bank);
airship.quaternion.copy(qYaw).multiply(qPitch).multiply(qBank);
顺序不能颠倒------先偏航,再俯仰,最后侧倾,才能得到正确的世界朝向。
六、相机:平滑跟随但不眩晕
相机用阻尼插值跟随飞艇后方,注视点则略微前移,形成"看向前方"的视野:
js
tmpDesired.set(
Math.sin(ship.yaw) * 12,
4.2,
Math.cos(ship.yaw) * 12
).add(ship.pos);
const k = 1 - Math.pow(0.0022, dt);
camera.position.lerp(tmpDesired, k);
用 1 - Math.pow(base, dt) 而不是固定系数,是为了让阻尼效果与帧率无关------无论 30fps 还是 144fps,相机跟随的"软硬程度"都一致。
注视点同样做低通滤波,并且额外往前偏移 7 个单位,让飞艇始终处于画面下方三分之一处。
七、性能与细节
-
海面单次绘制 :190×190 分段全部在 GPU 计算,CPU 只传一个
uTime。 -
阴影范围收紧:平行光的正交相机只覆盖岛屿周边 52 单位,保证阴影贴图分辨率利用率。
-
雾与天空球配合:远处物体不渲染细节,直接融入雾色,减少视觉噪点。
-
螺旋桨转速与速度挂钩 :
propeller.rotation.z += (3 + Math.abs(ship.speed) * 0.9) * dt;,视觉反馈更真实。 -
HUD 节流更新:每 0.1 秒刷新一次速度/高度,避免频繁操作 DOM。
八、结语
这个场景没有使用任何外部模型或贴图,全部依靠程序化生成和基础几何体组合。它的核心思路是:
-
把计算放到 GPU:海面波浪、顶点颜色、菲涅尔反射都在着色器里完成;
-
用数学函数塑造自然:海岸线、山丘、岛屿高度都是正弦与幂函数的叠加;
-
控制与姿态解耦:输入改目标值,阻尼逼近实际值,手感自然;
-
帧率无关的插值 :所有
lerp都用1 - pow(base, dt)形式。
如果你要扩展它,可以从这些方向入手:加入岛屿碰撞体积、让飞艇可以投下物资、增加昼夜循环,或者把海面换成 Water 类的反射实现。但无论如何,保持"每一层独立、每一帧可预测"的原则,场景就不会失控。
下载地址
https://download.csdn.net/download/suny8/93436378?spm=1001.2014.3001.5503