开发日志 · three.js

用 three.js 做风暴海面:海浪、浪花与一艘随浪起伏的船

一个高度函数,处处采样:惊涛帆影这个场景如何让海面动起来、不用物理引擎让船浮起来,再用障眼法做出一场以假乱真的风暴。

更新于 作者 胜遇工作室阅读约 8 分钟

惊涛帆影是我们网站上的一个实时 3D 小场景:一艘木帆船在漆黑天空下的滔天涌浪里颠簸,雨斜斜地扫过镜头,鲨鱼和海蛇在四周盘旋,船头还站着一名弩手,左键点击就能瞄准射击。它用 three.js(r160)在浏览器里运行,没有任何预制模型或贴图——一切都由代码生成。

这篇文章结合真实源码片段,讲解海面和船的运动是怎么做的。一句话版本:一个廉价的高度函数,是整片海唯一的事实来源,所有和水打交道的东西——网格、船、鲨鱼、浪花、弩箭——都去问同一个函数:水面在哪?

一艘木帆船在灰色的风暴涌浪上颠簸
惊涛帆影:船、涌浪、雨和雾,全部在运行时生成。

整片海只用一个高度函数

真正的海洋渲染器常用 FFT 频谱或 Gerstner 波。我们用的东西简单得多——五个朝不同方向、以不同速度传播的正弦波之和:

function waveH(x, z, t) {
  return Math.sin(x * 0.055 + t * 0.9) * 1.55
       + Math.sin(z * 0.042 - t * 0.7 + 1.7) * 1.25
       + Math.sin((x + z) * 0.03 + t * 0.55) * 1.85
       + Math.sin(x * 0.14 - z * 0.09 + t * 1.6) * 0.42
       + Math.sin((x * 0.75 - z) * 0.11 + t * 2.1) * 0.22;
}

前三项是涌浪:波长长、振幅大(1.25–1.85 单位)、速度慢,分别沿 x、沿 z 和对角线传播。后两项是叠在上面的短而快、振幅小的碎浪。因为方向各不相同,波峰永远不会排成明显重复的网格,海面看起来很不规则,尽管它完全是确定性的。

为什么不用更符合物理的方案?因为这个函数可以在 CPU 上随时随地调用,同样的 (x, z, t) 永远返回同样的答案。对这个场景来说,这一点远比频谱精度重要:正是它让船、鲨鱼和箭与你看到的水面保持一致。

移动网格并计算法线

海面是一个 520 × 520 单位、112 × 112 段的 PlaneGeometry——113 × 113 = 12,769 个顶点——旋转后平放。每一帧我们写入新的高度,并自己重新计算法线:

function updateOcean(t) {
  const p = oceanGeo.attributes.position, n = oceanGeo.attributes.normal;
  const e = 0.6;
  for (let i = 0; i < p.count; i++) {
    const x = p.getX(i), z = p.getZ(i);
    const h = waveH(x, z, t);
    p.setY(i, h);
    const hx = waveH(x + e, z, t), hz = waveH(x, z + e, t);
    let nx = -(hx - h) / e, nz = -(hz - h) / e;
    const inv = 1 / Math.hypot(nx, 1, nz);
    n.setXYZ(i, nx * inv, inv, nz * inv);
  }
  p.needsUpdate = true;
  n.needsUpdate = true;
}

法线来自有限差分。对于高度场 y = h(x, z),表面法线正比于 (−∂h/∂x, 1, −∂h/∂z)。我们分别沿 x 和 z 往前 0.6 单位采样高度来估算斜率,再归一化。这比 computeVertexNormals() 更省(不用遍历三角形),光照也平滑,而且不依赖网格分辨率。

每帧在 CPU 上更新约 1.28 万个顶点算不算浪费?有一点——每个顶点要算三次高度。把同样的数学挪进顶点着色器会更快,但那样海面的形状就会同时存在于 GLSL 和 JavaScript 里,两份代码必须保持一致。在这个网格规模下,CPU 版本绰绰有余;对我们来说,只维护一份实现,比省下那几毫秒更值。

用 onBeforeCompile 给浪尖加泡沫

水面用的是现成的 MeshStandardMaterial(深青色,粗糙度 0.38,带一点金属度),由天空生成的环境贴图照亮,反射就白捡了。泡沫不是写自定义材质加上去的,而是用 onBeforeCompile 给内置着色器打补丁。顶点着色器把高度传给片元着色器,后者在浪尖附近把颜色调白:

float crest = smoothstep(1.9, 3.7, vH);
float streak = 0.7 + 0.3 * sin(vWp.x * 0.35 + vWp.z * 0.5 + uTime * 0.8)
                         * sin(vWp.x * 0.13 - vWp.z * 0.21 - uTime * 0.5);
diffuseColor.rgb = mix(diffuseColor.rgb, vec3(0.87, 0.93, 0.95), crest * streak * 0.92);

crest 在高度 1.9 以下为 0、3.7 以上为 1,中间平滑过渡,所以只有最高的浪尖才起泡沫。streak 把泡沫打散成漂移的斑块,免得看起来像一顶均匀的白帽子。只修改 #include <color_fragment>,意味着 three.js 的光照、阴影、雾和色调映射全都保留。

让船随浪起伏

船没有做物理模拟。没有刚体,没有浮力,没有质量。我们做的是采样船体下方的海面,再让船的姿态去匹配它。五个采样点就够:中心一个,船头和船尾各一个(沿船长方向 ±10),左舷和右舷各一个(横向 ±4)。

const hC = waveH(0, 0, t);
const hBow = waveH(10, 0, t), hStern = waveH(-10, 0, t);
const hPort = waveH(0, -4, t), hStar = waveH(0, 4, t);
const k = 1 - Math.exp(-dt * 2.1);
shipState.y  += (hC * 0.85 - shipState.y) * k;
shipState.rz += (Math.atan2(hBow - hStern, 20) * 0.85 - shipState.rz) * k;
shipState.rx += (Math.atan2(hPort - hStar, 8) * 0.8 - shipState.rx) * k;
  • 升沉(上下)跟随中心点的高度。
  • 纵摇(船头抬起或压下)是船尾采样点到船头采样点连线的角度:atan2(高度差, 20),因为两个采样点相距 20 单位。
  • 横摇(左右摇摆)同理,只是换成 8 单位宽的横向。

有两个细节撑起了船的分量感。第一,每个目标值都缩小到 80–85%,所以船体永远不会完全贴合水面的坡度——一艘重船不会完美跟随每一道浪。第二,船不会瞬间跳到目标姿态,而是以 k = 1 − e−2.1·dt 缓动过去。这种指数平滑在 30、60 或 144 帧下表现完全一致(每帧简单地 lerp(a, b, 0.05) 就做不到),还带来自然的滞后:船总是比浪慢半拍,仿佛有惯性。

因为船体是一个 group,桅杆、船帆、栏杆、木桶、提灯和弩手都会自动继承这套运动。

船头浪花,以及其他碰到水的东西

知道了船的姿态,就能判断船头什么时候撞进浪里。我们根据升沉和纵摇估算船头在世界坐标中的高度,与它正前方的海面高度比较;如果水比船头高出 0.35 单位以上,就生成一个大浪花精灵——最多每 0.9 秒一次,免得一直喷个不停:

const bowWorldY = shipState.y + Math.sin(shipState.rz) * 12;
if (t > bowSplashCd && waveH(12, 0, t) > bowWorldY + 0.35) {
  spawnFrom(splashes, _v1.set(13.5, waveH(13.5, 0, t) + 0.5, (Math.random() - 0.5) * 3), 4.5);
  bowSplashCd = t + 0.9;
}

浪花来自一个 26 个精灵的小对象池,它们会变大、上升、淡出,游戏过程中不分配任何新对象。水里的其他一切也都由同一个 waveH 调用驱动:

  • 鲨鱼绕船游弋,深度与当地的浪高挂钩;每隔 8–15 秒,每条鲨鱼会沿半个正弦弧跃出水面,出水和入水时都溅起浪花。
  • 海蛇是沿一个圆排布的十个球体;每一节的高度等于浪高加上一个行进的隆起,所以它的背脊看起来在涌浪中翻滚,蛇头破水时还会溅起浪花。
  • 弩箭在简单重力下飞行,一旦高度低于所在位置的 waveH,就算落水。

弩的瞄准也遵循同样“简单、可预测”的思路。它先算出飞到目标所需的时间(距离 / 速度),按目标速度乘以这段时间的 80% 做提前量,再把瞄准点抬高 ½·g·t² 抵消下坠。这不是精确的弹道解,但在这个射程里,箭会落在你预期的地方。

用盒子捏船体,用平面做船帆

船体从一个细分过的 BoxGeometry 开始,逐个顶点弯成船的形状。沿船长方向,宽度按幂函数曲线收窄——船头收得急,船尾收得缓。一条“舷弧”曲线把两端抬起,让甲板在船头和船尾向上扬,船底也比顶部窄。甲板是一个更薄的盒子,用同一个函数弯曲,严丝合缝。

船帆是细分平面,按 sin(πx)·sin(πy)0.7 向外鼓起——一个边缘为零、中间最满的平滑鼓包。顶点着色器里一个小补丁随时间叠加两个正弦波,做出轻轻的飘动;桅顶的红旗用的是同一招的加快加强版,振幅朝旗子的自由端递增。所有贴图——船板、甲板、帆布、云、浪花、雨——都在启动时画进 canvas 里。

撑起风暴:天空、雾、雨、闪电与调色

海面之所以看起来风雨交加,全靠周围的这些东西:

  • 天空。three.js 的 Sky 对象,配上高浑浊度和低太阳角,得到一个朦胧阴沉的天穹。由它生成的 PMREM 环境贴图照亮了水面和船。
  • 雾。灰蓝色调的指数雾吞没了海平线,藏住了 520 单位海面平面的边缘,也让场景有一种被包围的感觉。
  • 雨。800 个点,使用条纹贴图和加法混合,以略有差异的速度下落并斜向一侧——每帧只更新一个类型化数组里的位置。
  • 云。精灵在距中心至少 130 单位的环上缓缓漂移,所以永远不会挡在镜头和船之间。
  • 闪电。一盏平时强度为零的方向光。偶尔它会在天空中的随机位置闪到满强度,趁还亮时可能再闪一下,然后指数衰减(flash *= 0.03dt)。闪电概率是逐帧掷骰的,所以高帧率下闪电会略微频繁一些——这个简化我们可以接受。
  • 一盏暖色提灯。船尾的一盏点光源,用两个正弦波叠加来闪烁。它是场景里唯一的暖光,给眼睛一个落脚的地方。
  • 后期处理。低曝光的 ACES 电影色调映射、一层柔和的泛光,再加一个自定义调色 pass:用 S 曲线拉对比度,把暗部染成钢蓝、高光略微偏暖,并用暗角压暗四周。

保持轻量

它跑在一个普通网页里,所以得守规矩:

  • 设备像素比上限 1.75——高 DPI 手机不需要按 3 倍渲染。
  • 帧时间上限 50 毫秒,标签页卡住后恢复时,船不会猛地一晃。
  • 弩箭、浪花和火花都来自固定对象池,游戏过程中不创建任何东西。
  • 场景滚出屏幕或标签页被隐藏时停止渲染;对偏好减少动态效果的访客,镜头的缓慢自动环绕会关闭。

要点回顾

  • 几个正弦波之和,就是一片完全够用的风格化海洋——只要方向、波长和速度选得各不相同。
  • 让高度函数成为唯一的事实来源。只要所有碰到水的东西都采样同一个函数,它们就会彼此一致。
  • 浮船不需要物理。采样船头、船尾、左舷、右舷,推导出升沉、纵摇和横摇,加上阻尼,再稍微缩小一点。
  • 用 1 − e−rate·dt 做与帧率无关的平滑。
  • 氛围占了一半功劳:雾、雨、一道闪光和一次调色,就能把一块蓝色平面变成一场风暴。

场景就在惊涛帆影——转动镜头、用鼠标瞄准,朝鲨鱼射几箭试试。如果你喜欢“有赌注”的大海,我们即将推出的生存游戏《逃出孤岛》的结尾,同样是一场在外海上的豪赌。