3DGS EP10: The vksplat Viewer — A Vulkan Compute Implementation
着色器不直接拿到内存指针,它读到的是”槽位”。一个描述符集就是一张槽位表,每个槽位用 (set, binding) 两个整数定位。CPU 侧通过 vkUpdateDescriptorSets 把某个 buffer 或 image 写进某个槽位,着色器里用 layout(set=0, binding=3) 去读它。
容易看错的点:
(set, binding) 再写一次,旧绑定就被顶掉,没有引用计数、没有所有权检查——每次写在验证层看来都合法。这是后面”同槽覆盖”bug 的温床。VkDescriptorPool)分配,池的大小要在创建时预估好各类型槽位数量,估小了分配直接失败。fence 是 CPU 等 GPU 的单向同步原语:提交命令时挂上一个 fence,GPU 执行完这批命令后由驱动把它置为 signaled;CPU 用 vkWaitForFences 阻塞等它。典型用途:复用一块缓冲前先确认上一帧的 GPU 工作已经读完它,否则就会”资源还在用就被覆盖”。
常见误解:fence 和信号量(semaphore)不是一个东西。fence 给 CPU 等;semaphore 只在 GPU 队列之间同步,CPU 不能等它。跨队列/交换链同步用 semaphore,回读与资源复用用 fence。
Vulkan 里着色器只是管线的一部分。一条图形管线把”着色器 + 顶点输入格式 + 光栅化状态 + 混合状态 + 渲染通道”全部打包编译成一个几乎不可变的对象(vkCreateGraphicsPipelines)。好处是驱动在创建时一次性做完所有校验和编译,渲染循环里只剩一次 vkCmdBindPipeline,不用每帧重新装配状态。代价是任何状态想改(比如换个混合模式)就得新建一条管线。
GPU 的工作都通过队列(queue)提交。硬件按能力把队列分成若干族(queue family):
一个设备通常两种族都有,vksplat 查看器只用 graphics 队列,自研训练器只用 compute 队列——分工原因见后文。
CPU 排序线程 ──直写映射缓冲──> instanced quad ──> 顶点着色器 ──> 片元着色器
(counting sort) vkCmdDraw(6, N) EWA 求椭圆 逐像素 α 混合
实例化渲染(instanced rendering)让一次绘制调用重复画同一份几何体 N 遍,每遍叫一个实例(instance)。vksplat 的几何体只是一个 quad——两个三角形共 6 个顶点;每个高斯对应一个实例,所以:
vkCmdDraw(cmd, 6, N, 0, 0); // 6 顶点/实例 × N 个高斯实例
着色器里用两个内建变量区分”我在画什么”:
gl_VertexIndex(0–5):当前是 quad 的第几个角,用来决定椭圆上的偏移方向;gl_InstanceIndex(0–N−1):当前是第几个高斯,用它去索引排序后的高斯数据(位置、协方差、颜色)。排序的结果是一个索引表,实例号经它间接取高斯——重排只动索引,不动百万条高斯本体,这是省带宽的关键。
EWA 投影(3D 协方差 → 雅可比 → 屏幕协方差 → 特征值求半径 → 约 截断)算出来的椭圆对每个高斯是常数,与该椭圆覆盖的哪个像素无关。因此它属于”每图元一次”的计算,放顶点着色器(每实例只跑 6 次),而不是放片元着色器(每覆盖像素都跑一遍)。
代价是同一个椭圆被 6 个顶点各算一遍——6 倍冗余。但相对”每像素重算一遍投影矩阵乘法”仍便宜几个数量级,这是 splatting 渲染器的标准取舍:顶点算形状,片元只算 并混合。
深度排序是百万级元素的 counting sort(16 位深度桶,整数键),CPU 常驻线程毫秒内数完,结果直写 Vulkan 映射缓冲——一块 host-visible 显存,CPU 写、GPU 读,不需要任何拷贝命令。配合”邮箱式”单所有权约定(同一时刻只有一个线程碰这块缓冲),渲染线程零等待。GPU 排序当然更快,但那样就得多一条 compute 管线、多做一次同步;对这个量级,CPU 已经够快,工程上”少一个环节”比”快一点”值钱。
vkCmdDraw(6, N) 里第二个参数是实例数,不是顶点总数;顶点总数是 。| 图形管线 | compute 管线 | |
|---|---|---|
| 执行单元 | 顶点/片元,按固定阶段顺流而下 | 自由线程组(workgroup),程序员定维度 |
| 每像素混合 | 固定功能混合单元原生支持,片元按序写帧缓冲 | 没有”混合单元”,得自己加锁/原子操作 |
| 共享内存与屏障 | 没有 | workgroup 内 shared memory + barrier |
| 乱序/逆序访问 | 执行顺序不由你控 | 完全由你控 |
查看器的核心操作是”排好序的 splat 逐个往帧缓冲上 α 混合”——这正是光栅化+固定功能混合单元干了一辈子的活,顺序由图元提交顺序保证,白送的。
训练器的核心操作是 per-tile 归约(一个 tile 内所有线程共同累加梯度)和反向的逆序重放(按渲染的逆序恢复不透明度链 )。这两件事都需要线程间共享数据、需要精确控制遍历顺序,图形管线没有这种执行模型,只能 compute。
所以不是”compute 更高级”,是两类活儿各自有原生擅长的硬件路径。
AoS(Array of Structs,结构体数组)布局,每个高斯恰好 32 字节,字段分组为 pos + pad / cov fp16 ×3 / color。示意(非源码逐字):
struct Gaussian { // 共 32 B,GPU/CPU 两边布局必须逐字节一致
float pos[3]; // 12 B 位置,全精度 fp32
float pad; // 4 B 对齐填充
uint16_t cov[3]; // 6 B 协方差三个独立分量,fp16 半精度
uint8_t color[4]; // 4 B 颜色(含 α)
// 其余字节为对齐填充,凑满 32
};
为什么是 32 字节:GPU 显存访问按对齐块走,32B 是缓存行友好的粒度,一个高斯一次事务取完;字段顺序、填充、对齐两边必须一致——错一个字节就是后面”color 字段错位”那种 bug。
半精度浮点(fp16)只有 10 位尾数,约 3 位十进制有效数字,但:
其中 (count leading zeros,前导零计数)是数一个 32 位无符号整数从最高位起连续有多少个 0,绝大多数 CPU 有一条指令直接给出(GCC/Clang 内建 __builtin_clz)。于是 就是”表示 需要的最少比特数”,也就是 (对 )。
瓦片号从零开始编号,最大编号是 。位宽只要装得下最大编号就够了。用 nTiles 会在 nTiles 恰好是 2 的幂时多算一位:比如 ,最大号 8191 只需 13 位,但 会给出 13 之外的错误直觉;写成 就把这个 off-by-one 消掉了。
tile 尺寸 16×16,向上取整分块:
最大瓦片号 。因为 ,需要 13 位。验算:8159 的二进制最高位是第 12 位(从 0 数),所以 32 位下前导零 :
32 位排序键把预算切成两段:
排序按整个 key 比较,等价于先比深度、同深度再按瓦片号稳定排列。
键宽写死会有两头都错的问题:
让 tileBits 跟着 走,分辨率一变键宽自动伸缩——分辨率升,tile 号多拿几位;分辨率降,省下的位全给深度。这就是”自适应键位”的全部含义。
黄金对拍(golden comparison):拿一个被信任的参考实现(antimatter15 的 splat)在同一输入下跑出结果,逐字节与自己的输出比。它回答的不是”画面好不好看”,而是”我的实现和参考语义是否一致”——把”对不对”这个模糊问题降维成”差多少、差在哪”这个可统计的问题。
本例的数字:golden.splat 共 39,241,600 字节,逐字节差异 1,159 个,差异率
“差 1159 个字节算不算通过”不能拍脑袋,判据按三个问题逐条过:
三问全过才算通过。通过线不是”差异为零”,是”差异可解释”。
只有两边走完全相同的浮点路径,才可能逐字节相等。这里参考实现与自研实现精度路径不同(协方差 fp16 vs fp32 中间结果),合法实现之间本来就会产生舍入级差异。把通过线定在”零差异”等于宣布”只允许一种实现”,对拍就失去了意义。
与”可容忍”对应的是零容忍清单:坐标和索引字段一个字节都不许差。理由很硬——索引错一位,排序结果就换了一个高斯,这是语义错误而不是舍入噪声;坐标错一个字节(哪怕只是低位)说明布局/对齐出了问题,同样不是噪声。所以判据是分字段定的:先定”什么样的差异无害”,再定红线,通过线本身是设计出来的,不是比完再找的。
字节对拍最便宜也最钝(它查不出”字节对但相机错了”这类问题)。其后两级逐级变贵、语义逐级变强:CDP 截图逐像素对齐(查渲染结果),指标阶梯交叉验证(查端到端质量)。一级过不了不必上一级。
机制。描述符集的槽位用 (set, binding) 定位,vkUpdateDescriptorSets 往里写是覆盖语义:对象甲绑进槽 3,对象乙再绑槽 3,槽 3 里就只剩乙——没有引用计数,没有”这个槽已占用”的提示。
为什么阴在”不报错”。验证层(validation layer)只检查”每次调用合不合法”,检查不了”你的意图是什么”。甲绑槽 3 合法,乙绑槽 3 也合法,于是每帧都合法、每帧都可能不对。画面忽明忽暗的根源是竞争:哪次写落在着色器读取之前,着色器就看到哪份数据,帧与帧之间读到不同对象,画面就像换了套数据。
修法。一对象一槽,槽位分配表写进架构文档。本质是承认一个事实:槽位是稀缺且无语义保护的资源,只能靠约定管理,那就把约定落到纸面。
机制。32 字节/高斯的 AoS 布局里,CPU 侧结构体与 GPU 侧读取的字段偏移必须逐字节一致。color 的偏移量算错一位(一个字段的宽度),着色器读到的就是相邻字段的字节拼出来的”颜色”——每个通道都错但错得有规律,画面整体像隔了层怪滤镜,而不是噪点。
为什么症状是”滤镜”而不是”花屏”。错位读到的仍是结构化数据(比如协方差字节),各通道之间相关性还在,所以颜色整体偏移而不是随机乱跳。症状形态本身就是线索:整体偏色 → 查布局/偏移;随机花屏 → 查未初始化/越界。
GPU 侧没有 printf,定位数据在哪一跳出错靠”探针”——强制着色器输出诊断色,把中间量直接画到屏幕上:
要点是每级探针只验证一个假设,“症状 → 假设 → 最小探针”循环收敛,而不是一次大改十处再碰运气。
半透明 splat 的逐像素合成是带权累积,权重就是 :
这套方程隐含一条不变量:画布初始状态必须是”空”= 全透明(rgba 全 0)。清屏色 alpha 写成 1,等于宣布”背景已经不透明”——后续每个半透明 splat 的贡献都要先和不透明的底做合成, 的 splat 被底吞掉权重,累积结果塌向黑色,整帧黑屏。
坑的普适性:这不是 Vulkan 特有的坑。凡是”先清屏再累积”的渲染器(预乘 alpha、under 合成、累积缓冲),清屏色都不是随便填的背景色,而是累积初值;把美学选择(想要黑底)和数学初值(空画布)混为一谈,就会踩中。想要黑底应该在合成之后再加,而不是清进累积缓冲里。
归一化的定义:
时分母为 0, IEEE 754 下 。触发链条:协方差以 fp16 存储,极小 splat 的协方差分量在半精度下退化到 0(不是精度差,是直接 underflow 成零)→ 由协方差算出的方向向量是零向量 → normalize 产生 NaN → NaN 沿顶点坐标传播。
NaN 的两个关键性质:
修法:归一化前判断模长,退化时写死一个安全方向。这类 与第七集反向梯度里的除零同宗——凡是除法,先问分母能不能为零。
fence 是一次性信号:GPU 完成后立起(signaled),CPU 用完后必须显式 vkResetFences 放下,才能随下一帧的提交重新使用;规范同时要求提交给 vkQueueSubmit 的 fence 必须处于未立起状态。漏了复位,围栏生命周期断拍,几十帧后时间轴卡死。修法只有一句话,但必须写进代码规范:等 → 复位 → 提交,三拍一拍不能少。
| bug | 违反的不变量 | 为什么沉默 |
|---|---|---|
| 清屏 alpha=1 | 累积初值=空画布 | 每帧都”合法”,只是全黑 |
| normalize(0,0) | 除法分母非零 | NaN 被驱动静默丢弃,无报错 |
| fence 未复位 | 一次性信号须复位 | 前几十帧正常,积累后才卡死 |
三者都不触发任何错误码——症状是唯一的报警器,这正是”症状 → 假设 → 最小探针”排查法存在的理由。
fence(围栏)是 Vulkan 里 CPU 与 GPU 之间的一次性信号,只有两个状态:unsignaled(未立)与 signaled(已立)。生命周期由四个调用构成:
vkCreateFence(&fence); // 默认 unsignaled;可带 VK_FENCE_CREATE_SIGNALED_BIT
vkQueueSubmit(queue, ..., fence); // 这批 GPU 工作完成时,驱动把 fence 置为 signaled
vkWaitForFences(fence, timeout); // CPU 阻塞,直到 fence signaled(或超时返回 VK_TIMEOUT)
vkResetFences(&fence); // CPU 显式把 fence 拨回 unsignaled,供下次提交使用
帧级同步的标准循环(设 fence 上一帧已用并复位过):
vkWaitForFences —— 确认这块 GPU 资源(命令缓冲、映射缓冲)上一帧已用完;vkResetFences —— 拨回 unsignaled;vkQueueSubmit 挂上这个 fence —— GPU 干完这帧的活,再次把它立起。三拍的顺序不能换:复位必须发生在等之后、提交之前——先复位再等,等的就是一个永远立不起来的围栏;提交后再复位,等于把”本帧已完成”的证据销毁。
这里值得把视频里”永远等”的说法落到精确的 API 语义上:
vkWaitForFences 遇到一个已 signaled 的 fence 会立即返回,不会卡。所以”等一个立着的旧围栏”本身不是卡死点。vkQueueSubmit 传入的 fence 必须处于 unsignaled 状态(有对应的 VUID 校验)。开着验证层,这一步会直接报错;关掉验证层则行为未定义——驱动可能拒绝、可能重复利用、可能在内部状态机上越走越乱,最终表现为”跑几十帧后卡死/驱动报错”。UINT64_MAX 就是字面意义的永远等。三条路殊途同归:fence 是一次性用品,复用前必须复位。这也是为什么修法不是”多等一会儿”,而是把”等 → 复位 → 提交”写进代码规范。
想不让 CPU 干等 GPU,标准做法是给每帧在飞(frame in flight)配一套独立的 fence + 命令缓冲 + 描述符资源,常见 2–3 套轮转。fence 的数量决定 CPU 最多领先 GPU 几帧;fence 复用 bug 在双缓冲下往往几十帧才发作——前几帧碰巧每个 fence 都被正确轮转,积累到某次轮转错位才爆。
Vulkan 的着色器资源绑定分三层,搞清楚这三层,“同槽覆盖”就是必然推论:
// 着色器侧:用 (set, binding) 两个整数寻址
layout(set = 0, binding = 3) uniform buffer Gaussians { ... };
VkDescriptorPool)分配出来的一张实例化槽位表——相当于插座板本体;vkUpdateDescriptorSets 把具体的 buffer/image 填进某个 (set, binding) 槽——相当于把插头插进槽位。vkUpdateDescriptorSets 对同一槽位的第二次写入直接替换旧绑定:
所以”对象甲插槽 3,对象乙也插槽 3”在 API 层面无懈可击,错的是意图:两个逻辑对象共享了一个本该独占的坐标。
覆盖何时被着色器”看见”取决于 CPU 写入与 GPU 读取的相对时序:
写入与执行在两条时间线上赛跑,胜负逐帧交替,画面就忽明忽暗。这类”每帧都合法、就是不对”的 bug,验证层查不出来(无规则违规),日志里也没有错误码,症状是唯一线索。
(set, binding) 一一对应,分配表写进架构文档——把靠记忆维持的约定变成可查的文档;核心教训可以推广到一切共享可变状态:合法 ≠ 正确,覆盖语义的资源必须配独占约定。