一、先搞懂捆绑包到底是个啥
在游戏或图形程序里,我们经常要跟GPU说“给我画这个,给我画那个”。日常操作是把一堆绘制命令挨个写进一个“命令列表”,然后交给显卡去干活。不过有些物体很次要,比如路边的小石子、草丛里的小草,它们的绘制命令很简单,但数量非常多。如果每画一个都重新组织一次命令列表,CPU就会累得够呛,就像你每次做菜都要重新翻菜谱一样。
DirectX 12提供了一种“捆绑包”技术,可以理解为提前把一组画小物体的命令打包成一个“快手菜包”。要用的时候,只需要把这个菜包直接丢进主命令列表里,GPU就照着执行了。这样我们就不用在渲染每一帧时重复记录那一堆细节命令,省了很多CPU时间。
这里有个关键区别:普通命令列表是主要干活的地方,而捆绑包只能在主命令列表的中间被“调用”。你可以在一个主命令列表里放多个捆绑包,也能在多个主命令列表里复用同一个捆绑包,只要条件允许。这就像你把常用的工具放在一个工具箱里,到哪个工地都能提起来用。
二、什么时候用捆绑包最划算
2.1 画大量重复的小物体
比如一片草地,风吹起来每根草的位置都不一样,但草的形状是相似的。你可以把“画一根草”的所有绘制命令打成一个捆绑包,然后循环调用几千次,每次改变它的位置和方向(通过常量缓冲区的偏移)。这比把每根草的绘制命令都写进主命令列表要快,因为捆绑包内部状态切换已经提前安排好了。
2.2 静态物体的提交
有些物体虽然小,但每一帧都在场景里,比如地上的石块、路灯、路边栏杆。这些物体不会动,它们的绘制命令在每一帧里几乎一样。那就太适合捆绑包了。你可以在初始化阶段把它们的绘制命令录制好,然后在每一帧的主命令列表里直接调用,完全不用重新记录。
2.3 多线程配合
捆绑包的另一个好处是可以在多个线程里同时录制。比如你有4个CPU核心,你可以让每个线程各自录一个捆绑包,然后再把它们一起放进主命令列表。这样能充分利用多核心,把录制的压力分摊开。不过要小心,捆绑包自身不能跨设备,而且录制完成之后,想修改它里面的内容就比较麻烦了。
三、一个简单但有代表性的示例
下面我们用C++配合DirectX 12来演示一把。这个例子不是完整游戏,而是把“记录一个捆绑包”和“在主命令列表中调用它”的核心步骤展示出来。请注意,为了把焦点放在捆绑包上,我们省略了交换链、堆内存设置等代码,只保留和捆绑包相关的部分。
// 技术栈:C++ / DirectX 12
// 假设已经创建了设备 device 和命令列表 commandList
// 1. 创建一个命令分配器,专门给捆绑包使用
ID3D12CommandAllocator* bundleAllocator = nullptr;
device->CreateCommandAllocator(
D3D12_COMMAND_LIST_TYPE_BUNDLE, // 注意类型是 BUNDLE
IID_PPV_ARGS(&bundleAllocator));
// 2. 创建捆绑包对象(本质上是一种命令列表)
ID3D12GraphicsCommandList* bundle = nullptr;
device->CreateCommandList(
0, // 节点掩码,单设备填0
D3D12_COMMAND_LIST_TYPE_BUNDLE, // 捆绑包类型
bundleAllocator, // 分配器
nullptr, // 管线状态(可以之后通过命令设置)
IID_PPV_ARGS(&bundle));
// 3. 开始录制捆绑包
bundle->Close(); // 先关一下,为了安全
bundle->Reset(bundleAllocator, nullptr);
// 4. 在捆绑包里放入绘制命令
// 假设有一个小石头模型,它的顶点缓冲和索引缓冲已经绑定好了
bundle->SetGraphicsRootSignature(rootSignature); // 设置根签名
bundle->SetPipelineState(pso); // 设置着色器状态
// 设置几个描述符表,让GPU知道要用哪些资源
bundle->SetGraphicsRootDescriptorTable(0, descriptorTableForTexture);
bundle->SetGraphicsRootDescriptorTable(1, descriptorTableForSampler);
// 设置这一块石头的世界矩阵(通常通过常量缓冲区)
bundle->SetGraphicsRootConstantBufferView(2, constantBufferAddress);
// 设置顶点和索引缓冲
bundle->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
bundle->IASetVertexBuffers(0, 1, &vertexBufferView);
bundle->IASetIndexBuffer(&indexBufferView);
// 绘制12个顶点
bundle->DrawIndexedInstanced(36, 1, 0, 0, 0);
// 5. 结束录制
bundle->Close();
// 6. 回到主命令列表,把捆绑包“扔”进去
commandList->Close();
commandList->Reset(commandAllocator, nullptr);
// 记录一些场景设置命令
commandList->SetGraphicsRootSignature(rootSignature);
commandList->SetPipelineState(mainPSO);
// ... 设置视口、裁减等 ...
// 想象我们要画1000块小石头,它们的位置都不同
// 为了简化,这里只画3次循环
for (int i = 0; i < 3; i++)
{
// 每次把当前石头的世界矩阵常量缓冲区更新一下(这里只是示意)
UpdateWorldMatrix(i); // 自定义函数,更新一块常量缓冲
// 直接调用捆绑包,相当于把捆绑包里的所有命令展开执行
commandList->ExecuteBundle(bundle);
}
// 最后结束主命令列表提交给GPU
commandList->Close();
这段代码最核心的一行是 commandList->ExecuteBundle(bundle)。它看起来只是调了一次,但CPU在那一帧里不再需要逐个去设置根签名、绑定描述符、设置顶点缓冲、调绘制命令,因为那些步骤全部被“打包”进捆绑包了。GPU虽然在执行时还是要完成那些工作,但是CPU的准备和记录开销大大降低了。
四、捆绑包有什么优点和坑
4.1 优点
- CPU开销明显下降。尤其当你有一千个类似的次要物体时,主命令列表从一千次重复设置变成一次循环调用捆绑包。
- 多线程录制友好。你可以把不同类型的次要物体分给不同线程,每个线程录自己的捆绑包,互不打扰。
- 代码逻辑清晰。把“画一棵树”“画一块石头”的逻辑封装成捆绑包,主场景只需要按需调用,读起来舒服。
4.2 限制与缺点
- 捆绑包里不能任意切换根签名和管线状态,太随意的状态设置会被拒绝。你只能在录制时固定好,调用时也不能覆盖。这就导致如果你的次要物体之间需要完全不同的渲染管线,捆绑包就不太能用上。
- 捆绑包不能跨设备使用。在双GPU系统里,你得各自生成各自的捆绑包。
- 捆绑包不能作为主命令列表提交到队列。它必须被另一个主命令列表执行,也就是说它只能被“嵌套”使用,不能独立提交。
- 录制完的捆绑包想要修改里面的某个参数(比如矩阵)很麻烦。一般做法是为每种不同参数组合生成多个捆绑包,或者用动态常量缓冲区,在调用前通过偏移量改变引用,但捆绑包本身的内容不能像普通命令列表那样随时改。
- 在GPU上有额外开销。执行捆绑包本身也有一定代价,如果物体数量很少,反而可能比直接画更慢。所以捆绑包适合“大量、小物体”,不适合“一个、两个大模型”。
五、使用捆绑包时要注意什么
第一,不要把渲染管线的切换放进捆绑包。你可以把捆绑包理解成“图纸”,图纸里最好不要包含“换机器”这种动作。应该在主命令列表里设置好公共管线状态,然后捆绑包只负责调用已经绑定好的资源。
第二,小心资源状态转换。如果你在捆绑包里画某个物体,而这个物体需要从普通状态变到渲染目标状态,这种转换在捆绑包内部可能会被忽略或者导致报错。最好在主命令列表里把资源状态转换搞定,捆绑包只负责使用当前状态。
第三,捆绑包的根签名和主命令列表的根签名要一致。如果两者不匹配,ExecuteBundle时会出错。实际情况中,你需要让捆绑包里的根签名与调用它的那个主命令列表中正在使用的根签名保持一致。
第四,避免在捆绑包中使用不稳定的索引。比如你在录制捆绑包时绑定的描述符堆,之后在调用时,那个堆的活体资源可能已经变了。所以捆绑包更适合录制那些“资源位置固定不变”的绘制操作,或者你想办法维护好资源生命周期。
第五,性能调优要脚踏实地。不要认为使用了捆绑包就一定更快。实际项目中需要用PIX等工具做Profiler,看看CPU时间是否真的降了。有时因为状态切换限制,你可能被迫做出很多捆绑包对象,反而占用了更多内存。
六、总结
捆绑包技术就像图形API世界里的“快捷指令”。它最适合画那些数量多、结构相似、绘制状态很少变化的次要物体,比如草、石头、小部件。它能把重复性的CPU工作大大减少,让主命令列表保持清爽,还能帮你利用多线程。但它的限制也不少,不能随意切换状态、不能独立提交、不能跨设备、不适合太复杂的渲染。一句话:如果你手里有成千上万的小东西要画,并且它们都乖乖地用同一种管线、同一个根签名,那就放心地用捆绑包;如果你画的大块头需要各种奇奇怪怪的状态切换,那就别难为捆绑包了,老老实实用普通命令列表吧。在游戏开发中,没有万能银弹,明白每种工具的性格,才能把它们放到合适的位置上。
评论
围绕“DirectX 12捆绑包技术加速次要物体绘制提交的适用场景与限制”参与讨论