GLSL的规范文档里,《Variables and Types》这章读起来很枯燥——没有纹理采样那种画面感,也没有光照模型那样能让脑子里立刻浮现出漂亮的渲染结果。但凡是真正在GPU上写过几个shader、调过几个深夜bug的人,最后都会回到这一章里找答案。比如切线空间算错,排查半天发现是mat3的构造参数顺序记反了;比如移动端画面远处出现色阶断层,查了一圈发现是法线变量被精度限定符偷偷降成了半精度;再比如uniform buffer里的数据对不上,仔细一对才发现std140的对齐规则和CPU端的结构体完全不是一回事。诸如此类,根子都出在变量声明和类型规则上。
这篇就从GLSL的变量与类型系统入手,把规范里那套规则掰开揉碎讲一遍。重点不光是“有什么类型”,更会讲“为什么是这个规则”“实际项目中踩过哪些坑”,以及哪些细节是规范里写了但日常文档很少强调的。适合刚接触GLSL的图形学初学者,也适合写了好几年shader但没系统读过规范的人用来查漏补缺。
1. 为什么说变量和类型是shader与GPU之间的契约
1.1 GLSL是严格强类型语言,这点和C/C++差别很大
GLSL在设计上继承了C语言的语法骨架,但类型系统远比C严格。C语言里int和float互相转换非常随意,写错类型通常只是警告,甚至编译器会默默帮你做隐式转换。GLSL不同,它在类型检查上几乎是“零容忍”的:类型不匹配就是编译错误,没有讨论余地。
为什么这么设计?因为shader是跑在GPU上的,GPU的每个执行单元都对应特定的数据通路。顶点属性进来是vec4,纹理坐标是vec2,变换矩阵是mat4——这些类型直接决定了硬件要准备多少个寄存器、怎么解析二进制流、怎么组织并行运算。如果类型可以随随便便转换,硬件层的资源调度就没法做了。所以GLSL用强类型来保证每一个变量在编译期就有明确的大小和语义,编译器才能把它映射到对应的物理资源上。
比如一个最简单的顶点着色器:
#version 330 core in vec3 position; in vec3 normal; uniform mat4 modelViewProjection; out vec3 vNormal; void main() { vNormal = normal; gl_Position = modelViewProjection * vec4(position, 1.0); }这里in vec3 position声明了顶点属性是一个三通道向量,GPU就知道要从顶点缓冲里按3个float为一组去读数据;uniform mat4 modelViewProjection声明了一个4x4矩阵,驱动层就知道要用glUniformMatrix4fv上传16个float。整个渲染管线的数据流,就是靠这些变量声明串起来的。
1.2 类型系统决定了shader能做什么、不能做什么
GLSL没有指针,没有动态内存分配,没有字符串,没有类。这些都被刻意砍掉了,因为GPU的编程模型就是数据并行:成千上万个线程同时运行同一段代码,处理不同的数据。指针和动态分配在这种模型下是灾难——每个线程各自管理内存,分布式内存分配的开销无法承受。
所以GLSL的类型列表非常精简,基本围绕“数值计算”这个核心场景展开:
- 标量:
bool、int、uint、float、double - 向量:由标量组成的2/3/4维向量
- 矩阵:2x2到4x4的方阵和非方阵
- 数组:固定大小的同类元素集合
- 结构体:把不同类型打包成一个自定义类型
- 不透明类型:采样器
sampler*、图像image*、原子计数器atomic_uint
这套类型系统没有“字符串”“链表”这类高级抽象,但足以覆盖实时渲染里绝大多数的数学运算。理解了这个前提,再看规范里的每一项规则,逻辑就顺了。
2. 标量、向量与矩阵:从维度数学到硬件寄存器
2.1 标量类型各自的定位
五个标量类型里,最常用的是float,GPU的ALU天生就是为浮点运算设计的。int和uint主要在索引、循环控制、位运算、整数纹理采样这些场景使用。bool用于分支判断,注意在大多数GPU上bool实际占用一个32位寄存器,不像CPU那样压缩到1位。double在桌面端GL 4.0以后才引入,移动端几乎不可用,而且双精度运算在GPU上非常慢(消费级显卡的双精度单元数量远少于单精度),除非做科学计算,否则实时渲染项目里基本不用。
用一个表格来对比会更直观:
| 类型 | 大小 | 典型用途 | 注意事项 |
|---|---|---|---|
bool | 实现相关,通常32位 | 分支条件、逻辑标志 | 不要在GPU上依赖位宽 |
int | 32位有符号 | 循环、索引、整数运算 | 与uint之间无隐式转换 |
uint | 32位无符号 | 位操作、原子计数器、索引 | 负数赋值会回卷 |
float | 32位IEEE754 | 几乎所有渲染计算 | 精度和范围要看具体GPU |
double | 64位IEEE754 | 高精度计算 | 桌面高端卡才靠谱,速度慢 |
2.2 向量类型:分量访问与swizzle操作
向量是GLSL里使用频率最高的类型,vec2、vec3、vec4分别对应二维、三维、齐次坐标。除了vec,同一套维度还配套了布尔向量bvec*、整数向量ivec*、无符号整数向量uvec*、双精度向量dvec*。这套“同构家族”设计的意义在于:数学向量运算是冗余的,只要给一个浮点向量实现了加法,其他类型的向量也能复用同一套语法。
向量的分量有三种命名空间:
- 位置坐标:
x、y、z、w - 颜色分量:
r、g、b、a - 纹理坐标:
s、t、p、q
三套名字可以互相访问同一个分量,v.r和v.x是同一个东西。但规则有一条硬性限制:一个swizzle表达式里不能混用不同命名空间的字母,比如v.xy合法,v.xr就是编译错误。
swizzle操作最大的价值是可以一次性重排分量,这在处理法线、切线、UV坐标时非常实用:
vec2 uv01 = texCoord.xy; vec3 normalFromTangent = vec3(bitangent * tangent.xyw); // 重排并升维 vec4 packedColor = albedo.rgba;有一点必须强调:当swizzle出现在赋值号左边时,不能有重复分量,v.xx = vec2(1.0)是非法的;但作为右值,重复分量完全没问题,vec2 duplicated = v.xx可以正常编译。这是因为GPU并行写同一个寄存器会出现竞争,规范从设计层面禁止了这种不确定性。
2.3 矩阵类型:构造方式与列主序存储
矩阵类型包括mat2、mat3、mat4,以及非方阵mat2x3、mat3x2、mat2x4等。第一个数字是列数,第二个是行数,例如mat3x2表示3列2行。
GLSL的矩阵存储是列主序的,这个细节影响深远。构造矩阵时,参数是按“列”来填充的:
// 三列分别对应: // col0 = (1.0, 0.0, 0.0) // col1 = (0.0, 1.0, 0.0) // col2 = (0.0, 0.0, 1.0) mat3 m = mat3(1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0);用单个标量构造:mat3 m = mat3(1.0);会生成对角矩阵,对角线上的分量都是1.0,其他位置都是0.0。这是最常用的初始化方式,相当于创建单位矩阵。
从低维矩阵升维,比如mat3 m = mat3(mat2);会保留左上角的2x2部分,其余位置按单位矩阵补全。从高维矩阵降维,mat2 m = mat2(mat4);则取左上角2x2。这套构造规则在处理法线矩阵、切空间旋转时经常用到,写的时候要心里有数。
矩阵乘法和向量的顺序不能记错。mat4 * vec4把矩阵作用到向量上,是常规的变换操作;vec4 * mat4相当于对矩阵做转置后再作用,结果完全不同。实际项目里,模型变换矩阵、视图矩阵、透视矩阵的链式乘法,M * V * P这种顺序要始终一致,否则会出现模型扭曲、方向翻转这类很难定位的问题。
3. 复合类型:结构体、数组与接口块
3.1 结构体:把零散的变量打包成有意义的数据单元
结构体在GLSL里的作用和C语言类似,把相关的变量聚合成一个逻辑单元:
struct Light { vec3 direction; vec3 color; float intensity; }; uniform Light u_light; void main() { vec3 diffuse = u_light.color * u_light.intensity * max(dot(normal, -u_light.direction), 0.0); }结构体可以嵌套,成员类型可以是标量、向量、矩阵、数组,甚至另一个结构体。两个结构体变量可以直接赋值,前提是类型完全一致;结构体之间支持==和!=比较,会逐一比较所有成员,但前提是成员类型也要完全相同。
需要注意,不透明类型(采样器、图像、原子计数器)不能作为结构体成员,也不能作为数组元素。这点和很多图形API的处理方式不同,本质原因是这些类型无法被拷贝,而结构体和数组的赋值语义要求成员可以被整体拷贝。
3.2 数组:大小、索引与length()
数组的声明有两种等价写法:
float arr[5]; float[5] arr2; // 4.0以后支持的类型名在前写法,主要用于函数返回值和参数数组大小必须是编译期常量,或者通过初始化列表推断:
float values[] = { 1.0, 2.0, 3.0 }; // 大小推断为3数组访问用[]运算符,越界访问在GLSL里是未定义行为——有的驱动会报错,有的驱动会静默返回垃圾值,这是极其隐蔽的bug来源。数组的成员数量可以通过.length()获取,距离值类型是int,在循环里做边界判断非常方便:
for (int i = 0; i < values.length(); ++i) { total += values[i]; }数组之间不允许整体赋值、比较、拼接。数组作为函数参数时按引用传递(省去拷贝开销),但形参必须声明数组类型,可以用空大小float[]表示“任意长度的数组”,也可以指定固定大小。
我见过不少人在for循环里用float i做索引,然后总是不功——因为GLSL里数组索引必须用整数类型,浮点索引直接编译报错。这个问题在移动端低精度优化时特别容易犯,后面精度部分会再展开。
3.3 接口块:跨阶段数据传递的正规渠道
接口块(interface block)是GLSL中非常重要但容易被初学者忽略的复合类型。它本质上是一个带存储限定符的结构体,用uniform或buffer限定,用于批量管理uniform数据和着色器存储缓冲区(SSBO)。
// uniform块声明在shader里 layout(std140) uniform TransformBlock { mat4 viewProjection; mat4 model; vec3 cameraPosition; float ambientFactor; };接口块和普通结构体最大的区别在于:块内的成员实际存储在GPU缓冲区中,CPU端通过glUniformBlockBinding关联一个缓冲区对象,然后往里填充数据。这就牵出著名的std140布局规则,它规定了GPU缓冲区里每个成员的字节偏移,CPU端结构体必须按同样规则排列才能对上号。
std140的规则归纳起来大致是:
| 类型 | 对齐规则 | 典型偏移 |
|---|---|---|
float/int/bool | 4字节 | 从4字节边界开始 |
vec2/ivec2 | 8字节 | 从8字节边界开始 |
vec3/vec4及更大 | 16字节 | 从16字节边界开始 |
| 数组 | 元素按自身对齐要求排列,整体对齐取其最大成员对齐的倍数 | vec3 arr[2]每元素占16字节 |
| 嵌套结构体 | 按内部最大成员对齐,且整体对齐到其最大成员对齐的倍数 | 内部成员各自对齐后再对外对齐 |
这里最反直觉的一点:vec3变量。表面上是12字节,但std140下它的对齐是16字节,也就是说三个vec3成员连续声明,内存里会是48字节而不是36字节。如果CPU端的C++结构体直接用float pos[3]、float normal[3]这样子排,缓冲区就会错位,渲染出来的模型会奇形怪状,或者干脆什么都不显示。
排查这类问题有个好方法:用glGetActiveUniformsiv查询每个uniform的偏移量,和CPU端结构体做对比。偏移量对不上,十有八九是std140对齐没处理好。
4. 存储限定符:数据从哪来、到哪去
4.1 in/out/const/inout:函数参数与阶段间通信
GLSL的存储限定符定义了变量的生命周期和可见范围。先看最基础的几类:
const:编译期常量,值不可修改,shader内联优化的重要依据。in:函数参数时表示输入参数;在着色器入口(main之外)用于声明从上一个阶段接收的数据,比如顶点着色器里的顶点属性。out:函数参数时表示输出参数,实际上是通过指针/引用语义写回;在着色器入口用于声明传递给下一个阶段的数据。inout:函数参数里既读又写,效果相当于引用传递。
函数参数默认是in,可以省略不写。inout在GLSL里不像C++有引用语法那么直观,但它就是“修改实参本身”的意思:
void applyTransform(inout vec3 pos, mat4 m) { pos = (m * vec4(pos, 1.0)).xyz; }调用后,外面的pos变量会被更新。这在封装光照、雾效、坐标变换这类逻辑时能少写很多返回值拼接。
在旧版GLSL(1.20之前)里,跨阶段变量用attribute和varying这两个关键字,attribute在顶点着色器里声明顶点属性,varying在顶点和片段着色器之间传数据。从GLSL 1.30开始统一替换为in/out。现在写新代码一律用in/out,但看老项目代码时遇到attribute可别懵。
4.2 uniform、buffer与shared:三种不同粒度的共享数据
uniform:着色器全局只读数据,在一次绘制调用中不变。所有可编程阶段都能访问。它适合放模型矩阵、材质参数、光照参数这些高频变更但每个绘制批次只设置一次的数据。现代GL更推荐把多个uniform打包进uniform块,减少状态切换的开销。buffer:声明着色器存储缓冲区(SSBO),支持读写。计算着色器里做通用计算、粒子系统更新、可见性查询都会用。buffer块可以用std430布局,比std140更紧凑,没有vec3那种16字节对齐的额外膨胀。shared:只在计算着色器里使用,用于同一个工作组内线程之间的数据共享。共享内存的大小有硬件上限(通常几十KB),访问要比全局内快很多,适合做分块归约、局部缓存这类优化。
4.3 插值限定符:flat、smooth与noperspective
当变量从顶点着色器输出到片段着色器时,GPU会在片元之间做插值。默认是smooth,就是透视正确插值;flat则不做插值,所有片元都取触发这个图元的顶点值;noperspective表示线性插值,不用透视校正。
这三个限定符里,flat的实用价值最大。典型场景是顶点色ID、图元索引这类数据——如果这些值被插值,边缘会糊成一片,像素级拾取就会出错,flat可以完美避开:
flat out int primitiveID;插值限定符错误导致的问题很典型:模型表面出现奇怪的颜色渐变,或者边缘出现细线。有人会把问题归到光栅化设置上,其实查一下out变量的插值限定符,很快就能定位。
5. 精度限定符:移动端性能的隐形开关
5.1 highp、mediump与lowp到底改了什么
GLSL的精度限定符是一个容易被桌面开发者忽视、但移动端必须重视的特性。三种精度:
| 精度 | 浮点范围(典型) | 典型尾数位 | 适用场景 |
|---|---|---|---|
highp | 32位IEEE754 | 23位 | 位置坐标、矩阵、深度 |
mediump | 范围约±65504 | 10位 | 颜色、UV、法线方向 |
lowp | 范围约±2 | 6位 | 环境光系数、高光强度 |
注意上面给的是相对常见的实现,实际精度由GPU决定,着色器里声明的精度只是“最低要求”。桌面GPU通常把所有精度都实现为32位浮点,所以桌面环境下mediump和highp性能可能没有差别;移动端就不同了——很多移动GPU把mediump实现为半精度浮点(fp16),运算更快、带宽更低,但范围和精度都有限。
highp、mediump、lowp可以修饰float和int,不能修饰bool和矩阵(矩阵的精度由其元素类型决定)。用法有两种:
// 变量级别 mediump vec2 texCoord; // 全局默认精度声明 precision mediump float; precision highp int;5.2 默认精度:片段着色器里的float没有默认值
规范对默认精度做了很严格的规定:
- 顶点着色器中,
float和int默认是highp,所以不写精度也能正常工作。 - 片段着色器中,
int默认是highp,但float没有默认精度——如果不显式声明,会直接编译报错,除非所有浮点变量都显式写了精度。
所以几乎每个片段着色器文件顶部都会有一行:
precision highp float;这句已经成为移动端GLSL的“惯例咒语”了,但很多人不知道它真正解决的是“没有默认精度”这个硬性规则。
5.3 移动端实战:mediump优化要谨慎,踩过的坑很真实
我自己印象最深的一个教训,是帮人排查一个移动端项目。现象是法线贴图在角色近景时边缘出现明显色带,远看以为是贴图压缩问题。后来把片元着色器里的法线变量从mediump vec3改成highp vec3,画面立刻正常。原因很简单:法线贴图采样之后要跟TBN矩阵做乘法,矩阵可能是highp的,两边精度不一致时中间结果被截断到半精度,误差被放大成可见的色阶。
另一个容易翻车的点:在循环里用mediump int做索引。mediump int在部分移动GPU上只有16位,值超过32767就会溢出,循环会变成死循环或者提前退出。这类问题很难肉眼发现,定位时可以把循环变量单独提到highp int验证。
所以我的建议很直接:移动端做精度优化时,优先把UV、颜色、光照系数这类“数值范围小、对误差不敏感”的量降成mediump;凡是要做矩阵乘法、要参与深度计算、要做循环索引的变量,一律保持highp。性能收益虽然存在,但为了一点性能换来画面撕裂和数值错乱的调试成本,性价比太低了。
5.4 invariant与precise:解决同一个表达式在不同shader里计算结果不一致的问题
这部分在《Variables and Types》里不算显眼,但遇到特定问题时极其关键。invariant保证同一个表达式在不同着色器里的计算结果严格一致——GPU编译器可以重新关联浮点表达式(类似CPU的-ffast-math),导致两个shader里写法完全相同的表达式生成了不同的指令顺序,最终数值有微小差异。
典型的场景是深度预渲染:主渲染pass用一套texture矩阵算深度,后处理pass里再用同一套矩阵算深度做测试,两边结果差了一点点,就会出现像素抖动。解决办法是在深度输出变量前加invariant:
invariant out vec3 vDepthPosition;precise是更新的限定符,要求某个变量的计算不能做会改变其值的优化,但允许具体的指令顺序有差异。它和invariant的区别在于:precise影响的是该变量相关的运算,invariant保证的是同一个表达式在不同编译单元里的位精确一致性。平时用不上,但如果做大型渲染管线且涉及到多pass复用相同数值,最好记住有这两个保命关键字。
6. 类型转换与构造:规则严谨,处处是坑
6.1 隐式转换:只做安全的加宽
GLSL并非完全禁止隐式类型转换,但规则非常保守。规范允许的隐式转换大致是“从窄到宽”的方向:
int可以隐式转换为float和doubleuint可以隐式转换为float和doublefloat可以隐式转换为double
注意有两类经常被误以为可以但实际不行的:
int和uint之间没有隐式转换。写uint a = intValue;如果intValue是int类型,编译直接报错。bool不参与任何数值隐式转换。不能把int当bool用,也不能把bool当float用。
隐式转换会发生在表达式内部,比如int + float会自动把int转成float再运算,结果是float。矩阵和向量的混合运算也有类似的隐式升级规则,但维度不匹配依然直接报错。
一个很容易让人困惑的实际例子:uint参与运算时,如果混入int,在某些编译器上会报错,在另一些编译器上会得到更宽的转换。最稳妥的做法是显式构造,不依赖编译器的“仁慈”。
6.2 构造函数与显式转换:一切都可以手动做
GLSL的构造函数同时承担了类型转换的职责,任何标量、向量、矩阵都可以通过显式的构造函数互相转换:
float f = float(intVal); int i = int(floatVal); // 截断,不是四舍五入 uint u = uint(intVal); // 负数会按模运算回卷 vec3 v = vec3(1.0, 2.0, 3.0); ivec3 iv = ivec3(v); // 每个分量独立截断 vec4 v4 = vec4(v3, 1.0); // 追加一个分量 vec3 v3 = vec3(v4); // 丢弃多余分量浮点转整数是截断行为,和C语言一致,不是四舍五入,也不会报溢出错误。对负数来说,转成uint会按2^32取模回卷:
uint u = uint(-1); // 结果是42949672956.3 运算符重载与矩阵乘法陷阱
GLSL为向量和矩阵重载了几乎所有的算术运算符。向量之间按分量逐一运算;向量和标量混合运算,会把标量广播到每个分量。这些都是很方便的语法糖,但有两个陷阱常踩。
第一个是标量乘矩阵和矩阵乘向量时,维数对齐问题。vec3 * mat3和mat3 * vec3都允许,但前者相当于对矩阵做转置后再作用。
第二个是运算结果类型随操作数升级。int * vec3会先把int隐式转成float再乘,结果是vec3;但uint * vec3呢?uint转float也是允许的,所以也能编译。这种“能编译”不代表“语义正确”,尤其在做精确整数计算时,转成浮点再回来会丢精度。需要固定整数语义时,务必把两个操作数都声明成同样的整数类型。
6.4 数组和结构体不能隐式转换
类型转换规则对复合类型不适用。两个结构体即使成员类型完全一样,只要声明类型不同(哪怕仅仅是名字不同?在GLSL里结构体相同定义就是相同类型?实际上结构体类型是按成员类型和顺序区分的,相同定义的结构体是同一个类型。),规范不允许结构体之间隐式转换,也不允许数组和相同元素类型的数组相互转换。所有复合类型的赋值必须是严格类型匹配的。写代码时如果感觉“这个vec3数组和那个vec3数组好像可以互相赋一下”——别想,编译不过。
7. 实操排查:常见编译错误与运行期异常速查
7.1 编译错误汇总表
| 错误信息关键词 | 原因 | 处理办法 |
|---|---|---|
cannot convert from 'float' to 'vec3' | 类型不匹配的赋值或函数实参 | 用构造函数显式转换,检查维度 |
no matching function for call to 'vec4(float, int)' | 构造函数的实参类型不匹配 | 把int实参显式转为float |
implicit conversion from 'int' to 'uint' is not allowed | 有符号整型与无符号整型混用 | 显式做uint()或int()转换 |
swizzle ... can't ... | swizzle表达式混用命名空间或左值重复分量 | 统一用xyzw或rgba,左值不重复 |
array index must be an integer | 用浮点或非整数类型做数组索引 | 索引变量改用int或uint |
'precision' must be ... | 片段着色器的float没有默认精度 | 文件顶部加precision highp float; |
undeclared identifier | 变量名拼写或作用域问题,或者#version不匹配导致关键字不存在 | 检查关键字与GLSL版本是否匹配 |
7.2 一个典型的“类型不匹配”实战案例
有位开发者在计算着色器里写了一个通用卷积函数,声明如下:
shared float cache[256]; void convolve(float[] data, int width, int idx) { ... }调用时传入了shared内存数组。结果编译报错:数组参数无法与shared数组匹配,因为共享变量和普通全局变量在存储空间上不兼容。最后把convolve改成直接操作shared数组的专用版本,或者用buffer块把数据搬出去处理,问题才解决。这类问题在GLSL里很常见:语法的“看起来能传”和规范层面的“存储空间不匹配”是两码事。
7.3 std140对齐错位的排查流程
uniform buffer数据错位的典型特征:同一份代码在桌面端正常,移动端或者某个特定驱动上表现异常,或者改了CPU端结构体之后立刻花屏。排查步骤建议如下:
- CPU端用
glGetUniformBlockIndex拿到块索引。 - 调用
glGetActiveUniformsiv查询每个uniform的GL_UNIFORM_OFFSET。 - 在shader里分别打印/调试每个成员的实际生效值,和CPU写入值做对比。
- 如果偏移不一致,重点检查
vec3、数组、嵌套结构体的对齐规则。
实际项目中我见过有人为了对齐,在C++结构体里手动插入float padding[1]来对齐16字节,结果shader端一改字段顺序,C++端忘了同步,又花了一晚上排查。这件事的教训是:不要依赖手动padding,统一的办法是C++端定义一个宏封装std140对齐规则,或者直接把所有vec3字段手动补成vec4。
提示:凡是跨CPU/GPU边界传递的结构体数据,不要在两边各写一份“看起来一样”的定义,一定要让CPU端和shader端共享同一个布局规则文档或头文件,布局变更时同步才不容易漏。
7.4 矩阵构造顺序错误导致的模型扭曲
某次做骨骼动画,顶点着色器里要先用蒙皮矩阵变换位置,再用模型矩阵变换到世界空间。代码写成了:
mat4 skinTransform = mat4(0.0); mat4 finalTransform = model * skinTransform; // 顺序写反成 skinTransform * model表现是模型每隔一段时间就“跳一下”,不是完全扭曲,但动画明显不对。后来发现不是动画数据问题,而是构造矩阵时第一个参数按行传了,mat4的列主序构造导致变换顺序全乱。GLSL里写矩阵构造时,记住三列是连续的,不能按行的习惯写,否则“旋转”“平移”效果会串位。
排查路径也分享一下:先在CPU端把同一个矩阵用数学库算好,再上传到uniform里做对比,如果CPU算出来的结果和shader计算的不一致,基本可以确定是构造顺序或者列主序/行主序搞反了。
8. 一些项目里的使用建议和最终经验
从实际项目角度,补充几条关于变量与类型使用的长期经验,这可能比单纯背规则更有用。
第一,写shader时先声明所有的in、out、uniform,再写函数和逻辑。这样编译器能更早发现类型问题,GPU编译器的优化也更有依据。很多人习惯把逻辑和变量声明混在一起写,最后变量多了,自己都忘了哪个是哪个。
第二,尽量使用新版布局限定符。现在的GLSL版本都支持location,尽量不用旧式的layout缺省绑定方式,显式指定location可以避免跨阶段接口匹配错位。
第三,计算着色器里索引变量统一用uint,避免和gl_GlobalInvocationID的类型做不必要的转换。顶点属性索引、纹理数组索引等也优先用int,不要随手写成float。
第四,类型转换要显示做,即使编译器允许隐式转换,代码里也应该优先写构造函数。这样在团队协作时,别人看代码能一眼理解你的意图,不用猜这个转换是“有意的”还是“手滑的”。
最后再提一个经常被忽略的点:GLSL的规范文档本身读起来虽然枯燥,但很多驱动编译器的行为是以规范为基准实现的。遇到“这个shader在A卡上正常、在B卡上花屏”这类问题,翻一翻规范里对应的章节,往往比在网上搜半天案例更有效。变量与类型这一章更是在底子上决定了shader能不能编译、编译后跑得对不对。把这里的规则吃透,后面学高级光照、几何着色器、计算着色器,都会顺畅很多。