OpenGL / OpenCL 编程指南 / 第 10 章:计算着色器
第 10 章:计算着色器
计算着色器(Compute Shader)是 OpenGL 4.3 引入的强大特性,它脱离图形管线,直接利用 GPU 的并行计算能力执行通用计算任务。
10.1 计算着色器概述
10.1.1 与图形着色器的区别
| 特性 | 图形着色器 | 计算着色器 |
|---|---|---|
| 管线位置 | 嵌入图形管线 | 独立于图形管线 |
| 触发方式 | 由绘制调用触发 | 由分派调用(Dispatch)触发 |
| 输入 | 顶点/片段数据 | 任意缓冲区/纹理 |
| 输出 | 帧缓冲 | 任意缓冲区/纹理 |
| 线程模型 | 逐顶点/逐片段 | 自定义工作组/线程 |
| 共享内存 | 无 | 工作组内共享内存 |
10.1.2 执行模型
Dispatch(numGroupsX, numGroupsY, numGroupsZ)
│
▼
工作组 (0,0,0) 工作组 (1,0,0) 工作组 (2,0,0) ...
┌────────────┐ ┌────────────┐ ┌────────────┐
│ 线程 (0,0) │ │ 线程 (0,0) │ │ 线程 (0,0) │
│ 线程 (1,0) │ │ 线程 (1,0) │ │ 线程 (1,0) │
│ 线程 (2,0) │ │ 线程 (2,0) │ │ 线程 (2,0) │
│ ... │ │ ... │ │ ... │
│ 共享内存 │ │ 共享内存 │ │ 共享内存 │
└────────────┘ └────────────┘ └────────────┘
| 概念 | GLSL 变量 | 说明 |
|---|---|---|
| 工作组大小 | gl_WorkGroupSize | 本地大小 (local_size_x/y/z) |
| 工作组 ID | gl_WorkGroupID | 当前工作组在网格中的位置 |
| 本地线程 ID | gl_LocalInvocationID | 线程在组内的位置 |
| 全局线程 ID | gl_GlobalInvocationID | 线程在全局网格中的位置 |
| 本地线程索引 | gl_LocalInvocationIndex | 一维索引 |
10.2 第一个计算着色器
10.2.1 着色器代码
// compute.glsl - 简单的向量加法
#version 460 core
layout (local_size_x = 256) in; // 每个工作组 256 个线程
layout (std430, binding = 0) buffer InputA {
float dataA[];
};
layout (std430, binding = 1) buffer InputB {
float dataB[];
};
layout (std430, binding = 2) buffer Output {
float dataOut[];
};
void main() {
uint idx = gl_GlobalInvocationID.x;
if (idx < dataA.length()) {
dataOut[idx] = dataA[idx] + dataB[idx];
}
}
10.2.2 C++ 端代码
// 加载计算着色器
GLuint computeShader = glCreateShader(GL_COMPUTE_SHADER);
std::string source = readFile("compute.glsl");
const char* src = source.c_str();
glShaderSource(computeShader, 1, &src, NULL);
glCompileShader(computeShader);
GLuint program = glCreateProgram();
glAttachShader(program, computeShader);
glLinkProgram(program);
// 创建 SSBO
const int N = 1024 * 1024; // 100 万个元素
std::vector<float> a(N, 1.0f), b(N, 2.0f);
GLuint ssboA, ssboB, ssboOut;
glGenBuffers(1, &ssboA);
glGenBuffers(1, &ssboB);
glGenBuffers(1, &ssboOut);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, ssboA);
glBufferData(GL_SHADER_STORAGE_BUFFER, N * sizeof(float), a.data(), GL_STATIC_DRAW);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, ssboB);
glBufferData(GL_SHADER_STORAGE_BUFFER, N * sizeof(float), b.data(), GL_STATIC_DRAW);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, ssboOut);
glBufferData(GL_SHADER_STORAGE_BUFFER, N * sizeof(float), NULL, GL_STATIC_DRAW);
// 绑定到绑定点
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, ssboA);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, ssboB);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, ssboOut);
// 分派计算
glUseProgram(program);
glDispatchCompute(N / 256, 1, 1); // N/256 个工作组
// 等待 GPU 完成
glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT);
// 读回结果
glBindBuffer(GL_SHADER_STORAGE_BUFFER, ssboOut);
float* result = (float*)glMapBuffer(GL_SHADER_STORAGE_BUFFER, GL_READ_ONLY);
// result[i] = 3.0f for all i
glUnmapBuffer(GL_SHADER_STORAGE_BUFFER);
10.3 工作组与线程配置
10.3.1 local_size 声明
// 一维
layout (local_size_x = 256) in;
// 二维
layout (local_size_x = 16, local_size_y = 16) in;
// 三维
layout (local_size_x = 4, local_size_y = 4, local_size_z = 4) in;
10.3.2 线程数量计算
总线程数 = local_size_x × local_size_y × local_size_z
× numGroupsX × numGroupsY × numGroupsZ
示例: layout(local_size_x = 16, local_size_y = 16) in;
glDispatchCompute(64, 64, 1);
总线程 = 16 × 16 × 64 × 64 = 1,048,576 (约 100 万)
10.3.3 工作组大小限制
// 查询限制
int maxWorkGroupSize[3];
glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_SIZE, 0, &maxWorkGroupSize[0]); // X
glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_SIZE, 1, &maxWorkGroupSize[1]); // Y
glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_SIZE, 2, &maxWorkGroupSize[2]); // Z
int maxTotalSize;
glGetIntegerv(GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS, &maxTotalSize); // 通常 1024
| 参数 | 典型值 (NVIDIA) | 典型值 (AMD) |
|---|---|---|
| max local_size_x | 1024 | 1024 |
| max local_size_y | 1024 | 1024 |
| max local_size_z | 64 | 1024 |
| max 总线程数/组 | 1024 | 1024 |
10.4 共享内存(Shared Memory)
10.4.1 概念
共享内存是工作组内所有线程可访问的快速片上存储,比全局显存快 10-100 倍:
全局显存 (VRAM): ~500 GB/s, 延迟 ~200-400 周期
共享内存 (Shared): ~数 TB/s, 延迟 ~20-30 周期
寄存器 (Register): 最快, 延迟 ~1 周期
10.4.2 并行归约示例
// parallel_reduce.glsl - 计算数组总和
#version 460 core
layout (local_size_x = 256) in;
layout (std430, binding = 0) buffer InputBuffer {
float inputData[];
};
layout (std430, binding = 1) buffer OutputBuffer {
float outputData[]; // 每个工作组输出一个和
};
shared float sharedData[256]; // 共享内存
void main() {
uint tid = gl_LocalInvocationID.x;
uint gid = gl_GlobalInvocationID.x;
// 加载数据到共享内存
sharedData[tid] = inputData[gid];
barrier(); // 同步:确保所有线程加载完毕
// 归约:树形求和
for (uint stride = gl_WorkGroupSize.x / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
sharedData[tid] += sharedData[tid + stride];
}
barrier(); // 每步同步
}
// 线程 0 写出结果
if (tid == 0) {
outputData[gl_WorkGroupID.x] = sharedData[0];
}
}
归约过程(8 个线程示例):
初始: [1] [2] [3] [4] [5] [6] [7] [8]
步1: [3] [ ] [7] [ ] [11] [ ] [15] [ ] stride=4
步2: [10] [ ] [ ] [ ] [26] [ ] [ ] [ ] stride=2
步3: [36] [ ] [ ] [ ] [ ] [ ] [ ] [ ] stride=1
结果: 36 = 1+2+3+4+5+6+7+8
10.4.3 barrier() 同步
barrier(); // 工作组内同步屏障
⚠️
barrier()必须在所有线程中统一执行。不能放在if分支中(除非保证所有线程都进入同一分支)。
10.5 图像处理实战
10.5.1 高斯模糊
// gaussian_blur.glsl
#version 460 core
layout (local_size_x = 16, local_size_y = 16) in;
layout (rgba8, binding = 0) uniform image2D inputImage;
layout (rgba8, binding = 1) uniform image2D outputImage;
// 5×5 高斯核
const float kernel[25] = float[](
1.0/256, 4.0/256, 6.0/256, 4.0/256, 1.0/256,
4.0/256, 16.0/256, 24.0/256, 16.0/256, 4.0/256,
6.0/256, 24.0/256, 36.0/256, 24.0/256, 6.0/256,
4.0/256, 16.0/256, 24.0/256, 16.0/256, 4.0/256,
1.0/256, 4.0/256, 6.0/256, 4.0/256, 1.0/256
);
void main() {
ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
ivec2 size = imageSize(inputImage);
if (pixel.x >= size.x || pixel.y >= size.y) return;
vec4 sum = vec4(0.0);
for (int y = -2; y <= 2; y++) {
for (int x = -2; x <= 2; x++) {
ivec2 samplePos = clamp(pixel + ivec2(x, y), ivec2(0), size - 1);
sum += imageLoad(inputImage, samplePos) * kernel[(y+2)*5 + (x+2)];
}
}
imageStore(outputImage, pixel, sum);
}
10.5.2 直方图计算
// histogram.glsl
#version 460 core
layout (local_size_x = 256) in;
layout (rgba8, binding = 0) uniform image2D inputImage;
layout (std430, binding = 0) buffer HistogramBuffer {
uint bins[256];
};
shared uint localBins[256]; // 工作组局部直方图
void main() {
uint tid = gl_LocalInvocationID.x;
// 初始化局部直方图
localBins[tid] = 0;
barrier();
// 每个线程处理多个像素
ivec2 size = imageSize(inputImage);
uint totalPixels = uint(size.x * size.y);
uint threadsPerGroup = gl_WorkGroupSize.x;
for (uint i = tid; i < totalPixels; i += threadsPerGroup) {
ivec2 pos = ivec2(i % uint(size.x), i / uint(size.x));
vec4 color = imageLoad(inputImage, pos);
uint luminance = uint(dot(color.rgb, vec3(0.299, 0.587, 0.114)) * 255.0);
atomicAdd(localBins[luminance], 1u);
}
barrier();
// 累加到全局直方图
atomicAdd(bins[tid], localBins[tid]);
}
10.5.3 与 OpenGL 纹理互操作
// 将 OpenGL 纹理绑定为计算着色器的图像
glBindImageTexture(0, inputTexture, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
glBindImageTexture(1, outputTexture, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA8);
// 分派
glUseProgram(blurProgram);
glDispatchCompute(width / 16, height / 16, 1);
glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);
// 之后可以直接用 outputTexture 进行渲染
10.6 性能优化
10.6.1 线程发散(Thread Divergence)
// ❌ 差:不同线程走不同分支
if (gl_LocalInvocationID.x % 2 == 0) {
// 偶数线程走这里
} else {
// 奇数线程走这里 → GPU 需要两路都执行
}
// ✅ 好:数据预排序,所有线程走同一路径
10.6.2 合并内存访问(Coalesced Access)
// ❌ 差:步长访问
float val = data[gl_GlobalInvocationID.x * stride];
// ✅ 好:连续访问
float val = data[gl_GlobalInvocationID.x];
10.6.3 占用率(Occupancy)
| 因素 | 影响 |
|---|---|
| 每线程寄存器数 | 越少越好(更多线程可同时驻留) |
| 共享内存使用量 | 越少越好(更多工作组可同时执行) |
| 工作组大小 | 太小浪费调度开销,太大限制灵活性 |
10.7 注意事项
⚠️ barrier() 只同步同一工作组内的线程。不同工作组之间无法通过 barrier 同步。需要跨工作组通信必须分多次 dispatch。
⚠️ 共享内存大小有限:通常 32-48 KB / 工作组。超出会导致编译错误或性能下降。
⚠️ imageLoad/imageStore 格式必须匹配:图像单元的格式声明必须与纹理的内部格式一致。
⚠️ 内存屏障:dispatch 之后必须
glMemoryBarrier()才能保证后续操作看到计算着色器的结果。
10.8 业务场景
| 场景 | 说明 |
|---|---|
| 实时图像处理 | 模糊、色调映射、边缘检测 |
| 粒子系统 | 位置/速度更新、碰撞检测 |
| 后期效果 | SSAO、体积光、运动模糊 |
| 数据处理 | 直方图、前缀和、排序 |
| 物理模拟 | 流体、布料、刚体 |
10.9 扩展阅读
| 资源 | 说明 |
|---|---|
| OpenGL Compute Shader 规范 | 官方 GLSL 规范 |
| GPU Gems 3 - Chapter 39: Parallel Prefix Sum | 经典并行算法 |
| Learn OpenGL - Compute Shaders | 计算着色器教程 |
本章小结
- 计算着色器独立于图形管线,由
glDispatchCompute分派 gl_GlobalInvocationID标识全局线程,gl_LocalInvocationID标识组内线程- 共享内存是工作组内的快速片上存储,适合数据复用和归约操作
barrier()同步同一工作组内的所有线程- SSBO 提供大容量可读写缓冲区,是计算着色器的主要数据接口
glBindImageTexture实现纹理与计算着色器的交互
上一章:第 9 章:实例化渲染 下一章:第 11 章:OpenCL 基础