ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用three.js在Canvas上实现液态背景动画:Shader、噪声与性能调优

用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,我会主动降分段数而不是挤压用户设备性能。液态背景作为装饰层,留给用户流畅感比炫技更重要。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进