新闻详情

新闻详情

首页 / 资讯中心 / 详情

Three.js海平面效果实战:Shader波浪、颜色分层与性能优化

发布时间:2026/9/18 4:09:19来源:尧图网络
Three.js海平面效果实战:Shader波浪、颜色分层与性能优化
做可视化项目这几年海平面效果算是我被问得最多的“水”类需求之一。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官方的核心库就可以了不用引额外插件。场景、相机、渲染器、光源四件套外加一个简单的轨道控制器方便观察。我习惯把相机放在稍微高一点的俯视角度比如y25这样既能看清波浪的起伏结构又能看到远方的海平线渐变效果。如果你主要做第一人称航海视角相机就贴近水面菲涅尔高光会被放大效果更震撼。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即使再完美也只是“一次性效果”把参数暴露出来让美术或者客户可以直接调才是能落地的工程方案。你按这套流程搭好基础后后面加船、加浪花、加水下焦散都只是在现有骨架上扩展。希望这篇实战记录能帮你少走几步弯路下次遇到“要个海面”的需求时心里能清楚知道从哪下手。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

变焦光学系统设计全解析:从原理到工程落地的关键技术与实战经验 2026/9/18 4:51:23

变焦光学系统设计全解析:从原理到工程落地的关键技术与实战经验

做光学设计这些年,凡是跟“变焦”沾边的项目,几乎没有一个是省心的。固定焦距的镜头设计,像差校正到一个状态就收工了,而变焦系统不一样——它要求你在整个变焦行程内,每个焦距段都要保持良好的像质,同时像…

阅读更多 →
background-agents多仓库自动化设计决策深潜:统一invocation模型的取舍 2026/9/18 4:51:23

background-agents多仓库自动化设计决策深潜:统一invocation模型的取舍

background-agents多仓库自动化设计决策深潜:统一invocation模型的取舍 【免费下载链接】background-agents An open-source background agents coding system 项目地址: https://gitcode.com/GitHub_Trending/ba/background-agents background-agents&#…

阅读更多 →
Civitai 单仓库改造全指南:基于 pnpm Workspaces 的基础设施包抽取实践 2026/9/18 4:51:23

Civitai 单仓库改造全指南:基于 pnpm Workspaces 的基础设施包抽取实践

Civitai 单仓库改造全指南:基于 pnpm Workspaces 的基础设施包抽取实践 【免费下载链接】civitai A repository of models, textual inversions, and more 项目地址: https://gitcode.com/GitHub_Trending/ci/civitai 本文以仓库中的 monorepo-conversion-pla…

阅读更多 →
长沙火王燃气灶维修电话|反复熄火故障排查|欧米到家服务电话 2026/9/18 4:51:23

长沙火王燃气灶维修电话|反复熄火故障排查|欧米到家服务电话

文章简介长沙家庭日常做饭频率高,燃气灶长期处于油烟、水汽、调料残留和高温环境中,容易出现打不着火、点火后松手熄火、火苗小、火焰发黄发红、燃烧不均匀、点火一直哒哒响、旋钮拧不动、灶头漏气异味、玻璃面板破损、熄火保护失效等问题。燃气灶故障与…

阅读更多 →
晶圆厂的“神经中枢”之争:2026年半导体MES软件五大厂商深度解读 2026/9/18 4:51:23

晶圆厂的“神经中枢”之争:2026年半导体MES软件五大厂商深度解读

MES,即制造执行系统(Manufacturing Execution System),是连接企业上层计划管理系统与底层设备控制系统之间的核心中间层。在半导体制造领域,MES并非单独存在,而是作为CIM(计算机集成制造&#x…

阅读更多 →
大模型System Prompt泄露风险与四层防御实战 2026/9/18 4:48:23

大模型System Prompt泄露风险与四层防御实战

1. 这不是“提示词泄露”,而是模型交互链路上的系统性暴露风险最近在多个技术社区和内部复盘会上,频繁看到“system_prompts_leaks”这个短语被当作一个独立术语使用——它既不是某个开源项目名,也不是某家厂商的专有功能,而是一个…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞