WebGPU 落地记录:从 Canvas 2D/WebGL 的绘制瓶颈到 Compute Shader
上个月接手的一个内部监控大屏要展示用户行为轨迹,一天的埋点数据量大概在三十万到五十万个点之间,产品要求能拖动时间轴看任意时段的分布,还要能缩放。我最初的实现很直接:Canvas 2D,一个 requestAnimationFrame 循环里 ctx.arc() 画点。数据量小的时候(几千个点)流畅得很,可一旦切到全天视图,五十万个点画一帧要吃掉四百多毫秒,拖动时间轴基本就是一张张卡住的静态图,谈不上"拖动"两个字。
先查是谁在拖后腿
我先怀疑是自己的聚合逻辑写得太糙,于是把画点之前的分桶、去重、坐标换算全部搬到 Web Worker 里,主线程只负责拿到最终坐标数组之后画图。结果延迟确实降了一部分,但主线程那段还是卡——Performance 面板录一段拖动操作,火焰图里最大的一块不是脚本执行,是 Canvas2D.fill 和 Canvas2D.stroke 这两类调用,累计占了单帧七成以上的时间。原因也不复杂:Canvas 2D 的绘制指令本质上是一条条提交给 CPU 光栅化的,它没有真正意义上的批处理,五十万次 arc 加 fill 就是五十万次独立的绘制工作,浏览器再怎么优化也只能线性地把这些活干完。
换 WebGL 是下一步。用一个点精灵(point sprite)着色器加一次 drawArrays,理论上五十万个点应该能一次提交给 GPU。第一版效果确实好了不少,帧时间从四百多毫秒降到六十毫秒左右,但这还不够——产品后来又提了个需求,要按时间做实时聚合成热力网格,网格的聚合计算原本打算继续放 Worker 里用 JS 算,五十万个点分到一个 200×150 的网格里,光是这层聚合,Worker 那边单次计算就要一百多毫秒,而且每次时间轴一动都要重新算一遍。我盯着这段耗时想了很久:GPU 已经在闲着等我把顶点数据传过去,为什么这种纯并行的累加运算不能也丢给它做?
翻了翻 WebGL 的资料才想起来,这条路在 WebGL2 里是可以走的,但走法很别扭——得把要处理的数据编码进一张纹理,用一个片元着色器"假装"在画一张图,实际上是在读纹理做计算,再把结果渲染到另一张纹理上,最后用 readPixels 把结果读回来当成普通像素解析。这套手法业内叫 GPGPU on WebGL,能用,但每一步都是在借用图形管线的语义去表达一个跟图形毫不相关的计算意图,调试的时候脑子要一直在"这是像素"和"这是数据"之间来回切换。就是这个别扭把我推去看了 WebGPU 的进展——上半年看到 Chrome、Firefox 的支持都已经稳定了一段时间,正好借这个项目试一把。
WebGL 那套 API 是从哪来的
WebGL 本质上是把 OpenGL ES 2.0(后来是 3.0)的 C 语言接口包了一层 JS 皮肤搬进浏览器,这个出身决定了它后来一系列让人别扭的地方。最直接的一点是状态机模式:gl.bindBuffer、gl.bindTexture、gl.useProgram 这些调用不是在传参数,而是在修改一个全局的隐式上下文,后面的绘制调用读的是"当前绑定的是什么",而不是显式传进去的对象。一个稍微复杂点的渲染函数写出来,往往要在开头先把一串绑定操作按正确顺序摆好,中间还得留意会不会被别的模块中途改了绑定状态、导致自己以为绑的是 A 纹理实际读到了 B。这种全局可变状态在小项目里问题不大,项目一大、渲染逻辑分散到多个模块之后,两段互不知情的代码抢着改同一个绑定点,排查起来经常要靠打日志一步步复现当前状态机走到哪一步了。
第二点是计算能力被焊死在图形管线里。WebGL 只有顶点着色器和片元着色器两种可编程阶段,前者处理顶点、后者处理像素,中间夹着光栅化这道浏览器自己完成、你插不进手的固定步骤。这意味着任何脱离"画图"语境的通用并行计算,都得像前面说的那样伪装成纹理读写去完成,能做,但表达力被图形管线的形状限制住了,复杂一点的并行算法很难写清楚。
第三点是数据传输路径也比较绕。每次要往 GPU 送新数据,gl.bufferData/gl.texImage2D 这类调用背后是驱动层去做同步的内存拷贝,没有特别显式的方式告诉浏览器"这批数据我打算复用好几帧、不用每次都当新的处理"。加上 WebGL 的错误处理基本靠 gl.getError() 轮询,很多问题要显式查询才能发现,不像现代 API 那样在对象创建时就能拿到结构化的失败信息。这几点堆在一起,就是我这次想换掉它的直接动机。
WebGPU 的两个入口对象
WebGPU 的设计明显是照着 Vulkan、Metal、Direct3D 12 这类现代图形 API 的思路重写的,第一步是拿到一个 GPUAdapter,代表某一块具体的物理或软件 GPU:
1if (!navigator.gpu) { 2 throw new Error('这个环境不支持 WebGPU'); 3} 4 5const adapter = await navigator.gpu.requestAdapter({ 6 powerPreference: 'high-performance', 7}); 8 9if (!adapter) { 10 throw new Error('拿不到可用的 GPU adapter'); 11} 12 13const device = await adapter.requestDevice();
adapter 只是一个描述能力的句柄,真正能拿去创建缓冲区、着色器、管线的是从它衍生出来的 GPUDevice。这一步分离挺重要:requestAdapter 阶段就能拿到这块 GPU 支持哪些 features、limits(比如最大缓冲区大小、最大工作组数量),可以在正式建设备之前先判断这台设备够不够格跑你打算跑的东西,而不是建到一半才发现某个能力不存在。
WebGPU 把工作分成两条互不干扰的管线:Render Pipeline 负责传统的"顶点进、像素出"这条图形流程,Compute Pipeline 是一条完全独立的通用计算通道,没有光栅化、没有片元这些图形概念,就是纯粹地把一批数据分发给大量并行执行的线程去处理。这条分离在 WebGL 里是不存在的——WebGL2 虽然加了 Transform Feedback 这类变通手段,但计算能力始终挂在图形管线的躯壳上;WebGPU 直接给了计算一条自己的路,这也是我这次真正想要的东西:把热力网格的聚合运算,从"伪装成画图"变成"就是在算数"。
把网格聚合搬到 Compute Shader 里
WebGPU 的着色器语言是 WGSL(WebGPU Shading Language),语法和 GLSL 不同,更接近 Rust 的风格,类型和绑定都要写得比较明确。我把五十万个点的坐标先按原样传进一个存储缓冲区,用一个 Compute Shader 把它们并行地累加进网格计数缓冲区:
1// grid-aggregate.wgsl 2struct Point { 3 x: f32, 4 y: f32, 5}; 6 7@group(0) @binding(0) var<storage, read> points: array<Point>; 8@group(0) @binding(1) var<storage, read_write> gridCounts: array<atomic<u32>>; 9 10const GRID_WIDTH: u32 = 200u; 11const GRID_HEIGHT: u32 = 150u; 12 13@compute @workgroup_size(256) 14fn main(@builtin(global_invocation_id) id: vec3<u32>) { 15 let index = id.x; 16 if (index >= arrayLength(&points)) { 17 return; 18 } 19 20 let p = points[index]; 21 let gx = u32(clamp(p.x, 0.0, 1.0) * f32(GRID_WIDTH - 1u)); 22 let gy = u32(clamp(p.y, 0.0, 1.0) * f32(GRID_HEIGHT - 1u)); 23 let cell = gy * GRID_WIDTH + gx; 24 25 atomicAdd(&gridCounts[cell], 1u); 26}
每个点的坐标已经在 Worker 里归一化到 0 到 1 区间,main 函数里的 id.x 对应的是这次调度里第几个线程在跑,一个线程处理一个点,atomicAdd 保证多个线程同时写同一个网格单元时计数不会互相覆盖丢失。对应的 JS 侧代码:
1const shaderModule = device.createShaderModule({ code: gridAggregateWGSL }); 2 3const pointsBuffer = device.createBuffer({ 4 size: pointsFloat32Array.byteLength, 5 usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, 6}); 7device.queue.writeBuffer(pointsBuffer, 0, pointsFloat32Array); 8 9const gridCountsBuffer = device.createBuffer({ 10 size: GRID_WIDTH * GRID_HEIGHT * 4, // 每格一个 u32 11 usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, 12}); 13 14const computePipeline = device.createComputePipeline({ 15 layout: 'auto', 16 compute: { module: shaderModule, entryPoint: 'main' }, 17}); 18 19const bindGroup = device.createBindGroup({ 20 layout: computePipeline.getBindGroupLayout(0), 21 entries: [ 22 { binding: 0, resource: { buffer: pointsBuffer } }, 23 { binding: 1, resource: { buffer: gridCountsBuffer } }, 24 ], 25}); 26 27const encoder = device.createCommandEncoder(); 28const pass = encoder.beginComputePass(); 29pass.setPipeline(computePipeline); 30pass.setBindGroup(0, bindGroup); 31pass.dispatchWorkgroups(Math.ceil(pointCount / 256)); 32pass.end(); 33 34// 把结果拷到一个可以 CPU 读取的缓冲区 35const readBuffer = device.createBuffer({ 36 size: gridCountsBuffer.size, 37 usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, 38}); 39encoder.copyBufferToBuffer(gridCountsBuffer, 0, readBuffer, 0, gridCountsBuffer.size); 40device.queue.submit([encoder.finish()]); 41 42await readBuffer.mapAsync(GPUMapMode.READ); 43const gridCounts = new Uint32Array(readBuffer.getMappedRange().slice(0)); 44readBuffer.unmap();
这段代码跑起来之后,五十万个点的网格聚合从 Worker 里的一百多毫秒降到了个位数毫秒,dispatchWorkgroups 那一行把工作拆成了大约两千个工作组、每组 256 个并行线程,GPU 里成百上千个执行单元同时在跑这个循环体,而 CPU 侧只是提交了一次调度指令就闲下来了。第一次看到这个耗时数字的时候我又重新跑了一遍确认没有测错——毕竟从"Worker 里跑一百毫秒"直接掉到个位数,落差有点大。
渲染管线画点:另一条独立的路
聚合完的网格数据要画出来,走的是 Render Pipeline,这条管线和上面的 Compute Pipeline 是完全分开创建、分开提交的两套对象,不会互相污染状态:
1// render-grid.wgsl 2struct VertexOut { 3 @builtin(position) position: vec4<f32>, 4 @location(0) intensity: f32, 5}; 6 7@group(0) @binding(0) var<storage, read> gridCounts: array<u32>; 8 9@vertex 10fn vs_main(@builtin(vertex_index) vIdx: u32, @builtin(instance_index) cellIdx: u32) -> VertexOut { 11 let gx = f32(cellIdx % 200u); 12 let gy = f32(cellIdx / 200u); 13 let cellX = (gx / 200.0) * 2.0 - 1.0; 14 let cellY = (gy / 150.0) * 2.0 - 1.0; 15 16 var corners = array<vec2<f32>, 6>( 17 vec2<f32>(0.0, 0.0), vec2<f32>(0.01, 0.0), vec2<f32>(0.0, 0.0133), 18 vec2<f32>(0.01, 0.0), vec2<f32>(0.01, 0.0133), vec2<f32>(0.0, 0.0133), 19 ); 20 let offset = corners[vIdx]; 21 22 var out: VertexOut; 23 out.position = vec4<f32>(cellX + offset.x, cellY + offset.y, 0.0, 1.0); 24 out.intensity = min(f32(gridCounts[cellIdx]) / 500.0, 1.0); 25 return out; 26} 27 28@fragment 29fn fs_main(in: VertexOut) -> @location(0) vec4<f32> { 30 return vec4<f32>(in.intensity, 0.2, 1.0 - in.intensity, 0.85); 31}
这里用 @builtin(instance_index) 让每个网格单元变成一个独立的实例,一次 draw 调用同时画出三万个格子(200×150),顶点着色器里根据实例索引算出这个格子在屏幕上的位置,片元着色器按格子里点的密度决定颜色深浅。JS 侧创建这条管线和上面的计算管线结构很像,多了一个渲染通道的目标配置:
1const renderPipeline = device.createRenderPipeline({ 2 layout: 'auto', 3 vertex: { module: renderShaderModule, entryPoint: 'vs_main' }, 4 fragment: { 5 module: renderShaderModule, 6 entryPoint: 'fs_main', 7 targets: [{ format: navigator.gpu.getPreferredCanvasFormat() }], 8 }, 9 primitive: { topology: 'triangle-list' }, 10}); 11 12const renderEncoder = device.createCommandEncoder(); 13const renderPass = renderEncoder.beginRenderPass({ 14 colorAttachments: [{ 15 view: context.getCurrentTexture().createView(), 16 loadOp: 'clear', 17 clearValue: { r: 0.05, g: 0.05, b: 0.08, a: 1 }, 18 storeOp: 'store', 19 }], 20}); 21renderPass.setPipeline(renderPipeline); 22renderPass.setBindGroup(0, renderBindGroup); 23renderPass.draw(6, GRID_WIDTH * GRID_HEIGHT); 24renderPass.end(); 25device.queue.submit([renderEncoder.finish()]);
一次 draw(6, 30000) 就把三万个格子全画出来了,这个数字级别的实例化绘制在 WebGL 里也能做(ANGLE_instanced_arrays 或 WebGL2 原生的 drawArraysInstanced),所以画格子这一步本身两边差距不算大,真正的差别在于上一步的聚合运算——WebGL 没有干净的方式表达"我要在不画图的情况下让 GPU 并行处理三十万条数据",WebGPU 用 Compute Pipeline 把这件事直接说清楚了。
报错体验也换了一套
写第一版 WGSL 的时候我把 atomicAdd 写成了普通的 +=,运行起来画面完全是空的,一开始还以为是坐标算错了。这要是放在 WebGL 时代,多半得靠 gl.getError() 一路轮询,或者干脆在着色器里插几行调试用的颜色输出去二分排查。WebGPU 这边不太一样,GPUDevice 有一个专门的错误事件可以订阅:
1device.addEventListener('uncapturederror', (event) => { 2 console.error('WebGPU 未捕获错误:', event.error.message); 3}); 4 5// 也可以在某一段操作前后主动捕获,定位更精确 6device.pushErrorScope('validation'); 7const badPipeline = device.createComputePipeline({ /* ... */ }); 8const error = await device.popErrorScope(); 9if (error) { 10 console.error('创建管线时的校验错误:', error.message); 11}
那次的报错信息里直接写着 array<atomic<u32>> 类型的字段不允许用非原子写法赋值,连出错的绑定位置都标出来了,改一行代码就好了。这种结构化的报错在 WebGL 里基本没有——gl.getShaderInfoLog() 能拿到编译错误文本,但运行时的状态错误(比如绑定了错误类型的纹理)经常是安安静静地画出一片黑,得靠经验去猜是绑定错了还是数据本身有问题。WebGPU 把整条创建和提交路径都做了显式校验,出错信息里通常会带上是哪个 binding、哪个 pipeline 出的问题,调试这次聚合着色器的过程比我预想的顺利不少。
数据传输上的具体差别
除了计算能力,WebGPU 在数据搬运这块也比 WebGL 明确不少。GPUBuffer 创建的时候就要声明用途(STORAGE、VERTEX、COPY_DST 这些标志位可以组合),驱动能提前知道这块内存打算怎么用、要不要频繁更新,从而选更合适的内存布局,不像 WebGL 的 bufferData 只能靠一个 usage 提示(STATIC_DRAW/DYNAMIC_DRAW)去猜。存储缓冲区(storage buffer)这个概念本身也是 WebGPU 独有的:着色器可以直接以数组形式读写一大块结构化数据,不需要像 WebGL 那样把数据编码进纹理的 RGBA 通道再解码出来——上面聚合网格那个例子如果放在 WebGL 里做,gridCounts 得先想办法编码成一张纹理的像素值,读取和写入两头都要处理精度和通道对齐的问题,WGSL 里一个 array<atomic<u32>> 就把这件事说完了。
命令录制的方式也不一样。WebGL 每调一次 gl.draw* 就是一次立即执行的调用,浏览器要在这一刻把状态机当前的绑定情况全部校验一遍;WebGPU 是先用 GPUCommandEncoder 把一串操作录制成一个命令缓冲区,最后一次性 device.queue.submit() 提交,校验和状态整理可以提前在录制阶段做完,真正提交的时候是一整包指令打包发给驱动,减少了运行时来回确认状态的开销。这也是为什么同样是画大量物体,WebGPU 下 draw call 数量高一些也不那么容易成为瓶颈——它已经不是"每次调用都要现场核对一遍状态"这套模型了。
浏览器里现在能不能用
Chrome 从 113 版起就把 WebGPU 稳定下来了,这两三年桌面端一直是最先跟进新特性的一个,Windows、macOS、ChromeOS、Linux 都覆盖到了;Android 上 Chrome 也支持,但部分老旧机型的驱动兼容性还是会出些奇怪的问题,我这次在一台两年前的中端安卓机上测试,Compute Shader 部分工作组数量开大了会直接丢帧甚至画面撕裂,不确定是驱动的锅还是设备本身的显存限制,还没细查。Firefox 这边今年到 141 版才把 WebGPU 默认打开,之前一直是需要手动翻 about:config 开 flag 的状态,141 之后 Windows 和 macOS 是默认开启,Linux 目前我看还是需要手动开,官方说明是驱动生态更碎,稳定性还在打磨。Safari 是三家里进度最慢也最保守的一个:18 版随 macOS Sequoia 把 WebGPU 作为默认能力上了,iOS 18 同步跟进,但那时候一些边角特性(比如某些 timestamp-query 相关的调试能力)还没补齐;今年的 26 版进一步把这些缺口填了不少,日常渲染和基础 Compute Shader 场景已经能放心用,只是一些依赖特定 feature 探测的高级用法我还是会先 adapter.features.has() 判断一下再决定要不要走那条分支。总体上桌面三大浏览器已经算是把 WebGPU 落到了能用的程度,移动端尤其是安卓中低端机型上,我目前的态度还是观望,这次的监控大屏也只针对内部 PC 端用户开放,没有考虑移动端适配。
这次上线前我也没打算赌浏览器支持率,入口那段做了很朴素的能力探测和回退:
1async function createRenderer(canvas) { 2 if (navigator.gpu) { 3 const adapter = await navigator.gpu.requestAdapter(); 4 if (adapter) { 5 const device = await adapter.requestDevice().catch(() => null); 6 if (device) { 7 return createWebGPURenderer(canvas, device); 8 } 9 } 10 } 11 console.warn('WebGPU 不可用,回退到 WebGL 渲染路径'); 12 return createWebGLRenderer(canvas); 13}
requestAdapter 和 requestDevice 都可能拿到 null 或者直接被拒绝,比如设备驱动黑名单命中、或者是在一个禁用了硬件加速的环境里,这些情况都不算异常,是这套 API 设计里本来就会出现的正常分支,直接判空走 WebGL 兜的那条老路就行,两套渲染器我在项目里保留了同一份数据接口,上层组件不需要关心具体用的是哪一条路径。
生态在跟进,但不是所有场景都值得换
这次踩过一遍之后我去翻了翻常用库的进展,发现已经不只是我一个人在这么折腾了。three.js 这两年一直在推 WebGPURenderer,配合它新引入的 TSL(Three.js Shading Language)可以写一套着色逻辑,同时编译到 WGSL(跑 WebGPU)和 GLSL(跑 WebGL 兜底),我看了下示例仓库,粒子系统、体积渲染这类原本要手写 GPGPU 技巧的场景,现在用 TSL 写起来清爽了不少。TensorFlow.js 的 WebGPU backend 也已经不是实验状态,官方基准里不少模型的推理速度比 WebGL backend 快出一截,尤其是矩阵乘法密集的模型——这背后的道理跟我这次遇到的差不多,卷积、矩阵乘法这类运算本质上就是大量独立的并行计算,Compute Shader 天然比"伪装成纹理读写"更贴近这类运算的形状。
但我不打算把项目里所有 Canvas/WebGL 的地方都换掉。团队另一个页面只是画几十个折线图上的数据点,量级小、交互也简单,WebGPU 那一整套 adapter/device/pipeline/bindGroup 的初始化成本对这种场景纯粹是负担,Canvas 2D 三行代码能搞定的事没必要引入这么重的一套 API。还有几个面向外部客户的落地页,浏览器版本分布杂得多,不排除还有一批用旧版 Safari 或者国产浏览器内核的访客,这类对兼容性要求高、访问量又大的页面,我短期内还是会守着 WebGL 或者干脆 Canvas 2D,等 WebGPU 的覆盖率再往上走一走。判断标准其实很朴素:数据量是不是大到 CPU 端处理或者逐点绘制会真的卡住,计算逻辑是不是天然可以拆成大量独立的并行任务——两条都占,才值得为它多背一套新 API 的学习和维护成本。
那个监控大屏上线之后,产品拖动时间轴、切换聚合粒度基本都能在一帧之内响应过来,五十万个点从卡成幻灯片到能顺畅交互,这中间省下来的时间基本都花在了把聚合计算从"骗着 GPU 帮我画图"改成"直接让 GPU 帮我算数"这一步上。