第三课:深入着色器语言GLSL——数据类型、变量修饰符、内置变量与函数

好,咱们接着聊。上一章我们把OpenGL ES的渲染管线捋了一遍,也写了一个能跑起来的三角形。但说实话,那个三角形的颜色是写死的,顶点位置也是硬编码的。这哪叫「从入门到精通」?顶多算「从入门到放弃的边缘」。

真正让GPU听你话的,是着色器语言——GLSL。这一章,我们就深入进去,看看GLSL到底有哪些数据类型、变量修饰符,以及那些好用的内置变量和函数。最后,我会带你手写第一个真正意义上的自定义着色器。

核心知识点一览

GLSL数据类型 → 变量修饰符 → 内置变量/函数 → 自定义着色器实战

GLSL 核心知识 数据类型 float / int / bool vec2 / vec3 / vec4 mat2 / mat3 / mat4 sampler2D / samplerCube 变量修饰符 attribute (顶点输入) uniform (全局常量) varying (插值传递) const (编译时常量) 内置变量/函数 gl_Position / gl_FragColor gl_FragCoord / gl_PointSize sin/cos/mix/clamp/length texture2D / normalize 输出:自定义着色器

GLSL的数据类型——比你想象的要丰富

GLSL是强类型语言。什么意思?就是你不能像写JavaScript那样,一个变量今天存整数明天存字符串。每个变量在声明时就必须确定类型。

我刚开始学的时候,觉得这很烦。但后来发现,强类型恰恰是GPU高效运行的基础——GPU是并行计算单元,它需要知道每个数据占多少字节,才能批量处理。

基本数据类型

类型说明示例
float单精度浮点数1.0, -3.14, 2.5e-3
int有符号整数42, -7, 0
bool布尔值true, false

注意:GLSL里写浮点数一定要带小数点。写float a = 1;会报错,必须写成float a = 1.0;。这个坑我踩过,编译半天过不去,最后发现是少了个点。

向量类型——GPU的拿手好戏

向量是GLSL的灵魂。GPU天生就是为向量运算设计的,一条指令能同时处理多个分量。

类型分量访问方式
vec22个float.xy, .rg, .st
vec33个float.xyz, .rgb, .stp
vec44个float.xyzw, .rgba, .stpq

我个人习惯用.xyzw表示位置坐标,用.rgba表示颜色。这样代码可读性更好。你想想看,看到color.rgb一眼就知道是颜色,看到position.xy就知道是坐标。

小技巧:GLSL支持swizzle操作,也就是可以任意重组分量。比如vec4(1.0, 2.0, 3.0, 4.0).zyxw会得到(3.0, 2.0, 1.0, 4.0)。这在做颜色通道交换时特别方便。

矩阵类型

矩阵在3D变换中无处不在。GLSL提供了mat2mat3mat4,分别对应2x2、3x3、4x4矩阵。

// 声明一个4x4单位矩阵
mat4 identity = mat4(1.0);

// 矩阵乘法
vec4 position = projection * view * model * vec4(inputPos, 1.0);

矩阵乘法顺序很重要。我记得有一次调试一个模型死活显示不对,最后发现是把model * view写成了view * model。矩阵乘法不满足交换律,这个一定要记住。

采样器类型

纹理采样器sampler2DsamplerCube是专门用来从纹理中取颜色的。它们只能声明为uniform,不能随意赋值。

uniform sampler2D uTexture;
// 在片元着色器中采样
vec4 texColor = texture2D(uTexture, vTexCoord);

变量修饰符——数据从哪里来,到哪里去

GLSL的变量修饰符,说白了就是告诉GPU:这个数据是CPU传进来的,还是顶点着色器算好要传给片元着色器的,还是每个顶点都不同的。

修饰符作用域说明
attribute顶点着色器每个顶点不同的输入数据(位置、法线、颜色等)
uniform全局所有顶点/片元共享的常量(变换矩阵、光源位置等)
varying顶点→片元从顶点着色器传递到片元着色器的插值数据
const局部编译期常量,不可修改

这里有个容易混淆的点:attribute只能在顶点着色器中使用,varying是连接顶点和片元着色器的桥梁。我在项目中见过有人把varying写在片元着色器里当普通变量用,结果编译报错——其实varying需要在两端都声明,类型和名字必须一致。

注意:在OpenGL ES 3.0中,attributevaryingin/out取代了。但2.0版本仍然广泛使用,而且很多教材还是用老写法。我这里讲的是ES 2.0的语法,兼容性更好。

内置变量与函数——GPU给你的免费工具

GLSL提供了一些内置变量,你不用声明就能直接用。这些变量是GPU运行时自动填充的。

顶点着色器内置变量

  • gl_Position:vec4类型,必须赋值。表示顶点在裁剪空间中的位置。
  • gl_PointSize:float类型,控制点精灵的大小。

片元着色器内置变量

  • gl_FragColor:vec4类型,输出片元的最终颜色。
  • gl_FragCoord:vec4类型,片元在窗口中的坐标。
  • gl_FrontFacing:bool类型,判断当前片元是否属于正面。

常用内置函数

分类函数说明
数学sin, cos, tan, abs, floor, ceil, mod基础数学运算
插值mix(a, b, t)线性插值,返回 a*(1-t) + b*t
限制clamp(x, min, max)将x限制在[min, max]区间
向量length, normalize, dot, cross向量长度、归一化、点积、叉积
纹理texture2D(sampler, coord)从纹理中采样颜色

mix函数我几乎每个项目都会用。做颜色渐变、透明度混合、动画过渡,它都是首选。比你自己写a*(1-t) + b*t要快,因为GPU对mix有硬件优化。

实战:第一个自定义着色器

好了,理论说够了。我们来写一个真正能跑的自定义着色器。这个着色器会让一个三角形从中心向外渐变颜色,并且随时间变化。

顶点着色器

// simple_vertex.glsl
attribute vec4 aPosition;
attribute vec2 aTexCoord;

uniform mat4 uMVPMatrix;

varying vec2 vTexCoord;

void main() {
    gl_Position = uMVPMatrix * aPosition;
    vTexCoord = aTexCoord;
}

片元着色器

// simple_fragment.glsl
precision mediump float;

varying vec2 vTexCoord;

uniform float uTime;

void main() {
    // 计算片元到中心的距离
    vec2 center = vec2(0.5, 0.5);
    float dist = distance(vTexCoord, center);
    
    // 根据距离和时间混合颜色
    vec3 color1 = vec3(1.0, 0.2, 0.2);  // 红色
    vec3 color2 = vec3(0.2, 0.2, 1.0);  // 蓝色
    
    // sin(uTime) 在 -1 到 1 之间变化,映射到 0 到 1
    float mixFactor = 0.5 + 0.5 * sin(uTime + dist * 3.0);
    vec3 finalColor = mix(color1, color2, mixFactor);
    
    gl_FragColor = vec4(finalColor, 1.0);
}

这段代码做了什么?

  1. 顶点着色器把纹理坐标传给片元着色器。
  2. 片元着色器计算每个片元到纹理中心(0.5, 0.5)的距离。
  3. sin函数让颜色随时间波动,距离越远波动相位越不同。
  4. 最终效果:一个从红到蓝渐变、并且像呼吸一样脉动的三角形。

我的经验:写片元着色器时,precision mediump float;这行一定要加。不加的话,有些GPU会默认用低精度,导致颜色出现条带。我曾经在一个项目里排查了半天,最后发现是精度声明的问题。

Java端如何传递uniform

着色器写好了,还得从Java端把uTime传进去。代码大概是这样的:

// 获取uniform位置
int timeHandle = GLES20.glGetUniformLocation(program, "uTime");

// 在渲染循环中更新
float time = System.currentTimeMillis() / 1000.0f;
GLES20.glUniform1f(timeHandle, time);

这里要注意:glUniform1f要在glUseProgram之后调用,否则传不进去。我刚开始经常忘记这个顺序,结果uniform值一直是默认的0。

避坑指南

最后,分享几个我踩过的坑:

  • 变量命名冲突:GLSL的变量名不能以gl_开头,那是内置变量的保留前缀。
  • 精度声明:片元着色器必须声明默认精度,否则在某些设备上会编译失败。
  • 循环次数:GLSL中的循环必须是常数次,不能用变量控制循环次数。因为GPU需要提前知道指令数量。
  • 条件分支:尽量少用if-else,GPU是并行执行的,分支会导致部分单元等待。能用mixclampstep解决的,就别用if。

嗯,这一章的内容就到这里。GLSL其实不难,关键是动手写。你把上面那个着色器跑起来,改改颜色值、调调sin的频率,很快就能找到感觉。


公众号:蓝海资料掘金营,微信deep3321