第 11 篇的软件光栅器已经能把网格变成颜色图,第 16 篇又把局部变换串成世界变换。到了 WebGPU,顶点变换、属性插值、深度测试和纹理采样由固定功能与着色器共同完成。迁移的难点在于让两条管线对同一批字节、坐标和颜色作出同一种解释。

本篇固定一个 128×128 场景:两个有重叠的倾斜四边形,共享一张 8×8 纹理,使用同一组 float32 MVP 矩阵。CPU 生成参考缓冲与诊断图,浏览器在真实 WebGPU 设备上渲染并读回颜色、辅助数据和深度附件。内部像素要求数值一致;几何边界单独统计,不把两种光栅器不同的覆盖规则伪装成错误。

先运行同场景实验

打开 CPU 与 WebGPU 对照实验。点击“运行 GPU 并核对 CPU”,再依次查看纹理颜色、UV、原始深度和差异位置。页面显示的是 GPU 附件读回值,不是 CPU 图片或预存截图。

实际验证日期为 2026-09-22。Codex 内嵌浏览器报告 Chrome/153.0.0.0,安全上下文为 true,适配器 vendor 为 apple、architecture 为 metal-3;device 与 description 字段为空,因此不据此猜测具体 GPU 型号。

正常深度测试下,5,170 个内部像素的对象编号全部相同。UV 最大绝对差为 1.651×1071.651\times10^{-7},颜色检查的 5,134 个样本全部在每通道 1 个码值的容差内,读回深度附件与 CPU 参考的最大差为 1.013×1071.013\times10^{-7}。另有 1,757 个边界带像素单列统计:对象编号和颜色没有不一致,UV 与深度最大差仍约为 1.53×1071.53\times10^{-7}1.00×1071.00\times10^{-7}。这些数字只描述本次固定场景与设备,不外推为跨 GPU 的逐位保证。

把两个物体的绘制顺序反过来后,颜色、辅助数据和深度三组读回缓冲逐字节不变。将深度比较故意改为 always 后,1,553 个像素的可见对象发生变化,其中 1,372 个内部像素对象号错误;页面明确拒绝该结果。正例说明共享约定成立,负例说明检查确实能发现遮挡错误。

一份场景,两种执行方式

CPU 程序继续复用累计的 scene.hppsurface.hppmath.hppcolor.hpp。场景树给出两个节点的世界矩阵,相机仍采用右手坐标、朝相机负 zz 方向观察,投影后的 WebGPU 深度位于 [0,1][0,1]。每个物体的矩阵计算完成后先量化为 float32,再同时写入 JSON 和 CPU 参考计算,避免把 CPU 的 double 输入优势算进对照结果。

两条路径的职责如下:

阶段 CPU 参考 WebGPU
顶点变换 C++ 矩阵乘法 WGSL 顶点着色器
裁剪与覆盖 软件裁剪、1/256 固定点边函数 实现定义的硬件光栅化规则
UV 插值 透视校正插值 默认 perspective, center 插值
深度 屏幕重心插值、strict less depth32floatdepthCompare:'less'
纹理 nearest 与 clamp-to-edge nearest sampler 与 clamp-to-edge
输出 线性 RGB 手动编码到 sRGB 码值 WGSL 手动编码后写入 rgba8unorm

跨实现对照应先固定输入语义,再比较可比的中间量。只比较最终彩色图时,矩阵转置、UV 翻转、深度错误和颜色空间错误可能互相抵消,画面“差不多”并不能定位原因。

坐标约定要写到每一道边界

场景与相机沿用前文的列向量记法:

pclip=PVMplocal.p_{clip}=PVMp_{local}.

WGSL 的 mat4x4f * vec4f 也按列向量使用。CPU 内存里的矩阵按行保存,不能直接交给 JavaScript 上传。导出的 16 个数改按四列排列,着色器读到的矩阵才与上式一致。平移量因此位于上传数组的第 12、13、14 个元素,而不是第 3、7、11 个元素。

裁剪空间经过透视除法后,WebGPU 的 NDC 中 x,y[1,1]x,y\in[-1,1]z[0,1]z\in[0,1]。framebuffer 原点位于左上,yy 向下。本实验只在视口映射处翻转一次 yy;纹理坐标另定义为

u=xlocal+12,v=1ylocal2,u=\frac{x_{local}+1}{2},\qquad v=\frac{1-y_{local}}{2},

所以纹理上传的第一行对应图像顶部。坐标系、图片行序和 UV 方向是三项独立约定,不能用一句“WebGPU 是左手系”代替。

CPU 颜色参考

CPU UV 与对象编号

颜色图显示最终纹理结果;UV 图以红、绿通道编码 u,vu,v,蓝通道区分两个对象。两张图中的几何轮廓一致,说明诊断量使用了同一覆盖结果。

布局错误通常仍是合法字节

每个顶点由三个 float32 位置分量和两个 float32 UV 分量组成,步长为 20 字节:

1
2
3
offset 0:  position.x, position.y, position.z
offset 12: uv.x, uv.y
stride 20

顶点 fetch 的步长和偏移属于 vertex buffer layout。uniform 的布局是另一套规则。WGSL 中 mat4x4f 大小为 64 字节、对齐为 16 字节,后接一个 vec4f 对象编号,共 80 字节。本例为两个物体分别创建 uniform buffer,不使用动态偏移,也就没有把动态 uniform offset 的 256 字节限制误套到结构本身。

布局出错常常不会越界。把矩阵按行上传,16 个数仍然齐全;把 UV 偏移写成 8,buffer 也可能足够大。API 能验证字节范围,却无法知道这些合法字节是否表达了预期的数学量。参考实现因此直接读回 UV、深度和对象编号,而不是只等一张彩色图。

深度值需要同时检查着色器输出和附件

片元辅助附件保存 (u,v,position.z,object)。片元阶段的 position.z 已是 framebuffer 深度;独立的 depth32float 附件则保存深度测试实际写入的值。正常运行中,两者都与 CPU 参考比较,最大绝对差分别约为 1.013×1071.013\times10^{-7}

CPU 原始深度

图中灰度只是把 [0,1][0,1] 映射到 0–255 便于观察,验证使用原始浮点数。两个表面深度接近,肉眼难以判断谁应遮挡谁;对象编号和浮点深度能直接回答这个问题。

故意将 depthCompare 改为 always 后,后画的物体覆盖先画物体,与相机距离无关。负对照造成 3,637 个颜色字节、18,871 个辅助附件字节和 4,651 个深度字节变化。该结果说明“反向绘制仍相同”不是偶然,因为错误深度状态会立刻破坏这项不变量。

边界差异为什么单独处理

CPU 光栅器把屏幕坐标量化到 1/256 像素并采用既定边规则。WebGPU 规范规定非 MSAA 时样本位于像素中心,但恰落在多边形边上的覆盖结果不承诺与这份软件实现逐位相同。浮点舍入还可能把几何边界推到样本中心的另一侧。

因此,CPU 先把对象轮廓周围半径两个像素标成边界带。内部像素执行严格数值断言,边界像素报告差异而不混入内部误差统计:

CPU 检查区域

绿色是内部区域,红色是边界带。边界带不是免责区。对象、颜色、UV 和深度仍全部记录,只是不因允许的覆盖规则差异直接宣称实现错误。本次运行恰好连边界对象与颜色也一致,但文章不把这次观察升级成规范保证。

纹理边界还要再排除一层。nearest 采样在纹素分界线两侧会选择不同 texel;极小 UV 舍入即可改变离散颜色,而连续 UV 本身仍正确。本次有 36 个内部像素距离纹素分界不足 0.002 texel,不参与颜色断言,但继续参与 UV、深度和对象编号检查。

颜色转换只做一次

8×8 纹理存放线性 RGBA8 值,GPU 纹理格式为 rgba8unorm。采样得到的数值仍按线性 RGB 解释,片元着色器执行一次分段 sRGB 编码,再写入非 sRGB 的 rgba8unorm 颜色附件。CPU 使用同一分段函数生成参考码值。

若输入改成普通图片文件,其码值通常已经是 sRGB 编码,上传和采样策略就要随之改变。若输出附件改用 sRGB 格式,也不应保留当前手动编码。颜色空间错误的典型特征不是位置错,而是中间亮度系统性偏离,所以颜色断言与 UV、深度断言必须分开。

复跑方法

CPU 参考生成器没有随机数,也不测性能。运行:

1
2
make -C examples/computer-graphics build/pipeline26_check
examples/computer-graphics/build/pipeline26_check examples/computer-graphics/build

成功时输出固定场景的覆盖计数、边界带计数、矩阵布局和纹理模式。--help 返回用法;缺少输出目录、目录不存在或参数过多均返回失败。

浏览器实验可在仓库根目录启动本地静态服务:

1
python3 -m http.server 8772 --bind 127.0.0.1 --directory examples/computer-graphics

打开 http://127.0.0.1:8772/gpu26.html。正常运行、反向绘制、关闭深度负对照、再次正常运行分别验证正例、不变量、可检出的错误和恢复能力。验证页面会等待 GPU 读回,这属于实验开销,不代表实时应用应逐帧映射三个附件。

练习与答案

练习一:若一个列向量矩阵的平移为 (tx,ty,tz)(t_x,t_y,t_z),上传给 WGSL mat4x4f 时三个值位于 16 个 float 的哪些索引?若误放到 3、7、11,会出现什么性质的错误?

答案:列布局下位于 12、13、14。放到 3、7、11 相当于把平移写进最后一行,矩阵不再执行普通仿射平移,还会改变裁剪坐标 ww;透视除法后产生随位置变化的非线性畸变。

练习二:为什么不能要求 CPU 与 GPU 在三角形边界逐像素完全相同?怎样避免把真正的 UV 错误藏在“边界容差”中?

答案:两者的坐标量化、舍入和边界包含规则不同,恰在边上的样本可能归属不同。应先把小范围边界带单独标记,内部像素继续执行严格 UV 断言;边界带仍报告对象、UV、深度和颜色差异。容差按区域与量分类,不能整张图一概忽略。

练习三:把输出附件从 rgba8unorm 换成带 sRGB 转换的格式时,当前片元着色器中的手动 sRGB 编码应怎样处理?

答案:应去掉手动编码,让线性着色结果由 sRGB 附件写入转换编码一次。两者都保留会重复编码,使中间亮度偏亮;两者都没有则会把线性值直接当显示码值,通常显得偏暗。

模式速查表

问题 先固定什么 读取什么证据
几何位置不一致 矩阵方向、NDC、视口原点 对象编号与轮廓
纹理翻转或错格 UV 定义、行序、sampler 连续 UV 与离散颜色
遮挡错误 深度范围、比较函数、清除值 片元深度与深度附件
亮度不一致 输入与附件的颜色编码 线性值和最终码值
只有轮廓差异 样本位置与边界规则 内部区和边界带分开统计

参考资料与导航

系列目录 · 上一篇:一次绘制怎样交给 GPU · 下一篇:为什么更多物体会变慢(待完成)。