ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Three.js顶点扰动实现真实感海面波动动画

2026/9/14 8:16:59 拓冰建站 浏览量
Three.js顶点扰动实现真实感海面波动动画 简介利用HTML5与Three.js构建逼真三维海平面波动动画的源码包主要面向前端开发者和Three.js爱好者帮助读者掌握在浏览器中创建三维场景、设置相机与光源、使用几何体与材质、并驱动动画循环的完整思路适合作为入门三维图形开发的练手项目。压缩包内共4个文件包含两个JavaScript脚本、一个HTML页面和一张水面贴图整体大小仅244KB结构简明清晰既方便直接运行也便于对照学习源码。动画通过正弦波函数实时更新平面网格顶点位置模拟海水连绵起伏配合水面贴图与平行光照可以呈现类似真实海面的反光与质感结合轨道控制器还能自由旋转、缩放视角从不同距离观察浪花形态与光照变化交互反馈非常直观。代码完整演示了场景初始化、渲染循环、事件交互和资源加载等关键环节同时保留了清晰的文件划分与引用关系适合在此基础上扩展出更复杂的流体或海面模拟效果。目前已有87人学习获得初步认可。1. 真实感海面动画关键不是贴图而是顶点扰动HTML5 Three.js 做三维海平面波动动画很多人以为这是拿现成的水面贴图在骗眼睛真正让一个平面网格动起来的是每一帧对顶点坐标的反复重算。这个源码包很小只有 index.html、three.min.js、OrbitControls.js 和一张 water.png 贴图但把 Three.js 的几何体变形、材质光照、动画循环和 GPU 渲染串成了一条完整链路。适合拿来做数字孪生场景背景、Web 3D 可视化首页也适合刚看完 three.js 教程想亲自动手处理变形几何体的人。搞懂这套海面后面换 HTML5 实现旗帜飘动、沙丘起伏、布料模拟只是换扰动公式的问题。2. 从几何体到海面Three.js 场景搭建与材质选型一个“像海”的场景前提不是贴图分辨率而是物体本身是一个足够精细的网格。Three.js 里所有能产生光影反射的物体都是几何体加材质海面也不例外。先把场景、相机、渲染器搭好再决定平面分段数和材质类型。2.1 场景、相机、渲染器的最小骨架Three.js 在 HTML5 里依赖canvas输出画面但开发者通常不和 canvas 直接交互而是把它交给WebGLRenderer。最常见的骨架如下!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title三维海面波动动画/title stylebody{margin:0;overflow:hidden}canvas{display:block}/style /head body script srcjs/three.min.js/script script srcjs/OrbitControls.js/script script const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 1000); camera.position.set(0, 6, 12); camera.lookAt(0, 0, 0); const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(innerWidth, innerHeight); document.body.appendChild(renderer.domElement); /script /body /html这段代码定义了透视相机参数60 度视野、宽高比来自浏览器窗口、近裁面 0.1、远裁面 1000。相机位置在 Y 轴 6、Z 轴 12正好斜俯视海面方便看到波浪的高度差。antialias: true会让边缘锯齿更少移动端如果帧率低可以关掉换成antialias: false。渲染器会把canvas自动挂到body上后续每一帧动画都通过这个渲染器输出。2.2 为什么用 PlaneGeometry 而不是直接贴图如果只是把水纹图片贴到一个矩形上旋转视角时会发现“水面”是平的永远没有起伏。原因是贴图只能改变颜色不能改变顶点高度。真正能承载波浪的必须是一个有足够顶点的几何体。const geometry new THREE.PlaneGeometry(20, 20, 128, 128); geometry.rotateX(-Math.PI / 2);PlaneGeometry第 3、4 个参数是宽、高方向的分段数。这里 128×128 一共 16641 个顶点每个顶点在动画循环里都有独立的 Y 坐标波浪才能平滑。rotateX(-Math.PI/2)把默认水平放置的平面转成从 Z 轴方向延伸这样在 XOZ 平面里使用 X、Z 顶点计算高度 Y更符合“海平面”的空间习惯。如果分段只有 8×8波浪会呈锯齿状越是小看越影响最终效果。2.3 材质选型MeshPhongMaterial 与水面质感材质决定顶点高度之外的所有视觉信息。下表是三种常用材质在这个场景里的差异材质是否受光照镜面高光适合海面的程度MeshBasicMaterial否无只有颜色像色块不推荐MeshLambertMaterial是无能看到明暗但水面的波光出不来MeshPhongMaterial是有能产生镜面高光最接近水面质感源码包里的 water.png 作为颜色贴图配合 Phong 材质可以同时得到纹理颜色和高光。我的做法是把贴图重复平铺让 20 单位宽的海面上出现多个波光点const texture new THREE.TextureLoader().load(images/water.png); texture.wrapS texture.wrapT THREE.RepeatWrapping; texture.repeat.set(4, 4); const material new THREE.MeshPhongMaterial({ map: texture, shininess: 100, specular: 0x444466, transparent: true, opacity: 0.9, side: THREE.DoubleSide });wrapS和wrapT设为RepeatWrapping后贴图可以重复铺设否则只能拉伸铺满整个网格波纹会被拉得很夸张。shininess控制高光集中程度值越大高光点越小specular用灰蓝色模拟水面反射天空的颜色transparent开启后opacity控制整体透明度90% 透明度可以让水下的地形若隐若现。side使用DoubleSide是为了防止相机转到海面下方时看到背面消失。2.4 光源与观察者视点没有光源时 Phong 材质的镜面高光不生效海面看起来是暗的。这里我一般加两个光源环境光提亮暗部平行光扮演太阳scene.add(new THREE.AmbientLight(0x404060)); const sun new THREE.DirectionalLight(0xffffff, 1.2); sun.position.set(5, 10, 5); scene.add(sun);平行光的方向由position决定从 (5,10,5) 照向原点会在波浪的向阳面产生亮区、背阳面留下阴影。环境光强度不要超过 0x606060否则波浪的明暗对比被冲淡层次感会变弱。相机放在斜上方还有一个额外好处波浪的横向运动和纵向起伏会同时叠加在屏幕空间里观众不用旋转视角也能感知纵深。3. 波浪生成的核心顶点坐标的数学扰动海面动画不是真的每帧生成新几何体而是复用同一个PlaneGeometry只修改顶点位置。顶点位置在 BufferGeometry 里是一个扁平数组每 3 个数值一组代表 x、y、z循环里只要改完数组再通知 Three.js 更新。3.1 从正弦波到真实海浪为什么不只用一个 sin最简单的波浪公式是y A * Math.sin(x * freq time)但这只会形成一排排对齐的波峰像梳子不像海。真实海面的特征是多个方向的波叠加、波长不同、相位也不整齐。用三角函数叠加是成本最低的做法function updateWaves(time) { const positions geometry.attributes.position.array; for (let i 0; i positions.length; i 3) { const x positions[i]; const z positions[i 2]; const y Math.sin(x * 0.5 time) * 0.3 Math.cos(z * 0.4 time * 1.2) * 0.2 Math.sin((x z) * 0.8 time * 2.0) * 0.1; positions[i 1] y; } geometry.attributes.position.needsUpdate true; geometry.computeVertexNormals(); }这段代码从数组里读取每个顶点的 x 和 z用三个正弦/余弦项求 Y。第一项是沿 x 方向传播的主浪振幅 0.3第二项是沿 z 方向传播的次级浪振幅 0.2且速度是主浪的 1.2 倍第三项沿斜向传播频率最高、振幅最小作用是打碎整齐的波峰让海面有细碎的反光。positions[i 1]就是每个顶点的 Y 坐标修改完后必须把needsUpdate置为 true否则缓冲区不会重新上传到 GPU。3.2 在 requestAnimationFrame 中更新 geometryThree.js 官网现在推荐用setAnimationLoop但源码包时代更常用requestAnimationFrame两者对性能的影响差别不大。关键在于时间参数不能用帧计数要用真实经过的时间const clock new THREE.Clock(); function animate() { requestAnimationFrame(animate); const elapsed clock.getElapsedTime(); updateWaves(elapsed); renderer.render(scene, camera); } animate();getElapsedTime()返回从Clock创建到当前帧的秒数它根据performance.now()计算不受浏览器标签页休眠影响。如果直接在每次动画里time 0.01显示器频率不同会导致波浪速度不同所以clock是所有动画类 Three.js 项目都应该保留的一个基础对象。3.3 源码中“像海”的参数是怎么来的想让波浪看起来自然频率和振幅要遵循一个比例关系。经验值是低频大振幅负责大浪高频小振幅负责波纹。下表给出了参考范围参数作用建议值过度设置的表现振幅波浪高度0.20.5超过 1.0 会顶穿相机像悬崖频率波峰间距0.31.0超过 2.0 会高频闪烁像振铃传播速度波速1.02.5太快像水烧开太慢像果冻叠加项数复杂度35 项超过 8 项 CPU 压力明显上升调整时可以只保留第一项 sin等波形方向符合预期后再逐项加。多数项目把振幅控制 0.3 左右、频率 0.5 左右就能通过视觉审查如果做的是俯视的二维半地图可以把振幅降到 0.1让波浪不至于遮挡业务元素。我一般会把振幅、频率和速度抽成全局变量放在updateWaves外面方便在真机上用浏览器调试器实时拖改不用每次刷新页面。3.4 法线重算与光照质感computeVertexNormals()必须放在顶点坐标修改之后。它根据相邻三角形的面法线重新计算每个顶点的法线Phong 材质的光照计算依赖法线方向。不调用的话法线保持初始的垂直向上波浪再高光照也不会跟着产生明暗变化。注意它逐帧调用有一定性能成本但如果不在updateWaves里调用看效果几乎全是平的。另外一个细节PlaneGeometry旋转后原方法计算的顶点数量不变computeVertexNormals仍然有效不需要手动干预。4. 源码包运行与 OrbitControls从解压到调通拿到 zip 后不是双击 index.html 就万事大吉。TextureLoader 在file://协议下会被浏览器拦截跨域请求所以第一步是起一个本地静态服务。4.1 目录结构与本地服务器解压后建议保持这样的结构项目目录/ ├─ index.html ├─ images/ │ └─ water.png └─ js/ ├─ OrbitControls.js └─ three.min.js在项目目录执行cd 项目目录 python3 -m http.server 8080然后用浏览器访问http://localhost:8080。Windows 没有 python3 的机器可以用python -m http.server 8080或者装 Node 后执行npx serve .。启动本地服务后控制台的跨域报错会消失贴图也能正常加载。如果看到页面加载但网格是白的先看控制台是否爆了 404多半是 js 或 images 目录路径不对。4.2 OrbitControls 接入与参数限制OrbitControls 是 Three.js 示例目录里的经典控制器它把鼠标拖拽、滚轮缩放到相机上。接入时只需要传入相机和渲染器对应的 canvasconst controls new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping true; controls.dampingFactor 0.05; controls.minDistance 4; controls.maxDistance 30; controls.maxPolarAngle Math.PI / 2.1; controls.target.set(0, 0, 0); controls.update();enableDamping打开后视角移动会有惯性海面这类长时间观看的场景手感更顺dampingFactor控制减速快慢0.05 是常见的“不太滑也不生硬”的值。maxPolarAngle限制镜头不能绕到海平面以下避免看到PlaneGeometry的背面和下面的空白。每次修改controls.target后都要调用一次controls.update()否则视角可能跳回原点。4.3 常见问题白屏、卡顿、波浪不动以下是我在实际运行时最容易遇到的三类问题症状原因处理方式页面白屏three.min.js 路径错误脚本加载失败打开控制台看 Network修正 script 标签的src波浪不动动画循环里忘了设needsUpdate在positions修改后加geometry.attributes.position.needsUpdate true拖动视角卡顿平面分段数过高如 512×512降到 128×128移动端降到 64×64再用法线贴图补细节海面闪烁透明材质绘制顺序冲突去掉transparent: true或把depthWrite设为false卡顿的另一个隐藏原因是开启了阴影渲染。海面波动属于变形几何体每帧阴影贴图都要重算对 GPU 压力极大。不是必须就不要给海面开castShadow/receiveShadow。4.4 把相机参数和几何体匹配几何体是 20×20 单位相机初始位置如果放在 (0, 1, 3)视图里几乎看不到海面。我一般在场景搭建阶段先用camera.position.set(0, 8, 16)再根据controls.minDistance和maxDistance调整视角范围。如果改成 50×50 的大平面相机距离要和几何体同比例放大例如(0, 16, 28)距离大约等于几何体对角线长度的一半。判断标准是海面边缘在视图中略微超出画面四角这样拖拽到极限时不会突然看到海面的边界。5. 进阶让海面更像真海——法线扰动、雾与多波叠加基础的 sin 叠加已经能动但波峰形状还是对称的圆弧。要更像海浪可以把波的横向偏移和高度一起改也就是 Gerstner 波再配合雾效把视野远处融掉。Gerstner 的核心思想是顶点不仅上下动还朝波浪传播方向微微偏移。实现时在updateWaves里追加以下逻辑const dirX 0.6, dirZ 0.8, steepness 0.2, amplitude 0.4; const phase x * dirX z * dirZ time * 2.0; const wx Math.cos(phase); const wz Math.sin(phase); positions[i] dirX * steepness * amplitude * wx; positions[i 2] dirZ * steepness * amplitude * wz; positions[i 1] amplitude * Math.sin(phase);steepness控制顶点向波峰汇集的程度0.150.25 期间波峰会变尖超过 0.3 顶点会自相交出现尖刺。方向向量(dirX, dirZ)需要归一化否则不同方向的波速度会不一致。Gerstner 波之所以被游戏行业大量使用是因为它把能量集中在波峰波谷变得平缓符合液体表面张力对波形的影响所以眼睛会看到波峰尖、波谷宽的形态。法线方面如果不想每帧调用computeVertexNormals可以在材质上叠一张小尺寸法线贴图用material.normalScale调节强度。缺点是法线贴图只影响表面细节大波浪的明暗仍然要依赖几何法线所以两者通常一起用。最后是雾。海面和天空相接的地方是最容易穿帮的位置加一层指数雾可以把海平线藏掉scene.fog new THREE.FogExp2(0x87a9c8, 0.03);FogExp2的第二个参数是密度0.03 表示距离大约 30 单位外开始明显泛白。把雾色和页面背景色设成同一个值拖拽视角时不会有接缝。若希望只在远处起雾改用THREE.Fog用near/far指定起止距离。你可以把相机切到侧视图观察单个波峰从出现到消失是否有一侧陡一侧缓如果两边对称说明steepness还不够如果出现顶点穿透按比例降低steepness。把雾的密度调到 0.04 以上海平线会完全被吞到背景里。本文还有配套的精品资源点击获取