Three.js海平面效果实战:Shader波浪、颜色分层与性能优化
2026/9/18 4:09:13 网站建设 项目流程

做可视化项目这几年,海平面效果算是我被问得最多的“水”类需求之一。Three.js里的水面实现网上教程不少,但多数是套一个Shadertoy的现成Shader,效果是好看,一放进自己的项目就出问题——要么和场景风格不搭,要么帧率垮掉,更常见的是一改光照方向水面就废了。这篇文章我想从零到一把Three.js海平面效果的完整实现拆开讲清楚,从顶点波动到高光反馈,再到法线扰动和颜色分层,每一步都说明白为什么要这么做,以及我踩过哪些坑。

我会用一段实用性很强的Shader方案,既能跑出海浪的波浪推进感,又能让颜色自然地往天际线渐变,算是在“真实感”和“性能”之间比较平衡的一种写法。全程基于Three.js官方库,不需要额外插件,你只要懂一点GLSL基础就能跟上。无论你是要做数字孪生的水域场景、大屏可视化里的海洋背景,还是游戏里的海面,这套方案都足够你改一改直接落地。

1. 整体实现思路与设计拆解

1.1 为什么不用贴图动画,而选择Shader

很多刚接触Three.js的小伙伴,第一次做海面会想:我找一张海洋贴图,做UV滚动,或者直接放一个视频当纹理,是不是就行了?确实能出效果,尤其从远处看,静态海面加一点UV偏移看起来还挺像回事。但问题在于:一旦摄像机拉近,或者你需要海面和风场、船只交互,贴图方案就露馅了——水面是平的,浪和浪之间没有真实的高度差,光的反射完全是假的。

用Shader写海面,本质上是把水面当成一张“动态的数学曲面”:每一个顶点的位置、颜色、透明度、高光强度,都通过GPUShader实时计算。顶点Shader负责让网格产生几何起伏,片元Shader负责上色和光照反馈。你不需要任何动画贴图,只需要让时间变量持续递增,波浪就会自己推着走。

我个人的习惯是:优先用Shader方案,哪怕需求只是“看起来差不多”的静态预览。因为Shader方案在扩展性上是碾压级的——从简单正弦波到Gerster波再到FFT海洋模拟,都是在这套骨架上加算法,后续产品提需求你不需要推翻重写。

1.2 海平面效果的三个核心层次

把“海平面效果”这个宽泛的需求拆开,我个人习惯拆成三层:

第一层是几何层。水面必须有波浪起伏,这个由顶点Shader负责。要让浪看起来不是机械地上下抖,而是有方向地推进,通常的做法是叠加多个不同频率、不同方向的正弦波在一起,形成“多个波系叠加”的视觉效果。

第二层是颜色层。海水的颜色不是单一蓝色,而是从脚下的深色渐变到天际线附近的浅色。太阳光照方向、观察角度都会影响水色的感知。片元Shader里要根据高度、视角方向做颜色混合,这部分处理好了,海面才会有“通透感”而不是一块蓝布。

第三层是光照层。海水表面会有太阳的高光点,而且这个高光会随着观察角度变化而移动(即菲涅尔效应)。这个效果做出来,海水才真正“活”了,从俯视和从水平视角看过去,高光的形态完全不同。

只要把这三层逐步叠加到一个Shader材质上,海平面效果的核心就算完成了。

1.3 技术选型:Three.js版本与兼容性注意事项

我代码里用的是Three.js r150以上的版本。这个版本范围里,ShaderMaterial的写法几乎没有破坏性变化,网上能找到的材质示例大多也兼容。需要注意一点:r150之前和之后,THREE.PlaneGeometry的顶点组织方式都是按行扫描的,网格细分宽度和高度必须合理规划,否则波浪会有一截一截的接缝感。

另外,建议开启相机的logarithmicDepthBuffer选项。海面往往是远视距场景,默认的深度缓冲在远距离下很容易出现闪烁叠面,开启对数深度缓冲可以避免海平线和天空之间的接缝闪烁问题。代价是精度略微下降,但对于海面这种大尺度平面非常值得。

2. 海面Shader核心参数与实现细节

2.1 顶点Shader:多波系叠加的波浪算法

波浪的核心算法其实不复杂,核心思路在顶点Shader里叠加多个正弦波。每一个波系由四个参数控制:方向、波长、振幅、速度。方向是二维向量,决定波向哪个方向推;波长决定波峰间距;振幅决定波的高度;速度决定波推进的快慢。

我把顶点Shader分成两个函数:一个叫waveHeight,用来算某个顶点在指定波系下的高度;另一个是主函数里的循环叠加。多个波系叠加之后,海面会出现相互干涉的复杂浪花,而不是一排整齐的“琴键波”,真实感会明显提升。

uniform float uTime; uniform vec2 uWaveDirection1; uniform vec2 uWaveDirection2; float waveHeight(vec2 pos, float amplitude, float wavelength, float speed, vec2 direction) { float frequency = 2.0 * 3.1415926 / wavelength; float phase = speed * frequency * uTime; float dotProduct = dot(direction, pos); return amplitude * sin(dotProduct * frequency + phase); } void main() { vec3 pos = position; float h = 0.0; h += waveHeight(pos.xz, 0.3, 8.0, 1.2, uWaveDirection1); h += waveHeight(pos.xz, 0.2, 5.0, 1.8, uWaveDirection2); h += waveHeight(pos.xz, 0.1, 3.0, 2.5, normalize(uWaveDirection1 + uWaveDirection2)); pos.y += h; gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); }

这里三元叠加的好处是:既有大波浪的整体推进感,又有小波浪的不规则碎浪感。如果你觉得浪太规则,还可以在波系数量上继续加,一般来说三个波系已经能获得不错的视觉效果。再往上加,视觉提升有限,但GPU开销直线上升。

提示:uTime这个uniform变量记得在动画循环里更新。如果忘记更新,海面会静止不动,这是新手最常见的“海面不动”问题来源。

2.2 片元Shader:三层颜色体系与法线扰动

顶点Shader解决了“形状”问题,片元Shader解决“质感”问题。质感的关键是颜色不能死板,法线不能是平的。

颜色分层我用了三层结构:第一层是深水层,一般位于地形最低处或俯视视角下,颜色取深蓝色;第二层是浅水层,颜色偏青蓝;第三层是天光反射层,靠近天际线的地方混入天空颜色。这三层之间通过两个因素混合:顶点的实际高度和视角的方向。高度越高颜色越浅,视角越贴近水平面颜色越偏天空色。

法线扰动方面,我不打算在顶点Shader里真的输出法线——因为正弦波的解析法线其实不好算。更实用的做法是在片元Shader里用邻域像素高度差去近似法线方向,也就是所谓的derivative-based normal。思路是:取当前点附近两个极小偏移量的点,分别算出高度差,再归一化成法线向量。这样法线会随着波浪起伏自然改变方向,光照高光的形状也会跟着波浪走,效果非常自然。

uniform vec3 uDeepColor; uniform vec3 uShallowColor; uniform vec3 uSkyColor; uniform vec3 uLightDir; uniform float uTime; varying vec3 vWorldPosition; varying float vHeight; vec3 getNormal(vec2 uv) { float eps = 0.1; float hL = waveHeight(vWorldPosition.xz - vec2(eps, 0.0) + vec2(uTime * 0.1), 0.3, 8.0, 1.2, vec2(1.0, 0.3)); float hR = waveHeight(vWorldPosition.xz + vec2(eps, 0.0) + vec2(uTime * 0.1), 0.3, 8.0, 1.2, vec2(1.0, 0.3)); float hD = waveHeight(vWorldPosition.xz - vec2(0.0, eps) + vec2(uTime * 0.1), 0.3, 8.0, 1.2, vec2(1.0, 0.3)); float hU = waveHeight(vWorldPosition.xz + vec2(0.0, eps) + vec2(uTime * 0.1), 0.3, 8.0, 1.2, vec2(1.0, 0.3)); vec3 normal = normalize(vec3(hL - hR, 2.0 * eps, hD - hU)); return normal; } void main() { vec3 normal = getNormal(vWorldPosition.xz); vec3 viewDir = normalize(cameraPosition - vWorldPosition); vec3 lightDir = normalize(uLightDir); float diff = max(dot(normal, lightDir), 0.0); float fresnel = pow(1.0 - max(dot(viewDir, normal), 0.0), 3.0); vec3 baseColor = mix(uDeepColor, uShallowColor, diff); vec3 finalColor = mix(baseColor, uSkyColor, fresnel * 0.8); gl_FragColor = vec4(finalColor, 0.95); }

这套写法有一段要注意的地方:getNormal中的waveHeight函数必须在片元Shader里也声明一遍。GLSL不允许在片元Shader里直接调用顶点Shader的函数,你需要把函数体复制过来。因为片元Shader逐像素执行,调用次数会比较多,所以这里的高频噪声算法一定要精简,不然Fragment阶段会成为性能瓶颈。

2.3 颜色参数的经验取值与视觉调优

颜色参数看起来很主观,但有几个经验基准值可以参考。深水颜色我常用#03396c#035b8c这种偏暗的蓝,浅水颜色用#37a6d4#68c8f0这类带一点青色的蓝,天空反射层可以直接取场景环境的淡蓝色#aedbf5

这里有个容易被忽略的点:海面颜色不是独立的,它和整个场景的渲染风格强相关。如果你是白天晴空场景,天空色的浅蓝和海水色接不上,远看海平线就会有一条很明显的“色差带”。解决方案是让Shader从场景的环境贴图取色,或者手动把uSkyColor调成和你场景scene.background接近的颜色。

菲涅尔强度的弱化系数,我在fresnel * 0.8这行用了0.8,这是适合多数室内/半室内场景的值。如果你场景里太阳光特别强,可以调到1.2让高光更亮;如果是阴天风格,建议降到0.6以下,否则水面会反射得太“刺眼”。

2.4 性能优化:网格精度与Shader复杂度的平衡

网格精度是海面性能的最大变量。PlaneGeometry(1000, 1000, 128, 128)这种128x128的细分可能在普通场景下没问题,但如果你的场景里还有大量建筑模型、粒子系统,这个网格量就可能成为瓶颈。

我的建议是:把网格细分控制在64x64以内,把波浪细节“骗”到片元Shader里去做。片元Shader的高度扰动法线实战里已经够了,顶点Shader只负责大尺度的波浪起伏。这是业界做海洋的通用策略——大波浪靠顶点,小碎浪靠法线贴图扰动,而不是全都堆在网格上。

另外,海面材质建议设置material.side = THREE.DoubleSide。单面渲染在从海面下方观察时会出现透明穿帮,尤其在做水下摄影机或者船体交互动画时,双面渲染省很多烦恼。代价是渲染面数翻倍,但结合64x64的网格完全可接受。

3. 从零构建海平面场景的完整实操流程

3.1 场景基础搭建与相机设置

先做基础场景搭建。这一步用Three.js官方的核心库就可以了,不用引额外插件。场景、相机、渲染器、光源四件套,外加一个简单的轨道控制器方便观察。

我习惯把相机放在稍微高一点的俯视角度(比如y=25),这样既能看清波浪的起伏结构,又能看到远方的海平线渐变效果。如果你主要做第一人称航海视角,相机就贴近水面,菲涅尔高光会被放大,效果更震撼。

import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; const scene = new THREE.Scene(); scene.background = new THREE.Color(0xaedbf5); const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 10000); camera.position.set(50, 25, 50); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = false; document.body.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0, 0); controls.maxPolarAngle = Math.PI * 0.48; controls.autoRotate = false;

这里有两个细节值得说明:renderer.shadowMap.enabled我建议关闭,海面本身不参与阴影计算,开阴影会白白消耗性能;controls.maxPolarAngle限制到接近水平面但不越过,防止用户把相机翻到海平面下方,穿透到天空,破坏沉浸感。

3.2 创建海面网格与ShaderMaterial

接下来是核心:创建网格对象,绑定ShaderMaterial。这一步的关键在于把二维波浪方向向量暴露成uniform,方便后续调试。

const waterGeometry = new THREE.PlaneGeometry(2000, 2000, 128, 128); waterGeometry.rotateX(-Math.PI / 2); const waterMaterial = new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 }, uWaveDirection1: { value: new THREE.Vector2(1.0, 0.3) }, uWaveDirection2: { value: new THREE.Vector2(-0.5, 0.8) }, uDeepColor: { value: new THREE.Color(0x03396c) }, uShallowColor: { value: new THREE.Color(0x37a6d4) }, uSkyColor: { value: new THREE.Color(0xaedbf5) }, uLightDir: { value: new THREE.Vector3(0.5, 0.8, 0.3).normalize() } }, vertexShader: vertexShader, fragmentShader: fragmentShader, transparent: true, side: THREE.DoubleSide }); const waterMesh = new THREE.Mesh(waterGeometry, waterMaterial); waterMesh.position.y = 0; scene.add(waterMesh);

创建海面网格时要特别注意rotateX(-Math.PI / 2)这一步。PlaneGeometry默认是在XY平面上的,海面需要躺平在XZ平面上。如果不旋转,海面会像一堵墙立在你面前。转完之后,顶点的y坐标就是海面的高度方向,Shader里对position.y赋值刚好对应波浪起伏。

3.3 时间变量驱动与动画循环

动画循环里只需要做一件事:更新uTime,然后重新渲染。时间变量本质上是一个持续递增的秒数,Shader里的波浪函数会用它计算相位变化,从而让波浪推进。

const clock = new THREE.Clock(); function animate() { requestAnimationFrame(animate); const elapsed = clock.getElapsedTime(); waterMaterial.uniforms.uTime.value = elapsed; controls.update(); renderer.render(scene, camera); } animate();

一个容易被忽略的细节是:用clock.getElapsedTime()而不是自己写累加计数器。getElapsedTime返回从时钟启动到当前的总秒数,如果用累加加法,帧率波动会导致波浪速度忽快忽慢,出现“卡顿感”。

3.4 uniforms调试面板:快速调整波浪与颜色参数

调试阶段我强烈建议引入lil-gui或者dat.GUI做一个轻量参数面板。海面效果的调试变量非常多:波向、波幅、颜色、光向,如果每调一个参数就改代码刷新页面,效率极低。

import { GUI } from 'three/addons/libs/lil-gui.module.min.js'; const gui = new GUI(); const waterUniforms = waterMaterial.uniforms; const folder = gui.addFolder('Water Parameters'); folder.add(waterUniforms.uWaveDirection1.value, 'x', -1, 1, 0.01).name('Wave1 Dir X'); folder.add(waterUniforms.uWaveDirection1.value, 'y', -1, 1, 0.01).name('Wave1 Dir Y'); folder.addColor(waterUniforms.uDeepColor, 'value').name('Deep Color'); folder.addColor(waterUniforms.uShallowColor, 'value').name('Shallow Color'); folder.open();

参数面板的价值不在于炫技,而是让你能直观理解“每个参数控制什么”。比如你拖动Wave1 Dir的x方向从0到1,能明显看到浪的方向从正北转向正东。调着调着,你对Shader参数的理解就上来了,后面做风格化项目根本不需要查文档。

4. 常见问题与排查技巧实录

4.1 海面静止不动,怎么回事

最常见的海面不动问题,九成是uTime没有在动画循环里更新。你检查代码的时候,确认一下有没有把waterMaterial.uniforms.uTime.value = elapsedTime这行放进requestAnimationFrame的回调里。

另一种可能是:着色器编译报错,整块海面全黑,但场景里别的物体正常。这种时候打开浏览器控制台,查找WebGL相关的红色报错。GLSL编译报错偶尔会给出具体行号,但有的时候只报ERROR: 0:12这样的提示,比较难定位。我的经验是:先把片元着色器里最复杂的那段getNormal函数注释掉,替换成固定法线vec3(0,1,0),看海面是否恢复。能恢复就说明是法线计算部分出了问题,再逐步排查。

4.2 海面颜色发灰发假,像塑料

颜色发灰通常是光照计算里diff值过低导致的。当太阳光和法线方向垂直时,dot(normal, lightDir)趋近于0,整个海面会暗成一片灰蓝色。解决办法有两个:一是把uLightDir往水平方向偏一点,让大部分波浪面都能受光;二是在diff上叠加一个环境光常数,比如diff = max(dot(normal, lightDir), 0.0) * 0.8 + 0.2,保证海面不会全黑。

海面颜色太“塑料”则和菲涅尔强度有关。菲涅尔项算出来的值如果太高,海面会像涂了一层清漆,反光过亮。我一般控制在0.6到1.0之间,具体看场景光照强度。

4.3 海平线闪烁或者接缝明显

海平线闪烁主要发生在相机距离非常远的时候。除了前面提到的logarithmicDepthBuffer以外,你还可以把海面网格做大一点,比如2000x2000,不要让它露出边界。如果海面边缘还是能看到“截断”的痕迹,可以在场景边缘加一圈雾效,把海面逐渐融入天空色,视觉上是平滑过渡的。

4.4 性能优化:帧率突然掉到个位数

帧率暴跌的元凶通常有三个:一是网格细分太高,比如256x256就是六万多个顶点,逐顶点做三级正弦波叠加,CPU和GPU都有压力;二是片元Shader太复杂,尤其getNormal里我做了4次waveHeight计算,如果你再加法线贴图采样和高光计算,像素填充率就成了瓶颈;三是开启了抗锯齿又叠加了全屏后期效果。

优化顺序我强烈建议:先降网格细分到64x64,再看Shader里能不能省掉几个waveHeight调用,最后考虑把高光计算从逐像素改成逐顶点插值。这三板斧下来,帧率通常能翻一倍以上。

注意:如果项目里还有大量其他动态物体,建议把海面的renderOrder设为-1,让海面最先渲染。半透明物体如果渲染顺序不对,会出现透过海面看不到水下物体的“穿帮”现象。

5. 进一步优化:把海面做成可交互的场景元素

5.1 叠加泡沫效果与浪尖白沫

海面效果做到前文这个程度,作为大屏可视化背景已经够用了。但如果你想让画面更出彩,可以叠加“浪尖白沫”效果。核心做法是在片元Shader里根据高度阈值判断:高度超过某个阈值时,混合一层白色进去;超过越多,白沫越明显。

float foam = smoothstep(0.8, 1.2, vHeight); vec3 foamColor = vec3(1.0, 1.0, 1.0); finalColor = mix(finalColor, foamColor, foam * 0.6);

阈值和混合强度需要反复调。白沫太多像倒了一桶牛奶,太少又看不到。我的经验值是阈值0.8、强度0.6,适合大多数卡通和半写实风格。写实风格可以拿0.4以下。

5.2 与船体、浮标的交互:按动态高度浮动

当海面上需要放置船、浮标或者其他漂浮物时,问题就从“渲染”升级到了“交互”:物体的高度必须跟随波浪起伏。这里有个简洁的思路:让漂浮物体的位置每帧从顶点Shader的waveHeight函数中采样对应位置的高度,然后赋值给物体的y坐标。

不过,GLSL里的waveHeight是GPU端的函数,JavaScript端无法直接调用。常见解法是把相同的波浪算法用JavaScript复制一份,每帧对物体的x、z坐标计算高度,再用object.position.y同步。因为网格是64x64,这个计算成本非常低。

function getHeightAt(x, z, time) { let h = 0; h += 0.3 * Math.sin(x * 0.785 + time * 9.6); h += 0.2 * Math.sin(z * 1.256 + time * 9.0); h += 0.1 * Math.sin((x + z) * 2.094 + time * 7.5); return h; }

注意:JS端复制的波浪参数必须和GLSL端完全一致,包括振幅、频率、相位偏移。如果参数不一致,船就会“悬空”或“埋进”海面里,非常出戏。这里没有捷径,就是把一份参数维护在两个文件里。

5.3 从海面特写到天空盒的整场景氛围统一

最后提一个容易被忽视的点:海面再好,如果天空盒和场景氛围不统一,整体效果依旧会“假”。Three.js的scene.background如果只设置纯色,远看海平线附近会显得很“平”。

我通常在scene.background里挂一个简单的渐变纹理:上方是天空蓝,下方是接近海平线的暖白色或淡紫色。海面Shader里的uSkyColor要和这个渐变的下方颜色一致。这样海面和天空在视觉上是“咬合”的,海平线的过渡几乎看不出来。

这个思路同样适用于黄昏、夜晚场景。把uDeepColor调成深蓝黑、uSkyColor调成橙紫色,海面会立刻切换到日落风格。本质上就是三个颜色uniform的联动调整,非常灵活。

做海面效果这几年,我最大的体会是:Shader效果好不好,很大程度上取决于调试工具和参数可调性。一个封死参数的Shader,即使再完美也只是“一次性效果”;把参数暴露出来,让美术或者客户可以直接调,才是能落地的工程方案。你按这套流程搭好基础后,后面加船、加浪花、加水下焦散,都只是在现有骨架上扩展。希望这篇实战记录能帮你少走几步弯路,下次遇到“要个海面”的需求时,心里能清楚知道从哪下手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询