WEBKT

移动端基于Compute Shader的Cluster Culling管线实现

15 0 0 0

在现代图形 API(如 Vulkan 1.3、DirectX 12)中,Mesh Shader(网格着色器)极大地改变了传统的顶点/几何处理管线,使得 GPU 驱动的微几何剔除(Cluster-based Culling)变得极其高效。然而,在广阔的移动端市场中,大量中低端设备依然仅支持 OpenGL ES 3.1 / Vulkan 1.0。这些设备缺乏硬件级 Mesh Shader,但普遍支持 Compute Shader(计算着色器)和 Indirect Drawing(间接绘制)。

本文将介绍如何在不支持 Mesh Shader 的老旧移动设备上,利用 Compute Shader 模拟一套完整的 Cluster-based Culling(基于簇的剔除)管线,实现亚网格级别的超高性能剔除。


1. 核心管线拓扑架构

Mesh Shader 的核心逻辑是:Task Shader 进行粗粒度剔除,Mesh Shader 进行细粒度剔除并输出 Primitive(图元)给光栅化器

在不支持 Mesh Shader 的老旧移动端,我们需要寻找替代方案。最经典且兼容性最佳的平替方案是:Compute Shader + GPU Index Compaction(索引压减)+ DrawIndexedIndirect(间接索引绘制)

其整体拓扑结构设计如下:

[ CPU 端 ] ---> 1. 提交初始数据(或只在初始化时提交一次)
                     |
[ GPU CS 阶段 ] -> 2. Cluster 级剔除计算 (视锥/遮挡/背面剔除)
                     |
                   3. 局部索引重映射与全局索引压减 (Compaction) -> 输出到一个紧凑的全局 Index Buffer
                     |
                   4. 动态写入 DrawIndexedIndirectArgument 结构体
                     |
[ GPU Draw 阶段] -> 5. 使用 glDrawElementsIndirect / vkCmdDrawIndexedIndirect 一次性绘制

在传统的剔除管线中,我们通常以 Mesh(网格)为单位进行剔除,这会导致大量不可见顶点的冗余计算。而在本管线中,我们将网格拆分为更小的 Cluster(也称 Meshlet)


2. 离线数据预处理(Offline Meshlet Slicing)

在 CPU 离线阶段,需要将复杂的 Mesh 划分为大小相近的 Cluster。每个 Cluster 通常包含固定上限的顶点数(例如 64 个)和三角面数(例如 124 个)。

推荐使用开源库 meshoptimizer 的 Meshlet 生成算法。对于每个生成的 Cluster,我们需要提取并保存以下边界和特征数据:

struct ClusterHeader {
    uint vertexOffset;   // 在全局顶点缓存中的偏移
    uint indexOffset;    // 在全局原始索引缓存中的偏移
    uint indexCount;     // 当前 Cluster 的索引数量
    
    // 剔除所需边界数据
    float3 center;       // 包围球中心
    float radius;        // 包围球半径
    
    // 背面锥体剔除所需数据
    float3 coneAxis;     // 锥体轴向
    float coneCutoff;    // 锥体夹角余弦值
};

3. Compute Shader 核心实现:剔除与索引压减

在旧款移动 GPU(如 ARM Mali-T800 系列或 early Adreno 5xx 系列)上,频繁的全局内存原子操作(Atomic Add)会带来严重的管线阻塞。因此,在 Compute Shader 的设计中,我们需要尽可能利用局部工作组(Workgroup)内部的 Shared Memory 进行局部压减,最后再统一提交到全局。

以下是一个优化后的 GLSL Compute Shader 实现:

#version 310 es
layout(local_size_x = 64) in; // 每个工作组处理一个 Cluster

struct ClusterHeader {
    uint vertexOffset;
    uint indexOffset;
    uint indexCount;
    vec3 center;
    float radius;
    vec3 coneAxis;
    float coneCutoff;
};

// 只读:所有 Cluster 的元数据
layout(std430, binding = 0) readonly buffer ClusterBuffer {
    ClusterHeader clusters[];
};

// 只读:相机与视锥体裁剪面参数
layout(std140, binding = 1) uniform CameraParams {
    mat4 viewProj;
    vec4 frustumPlanes[6];
    vec3 cameraPos;
};

// 只读:未剔除前的原始索引缓冲
layout(std430, binding = 2) readonly buffer OriginalIndexBuffer {
    uint originalIndices[];
};

// 写入:压减后的紧凑索引缓冲(供 DrawIndirect 使用)
layout(std430, binding = 3) writeonly buffer CompactedIndexBuffer {
    uint compactedIndices[];
};

// 写入:间接绘制参数结构体
struct DrawElementsIndirectCommand {
    uint count;
    uint instanceCount;
    uint firstIndex;
    uint baseVertex;
    uint baseInstance;
};
layout(std430, binding = 4) buffer IndirectDrawBuffer {
    DrawElementsIndirectCommand drawCmd;
};

// 共享内存:用于在工作组内收集通过剔除的索引偏移
shared uint s_localIndexOffset;
shared bool s_clusterVisible;

// 视锥体剔除函数
bool IsClusterVisible(vec3 center, float radius) {
    for (int i = 0; i < 6; ++i) {
        if (dot(frustumPlanes[i], vec4(center, 1.0)) < -radius) {
            return false;
        }
    }
    return true;
}

// 背面锥体剔除(Backface Cone Culling)
bool IsConeBackfacing(vec3 center, vec3 axis, float cutoff) {
    vec3 dir = normalize(center - cameraPos);
    return dot(dir, axis) >= cutoff;
}

void main() {
    uint clusterIndex = gl_WorkGroupID.x;
    uint localThreadId = gl_LocalInvocationID.x;
    
    if (clusterIndex >= uint(clusters.length())) return;
    
    ClusterHeader cluster = clusters[clusterIndex];
    
    // 0号线程负责执行剔除判定
    if (localThreadId == 0u) {
        s_clusterVisible = true;
        
        // 1. 视锥体剔除
        if (!IsClusterVisible(cluster.center, cluster.radius)) {
            s_clusterVisible = false;
        }
        
        // 2. 背面遮挡剔除(锥体法)
        if (s_clusterVisible && IsConeBackfacing(cluster.center, cluster.coneAxis, cluster.coneCutoff)) {
            s_clusterVisible = false;
        }
        
        // 如果当前 Cluster 可见,向全局申请写入紧凑 Index Buffer 的物理空间偏移
        if (s_clusterVisible) {
            // 使用原子操作获取当前工作组的写入起点
            s_localIndexOffset = atomicAdd(drawCmd.count, cluster.indexCount);
        }
    }
    
    barrier(); // 同步,确保 s_clusterVisible 和 s_localIndexOffset 已就绪
    
    if (s_clusterVisible) {
        uint indexCount = cluster.indexCount;
        
        // 每个线程分摊写入当前 Cluster 的若干个索引
        // 假设 local_size_x 为 64,而 indexCount 最大通常为 124 或 256
        for (uint i = localThreadId; i < indexCount; i += gl_WorkGroupSize.x) {
            uint origIndexValue = originalIndices[cluster.indexOffset + i];
            
            // 写入压减后的全局索引缓冲区
            compactedIndices[s_localIndexOffset + i] = origIndexValue + cluster.vertexOffset;
        }
    }
}

关键优化点剖析:

  1. 工作组内屏障 (barrier) 消除冲突:只有主线程(localThreadId == 0)会调用一次昂贵的全局原子累加操作(atomicAdd),为整个工作组申请连续的写入槽。一旦空间申请完毕,组内所有线程并发地从 originalIndices 读取数据并写入 compactedIndices,极大地降低了原子碰撞延迟(Atomic Contention)。
  2. 基底顶点偏移叠加 (vertexOffset):在传统的 DrawIndexedIndirect 中,通常使用 baseVertex 参数。但在我们合并了多个 Cluster 到同一个动态 Index Buffer 时,不同的 Cluster 对应的原始顶点区间各不相同。为了避免多批次提交,我们在 Compute Shader 压减写入时,直接将顶点的全局绝对偏移(origIndexValue + cluster.vertexOffset)算入索引中。这样在后续绘制时,只需设置 baseVertex = 0,实现真正的一次性(Single-Draw)合批。

4. 运行时的 API 调度逻辑

在 CPU 端,整个绘制流程变得非常干净。在每一帧的渲染循环中,只需进行以下步骤:

Step 1: 重置间接参数(Reset Draw Argument)

在分发 Compute Shader 剔除任务之前,我们需要重置 IndirectDrawBuffer。可以通过 glClearBufferSubData 清零,或者更推荐直接绑定一小段预存的默认结构体数据(instanceCount = 1, count = 0 等)。

Step 2: 派发 Compute Shader(Dispatch)

glUseProgram(cullingComputeProgram);
// 绑定相关的 UBO/SSBO (Bindings 0 ~ 4)
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, clusterBuffer);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, compactedIndexBuffer);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, indirectDrawBuffer);

// 派发,工作组数量即为 Cluster 的总数
glDispatchCompute(totalClusterCount, 1, 1);

Step 3: 设置内存屏障(Memory Barrier)

这是旧款移动 GPU 上最容易产生 Bug 的地方。由于 Compute Shader 写入了 CompactedIndexBufferIndirectDrawBuffer,而紧接着的绘制管线将读取这些缓冲区,因此必须正确设置内存屏障:

// 确保写入的索引和间接命令在顶点拉取和命令读取阶段可见
glMemoryBarrier(GL_ELEMENT_ARRAY_BUFFER_BARRIER_BIT | GL_COMMAND_BARRIER_BIT);

Step 4: 执行间接索引绘制(Indirect Draw Call)

glBindVertexArray(meshVAO); // 绑定不带 Index Buffer 的 VAO,或者绑定空的 VAO

// 关键步:绑定我们刚刚由 Compute Shader 填充的 CompactedIndexBuffer 
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, compactedIndexBuffer);

// 绑定用于控制绘制参数的 Indirect Draw Buffer
glBindBuffer(GL_DRAW_INDIRECT_BUFFER, indirectDrawBuffer);

// 执行单次间接索引绘制
glDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_INT, nullptr);

5. 针对老旧移动端(Mali / Adreno)的避坑指南与极限调优

旧款移动设备(如 Mali-G71, Adreno 506 级及以下)在处理 GPU 驱动管线时,会有很多底层特性的“暗坑”:

  1. 原子操作性能雪崩
    如果在 Compute Shader 中,每个线程都去执行 atomicAdd 来写入全局索引,会导致片上缓存(L2 Cache)频繁同步,帧率骤降。请务必使用本文介绍的 一个工作组仅进行一次全局 atomicAdd 的架构设计。

  2. 宽泛的内存屏障带来的气泡(Pipeline Bubbles)
    glMemoryBarrier(GL_ALL_BARRIER_BITS) 会强行清空所有硬件流水线。请精准配置屏障,仅保留 GL_ELEMENT_ARRAY_BUFFER_BARRIER_BITGL_COMMAND_BARRIER_BIT。如果使用 Vulkan,也应尽可能缩窄 VkBufferMemoryBarriersrcAccessMaskdstAccessMask

  3. 双缓冲区轮转(Double Buffering)防止管线阻塞
    在老旧 GPU 上,如果一帧内完成了 Compute Shader -> DrawIndirect,CPU 会因为等待上一帧的 GPU 绘制完全结束而发生同步阻塞。
    解决方案:对 CompactedIndexBufferIndirectDrawBuffer 建立双缓冲区(Double Buffering)或环形缓冲区(Ring Buffer)机制。当前帧 CS 写入 A 组,绘制 A 组;下一帧写入 B 组,绘制 B 组。这能在很大程度上消除 CPU-GPU 间的停顿时间。

  4. 带宽是第一生命线
    移动端的瓶颈往往在内存带宽而非算力。为了减少 Index Buffer 压减阶段的读写带宽,可以对原始索引使用 16-bit 格式(GL_UNSIGNED_SHORT)。如果 Cluster 包围盒(Bounding Sphere)可以使用半精度浮点数(Half Float)存储,也能节约大量的缓存带宽。

通过这一套架构,即使在五年前的千元移动设备上,也能够轻松支撑数万个微型 Cluster 的实时视锥裁剪与绘制,突破了传统 CPU 粗粒度剔除和提交(Draw Call Limit)的极限。

极客光影 GPU驱动管线移动端游戏开发

评论点评