4、着色器开发基础:GLSL/HLSL语法入门、顶点与片段着色器编写、着色器编译与链接、统一变量与属性绑定
着色器,说白了就是跑在 GPU 上的一段小程序。很多刚入行的同学觉得它神秘,其实没那么复杂。我个人习惯把着色器理解成「流水线上的工人」——每个工人只干一件事,但成千上万个工人一起干,画面就出来了。
这一章我们重点聊 GLSL 和 HLSL 的入门语法,以及怎么把着色器跑起来。嗯,这里要注意:语法只是工具,真正重要的是理解 GPU 的并行执行模型。
4.1 GLSL 与 HLSL 语法对比
先看一个最直观的对比。同样是输出一个红色像素,两种语言怎么写:
| 特性 | GLSL (OpenGL) | HLSL (DirectX) |
|---|---|---|
| 版本声明 | #version 330 core |
#pragma pack_matrix(row_major) |
| 输入输出 | in / out |
Input / Output 结构体 |
| 主函数 | void main() |
void main() |
| 内置变量 | gl_Position, gl_FragColor |
SV_Position, SV_Target |
| 向量类型 | vec2, vec3, vec4 |
float2, float3, float4 |
| 矩阵类型 | mat2, mat3, mat4 |
float2x2, float3x3, float4x4 |
| 采样器 | sampler2D |
Texture2D + SamplerState |
你看,核心概念几乎一样,只是命名风格不同。GLSL 更简洁,HLSL 更接近 C++ 的结构体风格。我个人偏好 GLSL 的写法,但工作中经常两个都要写。
4.2 顶点着色器编写
顶点着色器负责处理每个顶点的位置、颜色、法线等属性。它不关心三角形,只关心顶点。
来看一个最基础的 GLSL 顶点着色器:
#version 330 core
// 输入:每个顶点都有这些属性
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor;
// 输出:传递给片段着色器
out vec3 vColor;
// 统一变量:所有顶点共享
uniform mat4 uModelViewProjection;
void main() {
vColor = aColor;
gl_Position = uModelViewProjection * vec4(aPos, 1.0);
}
这里有几个关键点:
- layout(location):指定属性绑定的位置索引。我建议你显式声明,别依赖编译器自动分配。
- uniform:对所有顶点都一样的常量,比如变换矩阵。
- gl_Position:内置输出变量,必须赋值,否则 GPU 不知道顶点在哪。
我的习惯: 顶点着色器里尽量少做计算。位置变换是必须的,但光照计算我通常放在片段着色器里。为什么?因为顶点数远少于像素数,但顶点着色器的输出会被插值,精度损失可能带来视觉瑕疵。
4.3 片段着色器编写
片段着色器(也叫像素着色器)处理每个像素的颜色。它执行次数最多,是性能瓶颈的高发区。
接上面的例子,对应的片段着色器:
#version 330 core
in vec3 vColor;
out vec4 FragColor;
void main() {
FragColor = vec4(vColor, 1.0);
}
简单吧?但实际项目中远没这么轻松。我曾经在项目里遇到过一个坑:片段着色器里写了一个 for 循环,循环次数依赖 uniform 变量。结果在某个驱动上直接卡成幻灯片。
避坑指南: 片段着色器里的循环次数最好在编译期确定。动态循环次数在不同 GPU 上的表现差异极大,有些驱动会直接退化成串行执行。
4.4 着色器编译与链接
写好了着色器代码,怎么让 GPU 跑起来?这个过程类似 C 语言的编译+链接,但更严格。
标准流程如下:
- 创建着色器对象:
glCreateShader(GL_VERTEX_SHADER) - 上传源码:
glShaderSource(shader, 1, &source, NULL) - 编译:
glCompileShader(shader) - 检查编译状态:
glGetShaderiv(shader, GL_COMPILE_STATUS, &success) - 创建程序对象:
glCreateProgram() - 附加着色器:
glAttachShader(program, shader) - 链接:
glLinkProgram(program) - 检查链接状态:
glGetProgramiv(program, GL_LINK_STATUS, &success) - 使用程序:
glUseProgram(program)
每一步都可能出错。我见过最多的错误是:
- 源码里忘了写
#version声明,编译器直接报错。 - 顶点着色器的输出和片段着色器的输入类型不匹配。
- 使用了两个同名的 uniform 但类型不同。
调试技巧: 编译失败时,用
glGetShaderInfoLog() 获取错误日志。日志里会告诉你哪一行出错了,以及具体原因。别嫌麻烦,每次编译都检查状态,这是习惯问题。
4.5 统一变量与属性绑定
着色器写好了,怎么把 CPU 端的数据传进去?两个途径:
| 途径 | 用途 | 获取方式 | 更新频率 |
|---|---|---|---|
| 属性 (Attribute) | 每个顶点不同的数据 | glGetAttribLocation() |
每帧一次(绑定 VBO) |
| 统一变量 (Uniform) | 所有顶点/片段共享的数据 | glGetUniformLocation() |
随时更新 |
属性绑定我习惯用显式 layout location,这样不用每次查询:
// 着色器里声明
layout(location = 0) in vec3 aPos;
// CPU 端直接绑定
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (void*)0);
glEnableVertexAttribArray(0);
统一变量则需要在每次绘制前更新:
GLint loc = glGetUniformLocation(program, "uModelViewProjection");
glUniformMatrix4fv(loc, 1, GL_FALSE, glm::value_ptr(mvp));
性能建议: 不要把 uniform 更新放在最内层的绘制循环里。我习惯把「不随物体变化的 uniform」提前设置好,比如投影矩阵、环境光颜色。每帧只更新那些真正变化的 uniform。
4.6 知识体系总览
下面这张图概括了着色器开发的核心流程和关键节点:
从图上可以清楚看到:编写、编译、绑定、绘制,四个阶段环环相扣。任何一个环节出问题,画面都出不来。
4.7 实战建议
最后给几个实战中的小建议:
- 先跑通最简单的:别一上来就写复杂的 PBR 着色器。先让一个三角形显示出来,确认编译、链接、绑定的流程没问题。
- 善用工具:RenderDoc 是我最常用的调试工具。它能捕获每一帧的着色器代码、输入数据、输出结果,比肉眼盯着屏幕强一百倍。
- 注意精度:移动端 GPU 的
mediump和lowp精度差异很大。我曾经在项目里因为用了lowp导致颜色渐变出现明显色带,后来全部改成mediump才解决。
再次提醒: 着色器编译失败时,别急着改代码。先看错误日志,日志里通常直接告诉你问题在哪一行。我见过太多人凭感觉改代码,改了半天发现只是少了个分号。