用 Three.js 打造一座会呼吸的海岛:海风之屿飞艇巡航的技术实现

本文拆解一个完整的 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;
}

五组正弦波叠加,频率和方向各不相同,避免出现规律的"搓板"感。顶点位移后,把世界坐标和高度传给片元着色器。

片元着色器负责四件事:

  1. 深浅渐变:根据波高在深蓝与青绿之间插值;

  2. 浪尖白花smoothstep(0.42, 0.84, vH) 提取高波峰,混入白色;

  3. 细碎波纹:两组高频正弦相乘,叠加极微弱的亮度扰动;

  4. 菲涅尔反射:用视线方向计算边缘反射强度,远处混入天空色。

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。


八、结语

这个场景没有使用任何外部模型或贴图,全部依靠程序化生成和基础几何体组合。它的核心思路是:

  1. 把计算放到 GPU:海面波浪、顶点颜色、菲涅尔反射都在着色器里完成;

  2. 用数学函数塑造自然:海岸线、山丘、岛屿高度都是正弦与幂函数的叠加;

  3. 控制与姿态解耦:输入改目标值,阻尼逼近实际值,手感自然;

  4. 帧率无关的插值 :所有 lerp 都用 1 - pow(base, dt) 形式。

如果你要扩展它,可以从这些方向入手:加入岛屿碰撞体积、让飞艇可以投下物资、增加昼夜循环,或者把海面换成 Water 类的反射实现。但无论如何,保持"每一层独立、每一帧可预测"的原则,场景就不会失控。

下载地址

https://download.csdn.net/download/suny8/93436378?spm=1001.2014.3001.5503

相关推荐
yujunl1 小时前
U9 BE插件的调试
开发语言
牛油果子哥q1 小时前
多模态大模型工程入门:图文Embedding、图文RAG、图片解析、C++多模态接口封装实战
开发语言·c++·embedding
AIFQuant1 小时前
Python股票实时价格告警系统:WebSocket订阅与REST快照实战
开发语言·python·websocket·a股行情
八荒启·交互动画1 小时前
Web特效01—什么是渲染
前端·javascript·网页特效·八荒启-交互动画
wuyk5552 小时前
从零吃透 MQTT 通信|第 10 章 阿里云 / 腾讯云 MQTT 设备完整对接实战,三元组、签名、设备上云调试
c语言·开发语言·stm32·学习·阿里云·云计算·腾讯云
ocean21032 小时前
2025-2026年Java语言及生态面试高频知识点洞察
java·开发语言·面试
南京兴帝文化传媒有限公司2 小时前
基于地图平台的本地商户信息优化:药店夜间服务标注与客户转化实操
前端·javascript·数据库·人工智能·geo 优化·geo优化避坑·ai搜索获客
2302_1112 小时前
java时间类型
java·开发语言