☰
three.js液态抽象背景动画:从Canvas到可交互着色器实战
2026/10/26 7:55:52 网站建设 项目流程

简介:这份资源是一套基于three.js与HTML5 Canvas实现的液态抽象背景动画特效源码,面向具备一定JavaScript基础、希望为网页增添动态视觉效果的开发者与前端学习者。包内共4个文件,包含2个js脚本(three.min.js库文件与主逻辑脚本)、1个html入口页面和1个css样式文件,压缩包约133KB,结构精简,便于直接运行与二次修改。代码演示了场景、相机、渲染器的初始化,以及粒子系统与流体动画纹理的构建思路,并借助requestAnimationFrame驱动渲染循环,模拟液态流动的抽象效果。读者可从中掌握three.js三维场景搭建、粒子动画更新与Canvas自适应布局的完整实现路径,也可将其作为动态背景组件嵌入个人项目。目前已有127人学习下载,适合作为WebGL入门与视觉特效练习的实用参考。

1. 液态抽象背景动画:从一张静态 Canvas 到可交互的 three.js 场景

很多前端开发者第一次看到「液态抽象背景」这类效果,第一反应是打开 Canvas 2D 用requestAnimationFrame画一堆贝塞尔曲线,再叠几层径向渐变。能跑,但一旦要求「随鼠标流动」「颜色随滚动渐变」「分辨率自适应不糊」,2D 方案就开始翻车——因为你要手动处理每一帧的像素混合、模糊半径和坐标映射,代码量会迅速膨胀到难以维护。而 three.js 基于 WebGL 的渲染管线,天然适合做这类需要大量顶点扰动和片元级颜色混合的动画。这个标题里的「three.js + html5 canvas + 液态抽象背景动画特效」,本质上就是:用 three.js 在 canvas 上创建一个全屏平面,通过自定义着色器让平面上的像素产生类似流体扰动的视觉效果,再叠加时间与鼠标交互,形成抽象背景。它适合做官网首屏、产品落地页、数据大屏底图,也适合想从 2D Canvas 过渡到 WebGL 着色器的开发者练手。接下来我会按「最小可跑 → 参数调优 → 避坑 → 进阶」的顺序,把这条路径完整走一遍。

2. 用 three.js 搭出液态背景的最小可跑骨架

2.1 为什么选 ShaderMaterial 而不是直接操作 Canvas 2D

Canvas 2D 做液态效果,常见做法是用globalCompositeOperation叠加多个模糊圆形,再靠filter: blur()做柔化。问题在于:模糊是 CPU 侧或合成器侧的操作,分辨率一高就掉帧;而且你很难让「液态」有真正的流动感——2D 的渐变是静态的,动起来只能靠位移,缺少顶点级别的扰动。three.js 的ShaderMaterial允许你在片元着色器里对每个像素做噪声采样,用simplex noise或fbm(分形布朗运动)生成连续的扰动场,再映射到颜色上。这样得到的液态感是像素级的,分辨率无关,且性能主要压在 GPU 上。选型上,如果你只是要一个静态渐变,CSSradial-gradient就够了;如果要动态、可交互、不糊,ShaderMaterial是更稳的路线。

2.2 最小场景:一个全屏平面 + 时间 uniform

先不急着写复杂噪声,把 three.js 场景、相机、渲染器和一个覆盖全屏的平面搭起来,让片元着色器输出一个随时间变化的颜色。这一步的目的是验证渲染管线通了,后面再往里塞噪声函数。

// 引入 three.js(假设已通过 npm 或 CDN 引入) import * as THREE from 'three'; // 1. 创建场景和透视相机 const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera( 75, // 视场角 window.innerWidth / window.innerHeight, // 宽高比 0.1, // 近裁剪面 1000 // 远裁剪面 ); camera.position.z = 1; // 平面放在 z=0,相机后退一点 // 2. 渲染器绑定到页面上的 canvas const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 限制像素比,防止移动端过载 document.body.appendChild(renderer.domElement); // 3. 创建一个覆盖全屏的平面几何体 const geometry = new THREE.PlaneGeometry(2, 2); // 宽高为 2,配合相机刚好铺满 // 4. 自定义着色器材质 const material = new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 }, // 时间,驱动动画 uResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) } }, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; // 把 UV 传给片元着色器 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` precision highp float; varying vec2 vUv; uniform float uTime; uniform vec2 uResolution; void main() { // 用 UV 和时间做一个简单的颜色变化,先验证管线 vec3 color = vec3( 0.5 + 0.5 * sin(uTime + vUv.x * 3.0), 0.5 + 0.5 * sin(uTime + vUv.y * 3.0 + 1.0), 0.5 + 0.5 * sin(uTime + vUv.x * 2.0 + vUv.y * 2.0) ); gl_FragColor = vec4(color, 1.0); } ` }); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh); // 5. 动画循环 const clock = new THREE.Clock(); function animate() { requestAnimationFrame(animate); material.uniforms.uTime.value = clock.getElapsedTime(); renderer.render(scene, camera); } animate(); // 6. 窗口尺寸变化时更新 window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); material.uniforms.uResolution.value.set(window.innerWidth, window.innerHeight); });

这段代码的逻辑说明:PlaneGeometry(2, 2)配合camera.position.z = 1和默认视场角,平面刚好铺满视口,这是全屏背景的常见做法。uTime每帧更新,片元着色器里用sin函数生成随时间变化的 RGB 通道,虽然还不是液态,但能确认着色器编译通过、uniform 传递正常。参数上,setPixelRatio限制到 2 是为了防止高 DPI 屏幕上像素过多导致帧率下降,移动端尤其明显。uResolution暂时没用到,但后面做鼠标交互和噪声缩放时会用到,先传进去。

2.3 把噪声函数塞进片元着色器:液态感的来源

真正的液态感来自连续的噪声场。常见做法是用 2D simplex noise 或 value noise,对 UV 坐标加上时间偏移,得到流动的扰动值,再把扰动映射到颜色或位移上。下面是一个精简的 2D noise 实现,直接内联在片元着色器里,避免额外依赖。

// 在 fragmentShader 里加入以下函数 // 2D 随机函数 float random(vec2 st) { return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123); } // 2D 噪声:基于四个角随机值做双线性插值 float noise(vec2 st) { vec2 i = floor(st); vec2 f = fract(st); float a = random(i); float b = random(i + vec2(1.0, 0.0)); float c = random(i + vec2(0.0, 1.0)); float d = random(i + vec2(1.0, 1.0)); vec2 u = f * f * (3.0 - 2.0 * f); // 平滑插值 return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } // 分形布朗运动:叠加多层噪声,产生更丰富的细节 float fbm(vec2 st) { float value = 0.0; float amplitude = 0.5; float frequency = 1.0; for (int i = 0; i < 5; i++) { value += amplitude * noise(st * frequency); st *= 2.0; amplitude *= 0.5; } return value; }

然后在main里这样用:

void main() { // 让 UV 随时间缓慢偏移,产生流动感 vec2 uv = vUv * 3.0; uv.x += uTime * 0.1; uv.y += uTime * 0.05; // 用 fbm 生成扰动值 float n = fbm(uv); // 再叠一层不同频率的噪声,增加层次 float n2 = fbm(uv * 2.0 + n); // 把噪声映射到颜色,形成液态渐变 vec3 colorA = vec3(0.1, 0.2, 0.6); // 深蓝 vec3 colorB = vec3(0.8, 0.3, 0.5); // 紫红 vec3 colorC = vec3(0.2, 0.7, 0.8); // 青 vec3 color = mix(colorA, colorB, n); color = mix(color, colorC, n2 * 0.6); gl_FragColor = vec4(color, 1.0); }

逻辑说明:fbm叠加了 5 层噪声,每层频率翻倍、振幅减半,这是生成自然纹理的标准做法。uv.x += uTime * 0.1让噪声场横向缓慢移动,模拟流体。两层 fbm 嵌套(n2的输入里带了n)能让颜色过渡更有机,避免机械感。参数上,vUv * 3.0控制噪声的缩放,数值越大纹理越细;uTime的系数控制流动速度,0.1 是偏慢的,适合背景,想要更活跃可以调到 0.3。颜色混合的比例n2 * 0.6是经验值,让第三色不至于盖过前两色。

3. 让液态背景随鼠标和滚动动起来:交互与参数调优

3.1 鼠标位置如何映射到着色器 uniform

静态流动看久了会腻,加上鼠标交互才有「液态被搅动」的感觉。思路是把鼠标的归一化坐标(-1 到 1)传给片元着色器,在噪声采样时根据鼠标位置偏移 UV,或者用鼠标距离影响颜色亮度。下面是在原有代码上增加鼠标 uniform 的改法。

// 在 uniforms 里增加鼠标位置 uniforms: { uTime: { value: 0 }, uResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) }, uMouse: { value: new THREE.Vector2(0.5, 0.5) } // 默认居中 } // 监听鼠标移动,转换为 0-1 范围 window.addEventListener('mousemove', (e) => { const x = e.clientX / window.innerWidth; const y = 1.0 - e.clientY / window.innerHeight; // 翻转 Y 轴,匹配 UV 方向 material.uniforms.uMouse.value.set(x, y); });

片元着色器里,用鼠标位置影响噪声的采样中心:

uniform vec2 uMouse; void main() { // 计算当前像素到鼠标的距离 float dist = distance(vUv, uMouse); // 鼠标附近增强扰动 vec2 uv = vUv * 3.0; uv.x += uTime * 0.1; uv.y += uTime * 0.05; uv += (vUv - uMouse) * dist * 0.5; // 距离越远偏移越大,形成涟漪感 float n = fbm(uv); // ... 后续颜色混合不变 }

参数说明:dist * 0.5里的 0.5 是扰动强度,调大鼠标影响范围更广,调小则只有鼠标正下方有反应。(vUv - uMouse)的方向决定了扰动是向外扩散还是向内收缩,可以取反试试效果。注意鼠标坐标要翻转 Y 轴,因为 WebGL 的 UV 原点在左下角,而浏览器事件的原点在左上角,不翻转会导致上下颠倒。

3.2 滚动驱动颜色渐变:把 scrollY 映射到色相偏移

很多落地页希望背景随滚动从冷色过渡到暖色。做法是把window.scrollY归一化后传给着色器,用来偏移颜色混合的权重或直接做 HSL 色相旋转。

// 增加 uniform uScroll: { value: 0 } // 监听滚动 window.addEventListener('scroll', () => { const maxScroll = document.body.scrollHeight - window.innerHeight; const scrollRatio = maxScroll > 0 ? window.scrollY / maxScroll : 0; material.uniforms.uScroll.value = scrollRatio; // 0 到 1 });

片元着色器里,用uScroll调整颜色:

uniform float uScroll; void main() { // ... 前面的噪声计算不变 // 根据滚动比例偏移色相 vec3 colorA = mix(vec3(0.1, 0.2, 0.6), vec3(0.6, 0.1, 0.2), uScroll); vec3 colorB = mix(vec3(0.8, 0.3, 0.5), vec3(0.9, 0.6, 0.1), uScroll); vec3 colorC = mix(vec3(0.2, 0.7, 0.8), vec3(0.8, 0.4, 0.1), uScroll); vec3 color = mix(colorA, colorB, n); color = mix(color, colorC, n2 * 0.6); gl_FragColor = vec4(color, 1.0); }

这样滚动到页面底部时,背景从蓝紫过渡到红橙。参数上,mix的第三个参数uScroll直接控制过渡进度,如果想做非线性过渡,可以先对uScroll做smoothstep或pow处理。注意滚动事件触发频繁,实际项目中建议用requestAnimationFrame节流,或者直接在动画循环里读取window.scrollY,避免事件堆积。

3.3 性能与画质的平衡:分辨率、噪声层数、像素比

液态背景最容易翻车的地方是性能。片元着色器里每像素跑 5 层 fbm,在 4K 屏上就是几百万次噪声计算,帧率会掉到 30 以下。几个可调参数需要权衡:

参数作用建议值性能影响
setPixelRatio渲染分辨率倍数1.5 或 2线性影响像素数
fbm 层数噪声细节丰富度3 到 5每层增加一次噪声采样
噪声缩放vUv * N纹理粗细2 到 4不影响性能,影响观感
平面分段数顶点数量1x1 足够片元着色器不依赖顶点密度

我一般会先把setPixelRatio限制到 1.5,fbm 层数降到 3,在移动端再降。如果还是卡,可以把整个场景渲染到一个低分辨率的WebGLRenderTarget上,再用 CSS 放大,牺牲一点锐度换帧率。另外,PlaneGeometry的分段数保持 1x1 就行,因为所有效果都在片元着色器里,顶点多了反而浪费。

4. 液态背景动画的避坑与排查清单

4.1 现象:页面一片黑,控制台报着色器编译错误

原因通常是 GLSL 语法写错,比如float写成vec2、少分号、用了 WebGL1 不支持的函数。three.js 默认用 WebGL2,但如果你手动指定了WebGL1Renderer,一些内置变量会不同。解决:打开控制台看具体报错行号,逐行核对;在片元着色器开头加precision highp float;,避免移动端精度问题;用renderer.debug.checkShaderErrors = true(默认开启)让错误更明确。

4.2 现象:动画在本地正常,部署后不动

常见原因是uTime没有更新,或者requestAnimationFrame被页面可见性 API 暂停。检查动画循环里是否每帧都设置了material.uniforms.uTime.value。另外,如果页面在 iframe 里且被隐藏,requestAnimationFrame会暂停,这是浏览器行为,不是 bug。解决:确认动画循环在运行,必要时用document.visibilityState做恢复处理。

4.3 现象:鼠标交互方向反了或范围不对

WebGL 的 UV 原点在左下角,浏览器鼠标事件原点在左上角,不翻转 Y 轴就会上下颠倒。解决:const y = 1.0 - e.clientY / window.innerHeight;。如果鼠标影响范围太小,调大dist的系数;如果鼠标移动时背景跳变,检查是否在mousemove里直接改了 uniform 而没有做平滑,可以用lerp做插值。

4.4 现象:移动端帧率低、发热严重

移动端 GPU 对片元着色器的复杂度很敏感。5 层 fbm 在手机上可能只有 20fps。解决:根据window.devicePixelRatio和屏幕宽度动态降低 fbm 层数,或者用#ifdef宏在移动端编译不同版本。另外,setPixelRatio在移动端建议设为 1,不要用 2。

4.5 现象:背景颜色和设计稿对不上

着色器里的颜色是线性空间还是 sRGB 空间,取决于 three.js 的outputEncoding设置。如果设计稿是 sRGB,而渲染器输出线性,颜色会偏暗。解决:设置renderer.outputEncoding = THREE.sRGBEncoding;,并在着色器里用pow(color, vec3(2.2))做手动转换,或者直接用THREE.Color的convertSRGBToLinear()预处理颜色值。

5. 进阶:用离屏渲染和噪声纹理把液态背景做成可复用组件

5.1 把噪声预计算到纹理,减少片元着色器负担

如果 fbm 层数降不下来,又想要丰富细节,可以把噪声预计算到一张DataTexture里,片元着色器只做采样和混合。这样每像素只查一次纹理,性能提升明显。做法是用 JavaScript 生成一张 256x256 的噪声图,传给ShaderMaterial作为uniform sampler2D。

// 生成噪声纹理 const size = 256; const data = new Uint8Array(size * size * 4); for (let i = 0; i < size * size; i++) { const x = i % size; const y = Math.floor(i / size); // 这里用简单的 value noise,实际可换成 simplex const value = Math.random() * 255; data[i * 4] = value; data[i * 4 + 1] = value; data[i * 4 + 2] = value; data[i * 4 + 3] = 255; } const noiseTexture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat); noiseTexture.needsUpdate = true; // 在 uniforms 里加入 uNoise: { value: noiseTexture }

片元着色器里用texture2D(uNoise, uv).r替代fbm,再叠加时间偏移。注意纹理要设置wrapS和wrapT为RepeatWrapping,否则 UV 超出 0-1 会 clamp。

5.2 用离屏渲染做后期模糊,增强液态的「黏稠感」

液态抽象背景如果边缘太锐利,会显得像噪点而不是流体。可以在渲染完主场景后,再做一个高斯模糊后期。three.js 的EffectComposer配合RenderPass和ShaderPass可以做到,但会增加一次全屏渲染。如果不想引入额外依赖,也可以把主场景渲染到WebGLRenderTarget,再用一个全屏 quad 做模糊采样。

// 创建离屏渲染目标 const renderTarget = new THREE.WebGLRenderTarget( window.innerWidth, window.innerHeight, { minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter } ); // 渲染到目标 renderer.setRenderTarget(renderTarget); renderer.render(scene, camera); renderer.setRenderTarget(null); // 再用一个全屏平面做模糊(简化版:多次采样取平均) // 这里省略具体模糊着色器,思路是采样周围像素做加权平均

模糊的代价是性能,建议只在桌面端开启,移动端直接输出原图。模糊半径和采样次数需要根据分辨率调整,一般 9 次采样(3x3 核)就够。

5.3 封装成可配置的组件:参数外置与销毁逻辑

最后一步是把整个方案封装成一个类或函数,暴露colors、speed、noiseScale、mouseInfluence等参数,方便在不同页面复用。同时要注意销毁逻辑:取消requestAnimationFrame、移除事件监听、调用renderer.dispose()和geometry.dispose(),否则在单页应用里切换路由会内存泄漏。

class LiquidBackground { constructor(container, options = {}) { this.options = Object.assign({ colors: [[0.1, 0.2, 0.6], [0.8, 0.3, 0.5], [0.2, 0.7, 0.8]], speed: 0.1, noiseScale: 3.0, mouseInfluence: 0.5 }, options); // ... 初始化场景、相机、渲染器、材质 this.animate = this.animate.bind(this); this.animate(); } animate() { this.rafId = requestAnimationFrame(this.animate); // 更新 uniform 并渲染 } dispose() { cancelAnimationFrame(this.rafId); window.removeEventListener('mousemove', this.onMouseMove); window.removeEventListener('resize', this.onResize); this.renderer.dispose(); this.geometry.dispose(); this.material.dispose(); } }

我自己的习惯是:任何超过 50 行的着色器效果,都先写成独立组件,参数全部外置,销毁逻辑在写第一行代码时就加上。这样后面不管是在 Vue、React 还是原生页面里用,都不会因为忘记清理而翻车。液态背景这类效果,视觉上很抓人,但性能边界和兼容性坑也不少,先把最小可跑跑通,再逐层加噪声、加交互、加后期,每一步都验证帧率,比一次性堆完再调要省时间。希望帮到你。

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

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

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

立即咨询