Three.js封装了WebGL的大量底层细节,让开发者用几行代码就能把一个立方体渲染到浏览器里。但当你想实现水面波光、全息投影、溶解消失这类特效时,内置材质就不够用了,必须自己写着色器。着色器是运行在GPU上的小程序,理解它的工作方式是JavaScript开发者迈向三维渲染进阶的关键一步。本文将从渲染管线入手,带你系统掌握Three.js着色器的编写方法。

一、理解渲染管线:着色器到底在哪一步工作
先厘清概念。GPU渲染一帧画面的过程大致是这样的:CPU端准备好顶点坐标、法线、UV等数据,连同材质参数一起提交给GPU;GPU先对每个顶点执行顶点着色器,算出它在屏幕上的投影位置;随后光栅化阶段把三角形内部插值成一个个像素点;最后对每个像素执行片元着色器,决定这个像素最终是什么颜色。两个着色器分工明确:前者管形状和位置,后者管颜色和质感。
这解释了为什么着色器代码看起来和JavaScript完全不同。它用的是GLSL语言,语法接近C,没有垃圾回收,没有动态类型,变量必须在开头声明,循环边界必须是编译期常量。刚开始写会觉得很受限,但这些限制换来了极高的并行执行效率——GPU可以同时跑成千上万个片元着色器实例,这正是它能实时渲染复杂画面的原因。
Three.js中的材质体系是分层的。MeshBasicMaterial、MeshStandardMaterial这些内置材质内部其实也是着色器,Three.js只是帮你拼好了GLSL代码。而ShaderMaterial和RawShaderMaterial则把控制权交给你:前者会自动注入投影矩阵、模型视图矩阵等常用变量,适合大多数场景;后者什么都不注入,你面对的是最原始的WebGL环境,一般只在需要精细控制或对接其他渲染引擎时使用。初学阶段直接用ShaderMaterial即可。
二、动手写第一个着色器:流动的渐变球体
下面这个例子渲染一个球体,颜色随时间在表面流动。代码虽短,但包含了着色器开发的核心要素:uniform变量从JavaScript向GPU传递全局数据,varying变量在顶点和片元着色器之间插值传递,内置的projectionMatrix、modelViewMatrix由Three.js自动提供。
const material = new THREE.ShaderMaterial({
// 传入时间 uniform,每帧更新
uniforms: {
uTime: { value: 0 }
},
vertexShader: `
varying vec3 vPosition;
void main() {
// 把顶点局部坐标传给片元着色器,GPU会自动插值
vPosition = position;
// 投影到裁剪空间,这是顶点着色器的固定职责
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
varying vec3 vPosition;
void main() {
// 用正弦函数让颜色随时间和位置流动
float r = sin(vPosition.x * 3.0 + uTime) * 0.5 + 0.5;
float g = sin(vPosition.y * 3.0 + uTime * 1.3) * 0.5 + 0.5;
float b = sin(vPosition.z * 3.0 + uTime * 0.7) * 0.5 + 0.5;
gl_FragColor = vec4(r, g, b, 1.0);
}
`
});
const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 64, 64), material);
scene.add(mesh);
// 渲染循环中更新时间
function animate() {
material.uniforms.uTime.value = clock.getElapsedTime();
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
逐个看关键点。uniforms是CPU与GPU之间唯一的动态数据通道,类型必须是{ value: xxx }这种对象结构,每帧直接修改value即可,不需要重新编译着色器。varying声明的变量在顶点着色器中赋值后,片元着色器拿到的是三角形三个顶点之间插值后的结果,这是理解很多视觉效果(比如法线过渡、UV贴图)的基础。
还要注意GLSL的严格性:浮点数必须带小数点,写1会报错,要写1.0;vec3乘以标量时类型必须匹配;版本不同的GLSL语法略有差异,Three.js默认使用GLSL ES 1.0,如果你想用in/out这类新版语法,需要先了解GLSL 3.0的写法规则,避免混用导致编译失败。
三、实现光照与法线:从平面色块到立体质感
上面的球体虽然颜色花哨,但看起来像个贴图气球,没有立体感。原因是没有光照计算。要在着色器里实现漫反射,需要用到顶点法线。Three.js的内置属性里自带normal,但要注意:法线是在局部坐标系下定义的,当物体发生旋转缩放时,必须用normalMatrix变换到视图空间,否则光照方向会错。
vertexShader: `
varying vec3 vNormal;
varying vec3 vViewPosition;
void main() {
// normalMatrix 是模型视图矩阵的逆转置,专用于变换法线
vNormal = normalize(normalMatrix * normal);
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vViewPosition = mvPosition.xyz;
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
varying vec3 vNormal;
varying vec3 vViewPosition;
void main() {
// 简单的方向光漫反射
vec3 lightDir = normalize(vec3(1.0, 2.0, 1.5));
float diff = max(dot(normalize(vNormal), lightDir), 0.0);
// 环境光 + 漫反射
vec3 color = vec3(0.15, 0.15, 0.2) + vec3(0.7, 0.8, 0.9) * diff;
gl_FragColor = vec4(color, 1.0);
}
`
如果想让场景里的光源跟随Three.js的场景图(比如你加了DirectionalLight),可以改用onBeforeCompile方案:保留内置材质,只往它的着色器里注入一小段自己的代码。这样既能复用Three.js成熟的光照和阴影系统,又能实现自定义效果,是很多特效的实际做法,比从零写ShaderMaterial省事得多。
四、调试技巧与常见坑
写着色器最大的痛苦是出错时只有黑屏,没有任何提示。分享几个实用排查手段。第一,GLSL编译错误会打印在浏览器控制台,Three.js会把出错的着色器代码带行号一起输出,看到一长串代码不要慌,翻到最后通常有具体的错误信息。第二,怀疑某个变量有问题时,直接把它输出成颜色:gl_FragColor = vec4(vec3(vNormal * 0.5 + 0.5), 1.0)能把法线可视化,法线正确的物体表面会呈现蓝紫色调,这是图形开发中最经典的调试手法。
几个高频踩坑点也列一下:uniform类型写错不会报错但会静默失效,比如GLSL里声明vec3但传入的是普通数组,要改用THREE.Vector3或THREE.Color;贴图必须用THREE.Texture类型,采样时用texture2D;精度问题上,片元着色器默认是highp,但某些移动设备只支持mediump,做大场景计算时注意数值漂移;另外gl_FragColor是GLSL 1.0的写法,如果你看到别人的代码用out vec4 fragColor,那是3.0版本,不要直接照抄。
掌握这些基础后,就可以往噪声函数、菲涅尔边缘光、屏幕后期处理这些方向延伸了。着色器的学习曲线前期陡峭,但核心心智模型就一句话:顶点着色器决定像素在哪,片元着色器决定像素是什么颜色,所有视觉效果都是围绕这两点做数学文章。先把本文的例子跑起来,改改参数看效果变化,比读十篇教程都有用。