CSDN 版文章链接: {csdn-url} 知乎专栏版文章链接:{zhihu-url}
一句话点题:一帧 draw 命令,从这里开始被读懂
要是你已经看完了本系列前两篇,应该记得抓帧(ep02)把"一帧"定格成了 .rdc,回放(ep03)把这台机器从文件重新变成"活得可交互的状态"。那么问题来了:文件回来了、状态活了,你在界面上到底面对什么?
答案是:一个按时间顺序铺开的命令列表,RenderDoc 官方管它叫 事件浏览器(Event Browser),业内也常直呼"draw-call 列表"。官方文档把它定性为"浏览帧并选择不同 action 的主要方法",是第一落点窗口[event_browser]。社区里有文章干脆把它称作"the heart of analysis"和"central nervous system"[cosmiclearn-dx12]。
这一篇,我们就把这块"心脏"拆开:它列出来的到底是什么、树状层级从哪来、为什么 EID 老是不连续、点一下到底发生了什么、过滤器表达式怎么写才不会踩坑、时间轴那根条有什么用。主图取自 RenderDoc 官方仓库的 MIT 许可截图,官方对这些开关的行为声明原本就配了界面图,正好拿来图解;再辅以官方文档口径做旁证。想亲眼看真实的 .rdc 什么效果,自己机器上随便开一份复现即可。
把它排在 ep03 之后、ep05 之前不是顺手起的序号,而是有因果的:ep03 证明了"这台回放引擎能逐帧推进、按需产出资源",可那毕竟是引擎内部的事,我们看不见摸不着。事件浏览器正好是它对外开的第一扇窗——你在左边点一行,右边几十个窗口就跟着变,这个"联动"不是界面廉价效果,正是 ep03 那套"把回放状态设到某事件之后"的能力暴露成了交互。反过来说,你要是只会看整帧最终画面、不会在事件浏览器里定位和切状态,那 ep03 讲的回放确定性,对你来说就只是纸面上的概念。所以这一篇是承上:把 ep03 的能力变成手上能用的动作。
也先划个边界,本文不讲的范围有四块,省得你期望落空:一是 API 检查器(API Inspector)内部的逐字段怎么读,那是另一扇完整窗口,后续单独开题;二是抓帧工具本身的启动、连接、采集流程,那属于 ep02 的范畴;三是多帧/时序分析(怎么抓十帧、看趋势),它用的不是单帧回放这套心智,本系列讲 Perfetto 时再碰;四是 RenderDoc 的 Python/脚本自动化,它面向"批处理验证"而非"人眼排障",另文再说。本文只回答一件事:拿到一份单帧,如何在事件浏览器里把"命令流"读成"可验证的状态流"——范围卡在这,才不会又写成一本按钮说明书。
OK,言归正传,先把最容易混淆的一个词掰清楚。
一、先分清:你看到的是 action,不一概是 draw
1.1 术语澄清:action ≠ 光栅化绘制
事件浏览器里每一行事件,官方叫 action(动作),指的是"draws, dispatches, copies, clears, resolves,以及其他让 GPU 干活、或影响纹理和缓冲区等资源的调用"[event_browser]。也就是说,一次 DrawIndexed 是 action,一次 vkCmdDispatch(compute 派发)、一次 vkCmdClearColorImage(清屏)、一次 vkCmdCopyBuffer(拷贝)同样是 action。
官方文档特别给了一句提醒:"有时被称为 drawcall,但用 action 这个词更不歧义",因为它要和真正的光栅化绘制区分开。这并非咬文嚼字,而是读列表时的基本功:如果你心里只装着"draw",看到列表里一排 Copy、Clear、Dispatch 就会下意识把它们当下级的噪声过滤掉,可它们同样是撑起一帧的关键命令。
把这五类 action 摆成一张对比表,"都是 action、但各干各的活"就一目了然了:
| action 类别 | 典型 API | 在帧里承担什么 | 该不该当噪声滤掉 |
|---|---|---|---|
| 光栅化 draw | Draw / DrawIndexed / DrawIndexedInstanced |
真正把三角形画进 RT | 绝不,这是主线索 |
| compute dispatch | vkCmdDispatch / DispatchMesh |
跑 GPU 计算,不经过光栅化 | 高光/后处理常常是它,别只盯 draw |
| clear | vkCmdClearColorImage / ClearDepth |
把 RT 重置成底色/深度 | 频率高但必要,定位第一笔写时有用 |
| copy | vkCmdCopyBuffer / CopyImage |
在资源间搬数据、传回 CPU | resolve/回读耗时的大头常在这 |
| resolve | vkCmdResolveImage |
把 MSAA 的中间结果合并成最终像 | 全屏 pass 后常见,逐像素问题别漏它 |
读这张表的要点是:一帧的正确心智是"一条 action 流水线",而不是"一串 draw"。compute 负责的东西越来越多(Lumen 式光照、物理模拟、遮挡剔除都往 compute 跑),如果还只习惯看 draw,你会系统性漏掉 GPU 上真正吃时间的部分。这也呼应了第 6 章:那里的过滤表达式里 Dispatch 就是要能被单独筛出来的——它和 Draw、Copy 是平权的 action,不是某棵树下的小跟班。
1.2 默认只看 action:state 设置藏在别处
事件浏览器默认只列 action,这是很多人第一次打开时觉得"命令是不是被藏起来了一部分"的原因。真相是:状态设置等 CPU 更新调用(比如 vkCmdBindPipeline、vkCmdBindDescriptorSets、Maps)默认不出现,它们被归到另一扇窗口 API 检查器(API Inspector) 里[how_filter_events]。在 Vulkan 教程的说法里,RenderDoc 让你"freeze a single frame…re-execute individual draw calls and compute dispatches in isolation",但你看到的隔离粒度是这些 action[vulkan-tutorial-analysis]。
这意味着第一层读法就是:你想要"这条命令到底干了什么",看事件浏览器;你想要"这条命令之前的渲染状态是怎么配出来的",去 API 检查器。
一次完整的调试里,这两扇窗是轮流用的,分工其实很干净:
| 想知道什么 | 去哪扇窗 | 例子 |
|---|---|---|
| 这条命令画了什么、画了多少 | 事件浏览器 | 一次 DrawIndexed(6, 16) 本身 |
| 这条命令之前 pipeline / 状态是怎么配的 | API 检查器 | 该 draw 用了哪个 PSO、绑了哪些 descriptor set |
| 一个绑定值是谁在哪个时刻设的 | API 检查器 + 事件的调用序 | vkCmdBindVertexBuffers 是在第几条 action 之前被调用的 |
反直觉的地方在于:前者(action 流水)是逐光栅化动作的,后者(state 流水)是逐状态覆盖的。state 调用只有"最后一次生效"有意义,所以浏览器把它按覆盖顺序记录,而不是当成独立的可画东西。这也解释了为什么默认列表里看不到它们——不是被砍了,是它们本就属于另一条时间线。
二、树状层级从哪来:标记(Marker)把你的帧织成一张可折叠地图
2.1 平铺的调用链,如何变成一棵树
照理说,一次渲染一整帧,命令就是一条平铺的 API call → API call → API call 序列。可你打开事件浏览器,看到的却是可以展开折叠的树。这是怎么来的?
答案在 标记(marker,也叫 region/annotation)。应用可以在录制时用对应 API 的调试标签接口,把一组命令"包"成一个具名区段:Vulkan 用 vkCmdBeginDebugUtilsLabelEXT / vkCmdEndDebugUtilsLabelEXT,D3D 用 PIXBeginEvent / PIXEndEvent,OpenGL 用 glPushDebugGroup / glPopDebugGroup[how_annotate_capture]。RenderDoc 在回放时读这些标签,就把命令按区段组织成树节点。
Vulkan 教程有一段很实在的总结:这些标签"会以具名区段的形式出现在 RenderDoc 的事件列表中,使导航变得 trivial——即使这一帧里有几千条 GPU 命令"[vulkan-guide-debugutils]。
所以第二层读法就建立起来了:树的形状不是你显卡跑出来的,是开发者当初用标签"织"出来的。一个不注解自己帧的引擎,事件浏览器就是一马平川;一个像 UE 那样深度标记的引擎,事件浏览器就是一份目录清晰的"渲染分镜"。
把"织"这个过程落到一棵爱上特别直观。假设某个引擎把一整帧包成这样的标签层级:
帧 begin
├── Scene Render(场景主 pass 的 begin)
│ ├── Shadow Depth(阴影 pass 的 begin)
│ │ └── DrawIndexed(...) × 若干
│ │ └── (Shadow Depth 的 end)
│ ├── Base Pass(begin)
│ │ └── DrawIndexed(...) × 大量物件
│ └── (Scene Render 的 end)
└── Post Process(begin)
└── 各 blur / grade 的 draw
└── (Post Process 的 end)
展开这份树时,你真正在做的不是看渲染,而是在对着引擎当时给每段代码起的名字做导航:哪一段是阴影、哪一段是主场景、哪一段是后处理,全靠标签命名。那些 draw 全都撞在同一层里,就很难一眼分出"这条属于哪个 pass"——名字才是你的地图。所以接手一个陌生引擎的帧,第一步往往不是找那条最长的 draw,而是先把树的根摸清,给每个 marker 的命名"建立索引"。
标注力度差异也直接决定排查成本:UE 这类引擎在源码里调用了 PIXBeginEvent 式接口,把每个 pass、每类 mesh 都点名,树能一路层层展开;而有的自研引擎只在最外层包一个"Frame",中间全平铺——这种帧里要定位某个对象只能靠 numIndices、pipeline 之类的属性盲搜,效率天差地别。这也是为什么很多团队在接调试器时,会顺手给自家录制代码补注解,本质上是在"给未来的调试铺路"[how_annotate_capture]。
2.2 marker 的着色与"所在区段"指示条
标记不只决定树的形状,还带来两处视觉标记:一是区段的颜色会作用到该区段的整行行首,二是沿树左侧会画一条色条,标示当前事件正处于哪些区段里[event_browser]。这两点对读图特别有用:你点选一个很深的 draw,看左侧色条,就知道它一路处于哪些 pass 之下,一条垂直的"血缘"立刻可见。
这跟一棵树"看祖先"的直觉并不完全相同,值得说明白。单独看树,你能分清每个节点的父子;但一次 draw 到底是"Scene Render 内 Shading 内"的哪一级,有时要展开好几层才看清。而着色机制把"当前事件处在哪几个区段"一次性压进了最左侧那条竖条:每一段颜色代表一个尚在开启中的区段,上浅下深的堆叠顺序正好是区段的嵌套外到内。所以读深 draw 时,扫一眼竖条就能在两三个 PASS 之间快速建立"这条命令的血统",比逐层展开高效得多。这对不注解帧格外有用——一个只包了一层"Frame"的树,左侧几乎只有一抹颜色,你一眼就能判断这帧的标注力度在哪一级,从而决定下一步是直接搜属性还是先补注解[how_annotate_capture]。
同时,marker 的名字也不只是给人看的:RenderDoc 的事件浏览器把自定义名当成本质上"可搜索、可过滤"的一样东西。第 6 章会把这一点变成表达式里的 $parent(render scene),第 5 章会说到"自定义名其实反映驱动真正提交的东西"。所以 2.2 这一段先把结论立住——命名即索引:你给 marker 起的名字,会成为之后你通过书签、过滤器、EID 反查这整棵树的入口。这也是为什么很多引擎在 release 版也保留 marker:比起暴漏内部实现,它们更在意让你快速回到问题的现场。树、着色、命名这三件事合成一块,事件浏览器才能从"平铺命令表"升级成"可导航的帧地图"。
三、EID 语义:为什么事件编号老是不连续
3.1 EID 从 1 递增,每次 API 调用都 +1
事件浏览器第一列是 事件编号(Event ID,EID)。官方对它的定义非常朴素:"EID 从 1 开始,每发生一次 API 调用就增加一次"[event_browser]。
这句朴素的话暗含一个反直觉结论:EID 数的是"API 调用次数",不是"action 条数"。因为抓帧器会在每次 API 调用时都分配一个 EID,无论这次调用是 action(draw/clear/copy)还是 state 设置(bind pipeline / bind descriptor set)。而默认列表只显示 action,因此你看到的 action EID 之间会"缺号"——缺掉的那些序号,正是被隐藏的 state 事件占掉的。
举个例子:假设事件浏览器里某行的 EID 是 72,下一行直接跳到 75,那不是抓帧抓丢了,而是 73、74 分别是两次你默认看不到的状态绑定调用。
把这个"缺口"和"谁占用了缺口"摆成一张对照,读完整帧的 EID 就能心里有数了:
| 列表里显示的 | 占的 EID | 你看不看得见 |
|---|---|---|
一条 DrawIndexed |
假想 EID 72 | 默认可见 |
一条 vkCmdBindPipeline |
假想 EID 73 | 默认不可见(state,跑 API 检查器里) |
一条 vkCmdBindDescriptorSets |
假想 EID 74 | 默认不可见(state) |
下一条 DrawIndexed |
假想 EID 75 | 默认可见 |
所以第 72 行和第 75 行中间"跳掉"的其实从来不是丢了命令,而是这 73、74 号正藏在你关掉的 state 视图里。读 EID 的第一原则就是:相邻 action 之间的差,才能告诉你中间埋了多少条状态设置——差距越大,说明这条 draw 之前的状态越"翻箱倒柜",常常也越值得留意(比如是不是每帧都在重建 pipeline)。这条"用 EID 缺口反推状态活动"的读法,在 6.5 想专门把绑定调用调出来看时,直接就能对上号。
把 EID 当成"回放指针"来看就顺了:它既是列表里的行号,也是回放引擎内部可寻址的稳定句柄。ep03 讲过回放引擎会递增地"推进帧事件",这里用的正是同一个 EID 序列——你在界面上点 74、点 75,回放引擎就把状态分别推到"第 74 条调用之后"和"第 75 条调用之后"。正因为每次运行回放都会重新得到同一套 EID,它才稳定到能被其他窗口引用:一旦某个工具告诉你"问题出在 draw 9120",无论你关掉重开、换机器复现,只要还是这份 .rdc,9120 永远指同一条命令。这个"全局稳定"正是后面 3.2 能搜 EID 直达、以及各窗口能通过 EID 联动的根因。
3.2 用 EID 直达:搜数字跳跃到目标事件
正因为 EID 是"绝对时间次序"的锚点,事件浏览器提供了 Ctrl-F 查找,可以直接搜一个 EID 跳到对应事件[event_browser]。这在对接其他窗口时尤其好用:例如 Meta 官方文档提到,在性能计数器窗口里"每一行代表一个 draw call,双击这一行就能在事件浏览器里选中对应的 draw call"[meta-renderdoc-oculus]。其背后正是用一个 EID 把两个窗口串起来。
这个"搜数字直达"要比表面看起来有分量:它把 EID 从一个"列表行号"升级成了横跨整个调试工具的通信协议。性能计数器报"draw 9120 异常",你 Ctrl-F 输 9120,就落到那条命令;Pixel History 列出的每笔触达,也带 EID 供你反查;甚至你在脚本或团队工具里导出的每一条嫌疑记录,其实都靠 EID 承载"位置"。所以退化一步想:当你在任何窗口看到一串数字、一段能定位的 EID,别把它当普通序号丢掉,那很可能是这条线索的通关文牒。等到 9.2、9.3 讲查找和复制时,你会发现"搜 EID、拷 EID、报 EID"是同一枚硬币的三面。
四、点选一个 draw,到底发生了什么
4.1 选中 = 把回放状态设到"那条命令执行完的微秒之后"
这是事件浏览器最容易被低估的一个机理。社区文章的比喻是:"点选某个事件,就是告诉 RenderDoc:把整条图形管线的状态设到该命令执行完毕的那一个瞬时之后"[cosmiclearn-opengl]。官方更严谨:点选某事件会让其成为 current event,并体现在 UI 的所有其他窗口(管线状态、资源、纹理、网格……)[event_browser]。
再往细里走一点:如果你选中一个带子节点的节点,等于"视作它所有子事件都执行完"来显示状态。也就是说,你把一个 pass 的收尾节点选住,看到的就是整个 pass 收尾后的状态。
举个例子帮助记忆:假设 Scene Render 是一个标签节点,底下挂着一串 draw。你点选它,看到的是"这串 draw 全跑完之后"的 R/W 状态、输出资源的样子;你要点选它最底下那条 draw,看到的才只推进到那条命令的末尾。同一个节点里,点层级本身和点叶子命令,得到的状态是不同的——区别就在"包含我下面的所有,还是只含我一个"。这个差异在排查"整 pass 画面出问题"还是"单条 draw 出问题"时,是第一条要分清的判断闸。
"其余窗口跟着变"具体是哪些、各看到什么,值得列一张清单,读图时才不会左手找纹理右手找管线:
| 窗口 | 点选后给你看什么 |
|---|---|
| 管线状态(Pipeline State) | 当前事件用到的 PSO、各阶段着色器、顶点/索引布局 |
| 纹理查看器 | 选中纹理在当前事件之后的样子(R/W 状态同步到该点) |
| 网格查看器 | 当前 draw 喂进去的顶点/索引缓冲,解析成几何再呈现 |
| 资源/描述符(Descriptor)窗口 | 该事件引用的纹理、缓冲、采样器把手,可直接反查 |
| 常量缓冲(CB/UBO)查看器 | 当前事件绑定的常量缓冲内容,实时抓到这一版的数值 |
有了这张表,你"点一下边看"的路径就变成了:先在事件浏览器选行定状态,再跳到具体资源窗口追问细节。所有窗口都以同一个 current event 为基准——这正是 ep03"把回放状态设到某事件之后"能力在界面上的集体暴露[event_browser]。
4.2 逐 draw 隔离:re-execute in isolation
与"设到某瞬时"并行的,还有隔离重放:官方文档确认 RenderDoc 可以 "re-execute individual draw calls and compute dispatches in isolation"[vulkan-tutorial-analysis]。这正是"这到底画了什么"这类问题的标准答案:不再依赖整帧共同作用,而是单独放一条 draw,看这条命令自己产出的结果。于是"逐 draw 断点验证"就成了本系列自 ep03 引入回放确定性之后,真正落到界面上的第一把验证工具。
它真正用起来是这样一步步走的,整套动作就一条逻辑链:
- 在事件浏览器里选中一条可疑的 draw(比如那片不该出现在这里的高光);
- 打开纹理查看器(Texture Viewer),选中它写入的那张 RT;
- 点该 draw 的"isolate"动作——RenderDoc 会回放这一条 draw 并把它作为帧里唯一一次写操作的状态单独呈现;
- 盯着纹理看:这张 RT 上现在还剩下什么?是你怀疑的那片高光,就实锤是这一条画出来的;如果是空的,那高光多半来自后面某一笔叠加。
- 再前后移动 current event,配合第 4 步,就能逐步逼近"到底是第几条 draw 造成这个效果"。
这五步看着简单,实则吃透了 ep03 的两个机制:一是回放非破坏性——隔离执行不会把真帧弄脏,你随时能切回,所以可以放心大胆反复试;二是按需出声——隔离的默认状态来自"该 draw 之前的资源内容",正因为 ep03 里资源和状态可以按事件精确推进,这一步才成立。隔离验证的每一步,其实都是在替 ep03 的回放引擎做一次"状态求值"。也正因如此,事件浏览器 + 纹理查看器 + 网格查看器(Mesh Viewer) 这三扇窗口常常是排一条 draw 嫌疑时全程联动的那一组。官方文档里 isolate 专属的描述、以及各查看器与事件浏览器的联动,都在对应窗口的文档里写了[video_viewer]。
五、读参数:三档显示开关,把名字和外貌调出来
5.1 默认只显示"最有意义"的参数
事件浏览器里每一行的文字是"从该 API 调用的参数推导出来的"。默认情况下只展示最有意义的参数,参数名也不显示,只给值[event_browser]。所以你会看到空荡荡的 DrawIndexed(6, 16) 这种行——幸运的是,应用若设置了 marker 或对象名,名称就会更友好。
有一个很容易掉进去的读图误区要在这里先钉死:"最有意义"不等于"看起来顺眼",它取的是开发者在写这个 action 时认为最能定义它量级的那组值。正因为如此,DrawIndexed(6, 16) 的法则是"索引起点、索引数量、实例起点、实例数量",但很多图形 API 的 draw 参数顺序并不统一——OpenGL 的 glDrawElements 是 mode + count + type + indices[gl_draw_elements],Vulkan 的 vkCmdDrawIndexed 是 indexCount + instanceCount + firstIndex + vertexOffset[vk_cmd_draw_indexed]。所以别默认"第一个数就是顶点数",要先想清楚这个 action 用的是哪套 API 的哪个函数,它的第几个参数才是你关心的量。把参数名单个显示档打开(5.2 里有开关),正是为了在分辨不清时把"第几号是索引起点"这个歧义消掉——这一点放到排障里,能避免"把 firstIndex 当成 indexCount"这种低级却致命的误判。
"最有意义"四个字背后是有取舍逻辑的,值得读透。一次 DrawIndexed(6, 16),真正决定"这画了多少"的,是索引数量 6 和实例数量 16 这两个值;至于绑定的是哪个 vertex buffer、用哪份 descriptor set,那些属于"跟这台命令配的状态",默认不放进来是对的,因为塞进来你会被几十个 handle 淹没。所以默认档的核心设计是只保留"一看就知道这条命令量级"的参数,把"连接关系"留给 API 检查器和后续查看器去追问。读懂这个取舍,你才不会理所当然地以为"默认看不到就代表不存在"——它只是刻意不把噪音摆到你眼前。
5.2 两档开关把"谁"和"怎么配的"调出来
事件浏览器在 Settings & Help 里提供几个显示开关,读懂它们能迅速改变你看到的信息量:
- Show custom action names(显示自定义 action 名,默认开):决定是否用自定义名(比如 indirect draw 显示 GPU 侧实际 draw 参数、renderpass 的 load/store op)而不是原始调用名[event_browser]。
- Show parameter names and values(显示参数名和值,默认关):在值之外把参数名也列出来,例如不再是
(6, 16)而是(numIndices=6, instanceCount=16)。官方提示:对带自定义名的 action 此开关无效。 - Show all parameters(显示全部参数,默认关):把所有参数都铺开——官方的说法是"全展开后可能很难看清,因为有些函数有不重要的隐式参数(比如 command buffer)"[event_browser]。
第三层读法的价值就在这:默认状态是为了"干净",但当你需要确认一次 draw 到底绑了多少个实例、是从哪个 baseVertex 开始的,就要在默认名开、参数名开、必要时全参数开之间切换。一个常见场景:日本一支优化团队在 UE5 优化系列里就靠读 DrawIndexedInstanced(6, 16) 反推"这是小网格(树叶)×16 一起画的粒子或实例草"[furcraea]。
下面三张图是同一份捕获在三种显示档位下的实际样子:第一张只开默认名,行文字是最精简的自定义/API 形态,适合扫一眼整个列表的命令骨架;第二张打开参数名与值,numIndices、instanceCount 这类关键参数直接出现在行内,能快速确认一次 draw 到底投了多少实例、从哪个索引段画起;第三张把所有参数全部铺开,连 command buffer 这类隐式参数也现身,信息量最大、行也最长。同一个 draw 在三种档位下读出来的广度完全不同,这也解释了为什么默认档位刻意保持"干净"。
把三档摆成一张速查表,什么时候切哪一档就很清楚了:
| 档位 | 行的样子 | 什么时候用 |
|---|---|---|
| 默认名(默认开) | Scene_Render、DrawIndexed(6,16) |
扫命令骨架,看有哪些 pass / 哪些大对象 |
| 参数名+值 | DrawIndexed(numIndices=6, instanceCount=16) |
确认一次 draw 的规模、实例数、顶点索引段 |
| 全参数 | 所有参数铺开(含隐式参数) | 定位你跟的那条 draw 具体绑到哪个 handle、哪个 load/op |
注意一个反直觉点:默认名档开的是"自定义名",不是"原始 API 名"。所以当某次 draw 看起来"诡异"到你怀疑是不是引擎写错了参数时,先别急着信行里的数字,想一想这个名字是引擎自定义的、还是 API 原生的。indirect draw 这类自定义名甚至会显示 GPU 侧实际算出来的 draw 参数——那反而是你要的真相,因为它反映的是驱动真正提交的那份。判断"这个名代表什么",比"数字叫什么"更接近问题的根[event_browser]。
六、过滤表达式:把几千条命令变成想看的十几条
6.1 现代帧的量级问题
一个现代游戏的一帧可以有 5,000 条乃至更多事件[cosmiclearn-dx12]。官方文档和引擎教程都强调,frames 里"未知哪一个 draw 是罪魁祸首"时,手工翻找又慢又费劲[vulkan-ai-analysis]。所以事件浏览器顶部提供了一个过滤器输入框,配一套比普通搜索更强的表达式语法[how_filter_events]。这一节我们把语法规则翻译成人话,并标出最容易踩的坑。
6.2 基础规则:空格=OR,加号=AND,减号=排除,括号=分组
| 表达式 | 语义 | 举例说明 |
|---|---|---|
Draw Clear Copy |
空格分隔词之间是 OR | Draw、Clear、Copy 任一命中即显示;Dispatch 因不匹配任何词而被滤掉 |
-Depth |
前缀减号 = 排除 | 上个表达式加 -Depth 后,ClearColor 保留、ClearDepth 被排除 |
+Draw +Indexed -Instanced |
加号 = 必须含,减号 = 必须不含 | 必须含 Draw 且含 Indexed 且不含 Instanced |
+Draw +(Indexed Instanced) -Indirect |
括号 = 分组,内部仍是 OR | 必须含 Draw,且 Indexed/Instanced 至少一个,且不含 Indirect |
这四行规则已经覆盖了绝大多数日常过滤需求,把它们排成一张判定图能看得更清楚:空格与加号都表示"词要出现",区别在于空格是把多个备选词并列(命中任意一个即显示),加号则是把约束一步步收紧成 AND;减号在前面画一道排除线,括号则把一组 OR 词先捆成一个整体再去匹配。尤其要对比第三、四行:+Draw +Indexed -Instanced 要求 Draw 与 Indexed 同时命中且不含 Instanced,而 +Draw +(Indexed Instanced) 里括号把 Indexed 和 Instanced 捆成一组,命中其中任意一个就算满足。从 OR 换成 AND 的瞬间,显示的行会明显收窄——这正是从"扫一眼所有 draw"跨到"精确锁定某一类命令"的关键动作。
在给语法规则记熟了之前,先搞清楚一件事:这些词到底匹配的是事件的哪一个字段。官方口径是,过滤器的默认匹配对象是事件的 label(行文字),也就是我们在第 5 章调出来的那串名字(自定义名或 API 名加上参数)。所以 Draw 能命中 DrawIndexed,是因为它的名字里带着"Draw"这几个字;Copy 命中 CopyBuffer 同理。想靠原始 action 类型(比如只挑 Dispatch)来过滤时,就直接匹配类型名——Dispatch 这个词出现(或不出现)在名字里,就达到了"只看 compute"或"排除 compute"的目的[how_filter_events]。
另一件容易被忽略的事是:optional 词(不带 +)不是"匹配就能命中一把"的唯一条件,它是"事件只要 name 里出现过这个词就展示"的宽松匹配;required(带 +)和 excluded(带 -)才是硬约束。把这句记进心里,6.3 那个"坑"就顺理成章了。
6.3 一个大坑:required 词一出现,optional 词就被整组忽略
官方文档专门用了一整段 warning 强调:一旦过滤里出现了 required 词(带 +),那么所有 optional 词都会被完全忽略[how_filter_events]。原文给出的例子是:
+Draw -Instanced
与
+Draw Dispatch -Instanced
匹配的是完全相同的一组事件。为什么?因为官方语义下,"是否包含某事件"只由"它是否满足 required 词、且不违反 excluded 词"决定,optional 词在这条路径里根本排不上用场。这跟我们直觉的"权重越高越靠前"完全相反——事件永远是按显式顺序列出的,没有按匹配度重排这回事[how_filter_events]。
一句话记忆:要精确过滤,把想要的全挂在
+后面;optional 词留给"纯想偷懒多带一带"的场景。
这个"坑"值得用一个具体推演钉死,免得看完就忘。假设事件列表长这样:DrawIndexed、DrawInstanced、CopyBuffer、Dispatch。你分别敲三个表达式,看命中集会怎么变:
| 表达式 | 命中 | 为什么会这样 |
|---|---|---|
Draw Copy |
DrawIndexed、DrawInstanced、CopyBuffer |
optional 任一命中即展示 |
+Draw Copy |
DrawIndexed、DrawInstanced |
有 required 后,optional 全被忽略,只有含 Draw 的留下 |
+Draw -Instanced、+Draw Copy -Instanced |
DrawIndexed(两边完全一样) |
required/excluded 决定一切,optional 不参与 |
第三行最有迷惑性:+Draw Copy -Instanced 里那个 Copy 明明写在中间,看起来像是"要么 Draw 要么 Copy",结果 CopyBuffer 压根没出现。一旦你心里装着"optional 在 required 出现后等于不存在",就不会被写错还找不到原因。排查时只要发现"我明明加了词却没变多",先怀疑是不是踩了这个 required/optional 的坑。官方那句原话是给足体面的,但意思是绝对的:带 + 的词一出现,optional 那一组的全体就退场了[how_filter_events]。
6.4 过滤函数:$action() 与 $parent()
字符串匹配之外,还有过滤函数,能按属性做更精细的查询[how_filter_events]:
$action()是默认过滤器本身——匹配所有 action(draw/dispatch/copy/clear…)。$action(numIndices > 1000):只匹配"索引或顶点数超过 1000"的 action。这是"找大 draw"的利器,比如排查某个吞掉大量 verts 的对象。$parent(render scene):匹配"以某个标记为父"的事件。官方给的经典用法是把 draw 收窄到场景内、剔除 off-screen pass:$action(numIndices > 1000)且$parent(render scene)——两个条件都用上,才能兼顾"数量大"和"只在渲染场景里"[how_filter_events]。
官方还提醒一个联动细节:任何不含(递归地)任何被包含子事件的标记区段,会被整体隐藏[how_filter_events]。也就是说过滤器作用于整棵树,空区段自动折叠,画面更干净。
这个"递归折叠"是把双刃剑,值得单独点一下:好处是命中之外全打码,一眼只剩你要的那十几条;代价是你可能误以为某些 pass 不存在。举一反例:你想找"为什么阴影 pass 这么贵",敲了 $parent(Shadow Depth),结果列表里啥都没有——别急着下"阴影没画"的结论,先看这条过滤是不是把 Shadow Depth 的子 draw 全端掉了。真正要反向定位的画法,是把 $parent` 的实参换成不带过滤条件、或配合 `$parent(...) 去找该类下的所有 draw,再倒过来看它挂在哪个 marker 下。过滤器回答的是"现在剩下什么",而不是"原本有什么"——这个边界不清,很容易把我们带偏。
下面两张图取自官方的过滤效果文档,正好对应这两个函数的实际样子。
6.5 一个真实案例:把 bind 找出来
官方给出了完整案例:想在 action 之外也看绑定调用,就加 Bind 作为 optional 词,出现 pipeline 和 vertex/index buffer 绑定;只想看 buffer 绑定,把 Bind 改成 (+Bind +Buffer);再想让 marker 的 set 噪声消失,就按 action flags 排除掉 set marker[how_filter_events]。组合之下,"只留下我们想要的事件"这件事是完全可以调出来的——这也是过滤器设置窗口那个"保存/加载过滤器"按钮存在的意义。
把这个案例拆成一张三层推进的表,操作就非常具体了:
| 你想看 | 过滤器怎么敲 | 会看到 |
|---|---|---|
| 所有绑定调用 | Bind(optional) |
各种 pipeline / vertex / index buffer 绑定 |
| 只看 buffer 绑定 | (+Bind +Buffer) |
以 Buffer 结尾的那几类绑定,pipeline 绑定被挤掉 |
| 排除 marker 的 set 噪声 | 在上一条基础上按 action flags 排除 set marker | 干净到只剩真正关心的绑定 |
最后一行的"set marker"指的是那类只用来"设当前标记"、本身不产生绘制的调用。它们和绑定调用混在一起会稀释你的注意力,官方特意给了"按 action flags 排除"这个手段,就是要让你看清:过滤器的能力边界,足够把"噪音"和"信号"在树上切开。这也是"保存/加载过滤器"按钮存在的意义——你调好一套"排障配方"(比如"只要场景内大 draw 的绑定"),下次打开同一工程或同类问题直接复用,不用重新敲一遍[how_filter_events]。
七、Timeline Bar:站在整帧上方鸟瞰
7.1 时间轴上的另一张地图
除了树状列表,还有一个 时间线栏(Timeline Bar),把帧在水平时间轴上展开,标记之间上下可展开,是"整帧鸟瞰"[timeline_bar]。它有两个明显用途:快速跳转到帧内差异很大的位置;以及(下文 7.2)展示资源依赖。
当前选中事件在时间线上显示为一个绿色 pip,配一条浅灰竖线指示当前位置——即使所在区段没展开也能看到;左键点击时间线上某一点就直接跳到该处事件[timeline_bar]。滚轮缩放、横向滚动条移动,和树视图互补。
把 Timeline 缩到最顶,你得到的是整帧的"骨相":哪些位置 draw 密集、哪些位置一马平川、瓶颈大概卡在哪一段——都不用先展开树就能有个概貌。它的本地折叠也很有用:marker 区段在时间线上可以上下展开/收起,意味着你可以把"Scene Render 里到底有几根 draw 的长条"单独抖开,数一数这一 pass 的 draw 密度,比在树里一格一格点高效。下面这张图就是 Timeline 的典型样子,右边堆叠的一个个色块,正是被 marker 圈起来的若干区段,越靠上的区段包含的 duration 越长。
7.2 资源访问可视化:一张图看出一帧里纹理被谁用过
Timeline 另一个被低估的能力是资源访问可视化:只要纹理查看器或资源检查器里有选中资源,Timeline 就会把该资源在整帧中的使用摊铺上来。官方描述是:对当前纹理/资源,"usage 展示整个帧的读/写",读会画成小三角形;"不用费力一格格搜,就能直接看一个纹理在帧里被谁用、用到哪"[timeline_bar]。
这对渲染目标尤其有用——一个 RT 被 clear 了、又被写入、又被读取,DAG 般的依赖关系一眼可见,而不用去翻树里每一处引用。
把读法再拆细一点,这张"条带图"看着密集但信息很有序:开头通常是一段"清除"(把它画成第一条写),随后是若干"写入它的 draw"(读/写三角各自对应它们的 EID),再往后是"读它的后处理"。你要做的三件事,照顺序来:
- 找它的第一笔写——是谁把这张 RT 从"脏"变成"有内容",这是依赖链的起点;
- 找它的最后一次写——那是它的"成品态"被谁敲定,后面再读的都消费这一版;
- 数读它的区间——如果一段明显的读密集区没有任何写穿插,却能正常显示,就说明资源在复用/缓存,读的是上一帧或更早的内容。
对资源竞争/重复使用类问题,这张图几乎是直给的:一旦你看到同一资源在帧内被读写的区间重叠,或写入方和读取方相隔很远,就值得怀疑是不是出现了跨 pass 的别名。这类"谁在什么时候碰了这张图"的诉求,正是 Timeline 相对树视图的最大优势——树按提交顺序排,Timeline 按依赖关系摊,二者互补才把一帧看全[timeline_bar]。
7.3 Pixel History 联动:回到具体 draw 的"红绿审判"
当 像素历史(Pixel History) 窗口聚焦时,Timeline 会在每个 EID 上显示该像素历史的判定结果:红色三角 = 被拒绝的像素,绿色三角 = 通过的像素[timeline_bar]。这是从"一个像素的视角"回溯到"具体哪些 draw 触碰了它"的通道——和 4.2 的逐 draw 隔离验证正好一正一反:向前看是"这条 draw 画了啥",向后看是"这个像素被哪些 draw 画过"。
社区指南里有对应操作:对着某像素右键 → Pixel History,RenderDoc 会列出触碰该像素的每个 draw call[yoyonel-guide]。
完整走一遍这个"从像素回溯到 draw"的动作,分四步:先在纹理查看器里对某个可疑颜色区域右键,选 Pixel History;RenderDoc 随即列出触碰该像素的所有 draw,并按"谁先谁后"排好;每一行都会注明它是被遮挡、还是通过、还是被丢弃,以及分别是 depth / stencil / alpha 里的哪一种原因;最后把这些行的 EID 和在事件浏览器里对上的 EID 一对照,你就能把"这个像素为什么是这个颜色"翻译成"这条 draw 在这里把像素写成了这样"。这一连串动作里,真正让"像素→draw→EID"能闭环的,正是 EID 这个贯穿始终的稳定句柄——Pixel History 里每一笔登记的 EID,就是事件浏览器里你可以直达的那一行。
进到 Pixel History 明细之后,"红/绿"之外还要多读一层:被拒绝(rejected)有很多种死法。同样是红色三角,一条是 depth 测试没通过(被前面更近的像素挡了),一条是 stencil 没放行,一条是 alpha 混合后没贡献,背后的语义完全不同。你要判断"这个像素为什么不是预期的颜色",得先分清到底是"这条 draw 根本没画上去",还是"画了但被后来的覆盖了"。前者查 depth/stencil,后者查顺序。而 Timeline 上红绿三角按 EID 铺开,正好让你一眼看出"从哪条 draw 开始这个像素定了生死"——通常就是红色区间第一次变成绿色的那个转折点[timeline_bar]。
八、时长列与性能视角:从"画了什么"到"哪个最贵"
8.1 一点 timer,逐 action 的 GPU 时长就出来了
默认事件浏览器没有时长列;当你点 timer 按钮,RenderDoc 会跑一遍"给每个 action 计时"的过程,自动加上 duration(时长)列,随后即可按该列排序,一眼看到哪些 draw/dispatch 最贵[event_browser]。时间单位可以在设置里切换。
这正是本系列"性能剖析"题眼与事件浏览器交汇的地方:在 5,000 条命令里,性能问题往往不是"某一条画错了",而是"某一条画太久了"。计时列让"最贵"从感叹变成可排序的数字。
这一列数字是怎么来的,值得多想一层。RenderDoc 的计时不是拿 CPU 秒表去近似,而是在提交给 GPU 的命令里插桩(instrument)一段"为每个 action 记录开始与结束时间戳"的机制,回放时把这几对时间戳相减,得到每个 draw/dispatch 在 GPU 上真正花掉的微秒数。理解了"它是回放时算出来的",两个性质就顺理成章了:其一,这是帧内相对成本,不是绝对帧预算,因为回放机器的驱动、降频、后台负载都会影响绝对值,你应关注"哪条命令占了这个 draw 的大头",而非把单条数字当标尺;其二,计时本身有开销,开启后 debug 层下每组 marker 都多记录一次,所以遇到"开了计时现象变了"时,要意识到是桩子让它变慢,而不是命令本身更慢。把这两条记在心里,才不至于把一张相对成本表误读成 CPU 侧的负载真相[event_browser]。
另一个常被踩的认知坑:贵 ≠ 一定是 GPU 光栅化慢。一次 draw 的 duration 里,可能大头是顶点爬取(vertex fetch)、可能是着色器的 ALU、也可能是它喂出去的带宽让后面整个 RT 都被动变贵。所以拿到"这条 draw 3.2ms"后,别急着改这条命令,先回到 4.2 的隔离再验一次——把这条 draw 单独回放,看它是不是真的单独就慢;如果是和其他命令拼在一起才慢,那瓶颈往往在"它产生了多大、多脏的中间资源",而不在这条命令自己的计算量。时长列给的是候选,隔离回放给的是证词,两者配对才是这条"性能线"的完整读法。
注:duration 列是点击 timer 按钮后才动态生成的运行时数据,官方仓库没有针对性的静态示意截图。本文以官方行为声明 + 文字描述说明其用法,配官方 timer 按钮图标示意;你也可以在自己机器上打开任意 .rdc,点计时按钮亲手复现这一列。
8.2 装上"时间"的读法:看 pass 而不是逐条扫
kokkugames 的 UE 优化文章给了一个很好的性能读法:把事件浏览器当作帧的"脚本",20ms 的帧逐条看哪个命令最长;重点盯 "Time" 列,看某个 pass(Base Pass / Shadow Depth)时长是否突然跳升,那就是排查切入点[kokkugames]。反过来,配合 6.4 的 $action(numIndices>1000),你可以先把"大" draw 筛出来,再上计时列看"大"和"贵"是不是同一个。
把"大"和"贵"分开看,其实藏着性能分析最朴素的一句判断:大 draw(顶点多)不等于贵 draw(时间长)。一个 20 万顶点的静态地形,GPU 一次光栅化可能只花几十微秒;而一个顶点不多、但在 Fullscreen 上反复跑的后处理,反而可能吃掉大头。所以正确姿势是"双列对读":
- 点 timer 生成时长列,先按时长排序,抓出 TOP N 的贵命令;
- 再用
$action(numIndices X)按规模筛,看这些大 draw 的时长在不在 TOP 里; - 两者交集往往才值得优化,只大不贵说明 GPU 带宽没被它吃掉,只贵不大说明问题在光栅/ps 开销而不在顶点量。
时长单位的切换别看漏了:Settings 里能切微秒/毫秒,习惯用 ms 做预算时切过去,读数才直观[event_browser]。另外预告一句,本系列后面讲 Perfetto 时会再碰"时长"这个词——但那边的粒度是 CPU/GPU 帧与卡顿的宏观时钟,跟这里的逐 draw 微观计时正好一宏观一微观,配着看才能把"这帧卡在哪"钉死。
九、书签、查找与跳转:给帧里的关键位置贴上锚
9.1 书签:Ctrl-B 加入,Ctrl-1…0 跳转
一份复杂的捕获里,你往往要反复回到若干关键 draw。事件浏览器提供书签:Ctrl-B 把当前事件加入书签,书签会以 EID 列表出现在窗口顶部工具栏,Ctrl-1 到 Ctrl-0 可跳到对应书签,且这些快捷键在应用任何位置都有效[event_browser]。等于给帧内的关键位置贴上了全局锚点。
书签和过滤器组合起来,能沉淀出一套很实用的"嫌疑集合"工作流:先用外部指标(时长列、pixel history)定位十几个可疑 draw,把它们逐条 Ctrl-B 收进书签栏;之后无论你在纹理查看器、Mesh Viewer 还是 Timeline 里,都能靠 Ctrl-1…0 在嫌疑点之间瞬移,反复来回对照,不必每次重新翻树。这个"把帧里的重点钉成一条书签清单"的动作,和 9.3 的复制连成一条链路后,就是一份可交给同事/未来的自己复现的调试笔记骨架。
还有一个容易被忽略的通配:书签是"跟随 EID 走"的,不是"记住那一时刻在屏幕上长什么样"。这意味着你切走了过滤器、折叠了区段、甚至把 action 显示档位从全参数切回默认,书签仍稳稳指向同一个事件;反过来,如果原本那个位置的 action 因为过滤条件变化而在列表里消失了,跳转也依然有效。这个"锚在 EID 上"的性质,和第 3 章讲的 EID 稳定性是一起出现的——正因为 EID 是回放引擎内稳定的地址,书签才能跨视图、跨栏位地反复直达。也正因如此,书签里那一串数字本身值得留意:Ctrl-B 之后你完全可以把书签条的 EID 顺手记进笔记,那串数字在下一份捕获、下一个问题里,很可能就是线索的开头[event_browser]。
9.2 查找:Ctrl-F,还能搜 EID
Ctrl-F 打开 find 工具,可输入部分文本,同时匹配 label 与 action 事件,回车即搜[event_browser]。更有用的是与 EID 结合:若想直达某事件,可以直接搜它的 EID——查找结果只显示匹配事件,不会因子串撞号把多余的行带进来[event_browser]。未展开区段会自动展开到命中事件可见;反复回车可在多个命中之间跳转,左右箭头在同批结果里前后切换。
9.3 复制:Ctrl-C 带走一整行
事件浏览器支持复制:选中条目后 Ctrl-C 拷入剪贴板,方便把某个 draw 的 EID 与参数名贴进笔记或 issue[event_browser]。
为什么这事值得专门占一格?因为别人(或几天后的你)拿到这条记录,能不能无障碍复现,取决于你存的是什么。Ctrl-C 带走的是"该事件此时的 label + 关键参数",既包含 EID 这个稳定句柄,也包含名字。写 issue 时建议的格式是:事件浏览器里 Ctrl-C 一段 + 当前选中的资源名 + .rdc 里能定位它的上下文(比如所在 marker 名),三者缺一不可。光报"某帧颜色不对",对方要自己翻几千条 draw;报"EID 9120 的 DrawIndexedInstanced(6,16) 画进 RT A 时颜色异常",对方就能直奔现场。调试记录的价值,一半在"当时查到了什么",另一半在"别人能否凭它复现"——而后者几乎全靠 EID + 名字 + 资源这三样撑起来。
十、结语:把"按钮清单"升级成"帧的结构化索引 + 逐 action 验证台"
走到这里,事件浏览器的全貌应该清楚了。它不只是"draw-call 列表"这么简单:
- 它是一份按时间次序、由 marker 织成树的函数调用清单(action 而非一律 draw);
- EID 是它的锚点,只要知道计数口径,就能理解为何不连续、如何搜数字直达;
- 点选 = 把回放状态推进到某命令结束瞬时,配逐 draw 隔离成为"这到底画了什么"的验证台;
- 三档显示开关 + 过滤表达式,让它从几千条命令里筛出你关心的十几条;
- Timeline 与资源/Pixel History 联动,给资源依赖、像素归宿画了 DAG 般的图;
- 计时列让"哪个最贵"可排序。
一句话收束:事件浏览器 = 帧的结构化索引(怎么读)+ 逐 action 验证台(怎么验)。读,靠树、靠 EID、靠过滤;验,靠点选设状态、靠逐 draw 隔离。这块"前台"看懂了,下一站就是它背后真正存放资源的地方——Resource / Descriptor 检查(ep05),去看看一条 draw 到底喂给了 GPU 哪些纹理和缓冲。
相关链接 - 上一篇:【性能剖析工具链】(三)一帧被抓下来之后:.rdc 如何被回放引擎重放与分析 https://yyy3d.online/blog/graphics-profiling-ep03-replay/ - 下一篇(预告):【性能剖析工具链】(五)Resource / Descriptor 检查 - RenderDoc 官方文档:https://renderdoc.org/docs/ - RenderDoc 源码:https://github.com/baldurk/renderdoc
With Best Wishes.