先理解一个反常识:WebGL 不是画图 API
很多前端同学学 WebGL 2.0 的时候,都把注意力放在怎么把三角形画出来。照着网上的代码能跑通,但一旦画面里需要旋转、相机、光照,就完全不知道从哪里调。问题往往不是数学不够好,而是没有先把 WebGL 的渲染管线放进脑子里。

WebGL 和 Canvas 2D 最大的区别在于:Canvas 2D 是由浏览器维护了一套绘制状态,你调用 fillRect、stroke、rotate 时,浏览器帮你完成从图形到像素的转换。WebGL 更像是一条装配流水线。JavaScript 只负责把顶点数据送到 GPU,并告诉 GPU 使用哪一段着色器程序;至于数据怎么变成像素,取决于跑在 GPU 上的着色器。换句话说,WebGL 2.0 的着色器编程,不是给 JavaScript 增加一个 GPU 插件,而是让你真正进入图形管线的核心。
顶点着色器和片段着色器:一对分工明确的搭档
WebGL 2.0 中,着色器使用 GLSL ES 3.0 编写。一个完整的渲染任务至少需要两个着色器:顶点着色器(vertex shader)和片段着色器(fragment shader)。顶点着色器负责每个顶点,决定它最终在裁剪空间中的位置;片段着色器负责每个像素,决定它最终显示出来的颜色。两者通过 in/out 变量互相传递数据。
下面是最简单的一组着色器组合。它画出的三角形会有一条从红到绿的渐变效果,因为 vColor 在光栅化阶段被逐像素插值了。
#version 300 es
precision highp float;
in vec2 aPosition;
in vec3 aColor;
out vec3 vColor;
uniform mat4 uMVP;
void main() {
gl_Position = uMVP * vec4(aPosition, 0.0, 1.0);
vColor = aColor;
}
对应的片段着色器:
#version 300 es
precision mediump float;
in vec3 vColor;
out vec4 outColor;
void main() {
outColor = vec4(vColor, 1.0);
}
aPosition 是从缓冲区按每个顶点取上来的输入,aColor 是每个顶点的颜色,uMVP 则是 JavaScript 在 draw call 之前通过 uniform 传进来的矩阵。vColor 是输出给下一阶段的 varying。当 GPU 画一个三角形时,顶点着色器会对三个顶点分别执行三次,输出变换后的 gl_Position,以及一组 vColor。随后,光栅化阶段会把三角形拆成像素,并对 vColor 做插值。于是,片段着色器里读到的 vColor,就不再是某个顶点的颜色,而是当前像素位置经过插值后得到的颜色。
从顶点到片段,中间还发生了什么
很多人只把注意力放在两个 shader 上,忽略了它们之间那些固定功能阶段。如果不知道中间发生了什么,你很难解释一些奇怪渲染现象。
| 管线阶段 | 可编程性 | 需要关注的要点 |
|---|---|---|
| 顶点缓冲区与顶点属性 | 不可编程 | buffer 大小、顶点结构、VAO 绑定关系 |
| 顶点着色器 | 可编程 | 坐标变换、逐顶点属性、导出 varying |
| 图元装配与裁剪 | 不可编程 | drawArrays 的 mode、越界顶点裁剪 |
| 光栅化 | 不可编程 | varying 按透视校正插值 |
| 片段着色器 | 可编程 | 纹理采样、光照、透明度、最终颜色 |
| 深度测试与混合 | 半配置 | 通过 gl.enable、depthFunc、blendFunc 控制 |
从这张表能看出,真正要你写代码的只有顶点着色器和片段着色器。但固定阶段不等于不存在。有个常见的例子:当你画一个半透明物体时,如果忘了开启混合,片段着色器输出的 alpha 会被直接丢弃或直接覆盖,视觉上就是一片死板的色块。又比如,背面剔除忘了关,一个绕 Y 轴旋转的立方体转到某个角度会突然消失。
顶点着色器里到底在算什么:坐标空间的理解
顶点着色器里最重要的一行是 gl_Position = uMVP * vec4(…)。gl_Position 是一个 vec4,它表示的既不是世界坐标,也不是屏幕像素坐标,而是裁剪空间坐标。裁剪空间的范围可以理解为由 -w 到 w 组成的一个盒子。GPU 完成裁剪后,会除以 w,把坐标归一化到 [-1, 1] 的 NDC 空间,再根据 viewport 设置映射成窗口坐标。
所以,直接拿页面里的 CSS 像素坐标当顶点坐标传给 WebGL,通常不会得到你想要的位置。你需要先定义一个从局部坐标出发的变换链:model 矩阵把物体放到世界空间,view 矩阵把世界坐标系移到相机视角,projection 矩阵把可见范围压进裁剪空间。三者合在一起,就叫 mvp。
这里也有一个经常被问到的取舍:矩阵能不能在 CPU 端直接乘完,再把最终坐标上传?对于只有几千个顶点的简单图形,CPU 端算确实够用。但一旦场景里有几十万个顶点,而且矩阵每帧都在变化,CPU 端逐顶点做矩阵乘法就意味着每帧遍历几十万次,还要反复往 GPU 上传结果。顶点着色器让这些计算并行发生在 GPU 上,你只需上传原始坐标和矩阵。代价是调试起来更绕,因为你看到 gl_Position 的时候,它已经经过了一次矩阵变换。
入门时最常踩的四个坑
WebGL 2.0 着色器编程的语法并不复杂,复杂的是观念切换。很多初学者卡在同一个地方,和技术能力关系不大。
- 坐标系混淆:顶点着色器永远在处理空间坐标,不是屏幕像素坐标。裁剪空间范围是 [-1, 1],屏幕映射发生在更晚的视口变换阶段。
- attribute 和 uniform 分不清:attribute 是逐顶点数据,uniform 是一次绘制中不变的参数。矩阵、相机位置、时间都应走 uniform。把矩阵拆成 attribute,等于让每个顶点各自携带一份矩阵,带宽浪费非常明显。
- VAO 不是可选优化:WebGL 2.0 里,VAO 把顶点属性配置和 buffer 绑定关系打包保存。不用 VAO,每次绘制都要重新绑定 attribute,还容易留下状态残留。
- 片段着色器里堆了太多计算:片段着色器执行次数等于被覆盖的像素数。全屏特效在移动端很容易变成性能瓶颈。能放在顶点着色器或者 CPU 端算的数据,尽量不要放到 fragment 里。
除了这些,还有一个容易忽略的点:varying 的插值是可以在着色器运行中感知的,但不能从片段着色器反向修改顶点着色器的结果。GPU 的流水线是单向的,理解这一点能省下很多调试时间。
如果你的画面只是颜色不对,先从片段着色器查起;如果形状不对,先把 gl_Position 与矩阵查清楚。
一条更稳的入门路径
如果你是第一次接触 WebGL 2.0,不要急着搬整个 three.js,也不要去复刻炫酷的粒子系统。先把最基础的提交流程整理清楚。
一段最精简的绘制提交流程是这样的:
// 假设顶点数据、VAO、program 都已准备好
gl.useProgram(program);
gl.bindVertexArray(vao);
gl.uniformMatrix4fv(mvpLocation, false, mvpMatrix);
gl.drawArrays(gl.TRIANGLES, 0, 3);
这里的顺序很重要:先用 program,再绑定 vao,再设置 uniform,最后 drawArrays。很多人画不出来,往往不是着色器写错,而是这个顺序里少了某一步。
接下来可以按三个台阶走:第一步,只用 aPosition 和 aColor 画一个渐变三角形,不传矩阵,先把 gl_Position 和 varying 的关系跑通。第二步,加一个 uniform mat4,尝试旋转,重点体会局部坐标和裁剪空间的区别。第三步,把纹理坐标加进来,学会 sampler2D 之后,你才算真正碰到生产环境里最常见的渲染方式。
最后:管线理解比 API 记忆更值钱
WebGL 2.0 的 API 数量并不少,但真正决定你调试效率的,是你脑子里有没有那张从顶点到片段的管线图。顶点着色器决定几何在哪,片段着色器决定颜色是什么,中间的光栅化决定两者如何连接。当你遇到“画面不对”的时刻,先别急着翻文档,沿着管线问一遍:顶点数据是否按预期进入了 shader?gl_Position 是不是落到了可视范围?varying 插值是否符合直觉?片段着色器有没有打开混合和深度测试?
图形渲染管线是绕不开的地基。有了这张图,矩阵、纹理、光照这些后续内容才会变得有依托;否则不管换多少库,最终都会回到同一个困惑里。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/1008/