用three.js在Canvas上实现液态背景动画:Shader、噪声与性能调优 简介这是一份基于three.js与HTML5 Canvas实现液态抽象背景动画的实用前端资源适合对WebGL动态视觉效果感兴趣的网页设计师与前端开发者。资源采用粒子系统与渲染循环构建抽象流动画面可作为产品首页、活动专题页的动态背景方案进行改造复用。压缩包共4个文件包含2个JavaScript脚本、1个HTML页面和1个CSS样式表分别负责三维场景逻辑、页面结构与整体美化整体体积仅133KB轻量易部署。目前已有127人学习浏览适合作为学习three.js基础、Canvas渲染流程及前端动效实现的入门参考。通过阅读核心脚本并运行示例可迅速掌握场景初始化、相机设置、粒子创建与动画帧更新等关键步骤为后续开发更复杂的3D交互效果打下基础。1. 液态背景动画为什么用 three.js 渲染到 html5 canvas 而不是拿 2D API 硬画接到一个登录页改版需求甲方想要那种像熔岩、像波光、像墨滴在水里散开的流动背景。我第一反应是用原生 canvas 2D 画布加正弦波去模拟结果试了一晚上流体感始终出不来——要么像果冻要么像故障艺术完全不是“液态”。最后改回用 three.js 把渲染目标挂到一张 html5 canvas 上一个带噪声形变的平面网格就解决了。这里想先说一个反直觉结论这类“看起来是 2D 平面效果”的液态背景真正高效的实现路径恰恰是 3D 引擎——因为你要的不是“画”液体而是“算”液面。本文以一套典型的 three.js 液态抽象背景动画工程为例从最小骨架到 shader 细节再到踩坑记录讲清楚整条落地路径。适合被同样需求卡住的前端、可视化开发者和想拿 three.js 做交互背景的独立开发者。2. 最小框架先跑通three.js 如何挂到现有 canvas 上2.1 先选渲染器WebGLRenderer 与 canvas 的绑定方式标题里写明“基于 html5 canvas 绘制”这里要区分两件事three.js 自己创建的画布本身就是一个 HTMLCanvasElement你可以直接把它插进 DOM但很多业务项目里页面已经有一张 canvas 用于占位或叠层你想让 three.js 画进这张既有画布上就需要在创建 WebGLRenderer 时把 canvas 显式传进去。// main.js const canvas document.getElementById(liquid-canvas); // 页面里已存在的 canvas 元素 const renderer new THREE.WebGLRenderer({ canvas: canvas, // 关键把已有 canvas 交给 three.js alpha: true, // 背景透明方便透出页面底色 antialias: true, // 抗锯齿液态渐变边缘会更平滑 preserveDrawingBuffer: false }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));这段代码的逻辑是先拿到页面已有的 canvas 元素再把它作为 WebGLRenderer 的 canvas 参数传进去。这样 three.js 不会重新创建画布而是复用你指定的 DOM 节点符合“基于 html5 canvas 绘制”的字面诉求。alpha: true让画布背景透明液态动画可以作为遮罩或叠加层使用antialias会稍微拉低性能但对流体这种大面积渐变来说锯齿感非常影响观感建议打开。preserveDrawingBuffer不要开除非你要做实时截图否则它会持续拖慢渲染。2.2 写最小场景相机、场景、网格与渲染循环的骨架要让液态效果跑起来最少需要一个场景Scene、一个透视相机PerspectiveCamera、一个待形变的平面网格PlaneGeometry、一个承载液态材质的 Mesh以及一个持续渲染的 requestAnimationFrame 循环。相机放在 z 轴正方向上对准平面平面本身铺满画布视野。// main.js 续场景与渲染循环 const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(0, 0, 8); const geometry new THREE.PlaneGeometry(6, 6, 128, 128); // 分段数决定顶点密度 const material new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 } }, vertexShader: vertexShaderText, fragmentShader: fragmentShaderText, side: THREE.DoubleSide }); const mesh new THREE.Mesh(geometry, material); scene.add(mesh); function animate() { material.uniforms.uTime.value 0.02; // 每个动画帧累积时间 renderer.render(scene, camera); requestAnimationFrame(animate); } animate();这里把相机放得离平面足够远让整个网格正好覆盖视角。PlaneGeometry(6, 6, 128, 128)的 128×128 分段数是为了给顶点着色器足够的位移采样点——如果分段太少液面形变会出现明显的“折纸”感太多又会拉低帧率128 在绝大多数机型上是安全值。uTime是驱动整个动画的唯一外部变量每帧递增shader 里所有位移和颜色变化都依赖它。2.3 画布尺寸、像素比与动画循环的三个前置校验很多新手在这里翻车canvas 填满了屏幕但液态网格只占了左上角一小块。原因通常是 three.js 没有感知到画布的真实显示尺寸。需要让渲染器、相机和画布三者尺寸同步更新。function resize() { const w canvas.clientWidth || window.innerWidth; const h canvas.clientHeight || window.innerHeight; renderer.setSize(w, h); camera.aspect w / h; camera.updateProjectionMatrix(); } window.addEventListener(resize, resize); resize();逻辑说明先用 canvas 元素的 clientWidth/clientHeight 作为实际绘制尺寸避免拿 0 或全屏尺寸去设置画布接着调用 renderer.setSize 通知渲染器更新画布分辨率最后重算相机宽高比。setPixelRatio要在setSize之前调用否则高分辨率屏幕下画面会发虚。这个函数要放在 renderer 创建之后、第一次渲染之前执行否则第一帧就会以错误的尺寸绘制。3. 液态的核心在 shader顶点形变与颜色流动3.1 为什么是 ShaderMaterial 而不是 MeshPhongMaterial做液态背景最常见的误区是试图用多个叠加的 MeshPhongMaterial 或 MeshStandardMaterial 加贴图去模拟。这些材质是为“物理真实光照”设计的想做出流动的、无规律的液体感要么靠贴图序列帧要么靠一堆扭曲的几何体拼性能和可控性都差。我一般会用 ShaderMaterial 自己控制顶点和颜色。原因有三第一液态效果的“形变”本质是顶点位置的数学变化Shade 里写清 caveat。ShaderMaterial 自带顶点着色器和片元着色器可以在 GPU 上并行处理成千上万个顶点CPU 端不需要逐帧更新 BufferGeometry第二颜色渐变和流动完全由片元着色器按坐标和时间计算不需要任何贴图资源打包体积小第三交互比如鼠标影响液面只需把鼠标坐标传成 uniform响应直接作用于 shader 内的位移算法。3.2 顶点着色器把噪波变成液体起伏的位移算法液态形变的通用做法是在顶点着色器里读取顶点的原始位置叠加一个基于噪声函数的位移量再把新位置写回 gl_Position。位移的方向沿法线方向平面默认法线是 z 轴这样画面里的平面就会产生山丘一样的起伏经过光影和颜色的配合后看起来就像液面。// vertex shader uniform float uTime; varying vec2 vUv; varying float vElevation; // 简化版 3D 噪声函数完整版可换成 Ashima 的 simplex noise float noise(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453123); } void main() { vUv uv; vec3 pos position; // 用三个不同频率的噪声叠加形成更自然的波峰波谷 float n1 noise(vec3(pos.x * 1.5, pos.y * 1.5, uTime * 0.3)); float n2 noise(vec3(pos.x * 3.0, pos.y * 3.0, uTime * 0.6)); float elevation n1 * 0.4 n2 * 0.2; pos.z elevation; vElevation elevation; gl_Position projectionMatrix * modelViewMatrix * vec4(pos, 1.0); }这里的关键是把噪声函数作用于顶点的 x、y 坐标和时间三个维度得到每帧都不同的高度值。频率越高波峰越密集时间参数前的系数决定波动速度。position是 PlaneGeometry 自带的内置属性uv用来把坐标信息传给片元着色器。这套简化噪声用 sin/fract 模拟胜在代码量少、容易理解真要用在生产环境建议替换为真正的 simplex noise形态会更圆润不会出现明显的网格纹理。3.3 片元着色器让颜色随坐标和高度流动顶点着色器管“形”片元着色器管“色”。液态背景的视觉核心是颜色不能静止必须随平面坐标和时间变化同时最好和顶点高度联动——波峰偏亮、波谷偏暗这样立体感立刻出来。// fragment shader uniform float uTime; varying vec2 vUv; varying float vElevation; void main() { // 用 UV 加时间构造流动的色相变化 float r 0.5 0.5 * sin(vUv.x * 3.0 uTime * 0.8); float g 0.3 0.5 * cos(vUv.y * 2.5 uTime * 0.6); float b 0.6 0.3 * sin((vUv.x vUv.y) * 2.0 uTime * 0.4); // 混合高度信息让颜色有立体起伏 vec3 baseColor vec3(r, g, b); baseColor vElevation * 0.6; gl_FragColor vec4(baseColor, 1.0); }这段片元着色器的写法很直白将 UV 坐标和时间错开不同频率让各通道色值以不同节奏变化拼在一起就是流动的彩色液体。加上vElevation后波峰会自然变亮模拟光照效果。如果你想要单色液体比如青色渐变就把 r、g、b 计算公式换成统一色相附近的变化。参数uTime前的系数控制颜色流动速度和顶点形变速度建议错开避免“整体同步闪现”的机械感。4. 让液面“活”起来噪声选择、参数调优与实时调试4.1 为什么 fluid 场景下 Simplex 噪声是默认选择第 3 章代码里的简化噪声能用但做出的波纹棱角偏多形态上更像“电流”而不是“液体”。行业里做流体动画Shader 里几乎都用 Simplex 噪声由 Perlin 噪声改进而来。它的优势在于多维噪声计算量更低且没有明显的方形网格方向性波峰波谷的分布更圆润自然。我的习惯是直接在 shader 里内嵌一段 Ashima 出品的 simplex noise GLSL 实现开源且被 three.js 官方示例使用然后在顶点着色器里调用snoise(vec3(...))替代上面的简化噪声。它的输入是一个三维向量返回 -1 到 1 之间的浮点数负值会让顶点向下凹陷正值向上隆起比只用正值的简化版多了“波谷”的层次感。4.2 振幅、频率、速度三个参数怎么调才能像液体很多人拿到一段 shader 后乱改数字效果越改越糟。这里给出一个试错起点振幅控制在 0.3~0.8决定波峰高度太大会让平面穿帮边缘露馅太小像水面浮灰频率控制在 1.5~4.0频率越高波纹越密低频率更像大浪时间系数控制在 0.2~0.8决定流动快慢系数太大会让人看着头晕太小会觉得是静态图加滤镜。// 以这段噪声叠加为例 float n1 snoise(vec3(pos.x * 1.8, pos.y * 1.8, uTime * 0.35)); float n2 snoise(vec3(pos.x * 3.6, pos.y * 3.6, uTime * 0.55)); float n3 snoise(vec3(pos.x * 6.0, pos.y * 6.0, uTime * 0.75)); float elevation n1 * 0.5 n2 * 0.25 n3 * 0.125;这段代码是典型的八度噪声叠加octave noise低频率大振幅提供整体地势走向中高频率逐层递减叠加出液态表面那种“大波浪套小波纹”的复杂感。振幅按 2 的幂递减0.5 → 0.25 → 0.125是一个相对稳定的比例。若你的液面带钝感可以加大第一项振幅若画面看起来太碎把第二三项频率调低即可称之为“降噪”。4.3 用临时 GUI 面板快速试参数先把效果对了再固化成常量调参最忌改一行刷新一次。我一般会用 dat.GUI 这类轻量库在页面上挂一个临时面板把振幅、频率、速度全暴露成可拖拽的变量效果满意后再把数值固化到代码里。这只对 JavaScript 侧传进 uniform 的参数有效shader 内部的常量若想快速调需要把它们也声明为 uniform。// debug.js临时调参面板 import GUI from lil-gui; const gui new GUI(); const params { amp: 0.5, freq: 2.0, speed: 0.4 }; gui.add(params, amp, 0.1, 1.0, 0.01).onChange(v { material.uniforms.uAmp.value v; }); gui.add(params, freq, 1.0, 6.0, 0.1).onChange(v { material.uniforms.uFreq.value v; }); gui.add(params, speed, 0.1, 1.0, 0.01).onChange(v { material.uniforms.uSpeed.value v; });用 onChange 回调直接写入 ShaderMaterial 的 uniforms拖拽时画面实时更新不需要重建材质。调完顺手 GUI.destroy() 移除面板避免带到生产环境。这个流程帮我省掉了至少一小时的“刷新-改码-刷新”死循环。需要注意的是在动画循环里uTime是持续递增的而uSpeed应乘在时间系数上而不是直接改uTime的累加步长——否则流动速度会随时间越来越快。5. three.js 液态背景避坑黑屏、撕裂与性能排查5.1 画面全黑或纯白shader 编译错误是最常见的“黑匣子”现象页面打开后canvas 区域要么全黑、要么全白没有报错弹窗控制台只有一条类似“ERROR: 0:8: snoise : undeclared identifier”的信息。原因shader 代码有语法错误或变量未声明GPU 编译失败后材质无法通过渲染管线three.js 默认渲染成黑色。你可能没注意到的是WebGL 的错误信息只在控制台出现一次容易被忽略。解决先检查控制台最顶部的红色报错再用字符串拼接的方式把 shader 代码组装通过console.log打印出完整的 GLSL 代码然后逐个注释变量声明排查。一个很隐蔽的坑是在 GLSL 里把float写成了int或者vec3赋值时少写了一个分量这类错误在 CPU 端 JS 里完全不会出现。5.2 形变撕裂成“锯齿状折纸”分段数与顶点数的权衡现象波峰处出现明显的三角面棱线像低模地形而非平滑液面。原因PlaneGeometry 的分段数widthSegments、heightSegments太低顶点位移后的曲面没有足够顶点去拟合平滑曲线GPU 只能用三角形硬拼。我最早用 32×32 分段液态效果完全出不来“软”的感觉。解决把分段数提到 128×128个别场景甚至可以到 256×256但要配合性能观察256×256 意味着 6.5 万个顶点每帧都要执行一次顶点着色器低端机型可能吃不消。如果你的项目面向移动端更稳的做法是保持 128 分段同时在片元着色器里用 UV 坐标做“伪细节”——把噪声计算放在片元阶段视觉上平滑度提升顶点数不用增加。5.3 低帧率与“页面放着就卡”性能排查的顺序现象有人反馈“谷歌网页有 three.js 就卡卡的”不操作页面时 CPU 占用仍然很高。原因通常是三个点叠加渲染循环没有因页面后台而暂停canvas 的像素比跟随了设备像素比但没设上限3 倍屏直接拉满顶点数量本身过大。解决第一步在动画循环里加document.hidden判断页面切到后台时跳过渲染第二步setPixelRatio(Math.min(devicePixelRatio, 2))给分辨率上限第三步用状态管理变量控制流动效果的运行时开关。// performance.js后台暂停 像素比上限 document.addEventListener(visibilitychange, () { if (document.hidden) { cancelAnimationFrame(rafId); } else { animate(); } }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));逻辑说明visibilitychange 是浏览器原生事件切后台时取消下一帧渲染请求回到前台重新启动。这个处理能带来可感知的功耗下降特别是做常驻页面背景时非常关键。setPixelRatio上限设 2 而非 3是因为手机上 3 倍像素比的画布像素数是 2 倍时的 2.25 倍对 shader 类动效的填充率压力非常大而液体的模糊平滑特性使得高像素比收益极其有限。5.4 纹理或颜色不显示uniform 类型与初始化时机现象shader 里写了uniform sampler2D uTexture;也创建了纹理传给材质但画面就是白的。原因可能是三条——纹理还没加载完成材质就开始渲染uniform 变量的值类型写错比如把{ value: new THREE.Vector2(0.5, 0.5) }传给了 vec3或者纹理的 colorSpace 没设置。这条路最典型的体现形式是“three.js 贴图开始不显示”排查思路如下加载纹理时加回调回调里再给材质赋 uniform用 THREE.TextureLoader 的 load 回调而非直接在声明后赋值确认透明物体的渲染顺序材质侧transparent: true必须声明。// texture.js正确加载纹理到 shader const loader new THREE.TextureLoader(); const texture loader.load(liquid.png, () { material.uniforms.uTexture.value texture; material.needsUpdate true; }); texture.colorSpace THREE.SRGBColorSpace;这里先创建 loader然后调用 load。回调触发时贴图已经解码完成此时复制给 uniform 才不会出现白屏或黑贴图。needsUpdate true必须设否则材质不会重新编译 shader。这是新手经常漏掉的一步——JS 里对象改了值但 GPU 侧用的还是旧 uniform 缓存。6. 进阶技巧鼠标交互、颜色演化与前端框架里的封装习惯6.1 让液面跟随鼠标指针起伏液态背景的交互感是“高级感”的最大来源。实现方式是把鼠标归一化坐标传给 uniform在顶点着色器中让位移量与鼠标距离挂钩。// vertex shader 追加 uniform vec2 uMouse; uniform float uMouseStrength; // 在已有 elevation 基础上追加 float mouseDist distance(pos.xy, uMouse); pos.z smoothstep(0.0, 2.0, 1.0 - mouseDist) * uMouseStrength * 0.5;smoothstep让鼠标影响范围在半径 2 个单位内从强到弱平滑衰减避免出现生硬的圆形边界。配套的 JS 侧监听 mousemove把坐标换算到 -1~1 写入 uniform。6.2 颜色从固定渐变到随时间演化的色带想要颜色不是简单左右渐变而是像极光一样缓慢演化可以在片元着色器里增加第二个时间因子并把 RGB 计算改为三组不同频率的正弦波叠加。调色配方红绿蓝三通道分别用(0.5 0.5 * sin(...))压制到 0~1 区间再乘一个整体亮度系数饱和度不够就调高系数到 1.2 并 clamp 截断颜色会更有张力。6.3 在 Vue/React 里封装时容易踩的生命周期坑很多前端把这段效果接进框架后遇到“切路由白屏”“重复创建多个 canvas”“组件销毁后动画还在跑”等问题。我的习惯做法把渲染器、场景、动画循环封装成一个类提供start()/dispose()方法在组件挂载完成后调用 start在卸载前调用 dispose 取消动画帧模拟的 canvas 元素在模板里写好不要让它动态生成。这个思路在 Vue2、Vue3 和 React 里都通用。近几年做组件里 canvas 动效封装这是我最常用的一套模板。我做这类 three.js 背景动画的收尾习惯是先调鼠标交互再调颜色配比最后强制把帧率看一遍——Performance 面板里如果长时间低于 50fps我会主动降分段数而不是挤压用户设备性能。液态背景作为装饰层留给用户流畅感比炫技更重要。希望帮到你。本文还有配套的精品资源点击获取