4、着色器基础:GLSL 语言入门、数据类型与限定符、uniform 与 attribute 变量、varying 变量传递
好,咱们进入着色器的核心——GLSL。说实话,很多初学者把 OpenGL ES 学成了「API 调用师」,调一堆 glDrawArrays 却不知道 GPU 里到底发生了什么。我个人觉得,理解 GLSL 才是真正入门的标志。你想想看,没有着色器,GPU 就是个摆设。
4.1 GLSL 语言入门:它其实没那么神秘
GLSL 的全称是 OpenGL Shading Language。说白了,它就是跑在 GPU 上的一门小语言。跟 C 语言很像,但又有不少区别。我第一次写 GLSL 时,总觉得它像 C 的「阉割版」——没有指针,没有动态内存分配,也没有字符串。嗯,这里要注意:GPU 是并行计算的怪物,它不需要那些花里胡哨的东西。
一个典型的 GLSL 程序长什么样?我直接给你看个最简单的顶点着色器:
// 一个最简单的顶点着色器
attribute vec4 a_Position;
void main() {
gl_Position = a_Position;
}
看到了吗?就这几行。main 函数是入口,gl_Position 是内置变量,表示顶点最终的位置。我在项目中遇到过不少新手,上来就写几百行的着色器,结果跑都跑不起来。我的建议是:从最简单的开始,逐步叠加。
4.2 数据类型与限定符:选对类型,事半功倍
GLSL 的数据类型,我把它分成三类:基础类型、向量类型、矩阵类型。咱们一个一个说。
4.2.1 基础类型
| 类型 | 说明 | 我踩过的坑 |
|---|---|---|
| float | 单精度浮点,最常用 | 别写 double,ES 不支持 |
| int | 整数,循环计数用 | 注意精度,别溢出 |
| bool | 布尔值,true/false | 条件判断时别用 == 比较 float |
| sampler2D | 纹理采样器 | 这个后面细讲 |
这里有个坑:GLSL 里没有隐式类型转换。你不能把 int 直接赋值给 float。我曾经在项目里写过 float x = 1;,结果编译报错,查了半天才发现少了个小数点。正确的写法是 float x = 1.0;。
4.2.2 向量类型——GPU 的拿手好戏
向量是 GLSL 的精髓。vec2、vec3、vec4 分别对应 2、3、4 个分量的浮点向量。还有 ivec2、bvec3 这些变体。为什么 GPU 喜欢向量?因为一条指令就能处理多个数据,并行嘛。
// 向量的几种创建方式
vec2 v2 = vec2(1.0, 2.0);
vec3 v3 = vec3(v2, 3.0); // 混合构造
vec4 v4 = vec4(1.0, 2.0, 3.0, 4.0);
// 访问分量——我常用的方式
float x = v4.x; // 1.0
float y = v4.y; // 2.0
float z = v4.z; // 3.0
float w = v4.w; // 4.0
// 还可以用 rgba 或 stpq 访问
float r = v4.r; // 也是 1.0
float s = v4.s; // 也是 1.0
你可能会问:为什么有这么多访问方式?其实是为了语义清晰。位置用 xyzw,颜色用 rgba,纹理坐标用 stpq。我个人习惯在顶点着色器里用 xyzw,在片元着色器里用 rgba,这样代码读起来更自然。
4.2.3 矩阵类型
mat2、mat3、mat4 分别对应 2x2、3x3、4x4 矩阵。矩阵在图形学里太重要了——平移、旋转、缩放、投影,全得靠它。我记得刚学的时候,总搞不清矩阵是行优先还是列优先。GLSL 用的是列优先,也就是说:
mat4 m = mat4(
1.0, 0.0, 0.0, 0.0, // 第一列
0.0, 1.0, 0.0, 0.0, // 第二列
0.0, 0.0, 1.0, 0.0, // 第三列
0.0, 0.0, 0.0, 1.0 // 第四列
);
嗯,这里要注意:如果你从数学书上看来的矩阵是行优先,那在 GLSL 里要转置一下。我曾经因为这个 bug 调了整整一个下午,画面就是不对。
4.3 限定符:告诉 GPU 数据从哪里来
限定符是 GLSL 里比较绕的地方。简单来说,它告诉 GPU:这个变量是输入、输出,还是中间变量。常用的有四个:attribute、uniform、varying、const。
| 限定符 | 作用域 | 数据来源 | 可修改性 |
|---|---|---|---|
| attribute | 顶点着色器 | CPU 每顶点传入 | 只读 |
| uniform | 全局 | CPU 每帧传入 | 只读 |
| varying | 顶点→片元 | 插值生成 | 顶点写,片元读 |
| const | 局部 | 编译期常量 | 不可变 |
4.4 uniform 变量:全局统一的数据
uniform 的意思是「统一的」。同一个 uniform 变量,在一次绘制调用中,对所有顶点和片元都是一样的值。比如光源位置、颜色、变换矩阵,这些都应该用 uniform。
// 在着色器里声明
uniform vec4 u_Color;
uniform mat4 u_ModelViewMatrix;
void main() {
gl_Position = u_ModelViewMatrix * a_Position;
// 颜色用 uniform 传进来的
}
在 CPU 端怎么传?用 glUniform 系列函数。我习惯这样命名:uniform 变量以 u_ 开头,attribute 以 a_ 开头,varying 以 v_ 开头。这样一眼就能看出变量的类型。
4.5 attribute 变量:每个顶点都不一样
attribute 是每个顶点特有的数据。位置、法线、纹理坐标、颜色,这些都是 attribute。在 OpenGL ES 2.0 里,attribute 只能用在顶点着色器。到了 ES 3.0,它被 in 关键字取代了,但概念是一样的。
// 顶点着色器
attribute vec3 a_Position;
attribute vec2 a_TexCoord;
attribute vec3 a_Normal;
void main() {
gl_Position = u_ProjectionMatrix * u_ModelViewMatrix * vec4(a_Position, 1.0);
// 纹理坐标和法线传给片元着色器
v_TexCoord = a_TexCoord;
v_Normal = u_NormalMatrix * a_Normal;
}
你想想看,一个模型可能有几万个顶点,每个顶点都有自己的位置。attribute 就是用来装这些「个性数据」的。我在做地形渲染时,每个顶点除了位置,还有高度值和湿度值,都通过 attribute 传进去。
4.6 varying 变量:从顶点到片元的桥梁
varying 是 GLSL 里最巧妙的设计。它在顶点着色器里赋值,在片元着色器里读取。但关键来了:GPU 会自动对 varying 做插值!
什么意思?比如一个三角形有三个顶点,每个顶点颜色不同。你在顶点着色器里给 varying 赋了三个不同的颜色值,到了片元着色器,每个像素拿到的颜色是经过插值的——离哪个顶点近,颜色就更像哪个顶点。这就是所谓的「平滑着色」。
// 顶点着色器
attribute vec3 a_Position;
attribute vec3 a_Color;
varying vec3 v_Color;
void main() {
gl_Position = vec4(a_Position, 1.0);
v_Color = a_Color; // 传给片元着色器
}
// 片元着色器
varying vec3 v_Color;
void main() {
gl_FragColor = vec4(v_Color, 1.0); // 这里拿到的颜色是插值后的
}
我记得有一次做粒子系统,需要在顶点着色器里计算粒子的颜色渐变。我一开始在 CPU 端算好每个粒子的颜色,然后通过 attribute 传进去。后来发现直接用 varying 插值,效果更好,性能还提升了 30%。嗯,这就是经验。
4.7 综合示例:一个彩色三角形
最后,咱们把今天学的串起来。一个彩色三角形的完整着色器:
// 顶点着色器
attribute vec4 a_Position;
attribute vec3 a_Color;
uniform mat4 u_Matrix;
varying vec3 v_Color;
void main() {
gl_Position = u_Matrix * a_Position;
v_Color = a_Color;
}
// 片元着色器
precision mediump float;
varying vec3 v_Color;
void main() {
gl_FragColor = vec4(v_Color, 1.0);
}
这个例子虽然简单,但包含了我们今天讲的所有核心概念:attribute 传入顶点数据,uniform 传入变换矩阵,varying 传递插值后的颜色。你把这个跑通了,GLSL 的基础就算打牢了。
下一章,咱们会深入纹理映射。到时候你会看到 sampler2D 和纹理坐标是怎么配合的。做好准备,那才是真正出效果的地方。