第二十四章:计算着色器(Compute Shader):GPU通用计算,实现简单的N体模拟

说到OpenGL ES,大家第一反应肯定是画三角形、贴纹理、做特效。但你知道吗?GPU能干的事远不止渲染。计算着色器(Compute Shader)就是专门干这个的——让GPU帮你算数据,而不是画图。

我个人觉得,计算着色器是OpenGL ES里最被低估的功能。很多人学了半年OpenGL,都不知道还有这玩意儿。今天我们就拿一个经典的N体模拟来开刀,看看怎么用GPU做通用计算。

什么是计算着色器?

说白了,计算着色器就是一个跑在GPU上的小程序。它不关心渲染管线,不关心帧缓冲,只管一件事:算数。

你给它一堆数据,它用成百上千个线程同时处理。这就是所谓的GPU通用计算(GPGPU)。

核心区别:

  • 顶点/片段着色器:为渲染服务,输入输出有固定格式
  • 计算着色器:为计算服务,输入输出完全由你定义

我在项目中遇到过这样一个场景:需要实时计算几千个粒子的物理运动。如果用CPU算,帧率直接掉到个位数。换成计算着色器,轻轻松松60帧。这就是GPU并行计算的力量。

计算着色器的工作模型

计算着色器引入了一个新概念——工作组(Work Group)。你可以把它想象成一个线程块。

每个工作组里有一堆线程,它们共享局部内存。工作组之间是独立的,可以并行执行。

计算着色器执行模型 全局工作组网格 (Global Work Group) 工作组 0 线程0 线程1 线程2 线程3 线程4 线程5 工作组 1 线程0 线程1 线程2 线程3 线程4 线程5 工作组 2 线程0 线程1 线程2 线程3 线程4 线程5 共享内存 (Shared Memory) 共享内存 (Shared Memory) 共享内存 (Shared Memory) 全局内存 (Global Memory) — 存储所有粒子数据

每个线程都有一个全局ID,通过这个ID可以知道自己要处理哪部分数据。比如粒子模拟中,线程ID就是粒子的索引。

N体模拟的核心逻辑

N体模拟,就是计算N个粒子之间的引力。每个粒子都受到其他所有粒子的引力影响。

公式很简单:

F = G * m1 * m2 / r²

但问题是,N个粒子就要算N²次。当N=1000时,就是100万次计算。CPU扛不住,但GPU可以。

我的经验:N体模拟是学习计算着色器的绝佳案例。它逻辑简单,但并行度极高,能充分体现GPU的优势。

实现步骤

我们一步步来。先看计算着色器的代码,再看怎么在OpenGL ES中调用它。

1. 计算着色器代码

#version 320 es
layout(local_size_x = 256) in;

// 粒子数据:位置和速度
layout(std430, binding = 0) buffer PosBuffer {
    vec4 positions[];
};

layout(std430, binding = 1) buffer VelBuffer {
    vec4 velocities[];
};

uniform float deltaTime;
uniform float softeningSq;  // 防止除零

void main() {
    uint i = gl_GlobalInvocationID.x;
    uint N = gl_NumWorkGroups.x * gl_WorkGroupSize.x;
    
    if (i >= N) return;
    
    vec3 pos_i = positions[i].xyz;
    vec3 acc = vec3(0.0);
    
    // 计算其他粒子对i的引力
    for (uint j = 0; j < N; j++) {
        if (i == j) continue;
        
        vec3 pos_j = positions[j].xyz;
        vec3 diff = pos_j - pos_i;
        float distSq = dot(diff, diff) + softeningSq;
        float invDist = inversesqrt(distSq);
        float invDist3 = invDist * invDist * invDist;
        
        acc += diff * invDist3;
    }
    
    // 更新速度
    velocities[i].xyz += acc * deltaTime;
    // 更新位置
    positions[i].xyz += velocities[i].xyz * deltaTime;
}

注意看,每个线程只处理一个粒子。它遍历所有其他粒子,累加引力。这就是典型的N²算法。

曾经踩过的坑:我第一次写的时候忘了加softeningSq,结果粒子距离太近时直接爆炸了。除零问题在GPU上特别隐蔽,因为不会报错,只会出NaN。

2. 创建计算着色器程序

在OpenGL ES中,计算着色器的创建流程和普通着色器一样:

// 创建着色器
GLuint computeShader = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(computeShader, 1, &computeSource, NULL);
glCompileShader(computeShader);

// 检查编译状态
GLint success;
glGetShaderiv(computeShader, GL_COMPILE_STATUS, &success);
if (!success) {
    char infoLog[512];
    glGetShaderInfoLog(computeShader, 512, NULL, infoLog);
    // 处理错误
}

// 创建程序并链接
GLuint computeProgram = glCreateProgram();
glAttachShader(computeProgram, computeShader);
glLinkProgram(computeProgram);

// 清理
glDeleteShader(computeShader);

3. 创建SSBO(着色器存储缓冲对象)

计算着色器不能直接用VBO,得用SSBO。SSBO可以在着色器之间共享数据。

// 创建位置缓冲
GLuint posSSBO;
glGenBuffers(1, &posSSBO);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, posSSBO);
glBufferData(GL_SHADER_STORAGE_BUFFER, 
             N * sizeof(vec4), 
             initialPositions, 
             GL_DYNAMIC_DRAW);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, posSSBO);

// 创建速度缓冲
GLuint velSSBO;
glGenBuffers(1, &velSSBO);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, velSSBO);
glBufferData(GL_SHADER_STORAGE_BUFFER, 
             N * sizeof(vec4), 
             initialVelocities, 
             GL_DYNAMIC_DRAW);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, velSSBO);

这里有个细节:glBindBufferBase的第二个参数是binding索引,要和着色器里的binding = 0对应上。

4. 调度计算

一切准备就绪后,就可以让GPU干活了:

glUseProgram(computeProgram);

// 设置uniform
glUniform1f(glGetUniformLocation(computeProgram, "deltaTime"), 0.016f);
glUniform1f(glGetUniformLocation(computeProgram, "softeningSq"), 0.01f);

// 调度计算
// 假设有1024个粒子,工作组大小256,需要4个工作组
GLuint numGroups = (N + 255) / 256;
glDispatchCompute(numGroups, 1, 1);

// 确保计算完成
glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT);

个人习惯:我一般把工作组大小设为256,这是大多数GPU的warp大小,效率最高。当然,具体数值要看设备支持的最大值。

5. 渲染结果

计算完成后,SSBO里已经是最新的粒子位置了。怎么把它画出来?

最简单的方式:把SSBO绑定到顶点属性上,然后直接draw。

// 绑定位置SSBO到顶点属性
glBindBuffer(GL_ARRAY_BUFFER, posSSBO);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0);
glEnableVertexAttribArray(0);

// 绘制点
glDrawArrays(GL_POINTS, 0, N);

你看,计算和渲染用的是同一份数据。这就是计算着色器的威力——数据不用来回拷贝。

性能优化技巧

N体模拟虽然简单,但想跑得快还是有点门道的。分享几个我实际用过的优化:

优化手段 说明 效果
共享内存 把粒子数据加载到共享内存,减少全局内存访问 提升2-3倍
分块计算 把粒子分成小块,每块只和附近块交互 适合大规模模拟
精度控制 用mediump代替highp 提升30%左右
减少分支 避免if语句,用数学方式处理边界 提升10-20%

我曾经做过一个测试:1024个粒子,用共享内存优化后,帧率从45fps提升到了120fps。效果非常明显。

共享内存版本示例

这里给一个使用共享内存的优化版本片段:

shared vec3 sharedPos[256];  // 工作组大小256

void main() {
    uint i = gl_GlobalInvocationID.x;
    uint localId = gl_LocalInvocationID.x;
    uint N = gl_NumWorkGroups.x * gl_WorkGroupSize.x;
    
    vec3 pos_i = positions[i].xyz;
    vec3 acc = vec3(0.0);
    
    // 分块处理
    for (uint block = 0; block < gl_NumWorkGroups.x; block++) {
        // 加载当前块到共享内存
        uint idx = block * gl_WorkGroupSize.x + localId;
        sharedPos[localId] = positions[idx].xyz;
        memoryBarrierShared();
        barrier();
        
        // 计算当前块内粒子的引力
        for (uint j = 0; j < gl_WorkGroupSize.x; j++) {
            uint globalJ = block * gl_WorkGroupSize.x + j;
            if (i == globalJ) continue;
            
            vec3 diff = sharedPos[j] - pos_i;
            float distSq = dot(diff, diff) + 0.01;
            float invDist = inversesqrt(distSq);
            acc += diff * (invDist * invDist * invDist);
        }
        
        memoryBarrierShared();
        barrier();
    }
    
    velocities[i].xyz += acc * 0.016;
    positions[i].xyz += velocities[i].xyz * 0.016;
}

注意:共享内存的大小是有限制的。OpenGL ES 3.1要求至少32KB。如果你的工作组太大,可能装不下。我建议先查一下设备的最大共享内存大小。

调试技巧

调试计算着色器比调试普通着色器麻烦。因为你看不到中间结果。我的经验是:

  • 先用小规模数据测试,比如4个粒子
  • 把计算结果回读到CPU,和CPU计算结果对比
  • glGetError()检查每一步的错误

回读数据的代码:

glBindBuffer(GL_SHADER_STORAGE_BUFFER, posSSBO);
vec4* result = (vec4*)glMapBufferRange(
    GL_SHADER_STORAGE_BUFFER,
    0,
    N * sizeof(vec4),
    GL_MAP_READ_BIT
);

// 检查结果
for (int i = 0; i < N; i++) {
    // 和CPU计算结果对比
}

glUnmapBuffer(GL_SHADER_STORAGE_BUFFER);

嗯,这里要注意:glMapBufferRange会阻塞CPU,直到GPU完成计算。所以别在每帧都调用,只在调试时用。

总结

计算着色器让GPU不再只是画图工具。N体模拟只是冰山一角,你还可以用它做物理模拟、图像处理、甚至机器学习推理。

我个人觉得,掌握计算着色器是区分普通OpenGL开发者和高级图形工程师的分水岭。它打开了GPU通用计算的大门,让你能用GPU解决渲染之外的问题。

最后提醒一句:不同设备对计算着色器的支持程度不同。开发时记得检查GL_MAX_COMPUTE_WORK_GROUP_SIZE等参数,别想当然。

核心要点回顾:

  • 计算着色器跑在GPU上,用于通用计算
  • 工作组是基本执行单元,线程通过全局ID索引数据
  • SSBO是计算着色器和渲染管线之间的数据桥梁
  • 共享内存能大幅提升性能,但要注意大小限制
  • 调试时用小规模数据+回读对比
公众号:蓝海资料掘金营,微信deep3321