使用 TypeGPU 以 TypeScript 编写 GPU 着色器
使用 TypeGPU 编写 TypeScript GPU 着色器。配置类型化缓冲区、运行计算着色器,并在编译时检测绑定不匹配。
TypeGPU 是一个 TypeScript 库,它将 WebGPU 封装为带类型的原语,让 TypeScript 编译器来检查你的缓冲区 schema、绑定组布局和 GPU 函数,而不是任由这些错误在运行时于 GPU 上暴露出来。
如果你跟着做过 WebGPU 教程,应该对这种情况不陌生:JavaScript 端的类型检查一切正常,可 WGSL 字符串里某个结构体字段偏移了一个 float,最终你只拿到一个满是垃圾数据的缓冲区,却没有任何报错。本指南假设你已经了解 WebGPU 是什么。如果还不了解,可以先阅读为 UI 添加 WebGPU 特效和 WebGPU 与 WebGL 的区别。本文将带你完成 TypeGPU 的配置、构建带类型的缓冲区、从头到尾运行一个计算着色器,并亲眼看到原生 WebGPU 无法提供的类型错误提示。
核心要点
- 要以 TypeScript 函数的形式编写 TypeGPU 着色器,需要用
'use gpu'标记该函数,并添加unplugin-typegpu构建插件(除此场景外,该插件是库中的可选部分)。 - 每个 TypeGPU 缓冲区都由 schema 创建,例如
d.arrayOf(d.u32, 16)。schema 决定了缓冲区的字节大小、JavaScript 值如何转换为二进制,以及.write()接受哪些值。 - 在 WGSL 数组中,每个
vec3f元素占用 16 字节。TypeGPU 会自动为普通数组和对象添加填充,但对Float32Array则逐字节原样复制,因此你需要自行添加填充。 - 如果传给
root.createBindGroup的缓冲区的 schema 或 usage 与布局不匹配,TypeScript 会在编译期报错。 - TypeGPU 目前处于 1.0 之前的版本(0.12.x 系列),小版本更新曾移除过已弃用的 API。
为什么大多数 WebGPU bug 源自 WGSL 及 JS 与 GPU 之间的边界
大多数 WebGPU bug 并非来自着色器逻辑本身,而是发生在边界上。WebGPU 着色器使用 WGSL 编写,这是一门独立的语言,在你的 TypeScript 工具链看来只是一个不透明的字符串。缓冲区的字节布局、数字形式的绑定索引,以及每个绑定组与着色器声明之间的对应关系,全都要靠手动保持同步。一旦某处出错,你通常看到的是悄无声息的错误数据,而不是编译器报错。
TypeGPU 把两端都放进了 TypeScript。同一个 schema 同时描述 CPU 端和 GPU 端的数据,着色器函数也用 TypeScript 编写。
在 Vite 中配置 TypeGPU
先安装运行时,再将构建插件和 WebGPU 类型定义作为开发依赖安装:
npm install typegpu
npm install --save-dev unplugin-typegpu @webgpu/types
在 tsconfig.json 中注册 WebGPU 类型。如果 TypeScript 无法识别 GPUDevice 等 WebGPU 类型,入门指南的故障排查部分也会指引你检查这项设置。
{ "compilerOptions": { "types": ["@webgpu/types"] } }
对 TypeGPU 整体而言,unplugin-typegpu 是可选的。只有当你要以 'use gpu' TypeScript 函数的形式编写着色器时才需要它。它基于 unplugin 构建,因此除 Vite 外,还提供 esbuild、Farm、Rolldown、Rollup、Rspack 和 webpack 版本,另有一个面向 React Native 的独立 Babel 插件。
// vite.config.ts
import { defineConfig } from 'vite';
import typegpu from 'unplugin-typegpu/vite';
export default defineConfig({
plugins: [typegpu()],
});
在运行时,await tgpu.init() 会返回一个 root 对象。下文中所有的缓冲区、绑定组和管线都由它创建。
带类型的缓冲区与 Schema
TypeGPU 缓冲区总是基于由 d.* 构造函数构建的 schema 创建。正如 TypeGPU 缓冲区参考文档所述,TypeGPU 利用 schema 来计算缓冲区所需的大小、在 JavaScript 值与字节之间进行相互转换,并确定其 .write() 和 .read() 方法所接受和返回的类型。
import { tgpu, d } from 'typegpu';
const root = await tgpu.init();
const Point = d.struct({
position: d.vec3f,
weight: d.f32,
});
const points = root
.createBuffer(d.arrayOf(Point, 2), [
{ position: d.vec3f(0, 0, 0), weight: 1 },
{ position: d.vec3f(1, 2, 3), weight: 0.5 },
])
.$usage('storage');
points.write([
{ position: d.vec3f(4, 5, 6), weight: 2 },
{ position: d.vec3f(7, 8, 9), weight: 3 },
]);
console.log(await points.read());
.$usage('storage') 会设置 WebGPU 的 usage 标志,同时将其记录到缓冲区的类型中。当缓冲区无法被映射时,.read() 会通过一个临时的暂存缓冲区(staging buffer)复制其内容,因此你无需亲自编写 mapAsync。
按照 WGSL 的对齐规则,vec3f 数组元素占 16 字节:12 字节数据加 4 字节填充。如果向 .write() 传入普通数组或对象,TypeGPU 会自动插入填充;如果传入 Float32Array,.write() 则会原样复制其字节,因此该数组必须已经包含填充:
const dirs = root.createBuffer(d.arrayOf(d.vec3f, 2)).$usage('storage');
dirs.write([[1, 0, 0], [0, 1, 0]]); // padded for you
dirs.write(new Float32Array([1, 0, 0, 0, 0, 1, 0, 0])); // every 4th float is padding
端到端运行一个计算着色器
下面这个完整的 TypeGPU 程序在 GPU 上将一个包含八个整数的数组翻倍,然后读回结果。传给 root.createGuardedComputePipeline 的 'use gpu' 函数会接收一个线程索引。调用 dispatchThreads(n) 会运行 n 个逻辑线程,索引会从 0 到 n − 1 各取值恰好一次。
import { tgpu, d } from 'typegpu';
const root = await tgpu.init();
const N = 8;
const values = root
.createBuffer(d.arrayOf(d.u32, N), [1, 2, 3, 4, 5, 6, 7, 8])
.$usage('storage');
const layout = tgpu.bindGroupLayout({
values: { storage: d.arrayOf(d.u32, N), access: 'mutable' },
});
const bindGroup = root.createBindGroup(layout, { values });
const double = root.createGuardedComputePipeline((x) => {
'use gpu';
if (x >= N) {
return;
}
layout.$.values[x] = layout.$.values[x] + layout.$.values[x];
});
double.with(bindGroup).dispatchThreads(N);
console.log(await values.read()); // [2, 4, 6, 8, 10, 12, 14, 16]
布局声明了着色器的期望,绑定组则提供实际的缓冲区。你既不用计算工作组数量,也不用编写暂存缓冲区。如果不需要切换缓冲区,可以使用 root.createMutable(schema) 这种更简洁的固定资源方案:着色器通过 .$ 访问它,你则用 .read() 读回结果。
绑定组布局与编译期类型安全
对绑定组进行编译期检查,是 TypeGPU 与原生 WebGPU 差异最大的地方。绑定组布局描述着色器所需的资源,绑定组则提供具体的缓冲区。在 TypeGPU 中,如果缓冲区的 schema 或 usage 标志有误,在创建绑定组时就会无法通过类型检查。绑定组指南中记录了这一行为。
存储条目默认是只读的,这就是上面的布局要设置 access: 'mutable' 的原因。由于着色器引用的是 layout.$ 而非某个特定的缓冲区,同一个管线可以处理不同的数据:
const more = root
.createBuffer(d.arrayOf(d.u32, N), [10, 20, 30, 40, 50, 60, 70, 80])
.$usage('storage');
const bindGroupB = root.createBindGroup(layout, { values: more });
double.with(bindGroupB).dispatchThreads(N); // same shader, new data
const floats = root.createBuffer(d.arrayOf(d.f32, N)).$usage('storage');
root.createBindGroup(layout, {
values: floats, // type error: d.f32 elements don't match the d.u32 layout entry
});
绑定组是不可变的。应为每种资源组合创建一个绑定组;如果组合数量少且固定,可以将它们缓存起来。而在原生 WebGPU 中,编译器完全不知道 WGSL 声明了什么,所以在着色器期望 u32 的地方绑定一个 float 缓冲区,照样能顺利编译,然后悄无声息地算出错误的数值。
同一个着色器:WGSL 与 TypeGPU 对比
TypeGPU 着色器与手写的等价 WGSL 代码体量相当,区别在于它们周边的宿主代码。翻倍着色器的手写 WGSL 版本如下:
@group(0) @binding(0) var<storage, read_write> values: array<u32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
if (id.x >= arrayLength(&values)) {
return;
}
values[id.x] = values[id.x] * 2u;
}
而前面示例中的 TypeGPU 版本是:
const double = root.createGuardedComputePipeline((x) => {
'use gpu';
if (x >= N) {
return;
}
layout.$.values[x] = layout.$.values[x] + layout.$.values[x];
});
用原生 WebGPU 运行 WGSL 版本,意味着要手写以下所有宿主代码:
- 使用 WGSL 源码字符串调用
device.createShaderModule device.createComputePipeline- 绑定组条目,其中数字形式的
binding索引必须与@binding(0)一致 - 为
dispatchWorkgroups计算工作组数量 - 一个
MAP_READ暂存缓冲区、一条复制命令,以及用于读回结果的mapAsync
使用 TypeGPU 时,布局对象取代了数字索引,.read() 则负责处理暂存。
局限性、逃生舱与浏览器支持
TypeGPU 尚处于 1.0 之前的阶段。0.12 版本系列于 2026 年 8 月 12 日发布,而小版本更新曾移除过已弃用的 API,因此在升级前请阅读 0.12 迁移指南及后续版本的指南。该库采用 MIT 许可证,由 Software Mansion 开发。
必要时退回原生 WebGPU。 你可以用 root.createBuffer(schema, existingBuffer) 包装一个现有的 GPUBuffer,以获得带类型的读写能力。此时你需要自行管理该缓冲区的生命周期,并确保其内容与 schema 一致。若要调用 .write(),它还必须具有 COPY_DST 标志:
const raw = root.device.createBuffer({
size: 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const counter = root.createBuffer(d.u32, raw);
counter.write(42);
守卫索引。 WebGPU 会阻止越界访问触及绑定范围之外的内存,但并不能保证结果正确。只要调度规模可能大于数组长度,就要对索引进行守卫检查。
避免跨线程读取。 GPU 线程的执行顺序没有任何保证。如果在同一次调度中,一个线程读取的元素正被另一个线程写入,结果将是不确定的:
// Nondeterministic: thread x reads index x + 1 while thread x + 1 may be writing it
const smear = root.createGuardedComputePipeline((x) => {
'use gpu';
if (x + 1 >= N) {
return;
}
layout.$.values[x] = layout.$.values[x] + layout.$.values[x + 1];
});
WebGPU 还限制了单个轴向上可调度的工作组数量。对于大型网格,应将工作拆分到 x 和 y 两个维度上。
浏览器支持。 只要支持 WebGPU 的环境,TypeGPU 就能运行。请查看 caniuse 上 WebGPU 的最新支持情况,以确认是否覆盖你的目标用户。
清楚何时不该使用它。 TypeGPU 官方文档建议,如果要在页面上放置可交互的 3D 模型,应使用渲染框架。对于这类需求,使用 Three.js 构建 3D 动画是更合适的选择。
总结
WebGPU 中最难排查 bug 的部分,是 JavaScript 数据与着色器内存之间的边界。TypeGPU 通过用同一个 TypeScript schema 描述两端、并对每个绑定进行类型检查,解决了这一问题。不妨先在 Vite 项目中添加 unplugin-typegpu 并运行翻倍示例,然后尝试向 createBindGroup 传入一个不匹配的缓冲区,看看编译器如何捕获这个错误。
常见问题
能否在 TypeGPU 中使用现有的 WGSL 着色器?
可以。tgpu-gen CLI 能读取现有的 WGSL 着色器文件,并生成包含对应 TypeGPU 定义的 TypeScript 文件。它支持 glob 模式,并提供监听模式,在着色器变更时重新生成定义。此外,TypeGPU 函数外壳既可以接受 WGSL 字符串,也可以接受 JavaScript 函数作为实现,因此你可以逐个函数地迁移代码库。
'use gpu' 函数也能在 CPU 上运行吗?
可以。标记了 'use gpu' 的函数仍然是一个普通的 TypeScript 函数,因此你可以在 JavaScript 中直接调用它并获得返回结果。你也可以将它传给 tgpu.resolve 来生成 WGSL,或通过管线在 GPU 上运行。这样,你就能在着色器辅助逻辑上 GPU 运行之前,先用常用的 JavaScript 测试运行器对其进行单元测试。
如何从 TypeGPU 中获取生成的 WGSL?
调用 tgpu.resolve 并传入所需的 TypeGPU 资源,例如 'use gpu' 函数和 schema。它会返回一个可直接使用的 WGSL 字符串,其中包含所有传递依赖。当其他 WebGPU 引擎或材质系统需要原始 WGSL 时,这会非常有用。如果使用 TypeGPU 管线,则无需自行调用 resolve,因为管线会在内部完成着色器的解析与编译。
TypeGPU 能在 React Native 中使用吗?
可以。TypeGPU 通过 react-native-webgpu 包在 React Native 中运行。要编写 'use gpu' 着色器,需将 unplugin-typegpu/babel 添加到 babel.config.js 的 plugins 列表中,必要时清除 Metro 缓存。React Native WebGPU 无法在 Expo Go 中运行,因此 Expo 项目需要执行 expo prebuild。在 iOS 模拟器上,还需要在 Xcode 中关闭 Metal API Validation。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k