Skip to main content

Chapter 114: Draw Call Batching

本章讨论一个很具体的帧成本问题:场景里有大量小物体,每个物体的三角形数量很低,单个 shader 也很轻,但 frame time 仍然偏高。读完本章后,读者应能定位 draw call 成本来自 CPU 提交、API 状态校验、command buffer 录制、GPU 队列空隙、状态切换、实例数据带宽中的哪一段,并能判断静态批处理、动态批处理、instancing、indirect draw 和状态排序各自适用的边界。

贯穿本章的材料是一帧“城市广场”画面:场景里有 8000 个路灯、广告牌、花盆、窗框和箱子。它们大多使用少量 mesh 和少量材质,屏幕上看起来只是普通环境细节。未优化版本为每个对象提交一次 draw,RenderDoc 或同类 frame capture 中可以看到几千个 draw event,CPU profiler 中可以看到渲染线程在提交阶段耗时升高,GPU timeline 中部分 pass 出现等待或短小 draw 之间的空隙。

Draw call batching 的核心结论是:批处理通过提高一次提交承载的几何数量,减少 CPU 侧命令数量和状态切换数量;它的收益取决于瓶颈位置。若瓶颈是 CPU submit、API validation、descriptor / resource bind、pipeline state change,批处理通常有效。若瓶颈已经在 fragment shader、overdraw、render target bandwidth 或复杂材质采样上,简单合并 draw 只会改变提交形态,帧率提升有限,甚至会因实例数据读取、排序破坏或 culling 粒度变粗产生新成本。

官方 API 语义也能支撑这个判断。Vulkan vkCmdDrawIndexed 把一次 indexed draw 表达为当前 pipeline、当前 index buffer、indexCountinstanceCountfirstInstance 的一次命令记录;D3D12 DrawIndexedInstanced 也以 index count、instance count、start index 和 base vertex 描述同类调用。批处理优化改变的正是这些命令的数量、每条命令携带的实例规模、以及命令之间的 pipeline / resource 状态连续性。

114.1 Draw Call 的成本与瓶颈来源

Draw call 是 CPU 向图形 API 记录或提交一次绘制意图的单位。它通常不会只包含“让 GPU 画三角形”这一件事,还会隐含当前 pipeline state、vertex / index buffer、descriptor 或 bind group、常量数据、纹理资源、render target 状态、viewport、scissor、blend、depth / stencil 规则等上下文。一次 draw 能否低成本执行,取决于这些上下文在上一条命令之后是否保持兼容。

在城市广场帧中,一个花盆只有几百个三角形。若每个花盆单独调用一次 draw,CPU 要为每个对象准备 transform、材质参数、资源绑定和命令记录。GPU 端每次 draw 的 vertex 工作量很小,短 draw 之间的调度与状态切换开销会变得醒目。这个帧的瓶颈表现通常是 draw count 很高、单个 draw 的 GPU duration 很短、CPU render thread 或 render graph build 阶段耗时高。

Draw call 成本可以拆成五类。第一类是 API validation 和运行时检查。现代显式 API 把很多状态前移给应用维护,但仍然需要检查命令合法性、资源状态、绑定布局、格式兼容和同步前提。WebGPU 这类安全边界更强的 API 还会在命令和资源使用上做更多校验。第二类是 state change。切换 pipeline、shader、blend、depth、descriptor set、bind group、sampler 或 render target 会破坏连续性。第三类是 command buffer 工作。命令录制、内存分配、二级 command buffer 拼接、命令列表关闭和队列提交都会占用 CPU 时间。第四类是 driver / runtime work。不同 API 和平台在驱动层缓存 pipeline、翻译状态、管理资源驻留时会有不同成本。第五类是 GPU bubble。GPU 等待资源状态转换、等待前序 pass、等待数据上传或频繁执行极短 draw 时,硬件执行单元可能无法形成连续吞吐。

这五类成本不在同一个层级。API validation 与 command recording 属于 CPU 侧;state change 同时影响 CPU 记录和 GPU 执行连续性;GPU bubble 需要在 GPU timeline、timestamp query 或 frame capture 的 event duration 中观察。调试时应先分清 CPU 帧时间与 GPU 帧时间,再进入 draw call 数量。若 CPU frame time 先升高,重点看 render thread、command recording、resource binding、pipeline lookup。若 GPU frame time 先升高,重点看 pass timing、overdraw、shader cost、bandwidth 和同步等待。

下面的 Mermaid 图把一次普通 draw 从对象到 GPU 执行的成本路径压缩成可检查链条。它的边界是单个 graphics pass,暂时不覆盖跨队列 async compute 和呈现等待。

图中最容易误判的是 Draw Command → Queue 这一段。应用看到的是一条 API 调用,工具里看到的是一个 event,硬件看到的是带有当前状态的命令流。若 8000 个对象产生 8000 条短命令,GPU 未必在每条命令上执行很久,但 CPU 要组织这些命令,driver / runtime 要维护状态,GPU 队列也要处理这些小段工作之间的依赖。

在 Vulkan 语境下,vkCmdDrawIndexed 被记录进 command buffer,真正执行时根据当前 primitive topology、index buffer、bound graphics pipeline 和实例参数组装 primitive。这个语义说明 draw call 的成本无法脱离“当前绑定状态”讨论。D3D12 和 Metal 也类似:draw 方法本身参数很短,决定实际行为的是命令之前绑定的 pipeline、buffer、descriptor / argument buffer 和 render pass 状态。

判断 draw call 是否成为瓶颈时,先看三个证据。第一,draw count 是否高到和可见对象数量近似相等,且每个 draw 的 index count 很小。第二,CPU render thread 是否在 command recording、state setup、resource binding 或 render graph 构建上占用明显时间。第三,capture 中同一材质、同一 mesh、同一 pipeline 的 draw 是否被其他状态打散。如果三个证据同时成立,批处理比 shader 微优化更接近主问题。

114.2 批处理(Batching)基本策略

批处理是把多个可兼容的绘制对象合并到更少 draw 或更少状态切换序列中的组织方法。它解决的直接问题是提交粒度太小。对象能否进入同一个 batch,取决于它们是否共享 pipeline、材质资源、vertex layout、index format、render state、shader variant、光照路径和必要的 per-object 数据表达方式。

城市广场帧里,花盆、箱子、窗框看似都是“静态环境物”,但它们的 batch 条件并不相同。花盆复用同一个 mesh 和材质,适合 instancing。窗框分布在建筑立面上,材质相同但 transform 已经固定,适合离线或加载阶段合并到更大静态 mesh。广告牌共享几何,但贴图不同,适合 texture array、atlas、bindless / descriptor indexing 或按材质分组。箱子位置会被 gameplay 改变,适合动态 batch 或 instance buffer 更新。

静态批处理适合 transform、mesh、材质和可见性长期稳定的对象。它通常在导入、烘焙、关卡加载或 chunk 构建阶段把多个小 mesh 合成一个 vertex buffer / index buffer 区间,运行时通过较少 draw 提交。它的收益是 CPU draw 数减少、状态切换减少、顶点数据可以更连续。它的代价是 culling 粒度变粗、内存副本增加、lightmap / material 边界处理更麻烦、局部编辑成本升高。对于远处建筑窗框,这个取舍通常合理;对于会被遮挡剔除的小道具,大块合并会让不可见几何继续进入 pipeline。

动态批处理适合每帧对象数量变化、mesh 很小、材质兼容、CPU 侧有足够预算整理数据的场景。典型做法是每帧把小对象的变换结果写入一个 transient vertex buffer 或 instance buffer,再按材质提交。它减少 draw count,但增加 CPU 数据变换、buffer 写入和同步管理。若对象本身顶点数较多,CPU 侧合并顶点会很快超过 draw call 节省的成本。动态批处理更适合 UI sprite、小粒子、简单 debug geometry、少量低顶点环境装饰。

材质批处理是按 shader variant、pipeline state 和纹理绑定把对象分组。它通常不改变 mesh 数据,只改变提交顺序。它的收益来自状态连续性:相同 pipeline 连续提交,descriptor / bind group 更新次数下降,pipeline cache 和驱动状态切换更稳定。它的边界是透明物体和深度排序。透明物体需要按深度顺序混合,材质排序只能在深度正确性允许的范围内进行。深度预通道、alpha test 和 blended pass 的排序目标也不同。

批处理策略可以按数据变化频率选择。静态对象优先考虑离线合并或 chunk 合并;同 mesh 多实例对象优先考虑 instancing;同材质不同 mesh 优先考虑状态排序和 multi-draw / indirect;频繁变化的小几何优先考虑动态 batch;材质差异主要来自贴图索引时,优先考虑 atlas、array texture 或 bindless 风格资源布局。这个顺序能让“减少 draw”与“保持 culling、材质、同步和内存成本可控”同时成立。

下面的简化 C++ 风格伪代码展示批处理准备阶段的关键思想。代码目标是说明 batch key 应当包含 pipeline 与资源兼容性,并且需要把 mesh 名称之外的渲染状态纳入分组。

struct BatchKey {
PipelineId pipeline;
MaterialLayoutId materialLayout;
MeshLayoutId meshLayout;
RenderStateId renderState;
TextureSetId textureSet;
};

struct BatchItem {
MeshId mesh;
MaterialId material;
Matrix4x4 worldFromObject;
Bounds worldBounds;
};

std::unordered_map<BatchKey, std::vector<BatchItem>> buckets;

for (const RenderObject& object : visibleObjects) {
BatchKey key = buildBatchKey(object);
buckets[key].push_back(makeBatchItem(object));
}

for (auto& [key, items] : buckets) {
sortForLocality(items);
emitBatchDraws(key, items);
}

这段伪代码的关键点是 BatchKey。若 key 缺少 RenderStateId,深度写入、blend、cull mode 或 shader variant 不同的对象可能被错误合并,画面会出现混合错误、背面剔除错误或材质参数错读。若 key 过细,每个对象都落入独立 bucket,draw count 下降很少。工程上通常先定义最小兼容 key,再用 profiler 检查每类 key 的对象数量分布。

批处理还需要处理资源生命周期。静态 batch 的 buffer 可以长期驻留;动态 batch 的 transient buffer 要按帧环形分配,防止 CPU 写入 GPU 仍在读取的内存区间;instance buffer 需要满足对齐和访问模式;材质贴图打包需要考虑 mipmap 边界和过滤串色。批处理代码若只关注 draw count,容易把成本转移到 buffer update、resource transition 或 texture sampling 上。

114.3 Instanced Rendering and Batch Merging

Instanced rendering 是把同一个 mesh 和同一套 pipeline 在一次 draw 中绘制多份,并通过 instance index 读取每个实例的 transform、颜色、材质索引或其他 per-instance data。它适合城市广场中的路灯、花盆、箱子、重复窗框、草、石头等重复几何。它的收益来自一次 draw 承载多个对象,GPU 端仍按实例区分数据。

API 层对 instancing 的表达很直接。Vulkan 的 vkCmdDrawIndexed 参数中包含 instanceCountfirstInstance;D3D12 的 DrawIndexedInstanced 包含 InstanceCountStartInstanceLocation;Metal 的 draw 方法也提供 instance count 变体;WebGPU 的 drawIndexed 同样提供 instance count 和 first instance 参数。不同 API 名称不同,但工程含义一致:一次命令提交一段 index 范围,并让实例索引驱动 per-instance 数据访问。

实例数据通常存放在 instance vertex buffer、storage buffer、structured buffer、uniform buffer array、texture buffer 或 argument buffer 中。选择哪种资源,取决于数据量、访问方式、API 限制和更新频率。每个实例只需要 transform 和颜色时,instance vertex buffer 简单直接。每个实例需要材质索引、LOD、bounding sphere、上一帧矩阵、动画参数时,storage / structured buffer 更灵活。实例数据很小且平台 uniform 限制足够时,可以放入 uniform / constant buffer,但要注意大小和动态偏移成本。

下面的 WGSL 片段展示 instance index 如何把一次 draw 展开成多个对象。代码只展示关键路径:顶点 shader 根据 instance_index 读取实例 transform,并把同一 mesh 的顶点变换到 clip space。

struct InstanceData {
worldFromObject: mat4x4<f32>,
baseColor: vec4<f32>,
};

@group(0) @binding(0)
var<storage, read> instances: array<InstanceData>;

struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@builtin(instance_index) instanceIndex: u32,
};

struct VertexOutput {
@builtin(position) clipPosition: vec4<f32>,
@location(0) color: vec4<f32>,
};

@vertex
fn main(input: VertexInput) -> VertexOutput {
let instance = instances[input.instanceIndex];
let worldPosition = instance.worldFromObject * vec4<f32>(input.position, 1.0);

var output: VertexOutput;
output.clipPosition = camera.viewProjection * worldPosition;
output.color = instance.baseColor;
return output;
}

这段 shader 改变了数据路径:每个顶点除了读取 mesh vertex buffer,还根据实例编号读取一次实例记录。draw count 下降的同时,vertex shader 的内存访问增加。若实例数据很大、访问不连续或缓存命中差,GPU 端会增加带宽压力。正确的实例化布局通常把高频访问、每顶点需要的数据放在紧凑结构中,把只在 fragment shader 使用或低频使用的数据拆到独立资源,减少每个顶点重复读取的大字段。

Batch merging 是 instancing 的上一级组织问题。一个 draw 能合并哪些实例,取决于同 mesh、同 pipeline、同材质布局、同 render state 和同资源访问模式。若花盆只有颜色不同,可以把颜色作为实例属性合并。若花盆使用不同纹理,可以把纹理放入 array texture 并用实例材质索引选择层。若不同花盆使用完全不同 shader variant,就需要拆成多个 batch。合并的目标是让差异进入数据,保持 pipeline 和绑定状态稳定。

间接绘制(indirect draw)把 draw 参数放入 GPU buffer,由 GPU 执行时读取参数。Vulkan vkCmdDrawIndexedIndirect 的说明中,draw 参数来自 buffer,drawCount 表示要执行的 draw 数;D3D12 indirect drawing 则通过 command signature 和 argument buffer 组织间接命令。这个路径常用于 GPU culling:compute shader 先筛掉不可见实例,再写入 indirect 参数,graphics pass 根据结果绘制。

间接绘制把 culling 与 draw 参数生成从 CPU 移向 GPU,适合实例数量很大、可见性变化频繁、CPU 读回可见集成本高的场景。它的代价是 buffer 写入、barrier、counter 管理、调试复杂度和平台特性差异。城市广场中的远处重复窗框如果数量巨大,GPU culling + indirect 能减少 CPU 侧逐对象提交;近处少量复杂对象使用普通 instancing 已经足够。

Instancing 的失败情况也要明确。第一,实例之间材质差异太大,合并后仍然需要大量分支或纹理随机访问。第二,单个实例 mesh 很重,瓶颈在 vertex ALU 或 fragment shading,draw 数下降无法覆盖 shader 成本。第三,实例被大范围遮挡,但一个 batch 仍提交全部实例,culling 粒度变粗。第四,透明实例需要精确排序,单一 instanced draw 难以表达每个对象的混合顺序。第五,实例数据更新路径产生 CPU / GPU 同步,导致提交前等待。

可复用判断顺序是:先确认是否同 mesh 或同几何布局;再确认 pipeline 与材质布局是否兼容;再把对象差异压缩为 per-instance data;再检查 culling 粒度是否仍然可接受;然后检查 instance buffer 更新是否使用 ring buffer、persistent mapping 或平台推荐的 upload path;最后用 CPU submit time、draw count、GPU vertex cost 和 bandwidth counter 验证收益。

114.4 管线状态排序与减少切换策略

管线状态排序解决的是另一类成本:draw 数量可能仍然较多,但命令顺序让 pipeline、descriptor、texture、sampler、blend、depth、raster state 频繁变化。现代渲染器通常会给每个 draw 构建 sort key,把同 pass、同 pipeline、同材质、同 mesh 或同资源布局的 draw 放到更连续的位置。这样做可以降低状态绑定次数,并让 GPU 命令流更稳定。

城市广场帧的未排序版本可能以场景树遍历顺序提交:路灯、广告牌、窗框、花盆、透明玻璃、箱子混在一起。这个顺序对编辑器和游戏逻辑友好,却对渲染状态不友好。排序后,opaque pass 可以先按 pipeline 和材质聚合,再按 mesh 或 vertex buffer 聚合;transparent pass 需要保留深度相关顺序,只能在局部范围内减少状态切换;shadow pass 可按 shadow caster shader 和 mesh 聚合;depth pre-pass 可按 position-only pipeline 聚合。

排序维度应跟 pass 目标一致。Opaque pass 的主要目标是深度正确、状态稳定和 early-z 友好。若场景 overdraw 很高,可以加入粗略 front-to-back 排序,让近处物体先写深度。若 CPU state change 是主成本,可以让 pipeline / material 排序优先。Transparent pass 的主要目标是混合结果稳定,深度排序优先级高于材质排序。Shadow pass 的目标是写入 shadow map,材质差异通常可以压缩为 alpha test 与非 alpha test 两类。

一个常见 sort key 可以按高位到低位组织:pass id、pipeline id、material id、resource set id、mesh id、depth bucket、object id。高位变化会触发更重状态切换,低位用于稳定输出和调试复现。不同引擎会调整顺序。比如 forward opaque 中,front-to-back 可能放在 material 之前;deferred G-buffer 中,pipeline 和 material 一般更靠前;透明物体中,depth bucket 通常放高位。

下面的伪代码展示 sort key 的构建方式。它省略了具体位宽,重点是表达排序维度的层级。

uint64_t buildOpaqueSortKey(const DrawItem& item) {
uint64_t key = 0;
key = appendBits(key, item.passId);
key = appendBits(key, item.pipelineId);
key = appendBits(key, item.materialId);
key = appendBits(key, item.resourceSetId);
key = appendBits(key, item.meshId);
key = appendBits(key, item.depthBucket);
return key;
}

std::sort(drawItems.begin(), drawItems.end(), [](const DrawItem& left, const DrawItem& right) {
return buildOpaqueSortKey(left) < buildOpaqueSortKey(right);
});

这段代码对应的工程判断是:排序保持对象数量不变,主要减少状态跳变。若排序前后 draw count 不变,但 pipeline bind、descriptor bind、texture bind、vertex buffer bind 下降,CPU 和 GPU 都可能受益。若排序破坏了透明物体混合顺序或破坏了 early-z 友好性,画面或性能会变差。因此排序策略必须按 pass 定义,全场景使用同一 key 会带来错误边界。

减少切换还需要从资源布局入手。材质参数可以放入统一布局的 buffer,减少 shader variant;纹理可以使用 array texture、atlas 或 bindless / descriptor indexing 风格访问,减少 per-draw texture bind;常量数据可以按 frame、pass、material、object 分层,减少重复绑定;pipeline state object 应在加载期或缓存期准备,减少运行时创建。状态排序解决顺序,资源布局解决兼容性,两者结合才会稳定降低 draw 相关成本。

API 边界也要清楚。OpenGL 这类隐式状态 API 中,状态排序经常直接降低 driver 侧状态验证和隐式转换成本。Vulkan、D3D12、Metal 这类显式 API 中,应用已经承担更多状态组织责任,排序仍然有价值,因为 pipeline bind、descriptor set / bind group、argument buffer、resource barrier 和 command list 组织仍然占用 CPU 与 GPU 资源。WebGPU 中,bind group 和 pipeline 的切换也会影响命令编码与验证路径。

管线状态排序的检查顺序是:先按 pass 分开 opaque、alpha test、transparent、shadow、post process;再统计每个 pass 的 pipeline bind、descriptor / bind group bind、texture / sampler bind、vertex buffer bind;然后调整 sort key 的高位优先级;接着观察画面正确性,尤其是透明、alpha test、stencil、depth write;最后用 capture 对比排序前后的状态切换次数和 pass timing。这个顺序能把排序从“经验优化”落到可复查证据。

114.5 性能评估:批处理提升帧率效果分析

批处理评估要同时看 CPU、GPU 和视觉正确性。只看 draw count 会误判,因为 draw count 下降可能伴随实例数据带宽上升、culling 粒度变粗、overdraw 增加或透明排序错误。城市广场帧的评估目标应写成明确问题:把 8000 个环境小物体从逐对象 draw 改成静态 batch、instanced draw 和状态排序后,CPU submit time、GPU pass time、state changes、memory bandwidth、可见性质量分别怎样变化。

第一组指标是提交侧指标。记录 draw count、pipeline bind count、descriptor / bind group bind count、material switch count、command buffer recording time、render thread time、queue submit count。若优化有效,draw count 和重状态切换数量应下降,CPU render thread 在提交阶段的时间应下降。若 draw count 下降但 command buffer recording time 变化很小,说明瓶颈可能在场景遍历、剔除、资源更新或 render graph 构建。

第二组指标是 GPU 执行指标。记录 pass GPU time、vertex shader invocation、fragment shader invocation、occupancy、wave / warp stall、cache hit、memory throughput、render target bandwidth。Instancing 常使 draw 数下降,但每个顶点要读取实例数据。静态 mesh 合并可能提高 vertex buffer 连续性,但 culling 变粗会增加不可见顶点和 fragment 工作。状态排序可能减少 pipeline 切换,但 front-to-back 顺序改变会影响 early-z。GPU 指标能判断成本是否只是从 CPU 转移到了 GPU。

第三组指标是视觉与功能正确性。检查透明物体排序、alpha test 轮廓、lightmap UV、normal / tangent、per-object color、object id picking、motion vector、shadow caster、reflection probe、LOD 切换和 culling 结果。批处理经常把多个对象变成一个 draw 或一个 buffer 区间,原先依赖 object id、材质 slot、实例可见性或 per-object uniform 的功能需要重新映射。画面正确性应与性能指标同时验收。

下面是一张用于记录批处理收益的评估表。数值列应由实际工具填入,这里给出观察对象和判断含义。

指标观察位置下降说明上升时的解释方向
Draw countFrame capture event list提交粒度变大batch key 过细或材质差异过多
Pipeline bind countCapture state history状态连续性提高sort key 维度顺序不合理
CPU submit timeCPU profilercommand recording 成本下降场景遍历或 buffer update 成为新瓶颈
GPU pass timeTimestamp query执行时间下降fragment、bandwidth、overdraw 仍主导
Vertex invocationsGPU counterculling 或 LOD 更有效静态合并让不可见几何进入 pass
Memory bandwidthGPU counterbuffer / texture 访问更紧凑instance data 或材质索引访问过散
State changesCapture event details绑定次数减少pass 混合或透明排序限制了聚合

一个常用实验流程是三步。第一步只做状态排序,不合并几何,测量 pipeline / descriptor bind 下降幅度。第二步对同 mesh 对象做 instancing,测量 draw count、CPU submit time 和 instance buffer bandwidth。第三步对长期静态对象做 chunk 合并,测量 culling 粒度、vertex invocations 和 GPU pass time。这样可以把收益归因到排序、实例化和静态合并,防止一次性改动后无法判断哪部分有效。

帧率提升的计算也应回到 frame time。若原始帧为 CPU 18 ms、GPU 11 ms,优化后 CPU 12 ms、GPU 12 ms,实际帧时间从 18 ms 降到 12 ms,说明原瓶颈被解除但 GPU 成为新上限。若原始帧为 CPU 8 ms、GPU 22 ms,批处理后 CPU 6 ms、GPU 22 ms,帧率几乎不变,说明主瓶颈在 GPU pass 内部。若原始帧 CPU 16 ms、GPU 16 ms,批处理后 CPU 10 ms、GPU 18 ms,需要检查静态合并、实例数据带宽或 overdraw 是否增加了 GPU 成本。

批处理的最终决策可以按证据闭环执行:先定位 CPU 或 GPU 主瓶颈;再统计 draw 与状态切换;再选择静态 batch、dynamic batch、instancing、indirect 或排序;然后控制 culling、材质、透明和资源更新边界;最后用同一帧、同一相机、同一质量设置对比指标。这个闭环比单纯追求低 draw count 更可靠,因为它把提交成本、GPU 执行、内存带宽和视觉正确性放在同一个判断链中。

最小自检任务

给定一帧室外场景:画面里有 5000 个同 mesh 的路灯、1200 个同材质但不同 mesh 的建筑窗框、600 个透明玻璃牌、400 个会移动的小箱子。CPU profiler 显示 render thread 的 command recording 占 6 ms,GPU timestamp 显示 opaque pass 为 5 ms、transparent pass 为 3 ms。Frame capture 中 draw count 为 7200,pipeline bind count 为 180,descriptor / bind group bind count 为 1400。请设计一套批处理优化顺序,并说明每一步应该观察哪些证据。

答案要点

先把 opaque 与 transparent pass 分开评估。CPU command recording 已经占 6 ms,draw count 和 descriptor / bind group bind count 很高,第一目标应放在减少 opaque 小 draw 和资源绑定次数上。透明玻璃牌保留独立排序规则,只能在深度正确性允许的局部范围内做材质聚合。

路灯是同 mesh 重复对象,优先使用 instanced rendering。把 world transform、颜色或少量材质索引写入 instance buffer,一次或少量 draw 覆盖大量路灯。观察 draw count、CPU submit time、instance buffer update time、vertex shader bandwidth。若 GPU pass time 上升,检查实例数据布局、可见性剔除和 instance buffer 访问连续性。

建筑窗框是同材质但不同 mesh,优先按静态 chunk 合并或按 pipeline / material 做状态排序。若窗框长期静止,可以在加载阶段合并到建筑 chunk,保留合理 culling 分区。观察 pipeline bind、vertex invocations、culling 结果和 lightmap / normal / tangent 正确性。若合并后 vertex invocations 明显增加,说明 chunk 过大,culling 粒度需要收紧。

移动小箱子适合 dynamic batch 或 instancing,选择取决于 mesh 是否相同。若箱子 mesh 相同,使用 instancing;若 mesh 很小且变化频率可控,可用动态 batch 写入 transient buffer。观察 CPU buffer update、同步等待和 draw count 下降幅度。

最终验收用同一相机和同一帧比较:draw count、pipeline bind count、descriptor / bind group bind count、CPU command recording、opaque GPU time、transparent GPU time、memory bandwidth、透明排序正确性。只有 CPU 提交下降且 GPU pass、带宽、画面正确性保持在可接受范围内,批处理决策才成立。

本章知识点总结

  • Draw 成本:一次 draw 的成本由 API 校验、状态切换、命令录制、驱动运行时工作和 GPU 队列空隙共同决定。
  • 瓶颈定位:批处理前应先区分 CPU submit 瓶颈、GPU 执行瓶颈、内存带宽瓶颈和同步等待。
  • 兼容条件:对象进入同一 batch 需要共享 pipeline、材质布局、mesh 布局、render state 和资源访问规则。
  • 静态批处理:静态合并适合长期稳定的小物体,但会改变 culling 粒度、内存布局和局部编辑成本。
  • 动态批处理:动态合并适合小几何和频繁变化对象,但会增加 CPU 写 buffer、同步管理和数据整理成本。
  • 实例渲染:Instancing 适合同 mesh 多对象,通过 instance index 读取 per-instance data 来减少 draw 数量。
  • 实例数据:实例数据布局应紧凑,并区分每顶点高频读取字段和低频材质字段。
  • 间接绘制:Indirect draw 适合 GPU culling 后由 GPU 生成 draw 参数的大规模重复对象。
  • 状态排序:Sort key 通过稳定 pipeline、材质和资源绑定顺序减少状态切换,而 pass 类型决定排序优先级。
  • 透明边界:透明物体的深度混合顺序限制材质聚合和实例合并,需要单独设计排序规则。
  • 资源布局:材质 buffer、array texture、atlas、bindless 风格访问和分层常量数据能提高 batch 兼容性。
  • 评估指标:批处理收益应同时用 draw count、CPU submit time、GPU pass time、state changes 和 memory bandwidth 验证。
  • 正确性验收:批处理后必须复查透明、lightmap、normal、object id、motion vector、shadow caster、LOD 和 culling 结果。
  • 决策闭环:可靠流程是先定位瓶颈,再选择批处理策略,最后用同一帧的工具证据复盘收益与副作用。