同一个四边形重复绘制 4,096 次,可以提交 4,096 条绘制命令,也可以提交一条带有 4,096 个实例的命令。两种方法都要处理 24,576 个顶点,都覆盖同样的像素。减少绘制命令主要改变的是命令组织成本,不能据此推断三角形处理、着色或显示速度一定提高。

第 26 篇已经检查了坐标、颜色、布局与深度。本篇保留它的 WebGPU 与线性颜色约定,用互不重叠的小四边形隔离提交方式的影响。实验先检查两种方法生成相同的字节,再测量 CPU 编码提交、GPU render pass 和连续绘制时的帧回调间隔。

固定画面,再改变提交方式

打开实例化实验。点击“运行完整对照”,页面依次检查 64、1,024、4,096 个物体。完整原始样本保留在页面下方,可另查看本次实际测量 JSON。

真实 WebGPU 绘制与检查结果

图中每个小块都是 GPU 绘制的两个三角形。所有场景使用 512×512 附件,每像素一个样本,没有纹理、光照和 MSAA;背景黑色,不启用混合。每个格子的宽高占格距的 72%,避免交叠与排序影响。物体增多时单个小块会缩小,因此只在同一物体数内比较两种提交方式,不把不同物体数的耗时当作相同几何负载。

实例颜色是线性 RGB。片元着色器沿用第 25、26 篇的显式 sRGB 编码,将结果写入非 sRGB 的 unorm 附件。测试机选择 bgra8unorm,数值检查按其通道顺序读取;没有再次应用颜色转换。

2026-10-01 的真实浏览器运行报告适配器 vendor 为 apple,architecture 为 metal-3,支持 timestamp-query。device、description 为空,不能从这些字段确定具体 GPU 型号。浏览器报告 Chrome/154.0.0.0;兼容性用户代理中的平台文本也不能用来判断实际 CPU 型号。

三组场景中,两种方法的完整附件读回逐字节相同。各物体中心的颜色另外与独立算出的 sRGB 码值比较,每通道允许 1 个码值偏差;三组均没有错误。这两个检查用途不同:相互一致可以排除提交方式引入的差异,中心参考值可以检出两条路径共有的颜色或索引错误。

一条命令怎样描述多个实例

单个四边形用六个顶点描述,顶点缓冲只存局部位置。实例缓冲存平移、缩放与颜色。它们不是复制出的 4,096 套顶点,也没有为每个物体创建一个 pipeline。

数据 步长 内容
顶点 8 字节 两个 float32 局部坐标
实例 32 字节 四个 float32 变换量,四个 float32 颜色量

实例记录的前 16 字节按 offset.x, offset.y, scale.x, scale.y 排列,后 16 字节为 RGBA。顶点位置计算为:

pndc=plocal⊙si+oi.\mathbf p_{\mathrm{ndc}}=\mathbf p_{\mathrm{local}}\odot\mathbf s_i+\mathbf o_i.

这里只做二维缩放和平移,所以不上传完整矩阵。扩展到第 16 篇的三维场景时,可换成实例模型矩阵,但要重新核对步长、属性数量和法线变换。不要把本实验的两个缩放值直接当作完整的三维变换。

实例缓冲布局指定 stepMode: 'instance'。普通顶点属性按顶点前进,这个缓冲按实例前进。WebGPU 的 GPUVertexBufferLayout 定义了这两种步进方式。实际地址是实例编号乘以 32,再加相应属性偏移;它不是 uniform 的自动打包布局。

逐物体方式用一条 render pass,循环调用:

1
2
3
for (let i = 0; i < count; ++i) {
pass.draw(6, 1, 0, i);
}

实例化方式保持同一套缓冲与 pipeline,只调用:

1
pass.draw(6, count);

两种方式都只调用一次 queue.submit。变化的是 render pass 内的 draw 数量,不是 CPU 向队列提交 command buffer 的次数。把 draw call 与 queue submission 混在一起,就无法解释这组对照。

draw(vertexCount, instanceCount, firstVertex, firstInstance) 的第四个参数决定起始实例。逐物体路径用 i,因此能读取不同记录;实例化路径从默认的 0 开始。规范的 draw 算法 与 WGSL 的 instance_index 都把起始实例纳入编号语义。

点击“错误 firstInstance 负对照”会把所有逐物体绘制的起始编号改成 0。64 个物体重复画在第一个格子里,中心检查发现其余 63 个位置缺失。本次真实操作得到的错误数正是 63。这个反例说明画出了某个图形,不能证明实例索引正确。

实例化减少哪一部分成本

若每条 draw 的编码成本近似为 dd,每帧固定成本为 aa,逐物体路径的 CPU 成本可粗略写成 a+Nda+Nd。实例化把 draw 部分变成 a+da+d,但动态实例的数据准备与上传仍可能是 O(N)O(N)。

这个模型只用于定位成本,不能拿它替代测量。浏览器、驱动可能延迟处理命令;每条 draw 的成本也不一定相同。绑定切换、验证、资源状态与线程调度都可能影响实际时间。

GPU 顶点调用在两条路径中仍为 6N6N,覆盖与片元运算也没有减少。GPU 调度可能从批次组织中获益,也可能已经被其他工作限制。若片元着色器很重,减少 CPU draw 编码未必改变帧率。

可合并的物体还必须兼容同一 pipeline、顶点布局与资源组织。本实验颜色作为实例属性,所以颜色不同不需要切换绑定。真实材质使用不同纹理时,需要纹理数组、图集或其他合法索引方案;这些方案又受格式、尺寸和采样条件限制。

透明物体按深度排序时,不能任意打乱顺序来凑大批次。一个巨大的实例批次也不会自动解决逐物体剔除:把已经不可见的物体全部交给 GPU,可能抵消提交节省。实例化成立的条件是重复几何和兼容状态,而不是“物体越多越应该全部合成一批”。

三种时间必须分开

CPU 编码提交时间从创建 encoder 开始,到 queue.submit 返回结束。它包含命令编码、finish 和提交调用,排除实例生成、上传与读回。submit 返回只代表 CPU 调用结束,不代表 GPU 完成。

GPU 时间取 render pass 起止 timestamp 差,单位从纳秒换成毫秒。它排除实例上传、查询结果拷贝、映射等待和呈现。timestamp-query 是可选特性;不支持时页面保留像素与 CPU 检查,GPU 时间显示为不可用,不用队列等待时间代替。

实验另外保留 onSubmittedWorkDone 的 Promise 等待墙钟时间。这个数包括队列进度与异步通知延迟,定义见队列完成规范。它既不是纯 GPU 执行时间,也不是从帧开始到最终显示的完整延迟。

为分开每个测量样本,隔离测量每次提交后等待完成,再读回时间戳。这样会减少 CPU、GPU 连续工作的重叠,因此不能把隔离样本倒数当成游戏帧率。连续绘制另走一条没有逐帧读回和队列等待的路径。

连续路径在每个 requestAnimationFrame 回调里绘制画布,记录相邻回调间隔。它观察浏览器的帧调度,不证明每一帧都已经被显示器扫描出来,也不能直接测量 GPU pass。HTML 的渲染机会会受可见性和浏览器调度影响,因此实验在页面隐藏或设备丢失时作废。

真实结果怎样读

每种方式先预热 8 次,再运行 5 轮,每轮每种方式 12 个样本,轮内交替顺序,共 60 个样本。场景确定性生成,没有随机种子或随机终止条件。连续路径另丢弃前 10 个回调,保存 60 个间隔。

下表的数字为毫秒,中位数仅按显示精度舍入,原始值与 p10、p90 在 JSON 中保留。分位数统一取排序样本的下标 floor(p × (n−1));偶数样本的中位数取中间较小者,不对两个中间值取平均。

物体数 draw 方式 CPU 编码提交中位数 GPU pass 中位数
64 逐物体 0.0000 0.000000
64 实例化 0.0000 0.000000
1,024 逐物体 0.0000 0.065536
1,024 实例化 0.0000 0.065536
4,096 逐物体 0.1000 0.131072
4,096 实例化 0.0000 0.065536

零值暴露的是分辨率限制。CPU 样本集中在约 0.1 ms 的间隔,GPU 差值大量落在 0.065536 ms 的整数倍上。规范允许时间戳降低精度;本次观察到的量化不能解释成硬件执行没有耗时。

4,096 个物体的 CPU 五轮中位数,逐物体约为 0.1, 0, 0, 0.1, 0 ms,实例化均为 0。整体中位数差异可以报告,但逐轮结果不足以支持稳定的精确加速倍数。将 0.1 除以 0 得到“无限加速”是错误分析。

同组 GPU 的 p10–p90 为:逐物体 0.131072–1.703936 ms,实例化 0–1.245184 ms。尾部波动远大于中位数差异,不能只截取两个中位数宣称稳定两倍。这里的可确认结果是命令数量确实下降、画面一致,以及本次隔离测量出现了上述分布。

4,096 个物体连续绘制的回调间隔中位数,两种方式均约 8.3 ms。逐物体 p10–p90 约 7.6–9.2 ms,实例化约 7.5–9.0 ms。在这个很轻的场景中,回调统计没有显示明显提升;不能从中声称具体显示器刷新率,更不能把它解释成 GPU 耗时 8.3 ms。

上传调用也可以成为成本

draw 数变少以后,实例数据仍要到达 GPU。实验把相同的 32N 字节分两种方式写入同一个缓冲:一次上传全部,或每个实例调用一次 writeBuffer。数据内容、目标字节数相同,只改变调用数量。

1
2
3
device.queue.writeBuffer(instance, 0, data);
// 对照:每次传八个 float32。
device.queue.writeBuffer(instance, i * 32, data, i * 8, 8);

这段代码中目标偏移的单位是字节;TypedArray 的源偏移和数量单位是元素,八个 float32 恰好 32 字节。规范的 writeBuffer 还规定目标范围、用途和四字节对齐等验证条件。把源 size 写成 32 会传入 32 个元素,可能越界,而不是传入 32 字节。

每组采用 5 次上传样本,交替顺序。4,096 个实例的总量为 131,072 字节,一次写入的 CPU 调用时间中位数为 0,多次写入约 0.3 ms;1,024 个实例的多次写入约 0.1 ms,64 个实例无法用这次计时分辨。这里测的是 CPU 调用墙钟时间,排除完成等待,不能把它当作总线带宽测量。

静态实例可以只上传一次。动态实例需要考虑修改范围、CPU 数据生成、缓冲寿命与在途帧。本篇没有实现流式资源管理,也没有据五个短样本推导通用吞吐极限。选择一次连续上传,是当前相同数据布局下可以直接检验的改动。

练习与自检

练习一:定位实例颜色。 一个实例记录 32 字节,颜色从记录内偏移 16 开始。实例编号为 7 时,颜色属性从哪一个字节开始?逐物体 draw 的第四个参数应是多少?

解题要点:地址为 7×32+16=2407\times32+16=240,第四个参数为 7。起始实例编号参与实例属性读取;如果始终用 0,其他记录即使上传正确也不会被读取。

练习二:解释零中位数。 A 的 CPU 中位数为 0.1 ms,B 为 0。能否报告 B 无限快?还缺哪些证据?

解题要点:不能。零是当前计时粒度下的结果。应保留原始样本、分布与多轮结果,增加可分辨负载时仍保持两条路径画质相同。若改变测量为多个 pass 总计,也必须写清新计时区间及其批次重叠效应,不能混用旧口径。

练习三:决定批次边界。 两组四边形使用不同混合状态,但顶点布局相同,能否直接放进本文的一条 draw?如果仅颜色不同呢?

解题要点:一条 draw 使用当前 pipeline 的混合状态,不能直接保留两种不同状态,应拆批次或重新设计等价算法。仅颜色不同可以用实例属性,因为 pipeline 与绑定兼容。透明排序仍是另外的约束。

复跑与导航

累计入口为 examples/computer-graphics/gpu27.html,实例数据与统计函数在 instances27.mjs,GPU 管线与测量在 gpu27.mjs。运行 node examples/computer-graphics/instances27_check.mjs 可检查记录长度、范围与统计;该检查不能替代 GPU 绘制。

通过本地 HTTP 服务打开页面,保持可见,运行完整对照后检查负对照,再用数量选择器显示场景。页面内原始 JSON 是运行结果;文章附带 JSON 是 2026-10-01 的记录,不会随读者运行自动改变。截图为真实浏览器结果,不是示意图。

事实核验位置为 writing-plans/computer-graphics/evidence/research-27.md,数值与浏览器验收记录在同目录。源文件、原始样本与页面素材一起保存,便于判断测量条件是否相同。

系列目录 · 上一篇:软件管线怎样映射为着色器 · 下一篇:实时材质怎样近似离线光照。