本节摘要:GLSL 的函数语法接近 C,但控制流的代价模型完全不同——分支的开销取决于"组内线程是否走同一条路",而不是判断本身。本节讲清分支与循环的真实成本、函数的参数限定符,以及
in/out/uniform这组决定数据通道的关键字——它们是读懂一切着色器模板的钥匙。
CPU 程序员的直觉是"if 判断很便宜"。GPU 上这个直觉必须修正。着色器线程是成组调度的:一批线程锁步执行同一指令序列。遇到分支时,硬件把组内两条路径都走一遍,每个线程按自己的条件"假装"执行不属于它的那条路——术语叫分支发散。组内条件越杂,浪费越大。
// 反例:按片元条件分支,同一批线程条件各异,两条路都要走 if (texture(uTex, vUv).r > 0.5) { color = color * 1.5; } else { color = doExpensiveCalc(color); }
上面这段代码在最坏情况下等于把便宜路径和昂贵路径都执行一遍。改写思路有两种:用混合函数把分支抹平,或者确认条件在整组线程上一致。
// 改法一:无分支化,用 step 得到 0 或 1 的权重做插值 float k = step(0.5, texture(uTex, vUv).r); color = mix(doExpensiveCalc(color), color * 1.5, k); // 改法二:uniform 条件分支是安全的——所有线程看到同一个值,方向必然一致 if (uEnableVignette) { color *= vignetteFactor(vUv); }
step 配 mix 的无分支写法是片元着色器的日常武器,第 4 章会大量使用。判断分支是否值得写,只需要问一句:条件对所有线程是否同值?同值(uniform、常量)放心写;异值(依赖纹理、依赖插值属性)尽量无分支化。循环的规矩类似:循环边界是常量或 uniform 时,驱动能展开优化;边界依赖逐线程数据的循环则要慎用,且 GLSL 不保证动态循环的高效实现。
GLSL 函数与 C 函数神似,但有三条硬约束:不支持递归(调用栈在 GPU 上是奢侈品)、所有函数必须在使用前声明或定义、返回值不能是数组。日常写法如下:
// 工具函数:求亮度(人眼对各通道敏感度不同,权重不相等) float luminance(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); } // 参数限定符:in(默认,值传入)、out(结果传出)、inout(双向) void expandUV(inout vec2 uv, float scale) { uv = (uv - 0.5) * scale + 0.5; // 以中心为基准缩放 UV }
inout 参数是 GLSL 里少数能"修改调用者变量"的手段,相当于 C 里的传指针但语法更干净。工具函数的积累非常重要:成熟的着色器工程师都有一份自己的小函数库(亮度、哈希噪声、旋转矩阵、雾混合),第 4 章的数学工具箱会帮你攒起第一批。
💡 关键直觉:把着色器函数当"纯数学函数"设计——不改全局状态、不依赖执行历史,同输入必同输出。这个习惯能避开绝大多数着色器调试噩梦,也为将来迁移到 compute 场景打好底子。
一个变量前的限定符,宣告的是它站在哪条数据通道上。这是 GLSL 与普通语言差异最大的地方,用一张表对齐:
| 限定符 | 数据方向 | 变化粒度 | 典型内容 |
|---|---|---|---|
in |
上一阶段流入本阶段 | 每顶点 / 每片元各异 | 顶点坐标、插值后的 UV 与法线 |
out |
本阶段流向下一阶段 | 每顶点 / 每片元各异 | 变换后的位置、传给片元的属性 |
uniform |
CPU 流入 GPU | 一次绘制内全体相同 | 矩阵、时间、材质参数、灯光位置 |
const |
编译期确定 | 永不变化 | 数学常量、权重表 |
把两个着色器的 in/out 想成插头与插座:顶点着色器 out vec2 vUv; 声明了一个插座,片元着色器 in vec2 vUv; 必须提供匹配的插头——名字相同、类型相同,缺一不可。链接器会做匹配检查,对不上直接报链接错误。数据在两站之间经历插值:顶点处的值在三角形表面上按重心坐标线性混合后交给每个片元,这一机制是第 4 章的主角。
uniform 则是全体线程共享的只读常量,CPU 在绘制前上传,绘制期间不变。它是 CPU 与 GPU 之间最重要的参数通道:时间、相机矩阵、光源参数全走这里。误用 uniform 当可写变量是新手高频错误——尝试在着色器里给 uniform 赋值,编译器会直接拒绝,这是好事,说明通道语义被语言强制了。
// 顶点着色器:完整的通道视角 #version 330 core layout (location = 0) in vec3 aPos; // in:顶点缓冲流入,location 对应 CPU 侧绑定 layout (location = 1) in vec2 aUv; uniform mat4 uMVP; // uniform:CPU 上传,全体顶点共用 out vec2 vUv; // out:流向片元着色器的插座 void main() { vUv = aUv; gl_Position = uMVP * vec4(aPos, 1.0); }
顺带交代历史包袱:老教程里的 attribute 与 varying 分别是 in(顶点阶段)与 out/in(跨阶段)的旧名,GLSL 3.30 起全面换成现代写法。看老代码时在脑子里做个替换映射即可,新代码一律用现代限定符。
问:GLSL 有标准库吗?想排序、查字符串怎么办? 没有也不需要。着色器函数库是数学向的(三角、指数、向量、矩阵),字符串与容器这类 CPU 概念在 GPU 上没有立足点。真需要复杂数据处理,那是 CPU 的活,处理好再把结果喂进来——着色器只做它擅长的数值变换。
问:两个着色器阶段能用同一个函数吗? 可以,把工具函数放进公共源码字符串,编译顶点与片元着色器时各自拼接进去。工程里常见的组织方式是"公共头"——噪声函数、颜色空间转换、旋转矩阵这类通用件集中维护,各阶段按需引入。注意GLSL 没有真正的链接器,重复引入同名函数会直接编译报错。
问:uniform 数量有上限吗? 有,且各平台不一:移动端常见 256 个 vec4 的量级,桌面宽裕得多。超限的症状是编译或链接报错,也可能静默失败。参数多的材质应该优先改用纹理存参数(查表思路)或常量块分池——这也是引擎里"材质参数面板"背后做结构设计的原因。
step 加 mix 无分支化;inout 参数是修改调用者变量的合法通道;in 收上游、out 交下游、uniform 接 CPU、const 编译期定死;out 与片元 in 必须名字类型双匹配,中间自动发生插值。语法工具齐了。下一章进入顶点着色器的正面战场:坐标变换与 MVP 矩阵。