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 是内置变量,表示顶点最终的位置。我在项目中遇到过不少新手,上来就写几百行的着色器,结果跑都跑不起来。我的建议是:从最简单的开始,逐步叠加。

我的小技巧: 调试 GLSL 最痛苦的是没有标准输出。我习惯用颜色来调试——把中间变量的值直接赋给 gl_FragColor,看屏幕颜色就知道对不对了。

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_ 开头。这样一眼就能看出变量的类型。

曾经踩过的坑: uniform 变量如果声明了但没使用,有些驱动会优化掉,导致 glGetUniformLocation 返回 -1。我曾经因为这个崩溃了好几次,后来养成了习惯:声明了就一定要用,哪怕只是赋值给一个临时变量。

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);  // 这里拿到的颜色是插值后的
}
核心理解: varying 的插值是硬件自动完成的,不需要你写任何插值代码。这就是 GPU 比 CPU 快的原因之一——它天生就是干这个的。

我记得有一次做粒子系统,需要在顶点着色器里计算粒子的颜色渐变。我一开始在 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 和纹理坐标是怎么配合的。做好准备,那才是真正出效果的地方。