强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

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)
工作组 IDgl_WorkGroupID当前工作组在网格中的位置
本地线程 IDgl_LocalInvocationID线程在组内的位置
全局线程 IDgl_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_x10241024
max local_size_y10241024
max local_size_z641024
max 总线程数/组10241024

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 基础