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 语言的编译+链接,但更严格。

标准流程如下:

  1. 创建着色器对象:glCreateShader(GL_VERTEX_SHADER)
  2. 上传源码:glShaderSource(shader, 1, &source, NULL)
  3. 编译:glCompileShader(shader)
  4. 检查编译状态:glGetShaderiv(shader, GL_COMPILE_STATUS, &success)
  5. 创建程序对象:glCreateProgram()
  6. 附加着色器:glAttachShader(program, shader)
  7. 链接:glLinkProgram(program)
  8. 检查链接状态:glGetProgramiv(program, GL_LINK_STATUS, &success)
  9. 使用程序: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 知识体系总览

下面这张图概括了着色器开发的核心流程和关键节点:

着色器开发核心流程 着色器开发核心流程 1. 编写着色器源码 GLSL HLSL 2. 编译与链接 顶点着色器 片段着色器 3. 数据绑定 属性 uniform 4. 绘制调用 (glDrawArrays) 常见错误与调试 • 编译错误:版本声明缺失、语法错误、类型不匹配 • 链接错误:输入/输出接口不匹配、uniform 类型冲突 • 运行时错误:属性未启用、uniform 未更新、循环次数不可控 调试方法 • 使用 glGetShaderInfoLog / glGetProgramInfoLog 获取错误日志 • 使用 RenderDoc 或 NVIDIA Nsight 逐帧调试着色器执行

从图上可以清楚看到:编写、编译、绑定、绘制,四个阶段环环相扣。任何一个环节出问题,画面都出不来。

4.7 实战建议

最后给几个实战中的小建议:

  • 先跑通最简单的:别一上来就写复杂的 PBR 着色器。先让一个三角形显示出来,确认编译、链接、绑定的流程没问题。
  • 善用工具:RenderDoc 是我最常用的调试工具。它能捕获每一帧的着色器代码、输入数据、输出结果,比肉眼盯着屏幕强一百倍。
  • 注意精度:移动端 GPU 的 mediump 和 lowp 精度差异很大。我曾经在项目里因为用了 lowp 导致颜色渐变出现明显色带,后来全部改成 mediump 才解决。
再次提醒: 着色器编译失败时,别急着改代码。先看错误日志,日志里通常直接告诉你问题在哪一行。我见过太多人凭感觉改代码,改了半天发现只是少了个分号。