移动端基于Compute Shader的Cluster Culling管线实现
在现代图形 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;
}
}
}
关键优化点剖析:
- 工作组内屏障 (
barrier) 消除冲突:只有主线程(localThreadId == 0)会调用一次昂贵的全局原子累加操作(atomicAdd),为整个工作组申请连续的写入槽。一旦空间申请完毕,组内所有线程并发地从originalIndices读取数据并写入compactedIndices,极大地降低了原子碰撞延迟(Atomic Contention)。 - 基底顶点偏移叠加 (
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 写入了 CompactedIndexBuffer 和 IndirectDrawBuffer,而紧接着的绘制管线将读取这些缓冲区,因此必须正确设置内存屏障:
// 确保写入的索引和间接命令在顶点拉取和命令读取阶段可见
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 驱动管线时,会有很多底层特性的“暗坑”:
原子操作性能雪崩:
如果在 Compute Shader 中,每个线程都去执行atomicAdd来写入全局索引,会导致片上缓存(L2 Cache)频繁同步,帧率骤降。请务必使用本文介绍的 一个工作组仅进行一次全局 atomicAdd 的架构设计。宽泛的内存屏障带来的气泡(Pipeline Bubbles):
glMemoryBarrier(GL_ALL_BARRIER_BITS)会强行清空所有硬件流水线。请精准配置屏障,仅保留GL_ELEMENT_ARRAY_BUFFER_BARRIER_BIT与GL_COMMAND_BARRIER_BIT。如果使用 Vulkan,也应尽可能缩窄VkBufferMemoryBarrier的srcAccessMask和dstAccessMask。双缓冲区轮转(Double Buffering)防止管线阻塞:
在老旧 GPU 上,如果一帧内完成了Compute Shader -> DrawIndirect,CPU 会因为等待上一帧的 GPU 绘制完全结束而发生同步阻塞。
解决方案:对CompactedIndexBuffer和IndirectDrawBuffer建立双缓冲区(Double Buffering)或环形缓冲区(Ring Buffer)机制。当前帧 CS 写入 A 组,绘制 A 组;下一帧写入 B 组,绘制 B 组。这能在很大程度上消除 CPU-GPU 间的停顿时间。带宽是第一生命线:
移动端的瓶颈往往在内存带宽而非算力。为了减少 Index Buffer 压减阶段的读写带宽,可以对原始索引使用 16-bit 格式(GL_UNSIGNED_SHORT)。如果 Cluster 包围盒(Bounding Sphere)可以使用半精度浮点数(Half Float)存储,也能节约大量的缓存带宽。
通过这一套架构,即使在五年前的千元移动设备上,也能够轻松支撑数万个微型 Cluster 的实时视锥裁剪与绘制,突破了传统 CPU 粗粒度剔除和提交(Draw Call Limit)的极限。