AIGC标识 WebGPU 落地实录:把 Vercel Labs 的 vgpu 拆开看,以及我在验证环节栽的六个跟头

一次真实的上手记录。所有代码片段来自实际运行的文件,所有数字来自本机实测, 说错的地方也如实标出来(文末有一处对我自己前期判断的更正)。

0. 先说结论

事情起因是听说有个"很厉害的 WebGPU 框架叫 vgpu"。查证下来是 vercel-labs/vgpu (vgpu.sh,MIT,作者 Matias Gonzalez),npm 上是 vgpu@0.5.0。

但"框架"这个词容易让人误会。它不是 Three.js / Babylon / Cesium 那种东西,而是着色器优先的工具库。 我把它的导出清单拉出来看,实际长这样:

vgpu             init initFromDevice effect draw compute frame frameLoop
                 surface target texture sampler storage pingPong pingPongStorage
                 uniforms clock timer visibility bundle geometry Geometry Uniform VGPUError

vgpu/scene       14 种几何体 + fullscreenQuad · 4 种材质 · 环境光/平行光 · scene/group/mesh
                 透视/正交相机 · 轨道控制器

一句话概括它的设计取向:没有隐式状态。没有全局 uniform,没有隐藏的场景图遍历, 没有"引擎帮你做决定"。时间来自 clock(gpu),分辨率来自 target.size, 每一次 pass、每一次 clear、每一次 draw 都是你亲手写的。

这个取舍决定了它适合什么、不适合什么,后面细说。

1. 从 5 行 WGSL 开始

官方示例库里最朴素的那个叫 gradient(一共 3 个文件)。整个着色器:

@fragment
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
  let vignette = smoothstep(1.2, 0.2, distance(uv, vec2f(0.5)));
  return vec4f(uv.x, uv.y, 0.46 + 0.16 * vignette, 1.0);
}

配套的宿主代码(renderer.ts,整个文件就这么长):

import { effect, frameLoop, init, surface } from 'vgpu';
import fragment from './shader.wgsl';

export async function createRenderer(canvas: HTMLCanvasElement) {
  const gpu = await init();
  try {
    const output = surface(gpu, canvas, { dpr: [1, 2] });
    const shader = effect(gpu, fragment);
    frameLoop(gpu, (currentFrame) => currentFrame.pass(output, shader));
    return { dispose: () => gpu.dispose() };
  } catch (error) {
    gpu.dispose();
    throw error;
  }
}

这里有几个值得注意的点:

.wgsl 是作为模块被 import 的,不是当字符串读进来。vgpu 提供 Vite / webpack 插件做解析, 着色器之间可以 import { hash2 } from "@vgpu/wgsl-std/hash" 互相引用, build 时被解析成一份普通 WGSL。绑定名、类型、布局由反射自动维持正确,不需要手写 bind group layout。

init() 只调一次,返回一个 Gpu。之后每个入口都把 gpu 当第一个参数。 这就杜绝了"某个模块偷偷持有全局设备"这类问题。

frameLoop 的每一帧是显式的。currentFrame.pass(output, shader) —— pass 到哪、画什么,写在同一行里。 第一次看到会觉得啰嗦,但它换来的是:任何一帧的 GPU 状态都能在代码里顺着读出来。

2. set() 的三条规矩

vgpu 按 WGSL 里声明的名字反射出绑定,然后你用同名 key 写值:

struct Params { time: f32, texel: vec2f }
@group(0) @binding(0) var<uniform> params: Params;
const gradient = effect(gpu, SHADER, {
  set: { params: { time: 0, texel: canvasSurface.texelSize } },
});

canvasSurface.onResize(() => {
  gradient.set({ params: { texel: canvasSurface.texelSize } });
});

frameLoop(gpu, () => {
  gradient.set({ params: { time: time.time } });   // 每帧只写变化的那一个
  ...
});

三条规矩,踩过就记住了:

  1. 第一次 set() 必须把该给的全给上。 官方文档明确说绑定没设过会直接报 VGPU-R1-BINDING-NEVER-SET。 而且 vgpu 不保留 uniform 的可读副本 —— 所以做动画时第一帧要给 [起始值, 结束值] 两个关键帧, 之后它才自己记得住。
  2. set() 是立即写入,不是攒着。 所以循环里只写真正变的东西,别每帧把整个结构体重写一遍。
  3. 尺寸类参数属于 resize 回调,不属于每帧循环。 texelSize、aspect 这类东西每帧写一次纯属浪费。

3. 从 5 行到 5 个 pass:拆一个完整的 bloom 链

gradient 只能让你知道 API 长什么样。真正能看出设计取舍的,是 raymarched-fractal (光线步进的 Sierpinski 四面体)—— 它有完整的 HDR bloom:

export function renderScene(currentFrame, scene, output, orbit) {
  const { effects, targets } = scene;
  effects.scene.set({ params: orbit });
  currentFrame.pass({ target: targets.scene, clear: CLEAR }, (pass) => pass.draw(effects.scene));
  currentFrame.pass({ target: targets.bloomA, clear: CLEAR }, (pass) => pass.draw(effects.brightPass));
  currentFrame.pass({ target: targets.bloomB, clear: CLEAR }, (pass) => pass.draw(effects.blurH));
  currentFrame.pass({ target: targets.bloomA, clear: CLEAR }, (pass) => pass.draw(effects.blurV));
  currentFrame.pass({ target: output, clear: CLEAR }, (pass) => pass.draw(effects.composite));
}

五趟:场景 → 亮部提取 → 横向模糊 → 纵向模糊 → 合成。几个细节很有意思:

target 是靠 set() 互相连起来的。 没有"渲染图"这种对象,一个 effect 的输入就是另一个 effect 的输出 target:

effects.brightPass.set({ src: targets.scene });          // 读场景
effects.blurH.set({ src: targets.bloomA, blur: { texelSize: targets.bloomA.texelSize } });
effects.composite.set({ scene: targets.scene, bloom: targets.bloomA });

模糊是拿同一个 WGSL 跑两遍,只是 direction 不同:

blurH: effect(gpu, blurWgsl),
blurV: effect(gpu, blurWgsl),      // 同一个 shader,两个 effect 实例

中间格式是 rgba16float,不是默认的 8bit —— 因为 HDR 亮部要在 1.0 以上才留得住, 这是 bloom 能不能出效果的前提。整个 bloom 链固定按 360 像素高计算,避免大屏上爆显存。

最值得学的是 compile() 预热:

const results = await Promise.allSettled([
  () => effects.scene.compile(targets.scene),
  () => effects.brightPass.compile(targets.bloomA),
  () => effects.blurH.compile(targets.bloomB),
  () => effects.blurV.compile(targets.bloomA),
  () => effects.composite.compile({ colors: [output.format] }),
].map((compile) => Promise.resolve().then(compile)));
const failed = results.find((r) => r.status === 'rejected');
if (failed) throw failed.reason;

vgpu 的管线是首次使用时才编译的。如果不预热,用户看到的第一帧就要卡在五个管线的编译上。 官方示例的做法是:加载阶段并行编译,全部 settle 之后把第一个失败抛出去 —— 既拿到了并行, 又没有吞掉错误。这个小模式我在几个示例里反复见到。

resize 时的 target 重建,带失败回滚:

export function replaceTargets(gpu, scene, size) {
  const previous = scene.targets;
  const next = createTargets(gpu, size);
  try {
    bindTargets(scene.effects, next);          // 把 effects 重新接到新 target 上
  } catch (error) {
    quietly(() => bindTargets(scene.effects, previous));   // 失败就接回去
    quietly(() => destroyTargets(next));
    throw error;
  }
  scene.targets = next;
  destroyTargets(previous);
}

我一开始觉得这些示例"防御性代码写得有点多",直到我自己写画廊切换逻辑时才发现: GPU 资源的生命周期管理就是这类库最容易出事的地方,示例把坑都填好了,是给你抄的。

4. compute 才是正经红利:拆一个 Navier-Stokes 流体

fluid 那个示例是我认为最能说明"WebGPU 到底比 WebGL 强在哪"的一个 —— 它跑的是压力投影流体解算,用 compute shader 做的。在 WebGL 时代这东西只能靠片元着色器硬凑。

资源分配部分:

const GRID_SIZE = [128, 72] as const;
const DYE_SIZE = [GRID_SIZE[0] * 4, GRID_SIZE[1] * 4] as const;   // 512 × 288

const velocity   = pingPongStorage(gpu, CELLS * 8);
const dye        = pingPongStorage(gpu, DYE_CELLS * 16);
const pressure   = pingPongStorage(gpu, CELLS * 4);
const divergence = storage(gpu, CELLS * 4, 'read-write');
const curl       = storage(gpu, CELLS * 4, 'read-write');

pingPongStorage 返回 { read, write, swap() } —— 迭代解算的标准套路,库直接给你封装好了。

每一个解算步骤是一次 set() + 一次 dispatch():

p.advectVelocity
  .set({ input: dynamic, src: fluid.velocity.read, dst: fluid.velocity.write })
  .dispatch(16, 9);
fluid.velocity.swap();

dispatch(16, 9) 是工作组数量(16×9 个组,每组 8×8 线程 = 128×72,正好盖住网格)。

整个 stepFluid() 的顺序是教科书写法: 半拉格朗日平流 → 计算旋度 → 涡量约束(把平流抹掉的小尺度旋转补回来)→ 散度 → 3 次压力迭代(衰减系数首次 0.8、其后 1)→ 投影 → 平流染料。

// Confinement restores the small rotating details lost by semi-Lagrangian advection.
p.curl.set({ velocity: fluid.velocity.read, curl: fluid.curl }).dispatch(16, 9);

这就是 WebGPU 和 WebGL 的真实差距所在。 不是"画得更快",而是"能算的东西不一样了": 共享内存、存储缓冲、任意读写的 compute 通道,在 WebGL 里根本不存在。

我数了一下,这个流体整个 13 个文件、二十几 KB 源码,包含一个可交互的流体解算器。 放在 WebGL 时代,这是要写半个月的东西。

5. 3D 场景路径:两趟渲染,以及那个逃生舱

environment-map 展示了正经的 3D:一张 360° 等距柱状图,同时当背景和镜面金属立方体的反射源。

深度必须挂在离屏 target 上,所以标准结构是两趟:

const hdr = target(gpu, { size: output.size, format: 'rgba16float', depth: true });  // 离屏带深度
const cube = draw(gpu, { shader: metalWgsl, geometry: geometry(gpu, box({ size: 1.25 })) });

// 第一趟:3D 场景画进离屏 HDR
currentFrame.pass({ target: scene.hdr, clear: [0, 0, 0, 0] }, (pass) => pass.draw(scene.cube));
// 第二趟:后处理画到画布
currentFrame.pass({ target: output }, (pass) => pass.draw(scene.present));

draw() 是顶点路径(自带顶点着色器 + geometry),effect() 是全屏片元路径。这个区分贯穿整个库。

那个环境贴图的烘焙过程挺有看头:2048×1024,8 级 mip,每一级都做横竖两趟模糊, 而且横向模糊带一个 equirect 补偿系数(equirect_compensation)—— 因为等距柱状投影在极地附近 纹素拉伸得厉害,不补偿就会糊成一团。

然后是逃生舱。 烘焙 mipmap 需要 copyTextureToTexture,vgpu 没包这个,于是它直接下探到原始 WebGPU:

const encoder = gpu.gpu.createCommandEncoder();          // gpu.gpu 就是原始 GPUDevice
encoder.copyTextureToTexture(
  { texture: source.color.gpu },                          // .gpu 是原始 GPUTexture
  { texture: env.gpu, mipLevel: level },
  [source.size[0], source.size[1], 1],
);
gpu.gpu.queue.submit([encoder.finish()]);

每一层都留着 .gpu 出口。 这个设计我认为比"包得严严实实"要成熟 —— 封装不到位的地方,用户自己能下去,不用等库更新。

注意 vgpu 自己的资源描述用的是字符串用法,跟原生 WebGPU 的位标志不一样:

gpu.device.createTexture({
  kind: '2d',
  size: [...ENV_SIZE],
  format: HDR_FORMAT,
  mipLevelCount: 8,
  usage: ['texture_binding', 'copy_dst'],     // 不是 GPUTextureUsage.TEXTURE_BINDING | ...
});

6. 我在验证环节栽的六个跟头

前面都是"读代码读出来的"。但跑起来才算数,而这一段才是本文最想写的部分。

我搭了个画廊:一个页面切换 9 个效果(7 个官方 + 2 个我手写的), 然后用无头 Chrome 逐个点击、截图、做客观判定。六类坑全是这么撞出来的。

(其中第六个是最后一节,也是最扎心的一个:它是我全套自动化验证都通过之后,被人一眼看出来的。)

坑一:像素统计说"有内容",其实那是我的报错卡片

第一次跑 earth,脚本输出:

 1. Earth    亮度 20.5  方差 32.9  纯黑 47.4%  色数 221  ✓ 有内容

"方差 32.9、色彩数 221、只有一半是纯黑"—— 看起来完全是"星空背景 + 一颗行星"该有的样子。 我差点就信了。

但我在页面里埋了个自检口 window.__vgpuLab,脚本顺手读了一下:

{"mountedId": null, "phase": "failed",
 "errors": ["earth: TypeError: Failed to fetch dynamically imported module: .../renderer.ts"]}

模块压根没加载成功,那张"有内容"的截图是我自己的错误提示卡片。

这个教训比它看起来更重要:"画面不是空的"和"画面是对的"是两件事。 像素统计只能证明"有东西被画出来了",证明不了"画的是那个东西"。 后来我把判定改成两条同时成立才算过:

  • 像素层面:亮度、方差、纯黑占比、色彩数不是空的
  • 状态层面:断言"点击的 id == 页面自检口里真正挂载的 id"

坑二:两张截图逐字节相同,因为 Vite 在偷偷刷新页面

修好 lil-gui 之后重跑,出现了更诡异的现象:

 1. Earth              亮度 19  方差 32.1  纯黑 52.2%  色数 209  ✓
 2. Interactive Fluid  亮度 19  方差 32.1  纯黑 52.2%  色数 209  ✓     ← 连小数位都一模一样
 3. FFT ocean surface —— 点击失败: Execution context was destroyed, most likely because of a navigation.

切到第二个示例,截图和第一个逐字节相同;切到第三个,连接直接断了。

我去翻 dev server 的日志,一行字把案情说清了:

16:34:03 [vite] (client) page reload shots/01-Earth.png
16:34:35 [vite] (client) page reload shots/02-Interactive Fluid.png

我的截图脚本把 PNG 写进了工程根目录,Vite 的 watcher 看到新文件就当源码变更,触发了整页刷新。

于是:

  1. 页面重载 → 所有 ElementHandle 失效 → Execution context was destroyed
  2. 页面重载 → 又重新挂载了默认示例 → 第二张截图的第一帧还没渲染出来 → 拿到的是重载前那一帧

第二点特别阴险:它长得像"切换成功了但画面没变",会让人去查渲染逻辑。

解法一行:

server: { watch: { ignored: ['**/shots/**'] } }

顺带我把脚本也改了:截图前先 waitForStable(),等页面 4 秒内没有导航事件再动手; 所有 evaluate / screenshot 都套上重试。开发服务器的热更新会随机弄死你的自动化脚本, 这事得在脚本层面预期到,而不是祈祷它别发生。

坑三:lil-gui 不是 vgpu 的依赖

earth 和 fft-ocean-surface 的 renderer 里都有这一行:

import GUI from "lil-gui";      // 就是右上角那个参数调节面板

但 vgpu 的依赖里没有它 —— 那是官方示例宿主工程提供的。 我自己搭 Vite 工程时缺了这东西,Vite 直接 500,浏览器只告诉你"动态导入失败"。 真正的报错要去 dev server 终端看:

Failed to resolve import "lil-gui" from "examples/earth/renderer.ts". Does the file exist?

从"能跑的示例源码"里搬代码,一定要先把它的外部依赖列一遍。 我后来用一条命令把所有示例的裸导入过滤出来,才发现总共只有四个:vgpu、vgpu/core、vgpu/scene、lil-gui。

坑四:about:blank 上测 WebGPU,会得出完全相反的结论

这个坑在我正式开工之前就踩了。当时想先确认这台机器能不能跑 WebGPU,写了个探针, 四种参数组合全部返回:

navigator.gpu: false

差点就下结论"这台机器不支持 WebGPU"。但我多看了一眼 —— 返回的是 undefined, 不是"有 API 但拿不到适配器"。这两个现象的含义完全不同:

WebGPU 只在安全上下文里暴露。about:blank 和 data: 都不是安全上下文, 所以 navigator.gpu 直接不存在。

换成 http://127.0.0.1 打开,同一个浏览器、同样的参数,7/7 全部通过:

adapter     : {"vendor":"intel","architecture":"gen-12lp"}      ← Intel Iris Xe,真 GPU
读回像素    : [64, 128, 191, 255]  期望 [64, 128, 191, 255]     ← 渲染 + 读回校验
渲染结果    : ok

这个坑对生产部署的直接含义:用内网 IP + http 访问的页面上,WebGPU 会凭空消失。 必须 HTTPS 或者 localhost。项目里最好加一句明确的兜底提示,而不是让用户对着黑屏猜。

坑五:自动旋转把我三轮截图诊断全带偏了

这个坑发生在最后——我给画廊加了个自写示例:一只坐在电脑桌上的蓝色胖鲸鱼(光线步进 SDF 建模)。

第一次出图,我看到的是一个横躺着的蓝色团块,当场判断"模型比例全错了", 于是调整眼睛位置、缩放身体、重做鱼鳍……连着改了三轮,越改越迷惑,因为每一张图看起来都不对劲。

改到第四轮我才反应过来去核对相机:代码里写了"松手 2 秒后缓慢自转", 而我的截图是在 11 秒后拍的 —— 此时相机已经转了 (11-2) × 0.16 ≈ 1.44 弧度,接近 83°。 也就是说这三轮里我一直在看侧面,然后拿侧面的观感去修正面的比例。

// 松手 2 秒后开始慢慢自转
if (!dragging && spin > 0) {
  idleSeconds += time.deltaTime
  if (idleSeconds > 2) cam.yaw += time.deltaTime * 0.16 * spin
}

修法很简单,加一个开关把动画冻住,出图时用 ?spin=0:

const spin = num('spin', 1)   // ?spin=0 完全关掉自转

这条和坑二是同一个道理,值得单独起一句:

任何随时间变化的东西,都会让"截图对比"这种验证手段失真。 页面会被热更新重载(坑二),相机会自己转(坑五)—— 你测量的东西被测量手段改变了。 做视觉验证时,第一件事是把时间冻住。

改完之后一次就位。下面是同一时刻、同一模型的两个视角:

侧视(自转 83° 后,我误判了三轮的视角) 正视(?spin=0,模型真实的样子)
蓝色团块、"鱼鳍"像翅膀 圆滚滚的胖鲸鱼、大眼睛、腮红、微笑

附:写 SDF 角色时的几个手感

既然踩了,就一并记下来。这些都是"看代码看不出来、只能看图才知道"的东西:

  1. 眼睛的鼓出量要克制。 眼心放在身体表面之外 → 变成两颗外挂弹珠; 埋在表面以内太深 → 只露出一个白疙瘩。大约四成露出最像毛绒玩具。 (第一版我算错椭球方程,眼睛归一化值 0.97 < 1,等于埋在里面,只露了个白点。)
  2. 两只瞳孔必须朝同一个方向。 我一开始让它们各自"朝外前方",符合解剖学, 但从正面看就只剩两只白眼珠。改成都朝正前方、略微内收,视线立刻聚焦到镜头上。
  3. 腮红是颜色,不是几何。 用扁球体贴上去,露出的边缘看着像两道疤。 改成按"到脸颊中心的距离"把粉色画进基础色里,就自然了 —— 而且距离参数的尺度要贴着表面(我第一版中心放深了 0.08,整块腮红直接不显示)。
  4. 挖微笑:小球间距必须明显小于球半径。 我第一版用 5 颗球、间距 0.039, 挖出来的是一排小坑,像牙印。改成 11 颗球、间距 0.0145、半径 0.021 才连成一条沟。
  5. 鲸尾鳍又大又平,侧看就是一块飞盘。 做小、做薄、稍微上翘,才像鳍。

一句话总结:SDF 建模的难点不在数学,在于你没法"看着改"——只能渲染出来看。 所以渲染管线越短、出图越快,迭代效率越高。vgpu check 静态校验 + 无头截图这条链路, 一轮大概二十秒,我刚才能在一个下午里磨出一条鱼,靠的就是这个循环够快。

坑六:九项断言全绿,用户上手三秒就发现方向反了

鱼做完了,我跑了一遍全量验证:

9/9 画面有内容 · 9/9 挂载断言通过 · 截图 md5 无重复 · 0 条控制台报错

全绿。然后我把截图交出去,收到的反馈是:

你有没有感觉蓝色大肥鱼的拖拽变化逻辑写反了?和别的正好相反。

我盯着这句话看了很久,因为它戳中了一件事:我那九项断言,没有一项能发现这个问题。

它们全是"有没有"和"是不是"级别的判定——画面有没有内容、挂载的是不是点中的那个、 有没有报错、截图有没有重复。而镜像后的画面,这四项全部通过。 一条左右翻转的鱼,依然是一条有内容、没错、挂载正确的鱼。

定位:三个实现对下来,只差一个乘法的顺序

我没靠感觉改,而是把三个官方实现摊开对比:

输入处理 相机公式 屏幕右向量
raymarched-fractal(官方示例) yaw -= dx * 0.006 (sy·cp, sp, cy·cp)·d right = cross(forward, up)
vgpu/scene 的 orbitControls goalYaw -= dx * rotateSpeed sin(yaw)·cp·d, sin(pitch)·d, cos(yaw)·cp·d —
我的大肥鱼 yaw -= dx * 0.006 ✅ (sin·cp, sp, cos·cp)·d ✅ cross(up, forward) ❌

输入符号一样,相机公式一样。差别只在最后一行——而 cross(a,b) = -cross(b,a):

// 错:屏幕右向量被取反,整幅画面水平镜像
let uu = normalize(cross(vec3f(0.0, 1.0, 0.0), ww));
let vv = cross(ww, uu);

// 对:和官方示例一致
let uu = normalize(cross(ww, vec3f(0.0, 1.0, 0.0)));  // 屏幕右
let vv = cross(uu, ww);                               // 屏幕上

画面一旦水平镜像,同样的拖拽在观感上就变成反方向了 —— 输入和相机路径都没错,是光栅化那一步翻了,于是"转到哪边"整体反过来。 用户说"和别的正好相反",一个字都没说错。

回头看我自己的截图,证据其实一直在:马克杯在世界坐标 x = +0.66,却显示在画面左边。 我看了那张图不下十次,一次都没往这上面想——因为画面"看起来很正常"。

补一个必须注意的点:只改 uu 会让画面上下颠倒。 因为 vv = cross(ww, uu) 跟着变了方向,两个必须一起改。 这也是为什么这类 bug 靠"试一下"很难收敛——改一半会换一种错法。

修完之后,把"方向"也变成可断言的东西

原来的拖拽测试只回答"画面变了没有",而方向反了同样会变。所以我加了一个真正的断言:

# 向右拖 320px,等价于 yaw -= 320 × 0.006 = -1.92
node tools/shot.mjs "...&yaw=0&spin=0" 9000 --solo --drag 320
node tools/shot.mjs "...&yaw=-1.92&spin=0" 9000 --solo
node tools/diff-png.mjs shots/drag-blue-fish-right.png shots/yaw-neg1.92.png

结果:

drag-blue-fish-right.png vs yaw-neg1.92.png:  差异像素 0.0%   => 基本一致   ← 方向正确
drag-blue-fish-right.png vs yaw-pos1.92.png:  差异像素 31.8%  => 明显不同   ← 反方向长这样

0.0% 对 31.8%,这是一个二值化的方向断言,不再依赖我的眼睛。

附带的一个假警报

第一次做这个断言时,我得到的结论是"方向果然反了"——而那是假的。 原因:我的拖拽截图文件名是 drag-right.png,没带示例 id。 先测了鱼的 +320,又测了官方 fractal 的 +320,后者把前者覆盖了。 于是我拿黑底的 fractal 截图去和鱼的 yaw=-1.92 比,差异 79%,剖面是 34 28 6 8 38 64 100 52 5 0 (两侧全黑、中间一坨亮——那正是 fractal 长什么样)。

这和坑二是同一个毛病:验证产物之间互相污染。 修法也一样粗暴有效——文件名带上用例 id。

两条教训

  1. "有内容 / 没报错"这一类断言,永远抓不住手性、朝向、镜像这类错误。 它们不会让画面变空、不会抛异常、不会让 md5 重复。想覆盖它们,必须有参考图或等价参数对照。
  2. 像素级验证的盲区,最后是靠人眼补上的。 我的自动化套件跑了 9 个示例、几百张截图, 没发现这条鱼是镜像的;而人拖一下鼠标,三秒就知道了。 所以自动化验证的正确定位不是"替代人看",而是把人从重复劳动里解放出来,让他能专注于看那些机器判断不了的东西。

7. 一套可复制的"证明它真的渲染了"方法论

前面四个坑有个共同点:都是"看起来在跑"和"真的在跑"之间的缝隙。我的应对是把验证拆成四层:

层次 手段 能抓到的错误
静态 npx vgpu check x.wgsl WGSL 语法错、binding 声明错(连浏览器都不用开)
资源 截图亮度 / 方差 / 纯黑率 / 色彩数 空画面、纯色、渲染中断
状态 页面内自检口 + 挂载断言 切换失败但画面没变、模块加载失败、初始化异常
一致性 截图 md5 去重 页面被外部原因重载、卡帧

vgpu check 值得单独说。它不只是语法检查,会真的建一个 WebGPU 设备去验证, 并反射出绑定布局(下面的输出为便于阅读省略了 mangledName 等字段和部分嵌套):

{
  "diagnostics": [],
  "validation": { "mode": "auto", "attempted": true, "ok": true },
  "reflection": {
    "bindings": [{
      "group": 0, "binding": 0, "name": "params", "kind": "buffer",
      "struct": { "members": [{ "name": "time", "type": {"kind":"scalar","name":"f32"} },
                              { "name": "aspect", "type": {"kind":"scalar","name":"f32"} }] },
      "layout": { "align": 4, "size": 8 }
    }]
  }
}

在手写着色器的时候,这个反馈循环的质量差别巨大。 对比一下我们之前在 Cesium 里查 3D Tiles 的 NaN 包围球:没有报错、没有日志、瓦片就是不显示,最后靠 dump 矩阵才定位到 Matrix3.fromArray(box, 3) 读越界。而这里,binding 名字写错、结构体对不齐,编译期直接告诉你。

顺带一个实测结论:vgpu check 的 attempted: true 说明它在 Node 里真的建了设备, 也就是说 vgpu/node(Dawn 原生)这条无头渲染路线在这台机器上是通的。 配上它依赖里自带的 pngjs 和 pixelmatch,官方显然就是冲着"CI 里跑视觉回归测试"设计的。

8. 一处更正:我一开始误判了它的能力边界

写到这里必须自我更正一次。

我最早看的是文档站的 API Reference 侧边栏,vgpu/scene 那一栏只列了 box、unlitMaterial、 directionalLight 等寥寥几项。于是我得出的结论是"scene 层极薄,几何体只有 box 一种", 并据此判断"这东西做不了正经 3D"。

后来我把包的导出清单实际拉出来(Select-String '^export' node_modules/vgpu/dist/scene.js),发现:

几何体     box capsule cone cylinder disk dodecahedron fullscreenQuad icosahedron
           icosphere octahedron plane ring sphere tetrahedron torus   ← 14 种 + fullscreenQuad
材质       unlitMaterial lambertMaterial normalMaterial shaderMaterial
灯光       ambientLight directionalLight
场景图     scene group mesh SceneNode MeshNode
相机       perspectiveCamera orthographicCamera    控制器  orbitControls

比文档侧边栏展示的多得多。 官方示例之所以只用 box/sphere 和更底层的 draw(), 是因为它们本来就是"给着色器作者看的示例",不是"展示场景图 API"。

教训很直白:判断一个库的能力边界,去读它的导出清单和类型定义,别信文档站的目录树。 (我前面那句结论已经在项目 README 里改掉了,这里留个记录。)

9. 诚实的结论

vgpu 好在哪:

  • 反馈循环短。 一次 npx vgpu check 就能静态校验着色器 + 反射 binding 布局,不用开浏览器猜。
  • 没有隐式状态。 帧是显式的、绑定是显式的、时间来自 clock(gpu)、分辨率来自 target.size。 出问题时能在代码里顺着读出来,而不是去猜引擎内部做了什么。
  • compute 是真实红利。 流体那个例子放在 WebGL 时代要写半个月。
  • .gpu 逃生舱。每层都留了原生 WebGPU 的出口,封装不够用的地方你自己下得去。
  • 面向 Agent 的基础设施。npx vgpu docs/examples/check/mcp,站点还发 agents.md 和 llms.txt。 示例库是公开只读 API,带 sha256,可以脚本化拉取 —— 这点我实测用上了。

风险与不适合的地方:

  • 0.5.x,API 还会变。 生产项目要掂量。
  • 浏览器覆盖还不是 100%。 2026-09 的 caniuse 数据:全球 87.82%; Chrome/Edge 113+ 支持;Safari 26+ 仅部分支持; Firefox 到 158–160 仍然默认关闭。必须做 WebGL 降级,不能假设一定有。
  • 它不是地理空间引擎。 没有地形、没有 3D Tiles、没有投影与坐标系。 顺带一个实测:CesiumJS 1.146 整个包里搜 "webgpu" 是 0 处匹配;官方 2023 年的回复是"没有近期计划,但在雷达上"。 所以这两者短期内不存在替代关系。

它适合什么: 着色器密集型的效果、GPU 计算、可视化、需要严格可控渲染流程的项目, 以及——我认为最被低估的——CI 里的无头渲染 + 像素比对。

它不适合什么: 想要"给我一个场景就自动出画面"的 3D 应用;做城市级数字孪生; 面向必须兼容老浏览器的用户。

附:CLI 速查

npx vgpu docs cat getting-started.md      # 官方要求先读这个
npx vgpu docs find "<主题|符号|错误码>"    # 文档里搜
npx vgpu check x.wgsl --pretty            # 静态校验 + 反射绑定布局
npx vgpu examples search "raymarching"    # 搜示例(30 个)
npx vgpu examples show <id>               # 看示例 manifest(含每个文件的 sha256)
npx vgpu examples cat <id> <path>         # 打印单个文件
npx vgpu doctor                           # 体检 Node 渲染环境
npx vgpu mcp                              # 以 MCP 方式把文档和示例喂给 Agent

⚠️ npx vgpu examples pull <id> --out <dir> 在 Windows 上不可用:

{"error":{"code":"VGPU-EXAMPLES-FILESYSTEM","message":"Safe destination storage is unsupported on win32"}}

它用了 POSIX 专有的安全写入方式。好在示例库是公开的 hypermedia API (/.well-known/vgpu-examples.json → latest.json → index.json → manifest.json), 自己按 manifest 拉 + 校验 sha256 就行 —— 本项目里的 tools/pull-example.mjs 就是这么干的。


本文对应的可运行工程见同目录 README;使用细节见《使用手册》。

posted on 2026-10-08 19:34  fox_charon  阅读(2)  评论(0)    收藏  举报

导航