需要预先学习:


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 指定工作组数量,例如调度 64×64×164 \times 64 \times 1 个 8×8 工作组,总共执行 512×512512 \times 512 个线程:

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:每个线程拥有全局唯一的编号,与"线程要处理的数据元素"一一对应。处理一张 512×512512 \times 512 的纹理时,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 逐像素处理,再画到屏幕。流程分四步:

  1. 场景渲染到 FBO 的颜色纹理(见 帧缓冲);
  2. glDispatchCompute 启动 compute shader,对纹理逐像素反色;
  3. glMemoryBarrier 保证写入对后续采样可见;
  4. 用普通片元着色器把纹理画满屏幕。

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 的定位:渲染管线之外的通用并行计算单元。