一幅开放世界可能有十万个物体。若每帧 CPU 都遍历场景、裁剪、选材质、逐物体发 Draw Call,光是命令提交就能吃满主线程——GPU 反而在空等。GPU Driven Rendering 把这条链反转为"CPU 上传场景数据 → GPU 自行剔除 → GPU 生成绘制命令 → GPU 直接执行":可见性判断、LOD 选择、实例化批处理全部下沉到计算着色器。本文从 CPU 瓶颈讲起,覆盖间接绘制、视锥/遮挡剔除、Hi-Z、LOD 误差指标与批处理,与 游戏引擎架构 的场景组织思想相互印证。
一、CPU 提交瓶颈
一句话:每个 Draw Call 都有固定 CPU 开销(状态校验、命令编码、驱动处理),物体一多 CPU 提交成了卡帧主因——GPU 等待时间反超 GPU 执行时间。
1.1 Draw Call 的真实成本
一次 vkCmdDraw 不只是"把命令写进缓冲":
- 驱动校验管线状态、解析着色器绑定;
- CPU 与 GPU 之间的命令缓冲同步;
- 若状态切换频繁,驱动还要做重编译/重绑定;
- 每帧每物体至少一次提交,一万物体就有一万次。
经验上单线程 CPU 每帧能稳定提交的 Draw Call 约数千到一万,而现代 GPU 的三角形吞吐量远超于此——瓶颈在"喂"的速度。
1.2 传统优化的局限
- 合并静态网格:只能合并相同材质、相同变换的物体;
- 实例化(Instancing):同类物体一次提交,但需要 CPU 预先确定可见集;
- CPU 侧视锥剔除:每帧 CPU 遍历物体做裁剪,仍随物体数线性增长。
这些优化把瓶颈从"命令条数"推后,却绕不开"CPU 逐个物体决定画不画"这个本质。
1.3 数据流对比
传统:
CPU: 遍历物体 → 视锥剔除 → 选材质 → vkCmdDraw × N → 同步
GPU: 执行 N 个 Draw,被 CPU 拖慢
GPU Driven:
CPU: 上传 场景数据(位置/包围盒/网格) → 帧命令(间接绘制入口)
GPU: 计算着色器遍历物体 → 视锥/遮挡剔除 → LOD 选择 → 生成绘制命令
GPU: 直接执行生成好的间接绘制列表
二、间接绘制:GPU 生成绘制命令
一句话:间接绘制让 GPU 从一个缓冲里读取"画什么、画多少个、绑定哪份数据",CPU 只需一次提交,绘制参数由 GPU 上的剔除 Pass 动态写入。
2.1 VkDrawIndexedIndirect 与 DispatchIndirect
// Vulkan:间接绘制命令结构(GPU 可写的绘制参数)
typedef struct VkDrawIndexedIndirectCommand {
uint32_t indexCount; // 顶点索引数
uint32_t instanceCount; // 实例数(含实例化批的物体数)
uint32_t firstIndex;
int32_t vertexOffset;
uint32_t firstInstance;
} VkDrawIndexedIndirectCommand;
vkCmdDrawIndexedIndirect(cmd, indirectBuffer, offset,
drawCount, stride);
indirectBuffer 由 GPU 上的计算着色器填充:剔除 Pass 把"可见物体"的绘制参数写进该缓冲,drawCount 也可由 VkDispatchIndirect 结果驱动(可变数量的绘制组)。
// C++:每帧上传的间接绘制参数
DrawCommand commands[MAX_OBJECTS];
for (int i = 0; i < visibleCount; ++i)
commands[i] = makeDraw(obj[i].mesh, obj[i].instanceCount, ...);
vkCmdUpdateBuffer(cmd, indirectBuffer, 0,
visibleCount * sizeof(DrawCommand), commands);
2.2 DX12 对应:ExecuteIndirect
DX12 的 ExecuteIndirect 支持更丰富的命令签名(Command Signature):可以只更新顶点缓冲/常量缓冲/绘制参数中的某几项,配合 IndirectArgsBuffer 实现同样的 GPU 驱动绘制。
DX12 ExecuteIndirect 命令签名:
(可选) 根常量更新 → 根描述符表切换 → 索引/顶点缓冲 → 绘制参数
由 GPU 逐条读取并执行,省去 CPU 往返
2.3 关键的"可变绘制数"
传统的间接绘制每帧画固定条数;GPU Driven 通常需要"剔除后还剩多少画多少"。方法:
- 用
vkCmdDispatchIndirect触发剔除 Pass,其线程数由上一帧的结果决定; - 或用原子计数器统计可见物体数,交给下一次间接绘制。
这让绘制开销严格跟随可见集,空载场景的 GPU 负载几乎为零。
三、GPU 视锥剔除
一句话:视锥剔除在计算着色器里逐物体做包围球/包围盒 vs 视锥六面体测试,一次 dispatch 处理上万个物体,把不可见物体在绘制前就过滤掉。
3.1 剔除着色器结构
每个物体一条线程(或一个线程处理数个物体),从 GPU 场景缓冲读取包围盒与世界矩阵,测试六个视锥平面:
// GLSL:视锥剔除(每物体一个线程)
bool frustumTest(vec4 planes[6], vec3 center, float radius) {
for (int i = 0; i < 6; ++i) {
float d = dot(planes[i].xyz, center) + planes[i].w;
if (d < -radius) return false; // 完全在外侧 → 剔除
}
return true;
}
void main() {
Object o = objects[gl_GlobalInvocationID.x];
vec4 sphere = sphereFromAABB(o.bounds, o.transform); // 世界包围球
if (frustumTest(frustumPlanes, sphere.xyz, sphere.w)) {
uint slot;
InterlockedAdd(visibleCounter[0], 1u, slot);
visibleList[slot] = o; // 写入可见表
}
}
3.2 数据布局
场景物体以 StructuredBuffer(position / rotation / scale / bounds / meshId)常驻 GPU,CPU 只在物体增删或动画关键帧时更新。包围体用 AABB(可复用于 LOD 与遮挡)或球体(剔除便宜)。所有物体共享同一份几何数据(Mesh 库),剔除结果只是一个索引数组。
3.3 剔除的并行度
现代 GPU 一个 dispatch 可处理数百万线程,数万物体一次 dispatch 毫秒级完成。相比 CPU 遍历,GPU 剔除的优势不仅是快,而是并行度与带宽:物体数据只被 GPU 读一次,CPU 无需在每帧遍历同一个场景数组。
四、Hi-Z 遮挡剔除
一句话:视锥剔除只解决"不在视野",遮挡剔除解决"被前面东西挡住";Hi-Z 用上一帧的深度金字塔在 GPU 上快速测试物体是否被遮挡,能再砍掉 30~70% 的物体。
4.1 深度金字塔(Hierarchical Z-Buffer)
把上一帧的深度图连续降采样成金字塔:D0 是原始深度,D1 是 2×2 的最近深度(min),D2 是 4×4……每层代表一个区域内最近的深度。
深度金字塔(每层取邻域最小值,即"最近"):
D0: 全分辨率深度
D1: 每 2×2 取 min
D2: 每 4×4 取 min
...
4.2 遮挡测试
物体包围盒投影到屏幕后,其覆盖的深度区域与对应金字塔层比较:如果包围盒的最近深度(transform 后近平面)比该区域已记录的深度更远,说明被挡住,剔除。
测试流程:
1. 把物体 AABB 八个顶点变换到屏幕空间 → 投影矩形
2. 取该矩形覆盖的金字塔层(矩形越小用越高层)
3. 层内最近深度 D_layer
4. 若 AABB 近端深度 > D_layer → 被遮挡 → 剔除
5. 否则保守保留(多画一个物体也不至于漏画)
// GLSL:Hi-Z 遮挡查询
vec4 ndcMin, ndcMax; // AABB 投影后的 NDC 范围
vec2 uvMin = ndcMin.xy * 0.5 + 0.5;
vec2 uvMax = ndcMax.xy * 0.5 + 0.5;
vec2 boxSize = (uvMax - uvMin) * screenSize;
float level = ceil(log2(max(boxSize.x, boxSize.y))); // 选合适金字塔层
float occluderDepth = textureLod(hiZ, (uvMin + uvMax) * 0.5, level).r;
float objectDepth = ndcMax.z; // AABB 最近深度
if (objectDepth > occluderDepth) return; // 被遮挡
4.3 时序与正确性
Hi-Z 用上一帧深度,存在一帧延迟:新出现的物体可能在首帧被误剔除(pop-in)。缓解:
- 仅对"上帧可见或处于边缘"的物体做精细测试;
- 深度金字塔每帧重建,或两帧合并;
- 保守剔除:宁可多画,不可漏画。
Hi-Z 常与视锥剔除串行:先视锥,再遮挡,再 LOD,最后生成间接绘制。
五、LOD 系统与误差指标
一句话:LOD(Level of Detail)让远处物体用低面数网格、近处用高面数网格,把"看得见但看不清"的三角形从渲染里省掉——误差指标决定了切换的时机与连贯性。
5.1 从距离到误差
最简单的 LOD 按距离切换:dist < d1 用 LOD0,d1 < dist < d2 用 LOD1……但物体大小不同,同样距离下大物体的误差更明显。通用指标是屏幕空间误差:
error_screen ∝ (geoError × scale) / dist
切换阈值: error_screen 超过某像素数 → 需要更高精度
geoError 是该 LOD 与原始网格的几何偏差(如 Hausdorff 距离),随网格简化自动生成。
5.2 离散 LOD 与连续 LOD
| 类型 | 代表 | 特点 |
|---|---|---|
| 离散 LOD | 手动/工具生成 3~8 级 | 简单、切换可见(pop) |
| 连续 LOD | 简化算法实时选择 | 平滑但算法复杂 |
| 集群/异步 LOD | 异步生成更高级 | 消除加载卡顿 |
现代引擎普遍"离散为主 + 交叉淡化(dither fade)“隐藏切换:切换时做屏幕空间抖动淡出淡入,几乎不可见。
5.3 工具生成 LOD
网格简化常用 Quadric Error Metrics(QEM):迭代合并顶点,使合并前后二次误差最小,同时记录每步误差,天然输出 geoError 序列。
QEM 简化流程:
for step in steps:
找到"合并代价最小"的边 (v1, v2)
合并 → 顶点位置取误差最小处
更新邻边误差(二次误差矩阵)
记录累计误差 geoError
// C++:LOD 选择逻辑(GPU 剔除 Pass 内做)
float geoError = lods[o.meshId].levels[o.lod].error;
float screenError = geoError * o.scale * projScale / viewDist;
while (screenError > MAX_PIXEL_ERROR && o.lod < numLods - 1)
++o.lod; // 升级 LOD
while (screenError < MIN_PIXEL_ERROR && o.lod > 0)
--o.lod; // 降级 LOD
六、LOD 过渡与性能预算
一句话:LOD 的价值是"用可感知的质量换取可控的三角形预算”——它的成功标准不是误差最小,而是在任意视野下总三角形数不超过预算且切换无感。
6.1 屏幕空间误差的落地
把 MAX_PIXEL_ERROR(如 2~4 像素)作为全局预算:场景越密,平均每个物体允许的误差越大。这样远处森林整片用低模,近处主角才用高模,帧率稳定。
三角形预算分配示意:
近处(屏幕误差 < 2px) → LOD0,高模
中距离(2~4px) → LOD1
远处(4~16px) → LOD2/LOD3
极远(>16px) → LOD4 或剔除
6.2 过渡技巧
- 交叉淡化(dither):切换瞬间用棋盘噪声淡出淡入,视觉上无 pop;
- 延迟切换:只在物体移动/相机角度明显变化时才重新评估 LOD,避免抖动;
- 滞后(hysteresis):升级阈值比降级阈值更严格,防止在临界距离反复切换;
- 子网格 LOD:大型建筑可拆部件独立 LOD,近看门、远看整栋。
6.3 LOD 与剔除的协同
LOD 选择在 GPU 剔除 Pass 中与视锥/遮挡同步完成:先确认可见,再按误差选 LOD,最后把该 LOD 的网格 ID 写入间接绘制参数。这样一个物体的"可见性 + 精度 + 绘制命令"一次 dispatch 全部决定。
七、实例化与批处理
一句话:绘制指令的组织以"批"为单位:相同网格、相同材质、可共享状态的物体合并进一次绘制,实例化把重复物体压缩成一份顶点数据的多次变换。
7.1 批(Batch)的定义
一个批 = 一次 vkCmdDrawIndexedIndirect 的调用,包含:
- 一份索引/顶点缓冲(网格);
- 一个材质/管线状态(绑定组);
- 一个实例化数据缓冲(每个实例的变换矩阵、自定义属性)。
GPU Driven 的批由剔除 Pass 输出:把"可见 + 选中同一 LOD + 同一材质"的物体归入同一批。
7.2 实例化绘制
// GLSL:实例化顶点着色器(每个实例一个矩阵)
struct InstanceData { mat4 model; vec4 tint; };
layout(std430, binding = 1) readonly buffer Instances {
InstanceData instances[];
};
void main() {
InstanceData inst = instances[gl_InstanceIndex];
gl_Position = viewProj * inst.model * vec4(aPos, 1.0);
vColor = inst.tint;
}
绘制时 instanceCount = 批内实例数,几何数据只上传一份,变换从实例缓冲按 gl_InstanceIndex 取。相同网格(树林里的树)几千实例只占一份显存、一次绘制。
7.3 材质排序与状态切换
间接绘制仍受管线状态影响:不同材质需要不同着色器/描述符。GPU Driven 的批组织通常:
- 按"网格 × 材质"分组(Bucket);
- 组内实例化合并;
- 按绘制顺序排序减少状态切换。
CPU 端只需上传"分组定义 + 实例数据",剩下的排序可在 GPU 端用原子/基数排序完成,也可以让 CPU 每帧构建好分组表(分组数远小于物体数)。
八、完整 GPU Driven 架构与工程要点
一句话:GPU Driven 不是单一技术,而是一条"场景数据上 GPU → 计算着色器剔除 → 间接绘制执行"的完整管线,落地关键在数据布局、同步与保守性。
8.1 架构总览
帧流程:
1. 上传/更新 GPU 场景缓冲(物体表、网格表、材质表)
2. Compute Pass: 视锥剔除 → 可见表
3. Compute Pass: Hi-Z 遮挡剔除 → 精确可见表
4. Compute Pass: LOD 选择 + 批分组 → 间接绘制缓冲
5. 渲染 Pass: vkCmdDrawIndexedIndirect × 批数
6. 更新 Hi-Z(用本帧深度重建金字塔,供下帧遮挡测试)
8.2 同步与双缓冲
步骤 2~5 之间存在依赖:可见表要被绘制命令读取,Hi-Z 要被下帧使用。需要:
vkCmdPipelineBarrier:Compute 写 → Draw 读(BufferMemoryBarrier);- 场景数据双缓冲:本帧剔除读 A、上传写 B,避免读写竞争;
- Hi-Z 用上一帧结果,天然错开一帧,无额外同步。
8.3 保守性与调试
- 剔除必须保守:宁可画多了,不可画漏了(漏画比多画更伤画质);
- 包围球偏大、测试取最坏情况;
- 调试时用
RenderDoc查看可见表/间接缓冲内容,确认剔除正确性; - 提供"全可见"开关:把剔除 Pass 短路,对比帧率与画质定位问题。
8.4 常见陷阱
- 间接绘制
drawCount与剔除 Pass 输出不同步,导致绘制越界或漏画; - 实例数据未按
gl_InstanceIndex对齐(批内实例偏移错误); - Hi-Z 金字塔未按 min 深度重建,深度层级错误导致误剔;
- LOD 误差指标未包含
scale,大物体远处仍用高模; - 材质分组与着色器绑定顺序不一致,状态切换反而更频繁。
// C++:帧内同步示例
vkCmdDispatch(cmd, groupCount, 1, 1); // 剔除 Pass
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT,
0, 0, nullptr, 1, &indirectBarrier, 0, nullptr);
vkCmdDrawIndexedIndirect(cmd, indirectBuffer, 0, numBatches, stride);
总结
GPU Driven 渲染把"CPU 逐个提交"改造成"GPU 自己决定画什么":间接绘制让 GPU 生成绘制命令,计算着色器做视锥、遮挡(Hi-Z)与 LOD 三级剔除,实例化把重复物体压缩成一份几何数据。这套架构把 Draw Call 从数万降到几十,把剔除成本从 CPU 的线性遍历变成 GPU 的高并行 dispatch,是现代开放世界引擎(如 UE5 Nanite 的思路雏形)的基石。
| 环节 | 技术 | 解决的问题 |
|---|---|---|
| 命令提交 | 间接绘制 | CPU Draw Call 瓶颈 |
| 可见性 | 视锥 + Hi-Z 遮挡剔除 | 不可见物体白白绘制 |
| 精度控制 | LOD + 屏幕空间误差 | 远处高模浪费三角形 |
| 批处理 | 实例化 + 材质分组 | 重复物体与状态切换 |
| 数据流 | GPU 场景缓冲 + 双缓冲同步 | CPU↔GPU 数据搬运 |
实践路径:先做"CPU 视锥剔除 + 实例化"打底,再换成间接绘制并把剔除搬进计算着色器,随后加 Hi-Z 遮挡,最后接 LOD 误差选择与批分组。每一步都用 RenderDoc 检查可见表与绘制命令,确认 GPU 端数据流的正确性后再叠加下一层。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。