着色器基础:GPU 上的小程序
本教程共 40 篇 · 第 33 篇 · 更新于 2026-08-14 · 约 8 分钟阅读
本节目标:理解着色器是什么、顶点着色器和片元着色器怎么分工,掌握 attribute、uniform、varying 三种变量的数据流,会用 ShaderMaterial 写出第一个自定义材质。
从这一章开始,进入 three.js 最硬核也最自由的部分:着色器。内置材质做不到的效果——水面、岩浆、发光描边、粒子风暴——几乎都靠它。概念不复杂,但和普通 JavaScript 编程的思维完全不同。
着色器是什么
着色器(Shader)是跑在 GPU 上的小程序,用 GLSL 语言编写。普通的 JS 代码跑在 CPU 上,一条一条执行;着色器跑在 GPU 上,成千上万份副本同时执行。
每一份副本处理一个”小单元”:
- 顶点着色器(vertex shader):每个顶点执行一次;
- 片元着色器(fragment shader):每个像素(片元)执行一次。
一个 10 万顶点的模型,顶点着色器就被执行 10 万次;1920×1080 的屏幕,片元着色器最多被执行 200 多万次。所以着色器里的代码必须简单高效,任何计算都是放大百万倍执行的。
两个着色器的分工
一次绘制,两个着色器接力完成:
- 顶点着色器:决定顶点画在哪里。输入几何体的顶点数据,输出裁剪空间坐标(gl_Position);
- 片元着色器:决定像素是什么颜色。输入插值后的数据,输出颜色(gl_FragColor)。
中间过程由 GPU 自动完成:顶点着色器跑完后,GPU 把三角形连起来,扫描每个被覆盖的像素,把顶点的数据按位置插值,交给片元着色器。这个”自动插值”是理解 varying 的关键。
三种变量的数据流
自定义着色器里,数据通过三种变量进出:
attribute:每个顶点一份的数据,来自几何体。position、uv、normal 都是 attribute;uniform:所有顶点、所有像素共享一份,来自 JavaScript。每帧可更新,动画时间就放这里;varying:顶点着色器写给片元着色器的数据,GPU 会自动插值。
数据流可以画成一条线:
几何体 attribute ──> 顶点着色器 ──varying──> 片元着色器 ──> 屏幕
↑ ↑
uniform 从 JS 进来 uniform 从 JS 进来
uniform 两边都能用;attribute 只能进顶点着色器;varying 只在两个着色器之间传递。记住这条线,着色器代码就不会乱。
Notethree.js 里 attribute 和 uniform 的命名习惯:attribute 不带前缀(position、uv、normal),自定义 uniform 常用 u 开头(uTime、uColor)。这是社区约定,帮你一眼分清变量来源。
varying 的插值值得展开说。三角形三个顶点各有一个值,比如 vUv 分别是 (0,0)、(1,0)、(0,1)。三角形中间的像素该用哪个?GPU 按像素到三个顶点的距离加权平均,越靠近哪个顶点,越像哪个顶点的值。这就是插值:顶点提供”样本”,片元收到”渐变”。顶点着色器里任何 varying 都会被这样处理,所以片元着色器收到的 vUv 是连续渐变的,画出来的颜色才是平滑过渡。
three.js 自动提供的变量
ShaderMaterial 里不用自己声明,three.js 会自动注入一批常用变量:
| 变量 | 类型 | 含义 |
|---|---|---|
| position | attribute | 顶点局部坐标 |
| uv | attribute | 纹理坐标,范围 0~1 |
| normal | attribute | 顶点法线 |
| modelViewMatrix | uniform | 模型视图矩阵 |
| projectionMatrix | uniform | 投影矩阵 |
| cameraPosition | uniform | 相机世界坐标 |
标准公式 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0) 就是把这些内置变量串起来:先把局部坐标变换到视图空间,再投影到裁剪空间。刚学时可以照抄这个公式,不必深究矩阵细节(第 9 章讲过变换,矩阵运算在第 34 章)。
ShaderMaterial 的基本结构
ShaderMaterial 接收两个字符串:顶点着色器源码和片元着色器源码。着色器代码通常放在 HTML 的 script 标签里,用 textContent 取出来,避免在 JS 里拼字符串。
<script type="x-shader/x-vertex" id="vertexShader">
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
</script>
<script type="x-shader/x-fragment" id="fragmentShader">
uniform float uTime;
varying vec2 vUv;
void main() {
vec3 color = vec3(vUv.x, vUv.y, 0.5 + 0.5 * sin(uTime));
gl_FragColor = vec4(color, 1.0);
}
</script>
两个着色器都有 main 函数,这是 GPU 程序的入口。顶点着色器里给 vUv 赋值;片元着色器里同名声明 vUv,就能收到插值后的纹理坐标。颜色用 vUv 拼出来:左下角是 (0,0) 黑色,右上角偏 (1,1),蓝色分量随时间正弦波动。
NoteShaderMaterial 会自动注入 position、uv、modelViewMatrix 这些内置变量。想要完全空白、什么都不注入的材质,用 RawShaderMaterial——它连矩阵都要自己声明,入门用不到,知道存在即可。
JS 侧用 ShaderMaterial 组合它们:
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 }
},
vertexShader: document.getElementById('vertexShader').textContent,
fragmentShader: document.getElementById('fragmentShader').textContent
});
uniforms 对象的每个键对应着色器里的一个 uniform 声明。注意结构:{ value: 0 },值必须包在 value 里。
完整示例:动画渐变平面
把上面几段拼成一个能直接运行的页面:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>33 着色器基础</title>
<style>body { margin: 0; overflow: hidden; }</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.185.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/"
}
}
</script>
</head>
<body>
<script type="x-shader/x-vertex" id="vertexShader">
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
</script>
<script type="x-shader/x-fragment" id="fragmentShader">
uniform float uTime;
varying vec2 vUv;
void main() {
vec3 color = vec3(vUv.x, vUv.y, 0.5 + 0.5 * sin(uTime));
gl_FragColor = vec4(color, 1.0);
}
</script>
<script type="module">
import * as THREE from 'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 100);
camera.position.z = 2;
const renderer = new THREE.WebGLRenderer();
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 }
},
vertexShader: document.getElementById('vertexShader').textContent,
fragmentShader: document.getElementById('fragmentShader').textContent
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1.6, 1.6), material);
scene.add(mesh);
renderer.setAnimationLoop(() => {
material.uniforms.uTime.value += 0.02; // 每帧更新 uniform
renderer.render(scene, camera);
});
</script>
</body>
</html>
动画的秘诀在最后几行:每帧给 material.uniforms.uTime.value 加一点,片元着色器里的 sin(uTime) 就会持续变化。uniform 就是 JS 和 GPU 之间的传声筒。
顶点着色器也能做动画。想让平面像旗帜一样飘动,就在顶点着色器里改位置:
// 顶点着色器片段:按 x 坐标做波浪
uniform float uTime;
void main() {
vec3 pos = position;
pos.z += sin(pos.x * 3.0 + uTime) * 0.2;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
顶点着色器动的是”骨架”,片元着色器动的是”皮肤”。位移、变形这类几何操作放顶点着色器,颜色、光照放片元着色器,分工清楚,代码自然清晰。
着色器常见坑
写 GLSL 和写 JS 手感差很多,几个高频翻车点:
- 字符串里没有语法高亮和报错,出错时控制台只有编译日志,行号要自己数;
- GLSL 是强类型,float 和 int 不能直接混用,
1.0才是浮点数,1是整数; - 每条语句必须分号结尾,漏一个整个着色器编译失败;
- 变量必须先声明后使用,没有 JS 的变量提升;
- 着色器里不能 console.log,调试靠”把值写进颜色”:
gl_FragColor = vec4(0.5 + 0.5 * vUv.x, 0.0, 0.0, 1.0)就能把数值可视化。
Tip渲染全黑时,先检查顶点着色器的 gl_Position 公式;颜色不对时,检查片元着色器的取值范围——很多颜色值是负数或大于 1,会被截断成黑或白。
着色器是字符串这件事,是 three.js 长期被吐槽的点。下一章讲 GLSL 语法细节,第 35 章讲 three.js 的新答案 TSL——用 JavaScript 对象写着色器,彻底告别字符串。