Chapter 88: Resource Binding Models
本章讨论 D3D12 中资源如何从 CPU 侧对象进入 shader。读完本章后,读者应能追踪一张贴图、一个常量缓冲或一个 UAV buffer 从资源创建、descriptor 创建、descriptor heap 布局、root signature 绑定,到 shader 读取的完整路径,并能判断绑定布局是否会制造 CPU 提交开销、descriptor 覆盖风险、资源状态错误或跨 API 映射困难。
贯穿材料是一帧延迟渲染中的材质 pass:camera 常量每帧变化,light list 每个 pass 变化,材质贴图数量很大并由材质 ID 选择,object transform 每个 draw 变化,一个 compute pass 会先写入 particle buffer,随后 graphics pass 读取它。这个 frame 同时包含 CBV、SRV、UAV、sampler、descriptor table、root descriptor、static sampler、资源状态转换和 fence 回收问题,足以覆盖本章的资源绑定模型。
D3D12 的 resource binding 目标是把“shader 需要哪些资源”从隐式驱动状态改成应用显式描述。Microsoft Learn 的 Resource binding overview 把核心对象归纳为 descriptor、descriptor table、descriptor heap 和 root signature;Descriptor Heaps 说明 heap 是连续 descriptor 分配;Root Signatures 说明 root signature 定义 pipeline 可见的资源绑定形状。本章把这些文档事实整理成工程判断顺序:先固定资源访问类型,再按更新频率切分绑定表,再用同步规则管理动态更新,最后把同一套引擎级绑定 schema 映射到 Vulkan。
88.1 描述符(Descriptor)与根签名(Root Signature)基础
Descriptor 是 D3D12 中资源绑定的最小描述单元。资源对象本身只表示 GPU 可访问的内存和格式基础;shader 访问资源时还需要知道访问方式、格式解释、子资源范围和可见性。Descriptor 承担这层解释工作,所以一块 buffer 可以通过 CBV 作为常量读取,也可以通过 SRV 作为只读结构化数据读取,还可以通过 UAV 作为可写数据使用。这个定义把“内存是什么”和“shader 如何看这块内存”拆成两个对象。
本章贯穿 frame 中的 camera 常量适合 CBV,材质贴图适合 SRV,compute 写入的 particle buffer 适合 UAV,采样状态适合 sampler。CBV(Constant Buffer View)把一段 buffer 暴露为常量数据,常用于 camera、object transform、材质参数这类小块只读数据。SRV(Shader Resource View)把 texture 或 buffer 暴露为 shader 只读资源,常用于 albedo texture、normal map、light list、cluster grid。UAV(Unordered Access View)提供 shader 可写或读写访问,常用于 compute 输出、append buffer、屏幕空间后处理中间结果。Sampler 描述过滤模式、地址模式和各向异性等采样规则;它不指向纹理内容,而是控制 shader 采样纹理时如何解释 UV 和 mip。
Descriptor heap 是 descriptor 的连续存放区域。D3D12 常用的 shader-visible heap 分成 CBV/SRV/UAV heap 和 sampler heap;RTV、DSV、IBV、VBV 这类 view 也有 descriptor 或 view 对象,但它们的绑定路径和 shader-visible table 不同。材质 pass 中,大量 texture SRV 可以放进一个 CBV/SRV/UAV shader-visible heap,sampler 可以放进 sampler heap,render target 与 depth stencil 走 render pass 输出路径。这个区分决定了调试时要看两个方向:shader 读取错误先看 shader-visible descriptor table;render target 或 depth 绑定错误先看 RTV/DSV 与 render pass 状态。
Root signature 是 D3D12 中 shader 与绑定资源之间的契约。它不保存大批资源内容,而是定义 root parameter 列表:某个 slot 是 descriptor table,某个 slot 是 root constants,某个 slot 是 root descriptor,某些 sampler 是 static sampler。Pipeline State Object(PSO)会与 root signature 兼容性绑定在一起,所以 root signature 的布局会影响 shader 编译反射、PSO 创建、command list 录制和 draw call 绑定顺序。
D3D12 的资源访问路径可以写成一条稳定链路:resource memory → descriptor → descriptor heap → descriptor table → root parameter → shader register。这条链路解决了一个具体问题:CPU 在录制 command list 时只需要把 root parameter 指向某段 descriptor table,GPU 执行 shader 时再按 register 和 index 查到 descriptor,descriptor 再解释具体资源。材质 pass 中,shader 使用材质 ID 选择 Texture2D 数组元素时,真正被索引的对象是 descriptor table 中的一段 SRV,而 shader 读到的是该 SRV 描述的 texture 子资源。
Root parameter 的可见性(visibility)用于限定某个绑定在哪些 shader stage 可见。一个只在 pixel shader 采样的 material texture table 可以设置为 pixel 可见,一个同时被 vertex shader 与 pixel shader 使用的 camera CBV 可以设置为 all 或拆成更精确的 stage visibility。Stage visibility 的价值在于把绑定契约收窄到实际访问范围,减少 shader 接口歧义,并帮助调试工具在某个 draw call 上定位“哪一阶段应该看到这个资源”。
Root signature 的空间有限。Microsoft Learn 的 Root Signature Limits 给出 64 DWORD 的上限:descriptor table 成本是 1 DWORD,root constant 每个 32-bit 值成本是 1 DWORD,root descriptor 成本是 2 DWORD,static sampler 不计入该上限。这个数字形成了第一个设计边界:root signature 适合放少量高频参数和表入口,批量材质资源应留在 descriptor heap/table 中。把大量 per-material texture 直接搬进 root parameter 会迅速消耗 root argument 空间,也会增加 root state versioning 成本。
下面的图只描述本章贯穿 frame 的绑定主路径。它把资源、descriptor、heap、table、root signature 和 shader 之间的依赖关系压缩到一条 frame 内可检查路径。
这张图对应一个 draw call 的观察顺序。先确认 GPU resource 是否创建成功并处于正确用途;再确认 descriptor 是否描述了正确格式、维度和子资源;接着确认 descriptor 被放入正确 heap 区间;随后检查 root signature 的 table range 与 shader register 是否匹配;最后观察 shader 输出是否符合预期。绑定问题排查最容易失焦的位置是 resource 与 descriptor 的边界:资源存在并不代表 shader 正在用它,shader 声明存在也不代表 command list 已经把正确 table 绑定到对应 root slot。
88.2 绑定表结构与插槽管理策略
绑定表结构要回答两个问题:哪些资源放在同一张表中,哪些 root slot 在每次 frame、pass、material 或 draw 时更新。D3D12 的 descriptor table 逻辑上是一段 descriptor 数组;Microsoft Learn 的 Descriptor Tables 明确指出 table 是 descriptor heap 中的 offset 与 length。这个定义直接影响工程设计:表本身不拥有 descriptor,它只指向 heap 中一段区间,所以 table 的稳定性取决于 heap 分配与 descriptor 覆盖策略。
贯穿 frame 可以按更新频率拆出四类绑定。Per-frame 资源包含 camera buffer、frame constants、全局 blue noise、上一帧 history texture;它们每帧更新一次。Per-pass 资源包含 light list、shadow atlas、G-buffer、compute 输出;它们随 pass 改变。Per-material 资源包含 albedo、normal、roughness、metallic、material constants;它们随材质切换改变。Per-object 资源包含 transform、skinning palette index、object id;它们随 draw call 改变。这个频率分组比按资源类型分组更有工程价值,因为 command list 绑定成本来自“变化次数”,渲染正确性风险来自“变化时机”。
| 频率层级 | 典型资源 | 推荐绑定形态 | 主要风险 |
|---|---|---|---|
| Frame | camera CBV、frame constants | root descriptor 或小 descriptor table | 覆盖 in-flight upload 数据 |
| Pass | light list SRV、shadow atlas SRV、pass UAV | descriptor table | 资源状态和 pass 顺序错误 |
| Material | 多张 texture SRV、material constant | bindless table 或 material table | table 索引错位、descriptor 复用错误 |
| Object | transform CBV、object id root constants | root CBV 或 root constants | 高频 root 更新导致 CPU 录制压力 |
Bindless 是把大量同类型资源放进大 descriptor array,由 shader 使用索引选择资源的布局方式。材质系统中的数千张贴图适合这种方式,因为每个 material 只需要携带一组 descriptor index,draw call 不需要为每张贴图重绑 descriptor table。Bindless 的约束来自硬件 resource binding tier、shader model、descriptor indexing 能力以及越界访问策略。工程上应把 bindless 视为“大量低频资源的索引化访问模式”,再用 feature query 和 fallback path 决定实际启用范围。
Descriptor table 适合把一组同生命周期、同更新频率的资源交给 shader。一个 pass table 可以包含当前 pass 的 shadow atlas、light list、cluster grid、depth pyramid;一个 material table 可以包含材质贴图与材质参数 SRV。Table 的收益是一次 root table 绑定能覆盖多个 descriptor range,shader 端 register 布局也更稳定。Table 的成本是一次间接访问,以及 heap 区间生命周期需要由应用管理。
Root descriptor 适合小数量、高频、buffer 型资源,例如当前 draw 的 object constant buffer GPU virtual address。它直接把 GPU virtual address 放进 root argument,省去 descriptor heap 查找。它的边界也很清楚:root descriptor 成本是 2 DWORD,而且主要服务 CBV、SRV、UAV buffer 类访问;它不适合承载大量 texture,也不提供 descriptor table 的数组化索引能力。材质 pass 中 per-object transform 如果每个 draw 都变,可以使用 root CBV;大量 material texture 仍应留在 descriptor heap。
Static sampler 适合采样状态稳定的材质系统,例如 linear wrap、linear clamp、shadow comparison sampler。Static sampler 写入 root signature 后不再占用 sampler heap descriptor,也不计入 64 DWORD root signature 大小。它的限制同样来自静态性:动态切换 anisotropy、LOD bias、address mode 的材质需要 sampler heap 或材质参数分支。工程判断是先统计采样状态种类;少量全局固定采样器用 static sampler,大量材质自定义采样器用 sampler descriptor table。
插槽管理应围绕 register space 制定长期约定。一个可维护的 D3D12 绑定 schema 可以把 space0 留给 frame 与 pass,space1 留给 material 与 bindless texture,space2 留给 object 与 draw-local 数据。这样 shader 反射、root signature 构建、材质编译和 runtime 绑定都能使用同一套编号规则。下面的 HLSL 片段只展示接口形状,不展开完整材质计算。
cbuffer FrameConstants : register(b0, space0)
{
float4x4 viewProjection;
float3 cameraPosition;
uint frameIndex;
};
StructuredBuffer<LightRecord> gLights : register(t0, space0);
Texture2D gMaterialTextures[] : register(t0, space1);
SamplerState gLinearWrap : register(s0, space0);
cbuffer ObjectConstants : register(b0, space2)
{
float4x4 model;
uint materialBaseIndex;
uint objectId;
};
这个接口表达了三层绑定关系。space0 是 frame/pass 数据,通常在 frame 或 pass 开始时绑定;space1 是 material texture 数组,通常由一个长期驻留的 descriptor heap 区间承载;space2 是 object 数据,随 draw 更新。这样的 slot 组织把高频变化集中到少数 root parameter,把低频大数组留给 heap,把 shader 端资源声明和 CPU 端绑定策略对齐到同一张表。
Root parameter 的顺序也有性能意义。Root Signature Limits 建议把变化频繁或低延迟要求高的参数放在较前位置,因为某些硬件有更快的原生 root argument 空间,超出后可能进入较慢存储。工程上可以把 per-draw root constants、per-object root CBV 放在前面,把大型 descriptor table 放在后面。这个策略不会替代 profiling,但它给 root signature 初始布局提供了合理起点。
88.3 高效资源绑定布局设计
高效绑定布局从 frame graph 的资源关系开始设计。先列出每个 pass 的输入输出,再把 shader 访问类型转成 CBV/SRV/UAV/sampler,最后把 descriptor 按更新频率和生命周期放入 heap。这样设计能把可见的画面结果连接到资源路径:某个材质贴图错误,优先查 material descriptor index;某个 compute 结果未被 graphics pass 读取,优先查 UAV 写入、资源状态和后续 SRV table;某个 camera 抖动,优先查 per-frame upload buffer 与 root CBV 地址。
一个实用布局通常由 CPU-only staging heap、shader-visible global heap 和 per-frame dynamic region 三部分组成。CPU-only staging heap 负责长期保存资源 descriptor 模板,便于多线程创建与复制;shader-visible global heap 负责放置长期驻留的 bindless texture、全局 buffer 和稳定 sampler;per-frame dynamic region 负责当前 frame 的临时 CBV/SRV/UAV descriptor。每帧根据 frame index 选择独立区间,GPU fence 到达后再回收对应区间。
这条流程的核心是把 descriptor 生命周期和 GPU timeline 绑定起来。Frame graph 产生 pass 列表和资源读写关系,binding system 根据访问类型生成或复用 descriptor,command list 绑定 heap 区间与 root table,GPU 执行完成后通过 fence 暴露回收时机。CPU 侧看到 descriptor 可以立即重写,并不代表 GPU 侧已经结束访问;回收策略必须以 fence 值为准。
Per-frame 数据适合使用 upload buffer ring。D3D12 常量缓冲视图需要按 256 字节对齐,所以 camera、object、material 小常量可以从一个 per-frame upload allocator 中切片分配。每个切片记录 GPU virtual address 和所属 frame fence。Root CBV 直接绑定 GPU virtual address,descriptor table 也可以绑定到对应 CBV descriptor。选择 root CBV 还是 CBV descriptor table,取决于更新频率和 root argument 预算:每 draw 变化的小常量优先 root CBV;数量多、由 shader 索引的数据优先 descriptor table 或 structured buffer。
Per-material 数据适合拆成“稳定 descriptor + 小型索引记录”。贴图 SRV 放在长期驻留的 bindless descriptor heap 区间,material record 只保存 albedoIndex、normalIndex、roughnessIndex 这类整数,以及少量 scalar 参数。Draw call 绑定 object constants 后,pixel shader 通过 material index 查 material record,再从 bindless texture table 中采样对应纹理。这个布局把材质切换成本从多次 descriptor 绑定降成一次整数索引读取,并把材质资产生命周期从 draw call 录制中分离出来。
Per-pass 数据适合由 frame graph 分配独立 table。Shadow pass 输出 shadow atlas,lighting pass 读取 G-buffer、shadow atlas、light list,postprocess pass 读取 HDR color 和 history texture。每个 pass 的 descriptor table 应只包含该 pass 实际需要的 SRV/UAV/CBV,便于 PIX 或 RenderDoc 在单个 draw/dispatch 上检查“当前 pass 的输入是否完整”。当 pass table 过大时,错误定位会从资源访问问题变成索引管理问题;因此 pass table 适合保持中等规模,bindless 大数组则交给 material/global table。
Heap residency 是绑定布局中的资源可用性问题。D3D12 把 binding 与 residency 管理解耦,Microsoft Learn 的 Differences in the Binding Model from Direct3D 11 指出应用负责管理 residency、object lifetime、resource state 和 CPU/GPU mapped memory synchronization。工程上要把 descriptor 指向资源与资源处于 resident 状态分开检查:descriptor table 可以正确指向某张纹理,但纹理资源如果被驱逐或生命周期提前结束,shader 仍会产生错误访问。资产流式加载系统需要在 descriptor 对外可见前完成资源创建、上传、状态转换和 residency 确认。
一个可迁移的根签名布局可以从下面的最小表开始。它不追求覆盖所有引擎需求,而是给本章贯穿 frame 提供稳定骨架。
| Root slot | 绑定内容 | 更新频率 | 建议形态 |
|---|---|---|---|
| 0 | draw constants | per draw | root constants |
| 1 | object transform buffer | per draw | root CBV |
| 2 | frame/pass resources | per frame 或 per pass | descriptor table |
| 3 | bindless material textures | asset lifetime | descriptor table |
| 4 | dynamic UAV/SRV scratch | per pass | descriptor table |
| static | common samplers | pipeline lifetime | static sampler |
这个布局的检查顺序是从高频到低频。先看 slot 0 和 slot 1 是否随 draw 更新,因为它们最容易造成物体矩阵或材质 ID 错误;再看 slot 2 和 slot 4 是否随 pass 更新,因为它们最容易造成上一个 pass 的资源残留;最后看 slot 3 是否稳定,因为它通常由资产系统维护。调试时如果每个物体都采样错同一张贴图,问题多半在 bindless table 或 material index;如果只有某一帧闪烁,问题更可能来自 per-frame dynamic region 回收或 upload buffer 覆盖。
88.4 动态资源更新与同步问题处理
动态更新的核心规则是:CPU 可以提前准备下一批 descriptor 和 upload 数据,但正在被 GPU 访问的 descriptor 区间、upload buffer 切片和资源内存要等对应 fence 完成后再复用。D3D12 为了降低频繁绑定的 CPU 成本,把很多跟踪责任交给应用;绑定系统需要把 descriptor allocator、upload allocator、resource state tracker 和 fence recycler 放到同一条 frame timeline 上管理。
Per-frame allocator 是最常见的解决方式。使用两到三帧 in-flight 时,每个 frame index 拥有独立的 upload buffer ring 和 dynamic descriptor region。CPU 录制 frame N 时写入 region N,提交后记录 fence value;当 GPU 报告 fence value 完成,region N 才回到 free list。这样可以让 CPU 连续录制新帧,同时保持旧帧引用的数据稳定。这个规则同时适用于 CBV descriptor、SRV/UAV 临时 descriptor、root CBV 指向的 upload buffer 地址,以及 indirect argument buffer 等 GPU 可见数据。
Descriptor recycling 要区分 CPU-only staging heap 与 shader-visible heap。Staging heap 中的 descriptor 只是 CPU 侧模板,拷贝到 shader-visible heap 后,staging descriptor 可以继续保留或重写,只要 CPU 多线程写入没有冲突。Shader-visible heap 中被 command list table 引用的 descriptor 处在 GPU timeline 上,应用需要等 fence 后再覆盖对应区间。这个边界解释了很多随机贴图错误:CPU 看到 descriptor handle 相同,以为只是更新了表项;GPU 执行旧 command list 时仍按旧 table 地址读取,如果该地址内容已被新帧覆盖,就会采样到新帧资源。
Root Signature 1.1 引入了 descriptor 与 data static/volatile 的承诺。Microsoft Learn 的 Root Signature Version 1.1 说明,应用可以声明 descriptor 或 descriptor 指向的数据在某段时间内保持静态,以便驱动做优化。工程上可以把长期 material texture table 标记为 static,把每 frame 更新的 upload buffer 数据标记为适合的动态语义,把 UAV 输出保守地视作 volatile 数据。这里的关键是承诺必须和实际更新时机一致;承诺越强,驱动越有优化空间,错误更新带来的 undefined behavior 也越难在不同硬件上保持一致。
Resource state 与 descriptor 更新是两条独立路径。Descriptor 只说明 shader 如何访问资源,resource state 说明当前命令流中资源处于哪个用途。Compute pass 把 particle buffer 作为 UAV 写入后,graphics pass 若作为 SRV 读取,需要完成写后读的顺序约束,并把资源转到适合 shader read 的状态。对于同一 UAV 的连续读写顺序,UAV barrier 用来建立可见性和顺序;对于 RTV 到 SRV、copy dest 到 pixel shader resource 这类用途变化,需要 resource transition。Descriptor table 正确绑定无法替代资源状态转换。
下面的伪代码展示动态更新的最小顺序。它省略错误处理和完整 API 细节,只保留本章需要的资源路径。
FrameContext& frame = frames[frameIndex];
WaitIfFrameRegionStillInUse(frame.fenceValue);
GpuAddress cameraAddress = frame.upload.AllocConstantBuffer(cameraData);
GpuAddress objectAddress = frame.upload.AllocConstantBuffer(objectData);
DescriptorRange passTable = frame.descriptors.Alloc(4);
CopyDescriptor(passTable[0], shadowAtlasSrv);
CopyDescriptor(passTable[1], lightListSrv);
CopyDescriptor(passTable[2], particleBufferSrv);
CopyDescriptor(passTable[3], depthPyramidSrv);
cmd.SetDescriptorHeaps(globalSrvUavHeap, globalSamplerHeap);
cmd.SetGraphicsRootSignature(materialRootSignature);
cmd.SetGraphicsRootConstantBufferView(1, objectAddress);
cmd.SetGraphicsRootDescriptorTable(2, passTable.gpuHandle);
cmd.SetGraphicsRootDescriptorTable(3, bindlessMaterialTextures.gpuHandle);
cmd.DrawIndexedInstanced(indexCount, 1, 0, 0, 0);
frame.fenceValue = SubmitAndSignal(cmd);
这段代码有四个检查点。第一,WaitIfFrameRegionStillInUse 必须用 fence 保护 per-frame region。第二,upload allocator 分配出的 GPU address 必须在 GPU 执行期间保持有效。第三,passTable 的 GPU descriptor handle 必须指向 shader-visible heap 中尚未被覆盖的区间。第四,root signature slot、HLSL register space 与 table range 必须一致。任意一处错位都会表现为画面随机闪烁、材质串用、上一帧数据残留或 GPU validation 报错。
动态 UAV 还需要关注读写次序。以 particle buffer 为例,compute dispatch 先以 UAV 写入粒子位置,后续 graphics pass 以 SRV 读取同一 buffer。稳定顺序是:compute dispatch 录制完成后插入 UAV barrier 或合适的 resource barrier,随后把资源状态转为 shader read,再绑定 SRV descriptor table,最后 draw。若同一 buffer 在下一次 compute pass 再次作为 UAV 写入,需要从 shader read 转回 unordered access。这样做把“同一资源不同用途”显式写进 command list,调试工具也能在 barrier audit 中显示资源用途变化。
同步错误通常可以用现象反推。贴图偶尔变成另一种材质,优先查 descriptor recycling 与 material index。常量偶尔落后一帧,优先查 upload buffer ring 和 frame index。Compute 输出在 graphics pass 中为空,优先查 UAV 写入、barrier、资源状态和 SRV descriptor。GPU device removed 或 page fault,优先查资源生命周期、residency、descriptor 越界和 Root Signature 1.1 static 承诺。这个反推顺序比盲目增删 barrier 更可靠,因为它把症状映射到 descriptor、resource state、memory lifetime 和 shader index 四条路径。
88.5 与 Vulkan 绑定机制的对比与互通
D3D12 与 Vulkan 都把资源绑定显式化,但对象切分方式不同。Vulkan 规范的 Descriptor Sets 定义了 descriptor set layout、descriptor binding、descriptor type、descriptor count 和 shader stage visibility;D3D12 则用 root signature 定义 root parameter 与 descriptor table range。两者的共同目标是让应用提前声明 shader 资源接口,并在 command recording 时绑定具体资源。差异在于 D3D12 更强调 descriptor heap 与 root table handle,Vulkan 更强调 descriptor set layout、descriptor pool、descriptor set 和 pipeline layout。
可以用下面的映射表建立互通思路。这张表不追求 API 一一替换,而是给引擎级 binding schema 提供后端降级目标。
| 引擎级概念 | D3D12 落地 | Vulkan 落地 | 判断边界 |
|---|---|---|---|
| 全局绑定契约 | root signature | pipeline layout | 都要与 shader 接口兼容 |
| 一组资源表 | descriptor table range | descriptor set binding 或 descriptor array | Vulkan set 有 layout 和 pool 分配语义 |
| 大量材质贴图 | shader-visible descriptor heap + bindless table | descriptor indexing 或 descriptor array | 需要查询 feature 和 limit |
| 高频小常量 | root constants 或 root CBV | push constants 或 dynamic uniform buffer | push/root 空间都有大小限制 |
| 固定 sampler | static sampler | immutable sampler | 动态 sampler 仍需 descriptor |
| 资源生命周期 | fence + residency + descriptor reuse | fence/semaphore + memory + descriptor pool reuse | 同步对象与内存模型不同 |
Vulkan descriptor set layout 与 D3D12 root signature 的相似点是“先定义接口,再绑定实例”。Pipeline layout 持有一组 descriptor set layout 与 push constant range,D3D12 PSO 则要求 shader 与 root signature 兼容。引擎如果先定义 frame set、pass set、material set、object set,就可以在 D3D12 后端把它们降成 root parameter 与 descriptor table,在 Vulkan 后端把它们降成 set layout 与 binding。这个中间层可以保持 shader 资源语义一致,同时允许 API 后端使用不同分配器。
Bindless 在 Vulkan 中通常对应 descriptor indexing、runtime descriptor array 或更现代的 descriptor buffer/descriptor heap 路径。Vulkan Guide 的 Descriptor Arrays 展示了同一 binding 下 descriptor array 的用法,也说明 runtimeDescriptorArray 能让数组大小在运行时确定。D3D12 中,bindless 通常是 shader-visible heap 中一段大 SRV table,再由 shader 使用 index 访问。两个 API 的共同风险是索引边界、feature limit、更新时机和未初始化 descriptor;共同收益是减少每 draw 绑定频率,提升材质系统可扩展性。
Descriptor update 的时机在两个 API 中都需要显式管理。D3D12 中,shader-visible heap 区间被 in-flight command list 引用时,应等 fence 后再覆盖。Vulkan 中,descriptor set 被提交命令引用时,也要按规范的 update-after-bind、descriptor pool reset、set lifetime 和同步规则管理。Vulkan 的 VK_DESCRIPTOR_BINDING_UPDATE_AFTER_BIND_BIT 提供更灵活的更新语义,但它同样要求创建 layout、pool 和 feature 时声明能力。引擎层可以把这一类能力抽象成“descriptor 可在提交后更新”或“descriptor 提交后冻结”,再由后端选择对应 API 标志。
跨 API 互通时,最稳的策略是先建立平台无关的 binding schema,再对每个后端做降级。Schema 不应直接使用 D3D12 的 root slot 或 Vulkan 的 set number 作为业务概念,而应使用 frame、pass、material、object、bindless texture、scratch UAV 这些语义层。D3D12 后端负责把语义层映射到 root parameter、register space 和 descriptor heap;Vulkan 后端负责把语义层映射到 set index、binding number、descriptor pool 和 pipeline layout。这样可以在保持 shader 资源语义稳定的同时,给不同 API 留出对象生命周期和同步差异。
互通检查顺序可以固定为五步。第一,列出 shader 需要的资源语义和访问类型。第二,检查每个语义属于 frame、pass、material 还是 object 频率层。第三,检查后端 feature:D3D12 resource binding tier、root signature version、Vulkan descriptor indexing、descriptor buffer 或 update-after-bind。第四,生成后端 layout:D3D12 root signature 与 Vulkan pipeline layout。第五,在 capture 工具中对照 draw/dispatch 的实际绑定:D3D12 看 root parameters 与 descriptor heap,Vulkan 看 bound descriptor sets、set/binding、pipeline layout 与 resource state/synchronization。这个顺序让 API 差异回到可观察对象,并减少纯术语对照带来的误判。
最小自检任务
给定一个 frame:camera 常量每帧更新;1024 张材质贴图由 material ID 选择;每个 draw 有独立 object transform;compute pass 先把粒子位置写入 ParticleBuffer,graphics pass 随后把同一 buffer 作为 SRV 读取并绘制粒子。请设计 D3D12 绑定布局,并写出从 CPU 更新到 GPU 执行完成后的回收顺序。要求说明 CBV/SRV/UAV/sampler 放在哪里,root signature 至少包含哪些 slot,动态 descriptor 与 upload buffer 如何回收,compute 到 graphics 之间需要哪类同步。
答案要点
一个合理布局是:camera 常量放在 per-frame upload buffer 中,可通过 frame descriptor table 或 root CBV 绑定;1024 张材质贴图放在长期驻留的 bindless SRV table 中,material record 保存 texture index;object transform 放在 per-draw upload buffer 切片中,用 root CBV 或 root constants 绑定;ParticleBuffer 在 compute pass 作为 UAV 绑定,在 graphics pass 作为 SRV 绑定;常用采样状态使用 static sampler 或 sampler heap 中的稳定 sampler descriptor。
Root signature 可以包含:slot 0 放 draw constants,例如 material index 与 object id;slot 1 放 object root CBV;slot 2 放 frame/pass descriptor table,例如 camera CBV、light list SRV、particle SRV;slot 3 放 bindless material texture table;slot 4 放 compute scratch UAV table;static sampler 放全局线性采样器和 shadow comparison sampler。高频 root parameter 放前面,长期 bindless table 放后面。
CPU 更新顺序是:等待当前 frame region 对应 fence 完成;从 per-frame upload allocator 分配 camera 与 object 常量切片;从 dynamic descriptor allocator 分配 pass table 区间;把 ParticleBuffer 的 UAV/SRV descriptor、camera CBV descriptor 和其他 pass SRV descriptor 拷贝到 shader-visible heap;录制 compute dispatch 并绑定 UAV;插入 UAV barrier 或必要的 resource transition;录制 graphics draw 并绑定 SRV table 与 material bindless table;提交 command list 并记录 fence value。Fence 完成后,upload 切片和 dynamic descriptor 区间进入可复用状态。
关键边界是 descriptor 更新、资源状态和内存生命周期彼此独立。Descriptor 指向 ParticleBuffer 不会自动建立 compute 写入到 graphics 读取的顺序;UAV barrier 与 resource transition 才负责 GPU timeline 上的可见性和用途变化。Upload buffer 地址被 root CBV 引用后,CPU 要等 fence 完成再复用对应内存。Bindless material texture table 适合长期稳定,若资产系统更新其中 descriptor,需要保证旧 command list 已经结束或使用后端支持的安全更新语义。
本章知识点总结
- 绑定链路:D3D12 资源进入 shader 的主路径是 resource、descriptor、heap、table、root parameter 和 shader register。
- Descriptor 定义:Descriptor 描述资源的访问方式、格式解释、子资源范围和 shader 可见入口。
- Root Signature:Root signature 是 shader 与资源绑定之间的契约,并与 PSO 兼容性相关。
- Root 空间:Root signature 适合少量高频参数,批量材质资源应放入 descriptor heap 与 descriptor table。
- 频率分组:Frame、pass、material、object 的更新频率决定 descriptor table 和 root slot 的组织方式。
- Bindless 布局:Bindless table 适合大量低频资源,通过 shader 索引降低每 draw 绑定次数。
- Root Descriptor:Root descriptor 适合少量高频 buffer 访问,常用于 per-draw object constants。
- Static Sampler:Static sampler 适合固定采样状态,可以减少 sampler heap 绑定压力。
- Heap 生命周期:Shader-visible heap 区间被 in-flight command list 引用时,应以 fence 完成作为复用依据。
- 动态更新:Per-frame upload allocator 和 dynamic descriptor allocator 应共享同一套 frame fence 回收规则。
- 状态同步:Descriptor 绑定和 resource state 是独立路径,compute 写后读需要显式 barrier 或状态转换。
- Root 1.1 承诺:Descriptor 与 data 的 static/volatile 标志是应用给驱动的时序承诺,承诺内容必须匹配实际更新行为。
- Vulkan 对照:D3D12 root signature 可对应 Vulkan pipeline layout,descriptor table 可对应 descriptor set binding 或 descriptor array。
- 跨 API Schema:引擎应先定义 frame、pass、material、object 等语义层,再分别降到 D3D12 与 Vulkan 后端对象。
- 排查顺序:资源绑定问题应按 descriptor 内容、heap 区间、root slot、shader register、resource state 和 fence 生命周期逐项定位。