Compute Shader
需要预先学习:
1. Compute Shader 简介
计算着色器(compute shader)是 OpenGL 4.3 引入的着色器阶段。与顶点、片元着色器不同,它不在渲染管线里——不处理顶点、不产生片元,而是直接执行通用计算(GPGPU,General-Purpose GPU computing,用 GPU 做非图形计算)。
普通管线是单向的数据流:顶点 → 光栅化 → 片元 → 帧缓冲,着色器只是管道上的加工站。计算着色器是一块独立的"通用计算单元":你告诉它"开多少个线程、每个线程干什么",它就并行执行,结果可以写进纹理、缓冲,供后续渲染使用,也可以被 CPU 读回做逻辑。
2. 线程模型:工作组
计算着色器的执行模型分两级:一次调度(dispatch)启动若干个工作组(work group),每个工作组内又有若干调用(invocation,即线程)。
| 概念 | 说明 |
|---|---|
| 工作组 | 一次调度划分出的执行块,数量由调度参数决定 |
| 调用 | 工作组内的单个线程,数量由着色器内的 local_size 声明决定 |
#version 430 core
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
layout(local_size_*) 声明每个工作组内的线程数,必须写在 in 后面。C++ 侧用 glDispatchCompute 指定工作组数量,例如调度 个 8×8 工作组,总共执行 个线程:
glDispatchCompute(64, 64, 1);
2.1 内置变量
| 变量 | 含义 |
|---|---|
gl_WorkGroupSize |
工作组大小,即着色器里声明的 local_size |
gl_NumWorkGroups |
调度的工作组总数(glDispatchCompute 的参数) |
gl_WorkGroupID |
当前工作组在整个网格中的 ID |
gl_LocalInvocationID |
当前调用在工作组内的 ID |
gl_GlobalInvocationID |
全局调用 ID,等于 gl_WorkGroupID * gl_WorkGroupSize + gl_LocalInvocationID |
最常用的是 gl_GlobalInvocationID:每个线程拥有全局唯一的编号,与"线程要处理的数据元素"一一对应。处理一张 的纹理时,gl_GlobalInvocationID.xy 恰好就是像素坐标,一行代码完成"线程 → 数据"的映射:
ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
3. 数据的输入输出
计算着色器没有顶点属性、没有帧缓冲,它通过三种途径读写数据:图像(image)、着色器存储缓冲(SSBO)、共享内存(shared)。
3.1 图像(image)读写
图像(image)指可以随机读写的纹理。用 image2D 类型声明,配合 imageLoad / imageStore 直接访问任意像素,适合逐像素处理:
#version 430 core
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
layout(rgba32f, binding = 0) uniform image2D uImage; // 可读写的纹理
void main()
{
ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
vec4 color = imageLoad(uImage, pixel);
imageStore(uImage, pixel, vec4(1.0 - color.rgb, color.a)); // 反色
}
C++ 侧用 glBindImageTexture 把纹理绑定到对应的 binding 点:
glBindImageTexture(0, texColor, 0, GL_FALSE, 0,
GL_READ_WRITE, GL_RGBA32F);
注意:C++ 侧
glBindImageTexture传入的 internalformat 必须与着色器里的layout(rgba32f, ...)一致,否则读写结果是未定义的。图像不像纹理那样有采样器状态(过滤、环绕),它读到的就是原始纹素。
3.2 着色器存储缓冲(SSBO)
SSBO(Shader Storage Buffer Object,着色器存储缓冲对象)是 缓冲对象与顶点 中 buffer 的通用用法:把一块缓冲绑定到着色器,直接读写其中的数组。与 uniform 相比,SSBO 可写(uniform 只能读)、容量大得多(理论上数 GB),是 CPU 与 GPU、以及不同着色器阶段之间交换大批量数据的标准途径。
#version 430 core
layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in;
layout(std430, binding = 0) buffer ParticleBuffer
{
vec4 position[]; // 不定长数组,长度由缓冲大小决定
} particles;
void main()
{
uint i = gl_GlobalInvocationID.x;
particles.position[i].x += 0.01f; // 每个线程更新一个粒子
}
C++ 侧就是普通的缓冲操作,绑定到 binding 0:
// 创建、上传粒子数据(与 VBO 相同,见 [[缓冲对象与顶点]])
GLuint ssbo;
glGenBuffers(1, &ssbo);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, ssbo);
glBufferData(GL_SHADER_STORAGE_BUFFER, count * sizeof(vec4),
particles.data(), GL_DYNAMIC_COPY);
// 每帧:绑定到着色器 binding 0,调度
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, ssbo);
glDispatchCompute(count / 64, 1, 1);
注:
layout(std430)指定内存布局规则。std430 是 std140 的改进版,数组元素按vec4对齐,成员之间不留多余的填充,是 SSBO 的默认选择。两侧结构体定义不一致时,最常见的问题就是布局规则没对上。
3.3 共享内存(shared memory)
工作组内的调用可以通过 shared 变量共享数据,这是工作组内协作的基础。共享内存位于 GPU 的高速内存上(类似 CPU 的缓存),访问比反复读写全局内存快得多。
典型用法:每个线程把数据写入共享数组,同步后再读取其他线程写入的槽位。下面的例子让每个线程计算与左邻居的平均值:
#version 430 core
layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in;
layout(std430, binding = 0) buffer DataBuffer
{
float value[];
} data;
shared float cache[64]; // 工作组内的共享数组
void main()
{
uint idx = gl_GlobalInvocationID.x;
cache[gl_LocalInvocationID.x] = data.value[idx];
barrier(); // 等所有调用写完,之后才能读别人的槽位
// 现在可以安全读取左邻居写入的值
uint left = max(gl_LocalInvocationID.x, 1u) - 1u;
float neighbor = cache[left];
data.value[idx] = (cache[gl_LocalInvocationID.x] + neighbor) * 0.5f;
}
注意:
barrier()让工作组内所有调用同步——只有全部到达该语句后才会继续。没有它,一个线程可能读到另一个线程还没写入的旧数据,这是共享内存最常见的 bug 来源。
4. 内存同步与屏障
GPU 上并行执行的线程之间没有隐式的顺序保证。compute shader 相关的同步手段有三级:
| 手段 | 作用范围 | 用途 |
|---|---|---|
barrier() |
工作组内 | 所有调用同步到同一点,之后共享内存读写安全 |
memoryBarrier*()(着色器内) |
全局 | 保证此前对共享内存 / 图像 / 缓冲的写入对后续操作可见 |
glMemoryBarrier()(C++ 侧) |
全局 | 保证计算着色器的写入对后续管线阶段(纹理采样、顶点输入等)可见 |
典型的坑:compute shader 写完一张纹理,紧接着用普通渲染管线采样它,中间没有 glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT),采样可能读到旧数据——GPU 允许把写入延迟到可见性屏障处才真正落盘。
glDispatchCompute(64, 64, 1);
// 必须加屏障:把 compute 的写入对后续"纹理采样"变得可见
glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT);
// 之后采样该纹理的绘制才是安全的
注:
barrier()与memoryBarrier*()是两回事:前者同步执行流(所有调用等齐),后者保证内存可见性(写入对其他单元可见)。共享内存协作两者常常都要,跨阶段的数据交换主要靠glMemoryBarrier。
5. 完整示例:计算着色器做图像反色
把场景渲染到纹理,用 compute shader 逐像素处理,再画到屏幕。流程分四步:
- 场景渲染到 FBO 的颜色纹理(见 帧缓冲);
glDispatchCompute启动 compute shader,对纹理逐像素反色;glMemoryBarrier保证写入对后续采样可见;- 用普通片元着色器把纹理画满屏幕。
5.1 计算着色器
#version 430 core
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
layout(rgba8, binding = 0) uniform image2D uImage;
void main()
{
ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
vec4 color = imageLoad(uImage, pixel);
imageStore(uImage, pixel, vec4(1.0 - color.rgb, color.a));
}
5.2 C++ 侧调度
// 编译链接与普通着色器相同(见 [[着色器]])
GLuint computeProg = createShaderProgram("invert.comp");
// 每帧:
glUseProgram(computeProg);
glBindImageTexture(0, texScene, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA8);
glDispatchCompute(texW / 8, texH / 8, 1); // 工作组铺满整个纹理
glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT); // 写入对后续采样可见
// 之后正常绘制全屏四边形,采样 texScene 显示结果
注:
glDispatchCompute(texW / 8, texH / 8, 1)要求尺寸能被 8 整除。不能被整除时工作组数量要向上取整,着色器里用if (pixel >= uSize) return;剔除越界的线程。
6. 应用
计算着色器在渲染中的典型用途:
| 用途 | 做什么 |
|---|---|
| 后处理 | 模糊、锐化、色调映射等逐像素操作,比多 pass 片元着色器更灵活 |
| 粒子系统 | 每帧更新粒子位置与速度,位置缓冲直接喂给顶点着色器 |
| GPU 剔除 | 在 GPU 上做视锥剔除与 LOD 选择,减少 CPU-GPU 往返 |
| 骨骼蒙皮 | 并行计算顶点变换后的位置 |
| 物理模拟 | 布料、流体、刚体 |
| 稀疏数据结构 | 体素化、GPU 哈希网格 |
共同点:这些任务都不直接产生画面,而是产出数据(位置、颜色、标记),交给后续渲染消费——这正是 compute shader 的定位:渲染管线之外的通用并行计算单元。



