Three.js与GLSL着色器动画实战:从零打造全屏动态背景
先交代个背景。之前有段时间接了个品牌展示页的私活甲方要求首页来一块全屏的动态背景要那种“流动的、有质感的、不能太像PPT”的效果。我一开始试了CSS动画渐变根本刷不出层次感又试了Canvas 2D粒子一多就直接拉胯。折腾一晚上最后把心一横开始啃three.js和GLSL着色器算是彻底打开了新世界的大门。这篇文章就把我从零开始做“GLSL着色器动画”的完整过程捋一遍。里面不只有能直接跑的代码还有我为什么这么写的思考过程以及踩过的坑。如果你是想用three.js做点视觉特效、动态背景、或者纯粹想入门着色器编程的这篇文章应该能帮你省下不少弯路。1. 项目整体思路与架构设计1.1 为什么要用着色器做动画传统Web动画从最早的jQueryanimate到CSS3的transition和keyframes再到Canvas 2D逐帧绘制本质都是在“操作CPU能理解的数据”——修改DOM样式、更新Canvas画笔坐标、变换CSS属性。这种方式在元素不多、动画逻辑简单的时候完全够用但一旦你面对的是全屏像素级特效大面积流动波纹、几百个粒子、复杂的扭曲变形CPU就会因为频繁的布局计算和绘制调用被拖垮表现出来就是掉帧、卡顿、GPU占用率上不去。着色器Shader的思路则完全不同。它直接把计算任务扔给GPU让GPU上成百上千个并行计算单元同时处理每一个像素点。三个关键优势是处理的是像素级别画面细腻程度远高于DOM和Canvas 2D并行架构天然适合大量重复计算面积再大也不怕动画逻辑通过数学函数描述写一次可以在任意分辨率下保持一致性。打个比方如果你的动画是在画一幅十万只蚂蚁组成的图案用DOM和Canvas的方式是拿一支笔一只一只去画而着色器方式是给十万只蚂蚁每人发一张图纸让它们同时去画速度完全不是一个量级。1.2 three.js与GLSL的关系在我真正动手之前我也被这俩概念搞得有点懵我到底是在学three.js还是在学GLSL后来理解了。three.js是JavaScript侧的3D框架负责帮你管理场景、相机、灯光、模型还有最重要的——把CPU端的JS数据和GPU端的着色器程序连接起来。而GLSLOpenGL Shading Language是运行在GPU上的着色器语言它长得像C语言但它不是CPU代码它是一段描述“每个顶点往哪儿走、每个像素是什么颜色”的程序。在这个项目里我采用的分工是three.js负责搭建3D场景创建网格和相机GLSL负责视觉表现包括颜色过渡和扭曲变形的算法three.js的ShaderMaterial作为两者之间的桥把时间、鼠标位置这些动态数据作为uniform变量从JS侧传给GLSL。1.3 ShaderMaterial vs 其他材质three.js自带一堆材质MeshBasicMaterial、MeshStandardMaterial、MeshPhongMaterial……这些封好了光照、阴影、贴图等常用功能用起来很方便。但它们的缺点是视觉效果基本被锁定想定制化就得黑盒里做文章。而ShaderMaterial是彻底放开的你把顶点着色器和片元着色器的源码写出来GPU就按你的代码跑所有画面你来控制。RawShaderMaterial比ShaderMaterial更底层它连内置变量都不帮你声明必须全部手动写。正常开发中ShaderMaterial是最常选择的——three.js会自动帮你注入一些常用属性和变量声明省得自己处理繁琐的attribute和varying。我这个项目用的就是ShaderMaterial。从新手角度理解这件事如果你想做一道菜自带的材质是预包装好的料理包热一下就能吃ShaderMaterial是你自己从买菜、洗菜、切菜到炒菜全流程掌控。做动画特效基本都得走全流程这条路。2. 环境搭建与基础着色器语法2.1 开发环境准备不搞复杂的构建工具Webpack/Vite都行最轻量的方式是用CDN加载three.js。我用的是script typeimportmap方式引入模块。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleThree.js GLSL Shader 动画/title style * { margin: 0; padding: 0; overflow: hidden; } body { background: #000; } canvas { display: block; } /style /head body script typeimportmap { imports: { three: https://unpkg.com/three0.160.0/build/three.module.js } } /script script typemodule import * as THREE from three; // 后续代码写在这里 /script /body /html我用的three.js版本是0.160.0这算是一个相对稳定且API文档齐全的版本。如果你在本地用npm装npm install three0.160.0效果一样。2.2 最小可运行场景搭建搭建一个最基础的three.js场景只需要四步创建渲染器、创建场景、创建相机、把物体放进场景然后渲染。下面这段代码是我项目的起步骨架// 1. 创建渲染器 const renderer new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 防止高分屏过度采样 document.body.appendChild(renderer.domElement); // 2. 创建场景 const scene new THREE.Scene(); // 3. 创建相机 const camera new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(0, 0, 5); camera.lookAt(0, 0, 0); // 4. 创建几何体 const geometry new THREE.PlaneGeometry(4, 4); // 一个平面2D着色器动画最常用 const material new THREE.ShaderMaterial({ uniforms: {}, vertexShader: ..., fragmentShader: ... }); const mesh new THREE.Mesh(geometry, material); scene.add(mesh); // 5. 动画循环 function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate(); // 6. 窗口自适应 window.addEventListener(resize, () { camera.aspect window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });这段代码跑起来你屏幕上会出现一个白色正方形用了默认的着色器布局。看到这个白色方块说明环境OK接下来就可以开始写着色器了。2.3 GLSL的关键语法速览GLSL语法和C语言非常像如果你写过C或者Java上手基本没有障碍。但有几个特别重要的点需要牢记uniform从CPU传给GPU的全局变量每个顶点/像素都能读但不能修改。适合存时间、鼠标位置、分辨率这类全局参数。varying从顶点着色器传给片元着色器的变量它会做插值计算。比如你想让颜色从左到右渐变顶点着色器把每个顶点的位置信息传下去片元着色器收到的就是插值后的中间值。attribute每个顶点的属性比如位置、法线、UV坐标。在顶点着色器中使用。gl_FragColor片元着色器的输出变量决定当前像素最终的颜色。精度限定符precision highp float;声明浮点数精度写片元着色器时通常需要。这是GLSL的“三件套”理解attribute → 从几何体来每顶点 uniform → 从JS来全局共享 varying → 顶点着色器传给片元着色器插值3. 第一个GLSL着色器动画颜色脉冲3.1 写一个最基础的片元着色器现在开始写真正的着色器代码。先从一个最简单的效果开始全屏颜色随时间脉动。在ShaderMaterial里vertexShader和fragmentShader是两段字符串。顶点着色器负责处理顶点位置片元着色器负责处理像素颜色。const material new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 } }, vertexShader: varying vec2 vUv; void main() { vUv uv; gl_Position projectionMatrix * modelViewMatrix * vec4(position, 1.0); } , fragmentShader: uniform float uTime; varying vec2 vUv; void main() { // 基于时间正弦变化 float r 0.5 0.5 * sin(uTime); float g 0.5 0.5 * sin(uTime 2.0); float b 0.5 0.5 * sin(uTime 4.0); gl_FragColor vec4(r, g, b, 1.0); } });这段的数学逻辑是sin()函数的取值范围是[-1, 1]通过0.5 0.5 * sin()把它映射到[0, 1]范围正好对应颜色通道的取值范围。三个通道的相位差使得颜色不是单调变化而是RGB周期性轮转。3.2 uniform在动画中的作用上面代码里uniforms字段定义了uTime但它的value始终是0动画不会动。需要在JS的动画帧循环里更新它// 在 animate 函数中加上 material.uniforms.uTime.value 0.02; // 每秒约增加 1.2这样GLSL里的uTime会随着每一帧变化sin(uTime)就会产生周期性的颜色变化。具体选择0.02这个值是因为它恰好能让动画在60fps下每秒推进约1.2个单位视觉效果比较舒适不会太慢显得卡顿也不会太快让人眼花。当然你可以自己调我调过各种速度最终发现0.01~0.05之间的值最适合做流动感。3.3 从颜色到形状UV坐标的运用颜色会变化了但整块平面还是纯色没有空间感。这时候要引入UV坐标。UV坐标是贴图纹理的坐标系统范围是[0, 1]它告诉你当前像素在物体表面的位置左下角是(0, 0)右上角是(1, 1)。有了UV坐标就可以让不同位置的像素显示不同颜色形成视觉纹理。改造一下片元着色器varying vec2 vUv; void main() { // 用 UV.x 和 UV.y 构造颜色渐变 vec3 color vec3(vUv.x, vUv.y, 1.0 - vUv.x - vUv.y); gl_FragColor vec4(color, 1.0); }这会得到一个线性渐变图案左下角偏黑色往右下角偏红往左上角偏绿右上角偏蓝。这个阶段你可能觉得不好看但它是后续一切复杂效果的基础。4. 让着色器“动”起来时间驱动的视觉变化4.1 基于UV的波形动画静态的渐变没有灵魂加上时间变量后把UV坐标本身变成动态的立刻就会产生流动效果。以下是我项目中非常喜欢的一个效果横向流动的彩色波纹。片元着色器代码uniform float uTime; varying vec2 vUv; void main() { // 让 UV.x 随时间偏移 vec2 uv vUv; uv.x sin(uv.y * 8.0 uTime) * 0.05; // 基于偏移后的 UV 生成颜色 float wave sin(uv.x * 20.0 uTime * 2.0); vec3 color vec3( 0.5 0.5 * wave * uv.y, 0.5 0.5 * wave * (1.0 - uv.y), 0.5 0.5 * sin(uv.x * 10.0 uTime) ); gl_FragColor vec4(color, 1.0); }这里uv.x sin(uv.y * 8.0 uTime) * 0.05;是关键它在水平方向对UV坐标做了一个正弦扰动且扰动量方向与uv.y相关。简单理解就是水面上如果有一道横向波浪那么波浪位置的水纹会发生左右扭曲——这就是你看到的流动波纹效果的核心原理。4.2 用fract函数实现重复图案smoothstep函数是实现边缘过渡的利器fract函数则能制造无数重复的图案。比如我想做一个“移动的网格线”就可以用fract把UV坐标切割成多个小格子vec2 uv vUv; vec2 grid fract(uv * 10.0); // 把 UV 分成 10x10 的网格 float gridLine smoothstep(0.9, 1.0, max(grid.x, grid.y)); vec3 color vec3(gridLine);这会产生网格线效果。如果想要移动效果在取fract之前让UV坐标随时间偏移即可vec2 uv vUv uTime * 0.1; vec2 grid fract(uv * 10.0);这样网格就会向斜方向匀速移动。4.3 组合效果辉光脉冲圆环把前面学到的技巧整合起来做一个“辉光脉冲圆环”动画。这个效果有两种实现方式一种是用距离公式计算到圆心的距离另一种是用UV直接做极坐标变换。我用距离方式uniform float uTime; varying vec2 vUv; void main() { vec2 center vec2(0.5); // 屏幕中心 float dist distance(vUv, center); // 圆环距离 0.3 附近为亮其他地方为暗 float ring 1.0 - smoothstep(0.0, 0.05, abs(dist - 0.3)); // 脉冲半径随时间膨胀 float pulseRadius 0.2 0.15 * sin(uTime * 2.0); float pulse 1.0 - smoothstep(0.0, 0.02, abs(dist - pulseRadius)); // 辉光越靠近圆环中心越亮 float glow 1.0 / (1.0 dist * dist * 20.0); vec3 color ring * vec3(0.8, 0.2, 0.6) pulse * vec3(1.0, 0.6, 0.2) glow * vec3(0.1, 0.5, 0.9); gl_FragColor vec4(color, 1.0); }这里用到了distance()函数计算每个像素到中心的距离smoothstep()函数把距离值转换为有过渡的0-1值。圆环并不是硬边而是有一段过渡带这就是为什么看起来有“辉光”感。学过高中物理的人一定对驻波不陌生这个脉冲圆环本质上就是二维平面上的驻波模拟——能量向外扩散又被反射回来形成节点和波腹。5. 顶点着色器动画让平面“鼓起”成波浪到目前为止我们一直在片元着色器里通过修改颜色制造视觉动感。接下来进入一个更高级的领域用顶点着色器修改几何体本身的形状。5.1 顶点着色器修改几何形状顶点着色器决定了物体的几何形状。同一个平面如果在顶点着色器里把每个顶点的position.z值改成某个函数的值平面就会变成起伏的山丘或波浪。示例让一个平面变成波浪形。const vertexShader uniform float uTime; varying vec2 vUv; void main() { vUv uv; vec3 pos position; // 用 sin 函数让 z 坐标随 x 和时间变化 pos.z sin(pos.x * 5.0 uTime) * 0.3; gl_Position projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } ; const fragmentShader varying vec2 vUv; void main() { // 根据 UV 坐标生成渐变颜色 vec3 color mix(vec3(0.0, 0.3, 0.6), vec3(0.2, 0.8, 0.9), vUv.y); gl_FragColor vec4(color, 1.0); } ;注意这里的position是three.js内置传递的顶点坐标属性projectionMatrix * modelViewMatrix是标准的视图变换矩阵把所有顶点从模型空间变换到屏幕空间。如果你不理解矩阵乘法也没关系先记住这个套路后面深入学习时自然会明白。为了让效果看得更清楚我把相机角度调整一下从俯视角度观察这个平面camera.position.set(0, 2, 3); camera.lookAt(0, 0, 0);5.2 水面波浪效果的GLSL实现这就是被称为“three.js水面着色器”的核心技术。真正的完整水面效果还涉及法线计算、反射折射、菲涅尔效应等等但先从波浪形状入手。增强版的波浪顶点着色器uniform float uTime; varying vec2 vUv; void main() { vUv uv; vec3 pos position; // 叠加两个不同方向、不同频率的正弦波 float wave1 sin(pos.x * 3.0 uTime * 1.5) * 0.3; float wave2 sin(pos.y * 2.5 uTime * 1.8) * 0.2; // 再加一个对角方向的波让表面更自然 float wave3 sin((pos.x pos.y) * 2.0 uTime * 2.2) * 0.1; pos.z wave1 wave2 wave3; gl_Position projectionMatrix * modelViewMatrix * vec4(pos, 1.0); }这里的关键思路是波形叠加。真实水面绝不是一条正弦波而是多个不同波长、不同方向、不同相位的波的叠加。你叠加得越多水面看起来越真实。同样的道理也适用于做火焰、云朵、烟雾等复杂效果。配合我之前写的片元着色器的渐变颜色你会看到一个波澜起伏的彩色水面。5.3 通过导数求法线并添加光照纯形状起伏还不够如果想让水面看起来像真的水面需要光照参与。这就要计算每个像素的法线方向。在着色器里可以通过dFdx和dFdy函数求UV坐标下的偏导数进而算出法线varying vec2 vUv; uniform float uTime; void main() { vec3 pos position; // 高度函数 float h(vec2 p) { return sin(p.x * 3.0 uTime * 1.5) * 0.3 sin(p.y * 2.5 uTime * 1.8) * 0.2 sin((p.x p.y) * 2.0 uTime * 2.2) * 0.1; } float e 0.01; vec2 px vec2(position.x, position.y); // 用有限差分计算法线 float hx h(px vec2(e, 0.0)) - h(px - vec2(e, 0.0)); float hy h(px vec2(0.0, e)) - h(px - vec2(0.0, e)); vec3 normal normalize(vec3(-hx / (2.0 * e), -hy / (2.0 * e), 1.0)); // 简单光照 vec3 lightDir normalize(vec3(1.0, 1.0, 1.0)); float diff max(dot(normal, lightDir), 0.0); vUv uv; vNormal normal; gl_Position projectionMatrix * modelViewMatrix * vec4(pos, 1.0); }GLSL里不允许在main函数外部随便声明函数却不在全局作用域声明上面的写法在实际运行时需要把h函数提到main外。实际项目中我确实这么干过——在顶点着色器里用函数封装高度再用有限差分求法线。这虽然是近似但效果足够好而且性能开销很低。5.4 片元着色器中基于法线的光照与菲涅尔效果在片元着色器里接收法线信息加上光照计算后水面就有了立体感。再加一层菲涅尔效果——这也正是网上流传的“three.js水面着色器”的精髓。所谓菲涅尔效果就是视线与表面法线的夹角越大反射越强折射越弱。观察水面的时候靠近你脚边的水几乎透明可以看到水底而远处的水平面则像镜子一样反射天空。varying vec2 vUv; varying vec3 vNormal; void main() { vec3 lightDir normalize(vec3(1.0, 1.0, 1.0)); vec3 viewDir normalize(vec3(0.0, 0.0, 2.0)); // 简化假设视角朝向 vec3 normal normalize(vNormal); // 漫反射 float diff max(dot(normal, lightDir), 0.0); // 菲涅尔计算视角方向与法线的夹角 float fresnel pow(1.0 - max(dot(viewDir, normal), 0.0), 3.0); // 混合水面颜色和反射色 vec3 waterColor vec3(0.0, 0.4, 0.6); vec3 skyColor vec3(0.6, 0.8, 0.9); vec3 baseColor mix(waterColor, skyColor, fresnel); gl_FragColor vec4(baseColor * (0.5 diff), 1.0); }下载下来跑起来的那一瞬间你会觉得之前所有啃文档的夜都值了——这个水面已经有了像样的质感光照跟着起伏变化远处有镜子般的反射感。6. 常见问题与性能优化6.1 着色器编译报错排查我敢说凡是写过GLSL的人都经历过“着色器编译失败”的红色报错。常见的几类错误和解决办法错误类型典型报错信息解决方法类型不匹配 : cannot convert from float to vec3检查赋值类型比如vec3不能直接赋给floatvarying未匹配vUv : undeclared identifier顶点和片元着色器里都要声明varying vec2 vUv;精度问题No precision specified for (float)顶部加precision highp float;语法错误ERROR: 0:8: ; : syntax error检查分号和括号最常见的坑是中文分号排查技巧用renderer.debug.onShaderError回调可以看到更详细的错误日志或者直接用console.log(material)看加载的着色器代码。6.2 性能优化相关经验动画要流畅几件事必须注意避免在uniform传太多变量。每个uniform都会增加GPU寄存器压力传输本身也有开销。能用公式推导的别用uniform传。小心if分支。GPU是并行的分支会让部分核心走不同执行路径导致其他核心空转等待。能用数学函数mix,smoothstep,step替代的逻辑尽量不用if。控制几何体切割密度。PlaneGeometry(4, 4, 256, 256)和PlaneGeometry(4, 4, 16, 16)的顶点数差别巨大。做顶点动画时256x256的密集网格效果细腻但性能开销也大做纯片元着色器动画时用16x16就够了。设置renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))。高分屏Retina的像素比是2或更高不限制的话像素数量翻4倍着色器计算量直接翻4倍。全屏动画记得renderer.setClearColor(0x000000, 0)让背后的HTML页面透出来而不是默认的黑色背景。6.3 动画卡顿的排查思路如果你遇到chrome网页动画展示很卡的情况按这个顺序排查打开DevTools的Performance面板看是否是GPU长时间满负荷检查是否有其他DOM元素触发了大量重排尤其是全屏有毛玻璃效果的容器确认不是CPU侧JS代码太慢——比如每帧里做了大量数组操作检查requestAnimationFrame是否被其他定时器阻塞如果是在笔记本上确认没有开省电模式导致GPU降频。6.4 颜色显示不对的实际案例有次我在片元着色器里写了gl_FragColor vec4(1.0, 0.5, 0.5, 1.0)结果屏幕一直显示黑色。排查了很久才发现是framebuffer的默认输出格式和我的预期不匹配——我需要显式设置renderer.outputColorSpace THREE.SRGBColorSpace颜色才对。新版three.js里默认是线性颜色空间不转换的话颜色会偏暗。新手在调色时会遇到“怎么超过了0.5那么亮”的错觉其实就是颜色空间没配对。7. 进阶扩展与工作流建议7.1 从单着色器到多材质系统项目做到后面你会发现动画效果通常不是单独一个着色器完成的而是多个材质、多个网格、多层叠加的组合。我的做法是把不同效果分别封装成不同的ShaderMaterial实例然后把它们挂到不同的Mesh上最后统一渲染场景 ├── 背景层用全屏Plane做慢速流动的噪点渐变 ├── 中景层用半透明Plane做辉光粒子 └── 前景层用3D几何体做顶点波浪动画这样每个层单独调优参数互不影响。调试时关闭某一层也很方便直接mesh.visible false即可。7.2 Shader可视化调试技巧着色器调试最大的痛点是不能直接console.log。我在实际项目中摸索出一套比较实用的调试方法用颜色显示变量的值。比如想看某个变量pos.z的取值范围直接把pos.z映射到颜色通道gl_FragColor vec4(pos.z, 0.0, 0.0, 1.0)红色越深说明值越大。用分段阶梯显示数值。float stepped floor(value * 10.0) / 10.0;这样颜色的跳变能直观看到数值变化区间。在Shader里加一个uDebuguniform。需要调试时把值改为1走调试分支不需要时改为0走正常分支。7.3 动画工作流的建议做GLSL动画最大的感悟是“数学在前面代码在后面”。不要一上来就闷头写GLSL先想清楚你要什么效果用数学公式把效果描述出来再翻译成着色器代码。我现在的标准工作流是在Codepen或本地写一个纯GLSL的小Demo验证效果满意后接入three.js场景把uniforms参数暴露到JS侧设计交互逻辑鼠标移动、点击响应等通过uniform传给着色器最后做性能优化和适配。7.4 适合学习GLSL的资源我学习时觉得最有用的几个方向分别对应不同阶段入门阶段The Book of Shaders经典教材讲片元着色器的数学、Unity的Shader入门虽然平台不同但思路相通进阶阶段Inigo Quilez的个人网站大量shadertoy源码级别的文章包含各种数学技巧和图形学算法实践阶段Shadertoy上直接看别人的代码改参数感受变化理解了之后移植到three.js着色器学习没有捷径多看、多改、多写。我当年从“把别人代码跑起来”到“自己写出第一个原创效果”大概花了两周。这个速度在图形学领域算很快的了因为我的目标一直是“能做出自己想要的效果”而非“系统的图形学理论”。8. 我的个人实践体会文章写到这里想分享几点我在做这些着色器动画时最真实的感受。第一别怕数学。很多人一看到sin、cos、smoothstep就头大觉得数学太难。但实际写多了你会发现做90%的视觉特效所需要的数学不超过大学一年级水平三角函数描述波动线性插值过渡颜色向量运算处理方向就这么点东西来回组合。第二调试要耐心。着色器调试不像普通代码那样可以一步步断点很多时候你只能靠“把变量映射成颜色”和“注释掉一半代码看效果”来定位问题。这个过程确实磨人但每解决一个问题你对渲染管线的理解就会深一层。第三先做出来再优化。我第一次做“全屏辉光”效果时代码写得乱七八糟计算量也超标但效果先跑通了。之后我一遍遍简化数学、合并变量、减少分支最终性能提升了好几倍。如果你一上来就在美观和性能之间反复权衡很容易把自己卡死在第一步。最后送大家一句话three.js解决的是“怎么把东西放到屏幕上”的问题GLSL解决的是“怎么让屏幕上的东西有灵魂”的问题。两个都学会了你离做出真正让人惊叹的Web互动体验就不远了。