计算机图形学 25:一次绘制怎样交给 GPU
第 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 | |
第一行是顶部红色顶点,后两行分别在左下与右下。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 | |
输入 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 视口将它对应到:
设顶部红顶点权重为 r,右下蓝顶点权重为 b,左下绿顶点为 g。由三角形的坐标可直接解出:
将这三个线性权重分别 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 | |
打开 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 回调还可能晚到,所以状态更新要确认回调对应的仍是当前设备。
参考资料与导航
- WebGPU:GPU 接口、画布配置、writeBuffer,设备获取、格式、用途及上传单位。
- WebGPU:错误域与错误处理,异步验证和丢失设备的报告边界。
- WGSL 2026-09-15 草案:position与阶段接口位置,着色器输入输出及坐标含义。
- GPUWeb Explainer:命令编码与提交和设备丢失。说明材料中的旧 API 示例不直接作为代码依据;本篇按当前规范核对。






