第 11 篇的软件管线在 CPU 上依次处理顶点、覆盖、深度和颜色。换成 GPU 后,JavaScript 不再逐像素执行这些循环,但仍要明确告诉设备:顶点放在哪里,怎样解释这些字节,运行哪个着色器,结果写到哪张纹理。

先画一个三角形。三个顶点分别携带红、绿、蓝颜色,内部由管线插值。除亲眼检查画面,还读回一个内部像素与一个背景像素,再故意遗漏缓冲用途,观察错误怎样返回。这样可以把“提交了命令”“设备完成了数据写入”和“页面确实出现结果”分开验证。

先运行这一个页面

打开 WebGPU 三角形实验。点击“初始化 / 恢复”,再点击“重绘并读回像素”。页面必须显示彩色三角形及像素检查结果;不支持 WebGPU 时显示原因,不用静态截图替代。

实际验证日期为 2026-09-20。Codex 内嵌浏览器报告 Chrome/153.0.0.0,安全上下文为 true,适配器 vendor 为 apple、architecture 为 metal-3,首选画布格式为 bgra8unorm。设备与描述字段为空,不能从这些空字段推断具体 GPU 型号,也不能把浏览器的兼容性 User-Agent 当操作系统硬件清单。

本篇只使用一个 512×512 画布、一个三角形和单次绘制,没有深度缓冲、纹理采样、动画或随机实验。下面的读回检查会等待 GPU 数据,属于验证开销,不是实时渲染的推荐逐帧做法,也没有由它报告帧率。

JavaScript 负责准备什么

页面使用 type="module" 脚本,不需要打包器。const 声明不再重新赋值的绑定,let 保存会变化的当前设备状态。Float32Array 将数字存成连续的 32 位浮点数据;普通 JavaScript 数组本身并不是已经上传的 GPU 顶点缓冲。

设备请求和读回返回 Promise。async 函数中的 await 让后续代码等待该 Promise 完成,并不代表浏览器线程持续忙等。按钮处理器通过一个 busy 状态禁止重复操作,避免初始化还没完成就释放设备,或两个绘制同时共享错误域。

运行关系可以按职责分开:

对象或动作 本例职责
adapter 与 device 选择可用适配器,取得创建资源与提交工作的逻辑设备
buffer 与 writeBuffer 保存顶点字节,把 CPU 数组内容写入设备资源
shader 与 pipeline 定义顶点、片元计算及资源解释规则
command encoder 与 render pass 记录本次清屏、绑定与绘制命令
queue.submit 提交已完成编码的命令缓冲

requestAdapter() 可能得到 null;requestDevice() 也可能拒绝。设备创建成功不保证以后一直可用,所以还要监听 device.lost。画布上下文则通过 canvas.getContext('webgpu') 取得,配置时显式指定 device 与格式。这些对象不是“一个 GPU 对象”的不同别名。

本页选择浏览器首选格式,配置用途为 RENDER_ATTACHMENT | COPY_SRC。前者允许渲染写入,后者支持本实验的像素读回。显式设置 usage 时不能假定默认的 RENDER_ATTACHMENT 会自动补上。

60 字节怎样变成三个顶点

每个顶点包含二维位置和三维颜色,共五个 float32,即 20 字节。三个顶点的 CPU 数据为:

1
2
3
4
5
const vertices = new Float32Array([
0, 0.75, 1, 0, 0,
-0.75, -0.75, 0, 1, 0,
0.75, -0.75, 0, 0, 1
]);

第一行是顶部红色顶点,后两行分别在左下与右下。GPU buffer 大小取 vertices.byteLength,即 60;用途为 VERTEX | COPY_DST。VERTEX 说明绘制可以按顶点读取,COPY_DST 允许 queue.writeBuffer 写入。

这些用途不是优化建议,而是资源合法使用的约束。只有 VERTEX 的缓冲即使大小完全正确,也不能通过 writeBuffer 上传。另一方面,用途正确也不能弥补布局错误:设备不会从内容猜测哪两个浮点数是位置。

管线声明 arrayStride=20;位置属性从偏移 0 读取 float32x2,对应 shaderLocation 0;颜色从偏移 8 读取 float32x3,对应 shaderLocation 1。于是第 i 个顶点的位置起点为 20i,颜色起点为 20i+8。偏移与步长的单位都是字节,不能把“五个浮点数”误写成步长 5。

writeBuffer 的目标偏移也以字节为单位。若使用其可选 dataOffset 和 size,TypedArray 情况下这两个源参数以元素为单位。源元素单位与目标字节单位不同,是容易出现四倍偏差的地方;本例省略源范围,上传整个数组,并保持字节数与目标偏移满足四字节约束。

着色器接口与颜色输出

WGSL 顶点函数接收两个显式 location,返回裁剪空间位置与颜色:

1
2
3
4
5
6
7
8
9
10
11
struct Output {
@builtin(position) position: vec4f,
@location(0) color: vec3f
}
@vertex fn vs(@location(0) p: vec2f,
@location(1) c: vec3f) -> Output {
var out: Output;
out.position = vec4f(p, 0.5, 1);
out.color = c;
return out;
}

输入 location 0 与输出 location 0 可以表示不同内容,它们属于不同接口。本例顶点输入 location 0 是位置,顶点输出 location 0 是传给片元的颜色。管线中的 shaderLocation 只对应顶点输入布局,不能拿它解释所有阶段的 location。

裁剪位置的 w 固定为 1,所以透视除法不改变 x、y,深度为 0.5,位于 WebGPU 的零到一深度范围内。三角形内部默认进行透视校正插值;由于所有 w 相等,本例等价于屏幕重心插值。这是一个特例,不能由本例省掉下一步透视场景所需的正确属性处理。

片元入口返回 @location(0) vec4f,对应唯一颜色附件。顶点色按线性 RGB 插值,片元函数再执行第 04 篇的分段 sRGB 编码,alpha 固定为 1。画布使用非 sRGB 的 unorm 格式与默认视图,因此这里手动编码一次。若以后改用 sRGB 附件视图,就必须相应调整这一步,避免重复编码。

顶点输出的 @builtin(position) 是裁剪位置;若片元函数接收同名内建输入,它表示 framebuffer 坐标,像素中心带 0.5 偏移。名称相同不代表空间相同。当前片元函数没有用 position 计算颜色,读回参考在 CPU 独立计算像素中心。

一次绘制的提交边界

初始化时创建 shader module,检查编译信息,再创建 render pipeline。管线描述绑定布局、顶点格式、入口函数、triangle-list 拓扑和输出格式。本例不需要 uniform 或纹理绑定,所以 layout 可以使用 auto;它并不意味着所有资源都自动找到。

重绘时重新取得当前 canvas texture。不能长期保存上次呈现所用的纹理,并假定它仍是下一次输出目标。随后创建 command encoder,开始 render pass,指定颜色附件、黑色清屏值,以及 loadOp:'clear'storeOp:'store'

pass 内依次设置 pipeline 与顶点 buffer,调用 draw(3),最后 end()。这时仍是命令编码。encoder.finish() 生成 command buffer,queue.submit([commandBuffer]) 才提交。浏览器、驱动和 GPU 处理后续执行与呈现,JavaScript 不直接安排每个硬件线程。

本例在同一个 encoder 中将颜色纹理复制到读回 buffer,再提交。读取使用 MAP_READ,复制目的使用 COPY_DST。每行 512×4=2048 字节,恰好满足纹理到缓冲复制的 256 字节行距对齐。其他宽度不能盲目照抄这个无填充布局。

mapAsync 完成后读取映射内容,再 unmap 并销毁临时读回缓冲。这确认该次复制产生的数据可供 CPU 读取;只测 submit 调用前后的时间,主要得到 CPU 提交开销,不能当 GPU 执行时间。页面截图与交互检查则另外确认用户可见的画面。

用一个内部像素检查整个数据链

像素 (256,256) 的中心坐标是 (256.5,256.5)。512×512 视口将它对应到:

x=2256.55121,y=12256.5512.x=2\frac{256.5}{512}-1,\qquad y=1-2\frac{256.5}{512}.

设顶部红顶点权重为 r,右下蓝顶点权重为 b,左下绿顶点为 g。由三角形的坐标可直接解出:

r=y+0.751.5,b=1r+x/0.752,g=1rb.r=\frac{y+0.75}{1.5},\qquad b=\frac{1-r+x/0.75}{2},\qquad g=1-r-b.

将这三个线性权重分别 sRGB 编码、乘 255 并四舍五入,参考值是 (187,137,137,255)。真实 GPU 读回同样得到 (187,137,137,255),背景 (0,0) 得到 (0,0,0,255)。首选格式是 BGRA 时,代码先交换红蓝通道,再比较 RGBA,不能把存储顺序错误当成着色器插值错误。

检查允许每通道相差不超过 1,以容纳浮点及量化差异。顶部、左右底角与内部渐变也在真实画面中确认。不过一个内部像素加一个背景像素不能证明所有边界覆盖正确;它验证的是当前三角形的资源链、内部插值和输出编码。没有由此宣称不同 GPU 与软件管线逐像素完全相同。

错误为什么不一定抛在当前那一行

“测试缺少 COPY_DST”按钮新建一个只有 VERTEX 用途的四字节 buffer,再尝试 writeBuffer。操作包在 validation error scope 中。本机实际返回 GPUValidationError,消息明确指出 Vertex 用途未包含 CopyDst;正常资源没有被替换,之后可以继续重绘。

WebGPU 有同步异常、异步验证错误和设备丢失等不同通道,不能只写一个同步 try/catch 就认为覆盖全部失败。已知会出错的检查使用 pushErrorScope('validation')await popErrorScope();未捕获错误另外显示到实验记录。当前规范允许错误域返回其捕获的任一错误,不保证“总是第一个”。

本次正常绘制的错误域返回 null,仍另外检查读回值和设备状态。设备丢失后可能不再报告验证错误,所以 null 本身不是成功证据。

点击“释放本实验设备”会真实调用本页逻辑设备的 destroy。实测 device.lost 的 reason 为 destroyed,绘制按钮被禁用,页面提示重新初始化;恢复后重新请求设备、创建 buffer 与 pipeline,读回再次通过。这是主动释放的受控测试,不是假称发生了驱动崩溃。

适配器空值则用明确标注的复选框注入,实测显示“模拟:适配器不可用”,取消后可恢复。此次环境确实有可用适配器,不能把模拟分支写成实际硬件不支持;真实 navigator.gpu 缺失、requestDevice 拒绝的处理存在,但没有在这台支持设备上伪造已触发记录。

复跑与练习

仓库入口为 examples/computer-graphics/gpu25.html,文章同名素材目录保存可直接访问的相同页面。可在仓库根目录运行:

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

打开 http://127.0.0.1:8772/gpu25.html,按初始化、重绘、资源错误、释放、恢复的顺序操作。再勾选模拟空适配器、初始化,取消勾选后恢复。每一步查看状态与记录,不只判断画布是否仍留着上一帧。

练习一:若把 arrayStride 从 20 写成 16,为什么 buffer 大小 60 仍不能保证正确?第二个顶点的位置将从哪个字节开始读取?

答案:布局决定每次顶点取数的起点。错误步长会从第 16 字节开始读取第二个位置,即第一顶点颜色末尾与下一顶点位置之间,而正确起点是第 20 字节。容量足够只代表访问范围可能合法,不代表语义正确;API 验证不一定能识别这样的错误。

练习二:宽度改成 500,四通道八位读回的最小对齐行距是多少?CPU 取像素 (x,y) 时应怎样寻址?

答案:紧密行大小为 2000,向上取 256 的倍数得到 2048。偏移为 y×2048+x×4,不能仍用 (y×500+x)×4。映射数组可能包含每行填充,填充不属于图像像素。

练习三:为什么释放设备后不能仅把按钮重新启用、复用原 pipeline?

答案:资源与管线属于原逻辑设备。destroy 后这些资源不能因 UI 状态变化恢复,必须重新请求设备并创建其资源。旧设备的异步 lost 回调还可能晚到,所以状态更新要确认回调对应的仍是当前设备。

参考资料与导航

系列目录 · 上一篇:渲染验证 · 下一篇:软件管线怎样映射为着色器(待完成)。