CSDN 版文章链接: {csdn-url} 知乎专栏版文章链接:{zhihu-url}
一句话点题:事件列表讲了"画了什么",这里讲"GPU 是怎么把它算出来的"
前面 ep04 我们把事件浏览器读成了一帧 draw-call 的结构化索引,ep05 又把资源与描述符绑上了命令流。但你有没有想过,事件流那句 DrawIndexed 之后,GPU 到底对这堆顶点做了什么? 曾有人觉得"三角形是显卡自动填出来的",可实际上,顶点位置是顶点着色器(vertex shader)一行行算出来的,每个片元的颜色是像素着色器(pixel shader)一句句算出来的——而 RenderDoc 恰好把这两层都摊开给你看。
如果继续用破案来打比方:ep04 给了你"作案时间线",ep05 补了"作案工具清单",这一篇则把"案件经过"还原出来——顶点在进管线和出管线时到底变了多少数值,像素在光栅化之后到底算了什么颜色。官方对 Mesh Viewer 的定性很直白:它"在同一时间既展示 mesh 数据,也展示 mesh 在不同阶段(pre VS、post VS 等)的视觉表现"[mesh_viewer]。而 Shader Viewer 则"用于显示、编辑、调试 shader",并能展示 bytecode 反汇编[shader_viewer]。这一篇,就是把这两个窗口读透,并且回答那个最该问的问题:如何从反汇编里看出 GPU 在顶点与像素级到底算的是什么。
先给一张本篇的知识地图,让你知道接下来要爬的几座山分别是什么。全篇共九个一级章节:零章是点题与边界;一、二章吃透 Mesh Viewer 这个窗口本身(双形态视图、显示选项、VS Input/VS Output 两档阶段差);三章钻进顶点数据表(字段来源、实例选择、tessellation 特例);四章过一遍可视化模式(Flat Shaded、Exploded、UV 重投影),学会"换着角度看几何";五章从 Mesh Viewer 跳到 Shader Viewer,找到反汇编入口,讲清 bytecode IR 与可配置反汇编器;六章是全文的题眼,把反汇编指令逐条映射回 HLSL/GLSL 源码,回答"GPU 到底算了什么";七章讲 Shader Debugger 的逐行步进,以及它的入场券——调试符号;八章划清工具边界,列出已知缺陷与版本差异;九章收束成"顶点像素双向验证台"。这九座山连起来,就是一条从"看图"到"读算"的完整路径。
我们照旧先把边界划清楚,免得期望落空。本文不涉及五块:一是抓帧与回放机制本身,那是 ep02/ep03 的范畴;二是 Mesh Viewer 与 shader 调试之外的其他窗口逐个翻译(API Inspector 逐字段),ep05 已声明另文处理;三是渲染算法本身的数据流精简(某个具体光照技术怎么组织 shader),那是渲染管线系列的内容;四是 Perfetto 那套系统级内存/带宽跟踪,本系列不涉及它;五是让读者去看游戏引擎源码,那太远。本文只回答一件事:拿到一份单帧,如何在 RenderDoc 的 Mesh Viewer 里读"顶点进出管线变了多少",又如何在反汇编里读"GPU 到底算了什么"。范围卡在这,才不会又写成一本按钮说明书。
先从 Mesh Viewer 这个窗口的总览说起。
一、Mesh Viewer 总览:GPU 正在处理的几何,双形态摊开
1.1 一句话认识:数据表 + 3D 视图,同一份几何的两种读法
打开任何一个捕获,选中一条 draw,点开 Mesh Viewer,你会看到窗口被分成两半:上半是 3D 网格预览,下半是对应顶点的数据表。官方表述是:Mesh Viewer "同时展示 mesh 数据以及 mesh 在不同阶段的视觉表现(pre VS、post VS)"[mesh_viewer]。翻译成不绕弯的话:它把 GPU 正在处理的几何,用两份视图同时给你。
上面那张题图就是官方 Mesh Preview 的全景:一个着色的 3D 模型正在那里,旁边是数据表在滚。这跟 ep05 里 Texture Viewer 的"主区 + 缩略图"是一个思路——同一个资源对象,RenderDoc 永远给你"看得见的"和"读得懂的"两种形态。纹理是主图 + 通道,几何则是 3D 视图 + 顶点表。
那这两份视图为什么能对上?因为下表里每一行的数据格式,不是凭空来的,而是从管线相关阶段拉来的——shader 的 input/output 签名,或 vertex input attributes。所以表格里出现哪些字段、字段顺序是什么,直接反映这个 draw 当时 pipeline 里 shader 是怎么声明的。这正是 ep05 那条"绑定总板"的延伸:Pipeline State 告诉你绑了什么资源,Mesh Viewer 告诉你这些资源喂进去之后,顶点长什么样。
官方还给了两个小控制项,值得先记住:同步视图(sync views)按钮让数据表和 3D 视图联动,你点数据表哪一行,3D 视图就高亮哪个顶点对应的图元;offset 输入框让你指定一个偏移量并保持住,这样在事件之间来回切换时,你看的始终是同一行数据[mesh_viewer]。这两个小东西合起来,就是"同一份几何,两种读法"得以成立的操作基础:表里选中 → 3D 里高亮,3D 里点选 → 表里定位。
"在事件流里怎么走进来"这一点顺带交代一句,不然你不清楚它跟前几篇的窗口是什么关系。前两篇说过,事件浏览器(event browser)是整帧的命令流索引,你选中一条 draw,就是把回放状态"设到那条命令执行完的微秒之后"[event_browser]。Mesh Viewer 不是独立窗口,它是挂着这条 draw 走的——你切到另一条 draw,它里面显示的顶点数据、3D 视图就跟着换。回想 ep05 讲的 Pipeline State 也是同一套逻辑:这些分析窗口读的都是"当前回放状态"这一份确定性快照[pipeline_state]。所以你在事件浏览器里可以只挑出问题的那个 draw 打开 Mesh Viewer,被同一帧里其他 draw 的画面干扰,这在排查海量 draw 的场景里非常省眼睛。
1.2 显示选项:坐标系、投影与相机,让你"看得对"
3D 视图默认只能转,但你要是想"看得对",得先弄懂它的显示选项。屏幕上那个网格视图,背后有一套自己的投影与相机配置——官方给了几个可调项:投影矩阵(projection matrix)、坐标轴朝向,以及相机控制方案[mesh_viewer]。
这个"自己重画"的前提,正是显示选项要紧的地方。因为 Mesh Viewer 的 3D 视图不是"截图",而是用 GPU 产出的顶点数据自己重画一遍。要重画得对,就得知道当时的投影矩阵长什么样——矩阵猜错,视图就歪。官方甚至有专门的"投影矩阵猜测"面板,帮你在拿不到正确矩阵时恢复视图[mesh_viewer]。
这里有两个坐标细节,是新手最常踩的坑。第一个是坐标系朝向:官方文档说,对于中间或输入顶点数据,你可以选择坐标轴配置,默认是 Y-up、左手系[mesh_viewer]。也就是说,如果你的项目用的是 Z-up(比如某些建筑可视化软件),默认视图看起来就是"歪的"——不是数据错了,是坐标系没配对。第二个是投影矩阵:对于输出到光栅化器的数据,RenderDoc 会尝试猜测投影矩阵,再把输出数据反投影(unproject)回视图空间来显示[mesh_viewer]。猜测不是全能的:官方明确指出,近平面和远平面尤其容易猜不准,而 FOV 无法从顶点数据推断,所以默认取 90 度,你可以在此基础上改[mesh_viewer]。正交投影更是猜不出来,只能手动指定[mesh_viewer]。
为什么这里要专门讲"它对输出档叫猜测"?因为它的工作方式不是纯粹的猜。RenderDoc 的做法是:输出到光栅化的顶点通常带一个"已经各自做过裁剪除法"的归一化值,它按这个值和它猜到的投影矩阵,把数据逆映射回视图空间,再套上视图矩阵放回世界/相机空间给你看。近平面和远平面之所以最容易猜错,是因为裁剪后的归一化深度值即便弄错近远,画面也常常不崩、只是看起来整体缩放或挤出,你是被乖乖骗着看的。FOV 猜漏则更隐蔽:同一套数据换个 FOV 重画,物体的比例感会变,但你未必察觉。所以给新手一条铁律:如果你比对两个 draw 的几何,或者要从视图里读真实比例,务必核对显示的投影参数和真实一致,别拿"默认 90 度"当依据——尤其在做"反汇编数值 ↔ 视图呈现"互证时,视图可能因为投影参数不准而对不上寄存器里的数,那是查看层的偏差,不是计算层的错误。
把这两个细节记牢,你的排障起点就立住了:视图歪,先查坐标系和投影矩阵,别急着怀疑顶点数据。这是"看得对"的第一道门槛。
二、VS Input vs VS Output:GPU 在顶点级到底算了什么
2.1 阶段差的本质:进管线前 vs 出管线后
Mesh Viewer 最锋利的一刀,藏在它默认的两档视图里:VS Input(顶点着色器输入)和 VS Output(顶点着色器输出)。官方说得很清楚:预变换阶段默认用 Arcball 控制,后变换阶段默认用 Flycam 控制[mesh_viewer]。
为什么默认控制方案要不一样?因为两档视图面对的几何尺度完全不同。Arcball(轨迹球)适合"把物体拿在手里转",对应的是模型空间里、还没被变换的原始顶点;Flycam(飞行相机)适合"钻进大场景里飞",对应的是已经被变换到世界/裁剪空间的顶点——这时候几何往往铺得很开,你需要在里面自由穿行才能看清每一处。
这为什么是"定位矩阵/变换错误"的首选工具?因为原始顶点和变换后顶点,各在一个空间里。模型若在模型空间是规整的立方体,出管线后却飞了,那多半是 model/view/projection 矩阵之一写错。你在两档之间切一下,立刻能看出是"输入就歪"(顶点数据烂)还是"变换把它弄歪"(矩阵烂)——这是社区实战里反复验证的用法[cosmiclearn-dx12]。
空间这层关系值得再掰开一点,因为它决定了你在 VS Input 档到底该期待看到多少信息。空间坐标的本质就是你给顶点一个"家",大家约定好彼此的关系:模型空间(model space)是顶点出生时的位置,物体做自己的事情都在这里;模型变换(model transform)把它搬进世界空间(world space),让多个物体能同处一个舞台;视图变换(view transform)把相机摆成原点、看向自己该看的那个方向;投影变换(projection transform)则是把人眼看到的棱锥裁剪成规范性立方空间。RenderDoc 文档里对坐标空间分层的定义非常明确,你如果想去现场查到底哪一步错了,可以对着它一层一层对[mesh_viewer]。VS Input 档给你的是"模型空间的顶点",VS Output 档给你的是"管线最后一步之后的顶点"——中间的每一层变换,恰是你在反汇编里逐个追的寄存器数值。这一来,"阶段差"就从一句"进 vs 出"具象成了"模型空间 vs 裁剪空间"两头,中间夹着的正是 GPU 顶点级的那笔账。
用一次真实排查把这个"逐层对账"落成可复刻的动作。假设你捕获到一帧,某个角色的脚踩进了地板,你想知道是数据烂还是矩阵烂。第一步,在事件浏览器选中画这个角色的 draw,打开 Mesh Viewer 的 VS Input 档——如果模型空间里脚的顶点 z 值规规矩矩地躺在地板高度之上,说明顶点数据没烂;第二步,切到 VS Output 档——如果出来的位置整只脚穿到地板以下,那问题就锁死在模型/视图/投影变换之一;第三步,去 Shader Viewer 打开这个 VS 的反汇编,按 §6.2 的读法从 dp4 序列里找到矩阵乘法的常量寄存器,回 Pipeline State 核对那几个矩阵的数值。四步下来,问题被"数据层 → 变换层 → 指令层"三层钳死,这正是"顶点级到底算了什么"的现实用途:它不只是一句抽象口号,而是这套窗口组合每天都在干的活。
两个小功能让这个判断更顺手:重置相机(reset camera)把视角拉回默认位置——VS Input 下回到物体周围某个半径的轨迹球位,VS Output 下回到投影眼睛的位置[mesh_viewer];自动适配(auto-fit)则把相机对准 mesh 的轴对齐包围盒,一键让整个物体进入视野[mesh_viewer]。排查"某个变换把模型移到屏幕外"这类问题时,auto-fit 一下,东西立刻回到画面中央。
2.2 相机与漫游:Flycam 让你钻进场景里
后变换阶段默认 Flycam 是有原因的:post-transform 顶点往往在裁剪/世界空间铺得很开,你要确认"到底哪部分算错了",得能自由飞进飞出。官方为此单列了一套 Mesh Controls 键位[mesh_viewer]。
官方对这套键位描述得很细,值得原样抄一份备用:按住鼠标左键拖动,旋转相机的朝向(绕当前位置转);方向键前后左右平移,PageUp/PageDown 沿竖直轴上下飞;传统 FPS 的 WASD 也有效,而且用的是物理按键——比如 AZERTY 键盘上就是 ZQSD[mesh_viewer]。R 和 F 默认绑定了竖直移动[mesh_viewer]。这些都能在"Camera Settings & Controls"按钮里自定义主键和副键,还能设置显示的近远平面——这对数值范围极小的顶点数据特别有用[mesh_viewer]。另外选项里还有个 Flycam 速度倍率,用来把移动速度调到和 mesh 尺寸相称[mesh_viewer]。
这里有个 ep05 提到过的、打断实时数据的细节值得再强调:post-transform 顶点之所以要"飞进去看",恰恰是因为顶点着色器已经把模型从绑定时的空间搬进了世界/裁剪空间——这一步正是 GPU 在顶点级干的"生意"。你看懂了这两档,就等于看懂了顶点着色器那段最关键的计算。
两个调节项让 Flycam 真正可用。一是 Flycam 速度倍率:官方文档里明确提到这个选项,作用是把移动速度调到与 mesh 尺寸相称[mesh_viewer]。为什么非要这个?因为 post-transform 顶点可能是一个几毫米的小零件,也可能是一整个数十公里的地形分块——用同一套默认速度去飞这两种尺度,要么飞得一步撞穿,要么挪半天不动地方。速度倍率就是让你按当前 mesh 的包围盒量级把它调到"一步能走半个物体"的舒适档。二是 近/远平面调节:官方在相机设置里允许你设置显示的近远平面[mesh_viewer],这对数值范围极小的顶点特别有用——默认的远平面距离若远大于 mesh 尺度,深度精度会把细小的顶点差压成零,你看到的"贴在一起"其实是精度不够,把近远平面收窄后差异立刻现形。这两个调节项合起来,才让"飞进去看"真正成立。
三、顶点数据表:字段从哪来,实例怎么选
3.1 数据表的内容:shader 签名驱动的字段
3D 视图是"看得见",顶点数据表是"读得懂"。每一行的格式,官方说"取决于管线相关阶段:shader 输入或输出,或 vertex input attributes"[mesh_viewer]。也就是说,表格里出来哪些列,不是固定的 POSITION/UV,而是这个 draw 的 shader 恰好要的那些。
我们把这层关系拆成一张对照,方便你查表:数据表能列出的字段类别,大体可以归为三类,一是固定功能的顶点输入(位置、法线、切线、UV 这类由 vertex input attributes 喂进来的原始数据),二是着色器输入(shader 输入签名里声明的语义,比如 SV_Position 之外的插值量),三是着色器输出(shader 输出签名,比如 VS 输出的变换后位置、逐顶点法线、世界坐标等)[mesh_viewer]。每次 draw 到底属于哪一类,取决于你选的是 VS Input 还是 VS Output 档,以及管线里有没有开 tessellation(这个特例我们 3.3 会单独讲)。
字段列出来之后,还有两个直接的操作入口:右键点列头,可以把某一列指定为 Secondary 显示元素(第 4.4 节展开);4 分量列(比如 float4 的位置)右键时,可以选"用 RGB 显示颜色"或"用 Alpha 显示灰度"[mesh_viewer]。这个选择在 mesh 格式保持一致的 action 之间会被记住[mesh_viewer]——也就是说,你在一整个 draw 序列里,选中列的选择不会来回跳。
再补一层"字段背后怎么来的"心智,读表时会更顺:GPU 拿到一个顶点要做的第一件事,是去 vertex input attributes 那里问"每个分量从哪个 buffer 的哪个偏移读、隔多少字节一个大步进(stride)、多久才进展一次(instancing 下 step rate)"。这套"槽位驱动格式"的定义,在图形 API 里叫 input layout,RenderDoc 的 Mesh Viewer 正是按它来解码顶点 buffer。你看到一个字段叫 "POSITION",别默认它一定就是 XYZ——它是 layout 对应槽位解析出来的结果,偏移对不对、stride 对不对,都会让那一列显示成错位或乱码。这就是为什么清理文档里"字段 = layout 驱动"这句话比记具体字段名更重要[mesh_viewer]。
这直接连着 shader 源码:你在反汇编里看到一个 mov 往某个寄存器写值,回到顶点表里看那一列,数值对得上——这就是"反汇编 ↔ 数据"互证的起点。后面第六节我们会用一整节讲这个对照怎么落地。
3.2 实例:instanced drawcall 里选哪个 instance
instance 化绘制(instanced drawcall)每次都把同一批顶点画多份。官方明确:"对于 instanced drawcall,你可以在顶部工具栏选择查看哪个实例"[mesh_viewer]。更重要的是那个查看范围下拉——是只看当前 draw、连带之前的实例、该 draw 里所有实例,还是整个 pass 到当前为止[mesh_viewer]。
官方对这几个档位的语义说得非常具体,值得原样引用:显示 post-projection 输出时,下拉框让你选择显示这个 draw 本身、连同之前的实例、drawcall 里所有实例、或者整个 pass 到目前为止[mesh_viewer]。最窄的时候你只看到当前 draw——如果它是 instanced draw,那就是某个具体实例;可以展开到同一 drawcall 里的其他实例(到选中实例为止,或前后的所有实例);最后还能显示到当前 draw 为止、同一个概念渲染 pass 里的其他 mesh[mesh_viewer]。为了让你在多实例里分得清谁是谁,官方还规定了配色:当前 draw 总是深色调,同 draw 里的实例是浅红色,pass 里其他 drawcall 是浅灰色[mesh_viewer]。
这套范围要紧在哪儿?因为 instance 化最经典的 bug 就是"某个 instance 偏移算错导致少数几份绘制错位"。你要是只盯着"当前 draw"档,可能永远看不到那只错位的 instance。把范围切成"所有实例",一排排扫过去,错的那个立刻跳出来。配上那条配色规则,你甚至不用数数,扫一眼颜色就知道哪几份是"同实例组里的兄弟",哪几份是"别的 draw"。
3.3 tessellation:VS out 也当输入
如果这个 draw 开了细分曲面(tessellation),Mesh Viewer 有一段不太直观的行为:官方指出,"当 tessellation 激活时,VS out 的行为与 VS in 类似,两者都被当作输入数据(而非 post-transform 数据)"[mesh_viewer]。
理解这一点,就不会在 tessellation 环境里误把 "VS Output" 当成最终几何。背后的机制在渲染管线里是清晰的:tessellation 激活后,顶点不是 VS 一变就结束的,而是 VS 输出控制点 → hull shader(外壳着色器)计算细分因子 → tessellator(细分器)生成新顶点 → domain shader(域着色器)定位新顶点。这整条链上,VS out 只是"中间输入",不是"最终位置"。所以 Mesh Viewer 干脆把它当输入数据处理,不标成 post-transform——你看到 VS Output 档里的顶点,在 tessellation 场景下仍然是"未完成品"。这条细节对排查"细分网格位置不对"特别关键:如果你在 post VS 档看到的不是期望的最终形状,先别急着改矩阵,先确认是不是 tessellation 把这条链拖长了。
细分因子(tessellation factor)在这条链里是数值的核心,值得把"它是怎么算出来的"讲透,因为这决定了你在 Mesh Viewer 里看到的是几级细分。hull shader 负责给每个输出 patch 声明一组细分因子,比如每条边一个 outer factor、patch 中心一个 inner factor;GPU 的 fixed-function tessellator 再拿着这些因子,把原始 patch 按规则切出更多三角形。因子的量级直接决定顶点量:一个四边形 patch 若 outer 因子从 1 提到 4,它被切出的网格密度会肉眼可见地翻几倍。所以当你怀疑"某个曲面在屏幕上太糙或顶点爆多"时,第一步不是进 Mesh Viewer 数顶点,而是回到 hull shader 看它提交的细分因子——因子大了,顶点数据表的行数自然跟着暴涨,这是"顶点数异常"背后最常见也最隐蔽的推手之一[vulkan-tessellation]。
细分这条链为什么对 Mesh Viewer 特别容易踩坑,值得单独点破。因为正常的不开细分的管线,VS out 就等于"光栅化要用的最终顶点";这一直以来的直觉一旦套到 tessellation 场景就拐错弯——你看 VS Output 档是个规整的立方体,以为 OK,实际上市面上真正画出来的,是 domain shader 用了细分的曲面细分因子重新排过许多顶点之后的形状。两者中间的差距全靠 hull/tessellator/domain 这三级补出来。Mesh Viewer 的选择是务实的:与其猜"post-transform"想表达哪个确切位置,不如把 VS out 也当"仍待进一步处理"的输入给你,免得给你一个"半成品却标成成品"的误导。所以真正要诊断细分问题,你要看的不是 VS Output 档,而是结合 domain shader 的输出签名去找它到底把顶点送去了哪。这个"标成输入而非输出"的决策,恰恰是 RenderDoc 在帮你避坑。
四、可视化模式:Flat Shaded / Exploded / UV 重投影
3D 预览默认着个色,但 RenderDoc 给了几种可视化模式,每种帮你"看见"不同的东西。官方列了 Solid Color、Flat Shaded、Secondary(把第二个元素显示为颜色/透明度)、Meshlet、Exploded 等[mesh_viewer]。
先做一次全量清单,再归个类,免得用的时候抓瞎。按"帮你看见什么"来分,这五种模式可以归成三类:
- 看整体:Solid Color(每个三角形一个纯色,最朴素)[mesh_viewer]
- 看拓扑与共享:Flat Shaded(按三角形法线做基础平直光照)、Exploded(顶点颜色是顶点索引的函数)[mesh_viewer]
- 看数据与分组:Secondary(把选定的第二元素渲染成颜色)、Meshlet(给每个 meshlet 上不同颜色,仅 mesh shader 场景)[mesh_viewer]
Flat Shaded 会对每个三角形按法线做基础平直光照,官方说目的是"给出更好的 mesh 拓扑观感"[mesh_viewer]。落地的效果就是:让每个三角形边界更分明,你能一眼看出网格是怎么 tessellate 的、哪里三角形密哪里疏。
Exploded 则完全换一种思路:让顶点的颜色是顶点索引的函数(SV_VertexID / gl_VertexID)[mesh_viewer]。官方对它的机制讲得最透:相邻图元如果不共享完全相同的顶点,就会因为颜色突变而现出明显的"硬断";还有一个滑块,可以沿爆炸方向按顶点索引位移顶点,让共享/不共享的顶点看得更清楚,顺带暴露隐藏顶点、重复图元这类 mesh 问题[mesh_viewer]。这一招针对索引缓存很锋利:官方两张示例图就能说明问题。
这两张对读,正是索引缓存优化的"证据图":共享顶点多 → 缓存命率高 → 带宽省。你要是觉得某处网格怪,切 Exploded 看是"拼色"还是"渐变色",一眼就懂顶点到底被复用了多少。顺带一提,缓存命中的收益是能量化的:在顶点量大的 mesh 上,索引缓存(index cache)命中率往往能从 30% 以下抬到 80% 以上,直观体现就是顶点处理次数(vertex shader 执行次数)随命中率上升而显著下降,进而省下整个顶点阶段的执行时间——Exploded 就是让你在没跑 benchmark 之前,先用眼睛把这个差距看出来的工具。
讲到"顶点复用",背后的物理账值得算一笔,你会更信这两张图的分量。顶点处理器一个周期能处理多少个顶点,本质由它的输入速率决定,而索引缓存的作用是:当两个三角形共享同一个顶点索引时,后一个三角形不再重新执行一遍顶点着色器,而是直接命中缓存里的结果。因此顶点着色器执行次数 ≈ 缓存未命中数,而不是三角形数。一个顶点被二十个三角形共享,用不用索引缓存,顶点阶段的花费可以相差一个数量级。这也是"为什么模型要尽量把顶点做成'都共享'而不是'每个三角形自己特有那份'"的根本原因——它不只是让文件小几分,而是让 GPU 顶点阶段真的少干活。Exploded 把这条看不见的"共享关系"用颜色画了出来:无共享 = 每角一色(缓存全浪费),大共享 = 大片渐变色带(缓存高命率)。你要优化一个模型的顶点数,第一件事就是切到 Exploded 看共享率长什么样,而不是空猜。
Secondary / UV 重投影是另一种:能把第二个元素(比如 UV)当 position 渲染成 3D 视图。官方原话是"如果你想可视化其他数据,比如把 UV 坐标当位置渲染(等于把 mesh 渲染到 UV 空间)"[mesh_viewer]。UV 重叠错乱、镜像贴图这类问题,切成 UV 空间投影直接现形。用法上,右键点列头选 Secondary 列即可,输入输出两档可以分开指定[mesh_viewer]。
"UV 空间投影"的威力在日常排查里几乎天天用到,这里演示一个最常见的判断。假设你怀疑一个模型贴图像是左右镜像贴错了:在普通 3D 视图里,镜像贴图看起来可能只是颜色怪,很难一眼确认是 UV 反了还是贴图本身画反了。切到 Secondary(把 UV 当 XY)后,每个三角形在 UV 平面上被摊平排开,如果某块区域本该是连续平滑的条纹、投影后却突然折成"左手对齐"的镜像,你就知道是 UV 朝向了——而不是去怀疑贴花素材。更进阶的用法是绑第二元素的时候输入输出档分开:输入档用"原始顶点自带的那套 UV",输出档用"VS 算出来的插值 UV",两档一对照,能直接看出是不是顶点着色器把 UV 改坏了——输入 UV 对、输出 UV 乱,铁定是着色器在搬移。这个对照能力是 Secondary 模式相比纯外部 UV 检查器独一份的价值。
我们把四种主要模式排成一张对照表,遇到问题直接查:
| 可视化模式 | 原理 | 帮你看见 | 典型排查场景 |
|---|---|---|---|
| Solid Color | 每三角形纯色 | 整体轮廓 | 网格是否存在、位置是否合理 |
| Flat Shaded | 按法线平直光照 | 拓扑密度与三角形走向 | 三角形分布异常、法线方向 |
| Exploded | 颜色=顶点索引函数 | 顶点共享/断裂 | 索引缓存效率、重复/隐藏顶点 |
| Secondary(UV) | 第二元素当 position | 数据在参数空间分布 | UV 重叠、镜像贴图、position 未检测 |
五、Shader Viewer 与反汇编入口:从"源码"到"机器指令"
5.1 Shader Viewer:显示、编辑、调试,一套入口
顶点和像素级到底算了什么,最终要落到 shader 本身。RenderDoc 的 Shader Viewer 官方定位是"用于显示、编辑、调试 shader"[shader_viewer]。
关键点:从 Pipeline State 打开时,Shader Viewer 不止显示 shader,还显示它对应的输入输出签名[shader_viewer]。官方说得很清楚:这些签名构成了"这个 shader 阶段与渲染管线前后部分的接口"——比如顶点着色器会列出固定功能的顶点输入,以及传给下一阶段的插值量[shader_viewer]。这正好接上第三节的顶点数据表——表里那些列,就是这里签名的落地。你在这份签名里看到 SV_Position,回到 Mesh Viewer 里就能找到对应的位置列。
这里有必要把"从哪点进来、点进来能看到什么"再走一遍,因为它决定了你能不能找到自己关心的那个 shader。RenderDoc 的 Pipeline State 窗口会把整个图形管线的"接线总板"摊开给你看,每个阶段(IA→VS→...→OM)都挂着真正绑定的那批资源与参数[pipeline_state]。你想看顶点着色器,就在 VS 阶段那一段找到对应 slot,点 shader 图标,Shader Viewer 就以"当前这个 draw 真正在用的 shader"为目标打开——这跟事件浏览器里挑 draw 是同一套"跟着回放状态走"的逻辑。反过来,如果某个 draw 的 shader 是空占位、或者被偷换成别的,你在 Pipeline State 那一栏立刻能看到异常,不用等 Mesh Viewer 里出现怪图。所以把"事件浏览器挑 draw → Pipeline State 查阶段 → Shader Viewer 看真身"这三步练成肌肉记忆,你定位任何"这个 draw 的哪个 shader 有问题"都会快一大截。
5.2 反汇编:bytecode IR 的另一面
现代图形 API 提交给 GPU 的不是人类写的 HLSL/GLSL,而是中间表示(IR)派生的字节码。官方说得很清楚:对于用 bytecode IR 的 API,反汇编会作为单独一个标签页显示[shader_viewer]。
"反汇编形态"直接决定你能读到什么,值得先停下来把它摊开。官方明确:几种不同的反汇编形式都是可能的,取决于 API、可用插件和硬件支持[shader_viewer]。反汇编标签页上方是一个下拉框,列出所有可用的表示形式,包括 AMD GCN 这类硬件专用 ISA 格式[shader_viewer]。也就是说,你不只能看到 SPIR-V 反汇编,条件满足时还能直接看到驱动最终喂给 GPU 的硬件指令——那是离"GPU 到底算了什么"最近的一层。
而且这些反汇编器不是固定的:你可以在设置窗口里配置 shader 处理工具,官方举例就是 SPIRV-Cross 和 spirv-dis[shader_viewer]。不同工具解出来的风格不一样,SPIRV-Cross 偏"还原成接近源码的可读形式",spirv-dis 偏"忠实展开每条 SPIR-V 指令"——你排查不同层面的问题时,可以换着用。
这两种形态的出身也值得花半分钟看明白,因为"反汇编形态随 API/插件/硬件而变"这句话不是一句空话,它有实打实的链条。先说字节码这一层:SPIR-V 是 Khronos 给 Vulkan/OpenGL 等制定的标准二进制中间表示,一份 .spv 无论跑在哪个驱动上,前面的字节序列都一样,所以 RenderDoc 能稳定地把它展开成可读指令——这是"bytecode IR 反汇编"能够存在的前提;
SPIRV-Tools 是官方配套工具集,spirv-dis 就是其中的反汇编器,专做"忠实还原每条指令"这件事[spirv-tools];SPIRV-Cross 则是另一个视角,它优先把 SPIR-V 还原成接近 GLSL/HLSL 的源码形态,方便你在看不懂原始字节流时迅速抓到语义[spirv-cross]。而 D3D12 侧的 DXIL 是被 LLVM IR 影响的字节码格式,文档里对这条"中间表示"的存在理由讲得很透:它让 shader 的编译产物可以在不同驱动间做旧的保证下被稳定解析[dxil]。看清这层出身,你就明白为什么 RenderDoc 能"换着用"不同反汇编工具——它们读的是同一份字节码,只是站在不同的可读性角度。
我们把主流 API 的 IR 与反汇编形态列成一张表,这是全篇用得最勤的一张对照:
| API | 字节码 IR | 默认反汇编入口 | 可配置替代 | 硬件 ISA 支持 |
|---|---|---|---|---|
| D3D11 | DXBC | Shader Viewer 反汇编 tab | 自定义工具 | 视驱动而定 |
| D3D12 | DXIL | Shader Viewer 反汇编 tab | 自定义工具 | 视驱动而定 |
| Vulkan | SPIR-V | Shader Viewer 反汇编 tab | SPIRV-Cross / spirv-dis | 如 AMD GCN |
5.3 Current vs Locked Tab:unstable 与 stable 的对照
Shader Viewer 的标签页还有一个细节:当前标签(Current Tab)与锁定标签(Locked Tab)。ep05 在 Texture Viewer 里讲过 Locked Tab 钉住资源不受状态漂移影响,这里同理——你可以在 Pipeline State 里点开一个 shader 的 current 版本,再锁一份,然后切换 draw,current 跟着变、locked 不动,方便对照两个 draw 用的 shader 差异。
这个功能在"两个 draw 为什么一个正常一个异常"的排查里价值极高:一个 draw 的 shader 被锁住当基准,另一个 draw 的 current 跟在后面漂,两个版本并排看,差异一目了然——不用靠记忆。
走一遍具体操作会更清楚要盯哪里。先在事件浏览器挑中那条正常的 draw,在 Pipeline State 里点开它的 VS,Shader Viewer 打开后切到 "Locked" 标签把这份 shader 钉住;再回到事件浏览器挑中那条异常的 draw,Shader Viewer 里的 Current 标签会跟着换到异常 draw 的 shader——现在两个标签并排,你逐条扫它们的反汇编差异;如果看到的差异巨大到"常量不同"或"调用序列不同",那多半不是这组顶点数据的问题,而是这两个 draw 压根绑了不同的 shader;如果差异只在某条 mad 后面跟的常量值不同,那才是"同一个 shader、参数不一样"的教科书案例。这个"先锁基准、再放漂移"的动作,把口头上的"比对"落成了可重复的操作,比来回切 draw 靠眼睛记靠谱得多。
5.4 当前 shader 标签:一眼看到现在台上站着谁
Mesh Viewer / Shader Viewer 顶部往往有一个"当前 shader"的小标签,告诉你这一瞬间 GPU 正在跑哪个着色器。
这个标签虽小,却是"命令流 → 状态 → 当前 shader"三者互锁的最后一个环:你选中哪个 draw,它就指到哪个 shader,再去反汇编里翻对应指令——一条从命令到指令的完整链路就闭合了。
六、反汇编 ↔ 源码对照:把 GPU 算的东西映射回人话
6.1 为什么反汇编有用:GPU 只认指令,不认"你写的"
你在 HLSL/GLSL 里写 worldPos = mul(float4(pos,1), world);,编译器会把它编译成若干条中间指令(D3D11 是 DXBC、D3D12 是 DXIL、Vulkan 是 SPIR-V),再经驱动翻译成 GPU 硬件指令。反汇编把这条链子的中间表示层重新可读化。官方说的"反汇编形式随 API/插件/硬件支持不同而不同"就在这里体现[shader_viewer]。
为什么这对调试重要:因为"编译后变量/寄存器"和"源代码变量"不是一回事。Shader Debugger 能逐行步进看变量值,但前提是得有调试符号;而反汇编是无条件可看的——哪怕你的 shader 被优化得面目全非,指令流也永远摊在眼前。而且官方的底层原理说明可以给你兜底信心:RenderDoc 的回放本质是"把捕获的命令流按序重放,重放到当前事件为止",任何分析工具(Mesh Viewer、Shader Viewer、反汇编)读到的数据,都来自这个确定性的重放状态[how_works]。也就是说,你看到的指令流不是快照拼接,而是可复现的重放结果。
6.2 从指令到数值:寄存器是承载体
反汇编里的核心单位是寄存器与临时值。一个 mov r0, v0 意思大致是"把输入寄存器 v0 拷到 r0";mul r1, r0, r2 是"r0 乘 r2 存 r1"。要读懂 GPU 算了什么,就把这些指令按数据流串起来:输入是哪些,做了几次乘加,输出到哪。
拿一个真实的 HLSL 片段走一遍。假设你写的是:
float4 worldPos = mul(float4(pos, 1.0f), world);
float4 clipPos = mul(worldPos, viewProj);
这段在 DXBC/DXIL 里大致会展开成这么几条指令(伪码,真实指令集各异):
def c0, 1.000000, 0, 0, 0
mov r0.xyzw, v0.xyzw ; 取输入顶点 v0
mov r0.w, c0.x ; 手动把 w 补成 1.0
dp4 r1.x, r0.xyzw, c1.xyzw ; world 矩阵第 1 行点乘
dp4 r1.y, r0.xyzw, c2.xyzw ; world 矩阵第 2 行点乘
dp4 r1.z, r0.xyzw, c3.xyzw ; world 矩阵第 3 行点乘
dp4 r1.w, r0.xyzw, c4.xyzw ; world 矩阵第 4 行点乘
(上面是示意性的指令序列,不同 API/编译器会差得很多,但结构一致:一次矩阵乘法 = 若干条 dp4 点积指令,每行矩阵一次。)把这段反汇编和顶点数据表对照:r1 算出来的就是 world 空间坐标,你切到 Mesh Viewer 的 VS Output 档,找到对应列,数值应当一致——GPU 到底算了什么,在这一步被精确验证了。
这里要补一个真实的现状,免得你对着示意以为实际总是这么直白:编译器往往不会把你写的"一次矩阵乘"原样展开成人类爱看的四行 dp4,而是会做常数折叠、子表达式消除、向量化,甚至把两段 mul 合并。D3D 侧的 shader 编译工具(如 fxc)对这类优化是默认开启的,官方文档明确给出了多种编译标志,其中 /Od(禁用优化)是让反汇编更接近源码、配合调试的经典手段;而 D3DCompile 接口则把同样的这些开关以 D3DCOMPILE_SKIP_OPTIMIZATION 等标志暴露给不同语言调用[d3dcompile]。换句话说:你在反汇编里看到的指令,是"这一版本的编译器在它默认优化档下"的产物,不是"你源码的逐行镜像"。这不影响"寄存器承载数值"这个读法,但提醒你别对"行号一一对应"抱幻想——交换排在源码前面的简化路径,在优化后的指令流里可能被挪到后面去。这也是下一节"shuffle 法"存在的根本原因。
我们再给像素着色器也走一遍同样的读法,因为标题里那句"顶点与像素级"必须在这里兑现。像素着色器的典型输入是两个插值量(interpolant),比如世界法线 Nworld 和世界位置 Pworld,它们来自上一阶段 VS 输出的逐顶点属性,经光栅化器按屏幕位置做重心插值后逐像素送来。假设你写的片元代码是:
float3 N = normalize(Nworld);
float3 V = normalize(camPos - Pworld);
float NoV = saturate(dot(N, V));
它在 D3D 侧反汇编里大致展开成这么几条(伪码):先把插值输入的 Nworld 归一化(dp3 + rsq + mul 三连),再把 Pworld 从输入寄存器读出、和常量缓冲里的 camPos 相减、同样归一化,最后两法线点乘再 saturate 夹到 [0,1]。结构上它比顶点着色器的例子更"碎",因为每一步 normalize 都要拆成"点乘自身 → 开方倒数 → 逐分量乘回来"三条指令;HLSL 里这条 dp3+rsq+mul 模式正是向量归一化的译法[hlsl-normalize];HLSL 侧另有专门的 normalize 内建[hlsl-intrinsics],编译器通常仍展开成这三连。把这个和顶点数据表对照就更有意思了:像素着色器里的 Nworld 并不是"你在表里看到的那一列",而是那一列被光栅化插值过之后的中间值——所以你在寄存器窗口里看到的法线,几乎从来不会和顶点表里任何一行完全相等,除非恰好落在顶点上。这层"插值"正是像素级和顶点级的分水岭。GPU 在光栅化阶段对图元着色属性做逐像素插值(perspective-correct interpolation)是图形 API 定义好的固定功能,OpenGL 规范里用 flat/smooth 等插值限定符区分它是取质心值还是逐点插值[khronos-interpolation]:顶点级读的是几何在顶点上的离散值,像素级读的是光栅化在平面上摊开的连续值。读反汇编时别拿顶点表的数据去硬套像素寄存器的数,二者差的就是这一次插值。
6.3 对照技巧:Shuffle 法与反汇编定位
社区里一个实用技巧叫"shuffle 法":你在源码里调换某个表达式的位置或改个临时变量名,重新抓帧,看反汇编里哪几行跟着变——变的就是你的改动对应的指令。这比纯靠读指令猜快得多,尤其在 shader 被优化过、源码行号对不上反汇编时特别有效。这是把"GPU 到底算了什么"从抽象变成可控实验的落点。
它的原理是一种"示踪物法":shader 被优化、寄存器被复用、源码行号散了之后,你没法靠"读源码猜"来地图反汇编,但你一定能靠"改一处源码、看反汇编哪里动"来当探针。唯一要小心的是编译器有时会把你的改动优化消失——比如你往某个最终没被用到的临时变量写入,优化器可能整行删掉,反汇编纹丝不动。所以 shuffle 法有个配套纪律:chosen 的目标变量必须是最终被输出用到的(写了就一定会影响某个寄存器),或者用既改名字、又改写法的方式,逼编译器没法把它折叠掉。真遇到优化过度、改什么都无响应时,最可靠的两招:切反汇编风格(SPIRV-Cross 换 spirv-dis,可读性变了定位就变了),或者关掉优化重编一份(用 /Od 或调试档)专门来对源码——Debug 档的反汇编虽然不同,但和源码行号能对上,反而是查"这一段到底在干嘛"的捷径。
配套的还有官方 Shader Viewer 自带的编辑能力:它不只是"显示"shader,官方文档把编辑和自定义可视化 shader 都算在它的能力范围里[how_edit_shader]、[how_custom_visualisation]。这意味着 shuffle 法可以完全在工具内完成——改源码、重编译、看反汇编变化,不用切出工具重新抓帧。对一个"实验派"的调试者来说,这等于把"改一行 → 看指令变没变"的循环压到了秒级。
七、Shader Debugger:逐行步进,但先看有没有调试符号
7.1 能力边界:只有 D3D11 / D3D12 / Vulkan
反汇编无条件可看,但逐行源码级调试是有条件的。官方明确:shader 调试目前只支持 D3D11、D3D12、Vulkan[how_debug_shader]。OpenGL / GLES 不在其列,只能走反汇编。
官方还提醒了第二层边界:不是所有 shader 都能调试——比如用了不支持的 Vulkan 扩展时,调试选项会被禁用[how_debug_shader]。而且几何着色器和细分着色器目前还不可调试[how_debug_shader]。这两条边界意味着:你调试的目标主要是 VS/PS/CS,且 API 必须落在三家里。
调试的入口也值得记一笔,它是和 Mesh Viewer 直接联动的:顶点调试从 Mesh Viewer 发起——在 mesh 数据的顶点输入档选中一个顶点(3D 视图里会连带高亮它所在的图元),右键选 debug vertex,调试器就会带着自动填好的输入打开顶点着色器[how_debug_shader]。像素调试则从 Texture Viewer 发起,点像素历史里某个三角形即可[how_debug_shader]。这条链路把"几何 → 顶点/像素 → 着色器执行"完整串起来了。
把像素调试的入口再展开一点点,因为它是"像素级到底算了什么"最直接的门。"像素历史"(pixel history)是 Texture Viewer 里针对某个片元的回放记录:它把"这一帧里哪些 draw 往这个像素写过颜色"串成一条历史链,你点其中某次三角形绘制,调试器就带着"这个像素被光栅化的那组几何"打开对应的像素着色器[how_debug_shader]。和顶点调试最大的不同在于:顶点调试的输入是顶点数据表里那行离散值,像素调试的输入是"这个屏幕像素位置 + 该像素命中的那张三角形"——两者自动填好、无需你手工挑寄存器。这对"某个像素颜色不对"的定向排查极其顺手:点像素历史 → 找到最后一次写它的 draw → 步进它的 PS,看是哪条 mad 或 saturate 把颜色算歪的。像素级调试的这条"从颜色反查指令"路径,正好和 §6.2 顶点级那条"从数值对指令"路径互补,构成标题里"顶点与像素"的两条腿。
7.2 先决条件:调试符号是入场券
要把反汇编对回源码,调试符号是硬通货——没有它,step 只能走汇编,走不了源码。官方把"怎么把调试信息放进 shader"讲得很细,而且各 API 的开关都不一样:
- D3D11/D3D12:给 fxc 传
/Zi,或给D3DCompile()传D3DCOMPILE_DEBUG标志,就会带上额外调试信息[how_debug_shader]。/Qstrip_debug和/Qstrip_reflection会剥掉对调试有用的反射信息(比如常量缓冲里的变量名),官方明确建议别用[how_debug_shader]。调试体验最好的组合是/Od(或D3DCOMPILE_SKIP_OPTIMIZATION),它会默认开启 HLSL 源码级调试[how_debug_shader]。RenderDoc 还完整支持用/Fd分离的 PDB 信息[how_debug_shader]。 - Vulkan:RenderDoc 通过
NonSemantic.Shader.DebugInfo.100扩展指令集支持源码级调试[how_debug_shader]。信息可以由 glslang 用-gVS生成,或由 dxc 用-fspv-debug=vulkan-with-source生成,两种方式都会把调试信息嵌进 SPIR-V blob[how_debug_shader]。
这条"开关清单"本身就值得收藏:它划清了"想让 RenderDoc 走到源码级,你得在编译管线里做什么"。而引擎默认往往不保留——社区明确踩过:UE4 默认不保留调试符号,要改配置才能逐行调[aicdg]、[getperfguard]。如果你的目标是"打开即调试",得先确认引擎有没有替你把 /Od 或 debug info 开出来。
7.3 有符号之后:逐行步进与寄存器观察
一旦符号齐了,Shader Debugger 就变强了。官方对调试控件的描述,几乎就是一张完整的操作卡:F5 跑到程序末尾、Shift+F5 反向跑;F9 设/撤断点;F10 单步前进、Shift+F10 单步后退;Ctrl+F10 跑到光标处[how_debug_shader]。还有两个"查错专属"按钮:一个跑到下一条纹理加载/gather/sample 操作就停,另一个跑到下一次产生 NaN 或 inf的操作就停[how_debug_shader]——NaN 排查在着色器调试里有多高频,做过的人都知道。
显示层面官方也分得很细:常量窗口列输入和常量缓冲寄存器(执行期间不可变),寄存器窗口列可变寄存器(临时值随步进而更新),Watch 窗口让你写自定义表达式,支持 .xyzw/.rgba swizzle 和各种 typecast(u 无符号整型、i 有符号整型、f 浮点、x 十六进制、o 八进制、b 二进制、c 颜色带 RGB 色块)[how_debug_shader]。有调试信息时,Watch 里还能引用局部变量,并显示调用栈和 locals 映射[how_debug_shader]。
这正是第六节反汇编的价值补充:反汇编永远兜底。符号齐全,你走源码;符号丢光,你还有指令流可读。两条路合起来,顶点与像素级的计算无处可逃。
再把像素级的对照流程也完整走一遍,让"顶点与像素"这半句真正闭环。假设你看到某个高光点颜色发灰,怀疑是法线向量没归一化导致。"着色器里忘写 normalize"几乎是每本图形学入门书都会单拎出来的经典错——学习 OpenGL 教程在光照一章专门强调:模型变换可能引入非均匀缩放,法线不能直接用模型矩阵变换,否则方向会歪,必须在片元阶段重新归一化或预先用逆转置矩阵处理[learnopengl-basic-lighting]。典型流程:先在 Texture Viewer 里打开目标像素历史,找到最后一次写入它的 draw 对应的 PS;如果这份 shader 编译时带了调试符号,点进 Shader Debugger 逐条 F10 单步,Watch 窗口里盯着 N 和 Nworld 两个量——你会发现 Nworld 从插值寄存器进来时长度不是 1,而你的代码假定它是单位向量直接拿去点乘,亮度自然不对;如果这份 shader 没符号,退到反汇编:在 dp3 r0, r0, r0(点乘自身求长度平方)和 rsq(开方倒数)这三连里,看它是否真的先做了 normalize 再进 dp3 点乘——没有这三连,说明编译器或源码确实跳过了归一化。两条路都能把"颜色为什么灰"锁到"少了一次 normalize"。这就是标题里"像素级到底算了什么"的实操注脚:像素级和顶点级一样,数据在哪儿、指令在哪一步,都是可以被摊开看的。
我们最后再走一遍这句"无处可逃"到底在真实排查里意味着什么,用一帧 D3D12 顶点着色器为例。假设你怀疑某个 draw 的顶点位置算错了,典型流程是:先在事件浏览器选中它,打开 Mesh Viewer 的 VS Output 档,看某个顶点的 x 明显飞了;接着切 Pipeline State 打开这个 VS 的 Shader Viewer,在反汇编标签页里找到对应顶点着色器;如果你编译时带了调试符号(/Fd 分离 PDB 或把 debug info 嵌回),直接从 Mesh Viewer 右键 Debug Vertex 进 Shader Debugger,逐条 F10 单步,同时盯着 Watch 窗口里你想看的寄存器,一路观察它是哪一步被算歪的;哪怕你这份 shader 是别人发布的、没有任何符号,也照样能退到反汇编那一层,逐条读它把 vertex 数据流过了哪些 dp4、哪些 mad、哪些 sat。这就是"反汇编永远兜底"的实操含义:真图在几何层(Mesh Viewer),真算在着色层(反汇编/调试器),中间由"源代码变量/寄存器/物理值"三层对应,无论切断哪一层,都还有下一层可退。排查一个顶点算错的 bug,走到这一步基本就锁死了。
八、坑点与版本边界:工具不是万能的
用了前三节的能力,还要知道它的边界,免得排查到一半怀疑人生。
- Mesh shader 场景的已知缺陷:在 radv(AMD 开源 Vulkan 驱动)上,mesh shader 的几何数据损坏有报告(Mesa issue #10298),带 payload 的 mesh shader 甚至有 GPU reset 风险。这意味着你开着 mesh shader 的 draw,Mesh Viewer 里的几何可能不可信——这时要靠反汇编 + 时序交叉验证。这里把 Electron 图形栈、独立驱动、以及引擎集成的"三条质量线"连起来看会更清醒:RenderDoc 是挂在第三方 API 之上做捕获和回放的,它读到的数据质量,取决于这个 draw 对应的驱动后端把命令执行得多"标准"。官方在"它怎么工作"的原理页里也强调,回放是"把命令流按序重放到当下",任何一步驱动行为不标准,回放出来的几何都会有偏差[how_works]。所以遇到"反汇编正常但几何乱"的组合,优先怀疑驱动后端,而不是先怀疑自己的顶点数据。
- 极端顶点/索引数量的 clamping:顶点或索引数极端时,Mesh Viewer 内部计数会 clamping;该问题在 v1.8 已修复。新版遇到可疑的计数就检查版本。给个具体尺度感受一下"极端":网格的顶点/索引数量级在几十万到几百万是常见的游戏资产规模,而 Mesh Viewer 历史上在计数超过一定上限时会把显示值钳在某个阈值上,导致"表里明明只有 65535 个顶点、3D 视图却似乎画满了"这类看起来自相矛盾的观感——这不是数据坏了,是显示层计数被钳住了。v1.8 的修复公告专门提了这一点,所以排查的第一步永远是确认 RenderDoc 版本,别在旧版上跟一个假的计数死磕[changelog]。
- 跨 API 的行为差异:反汇编形态、shader 调试支持(OpenGL 不走源码级调试)、SPIR-V 兼容性都随 API/驱动不同而不同[yoyonel]。同样的 .rdc 在不同后端回放,结果可能不同。一个具体可复现的例子:同一份 SPIR-V,在 Vulkan 下走 SPIRV-Cross 还原出的 GLSL 和你在 D3D12 下看到的 DXIL 反汇编,变量命名、指令粒度都会不一样——这不是谁错了,而是两套中间表示本就不在同一抽象层;跨 API 对比时,要比"语义等价"而不是"逐指令相同"。
- 调试能力的三重限制:API 只支持 D3D11/D3D12/Vulkan,几何与细分着色器不可调试,不支持的扩展会让调试选项直接消失[how_debug_shader]。排查前先确认这三条,能省下一大段"为什么按钮灰着"的困惑。
- 调试图元丢失:mesh shader 的几何在 radv 上损坏时,顶点调试可能取不到正确输入[how_debug_shader]。
九、结语:把"读几何"升级成"顶点像素双向验证台"
回到最初那个问题:GPU 在顶点与像素级到底算了什么? 现在你把两套工具串起来了。正向:事件浏览器选一个 draw → Mesh Viewer 看 VS Input 与 VS Output 差了哪些数值 → Shader Viewer 打开对应 shader,反汇编里读它怎么一步步算。反向:反汇编里改一个寄存器位置 → 回到顶点数据表验证数值 → 再切 VS 前后确认变换。这一来一回,恰好补上 ep05 那条"命令↔资源"双闭环在几何与着色两端的完整形态——ep05 收在"命令 × 资源"互相反查,这一篇把它推进成"命令 × 资源 × 几何 × 着色"四向互锁。
这四向互锁不是口号,而是把前面所有窗口拧成一个"闭环":Mesh Viewer 管几何对不对(VS Input 对不上 Output = 变换烂),Shader Viewer 管 shader 是谁(反汇编就是证据),Shader Debugger 管算了什么(逐条指令/寄存器可读可停),Pipeline State 管接了什么(绑定与签名)。任何一个环节出问题,你都能在另外三环里找到交叉验证的入口。比如怀疑"顶点 buffer 布局错",你能在 Mesh Viewer 数据表里直接看到列错位,能切到 Pipeline State 看 input layout 的 stride/offset,能用 debug utils 的命名资源(ep05 讲过)回到资源那一侧反查是谁写的 buffer,还能用反汇编确认寄存器的读取路径。这就是把工具从"一个个能点的窗口"升级成一张可双向行走的验证网络。
我们把这条方法论再压缩成四句口诀,方便随身带走:
- 先分空间,再查矩阵:视图歪先查坐标系和投影矩阵,数据歪再查顶点本身(2.1)。
- 两档切换定责任:VS Input 与 VS Output 之间切一下,"输入烂"还是"变换烂"立刻分家(2.1)。
- 反汇编是兜底真相:符号丢光也有指令流;一次矩阵乘法 = 若干条 dp4,和顶点表对得上(6.2)。
- 先确认边界,再动手:API 支持、调试符号、已知缺陷,三条都过了再深挖(7、8 章)。
至此,本系列已经从"怎么看一帧"走到了"怎么读懂一帧里 GPU 的几何与着色计算"。
参考资料来源:本系列技术说明均以官方文档、源码、引擎官方文档为一手依据,未使用来源不明或"搬运配图声称自跑"的素材;具体引用见正文各条。
下篇预告:(七) 像素历史与帧剖析:把"这帧慢在哪"一步步拆到 draw 与像素。
开篇快捷键:想快速上手,先记住三样,Mesh Viewer 看几何进出、Shader Viewer 看反汇编、Flat Shaded 看拓扑。
With Best Wishes.