此示例需要 WebGPU(Chrome / Edge 113+、Safari 26+)。
规则
每个细胞看周围 8 个邻居:活细胞有 2 或 3 个活邻居就存活,空格恰好有 3 个活邻居就诞生,其余死亡。这里额外记录年龄:值大于 0 表示活了几代,小于 0 表示刚死去、正在淡出的“幽灵”。
为什么需要乒乓缓冲
下一代的每个细胞都依赖上一代的邻居。如果原地更新,一个线程写入时,隔壁线程可能还没读完旧值,结果就乱了。解决办法是两个缓冲:这一代读 A 写 B,下一代读 B 写 A。
js
pass.setBindGroup(0, stepBG[cur]); // [0]: 读 A 写 B;[1]: 读 B 写 A
pass.dispatchWorkgroups(Math.ceil(W / 8), Math.ceil(H / 8));
cur = 1 - cur;两个绑定组提前建好,每代只切换下标,几乎没有开销。
二维工作组
@workgroup_size(8, 8) 是 64 个线程排成 8×8 的方块,调度也是二维的。线程坐标这样算:
wgsl
let x = wg.x * 8u + lid % 8u;
let y = wg.y * 8u + lid / 8u;这就是 global_invocation_id.xy:工作组位置乘以 8,加上组内位置。这里的除法和取模都是对常量 8,编译器会变成位运算。边界环绕用 select 而不是取模,既省事又避免运行时整数除法。
实例化绘制网格
渲染时 draw(6, W * H):每个细胞一个方块实例。顶点着色器由 instance_index 推出 (x, y),从缓冲读出细胞值,按年龄映射颜色:新生是黄色,然后薄荷绿、青色,最老的是紫色。空细胞被推到屏幕外,不产生片元。
节奏
generations / s 决定每秒演化几代;一帧最多跑 4 代,多个计算 pass 录进同一个命令缓冲。在途帧数依旧用 onSubmittedWorkDone 限制在 2 帧,GPU 忙不过来时宁可跳帧也不让队列积压。
把 cell size 调到 2,就是几十万个细胞同时演化,这正是 GPU 擅长的规模。
核心 API
@workgroup_size(8, 8)@builtin(workgroup_id)@builtin(local_invocation_index)pass.dispatchWorkgroups(x, y)selectvar<storage, read_write>@builtin(instance_index)device.createBindGroup动手试试
- 1把计算着色器改成 @builtin(global_invocation_id) gid,用 gid.x、gid.y 作为坐标。
- 2改规则:把诞生条件换成 n == 3u || n == 6u(HighLife),观察会不会出现自我复制的结构。
- 3暂停后反复点 step once,一代一代地观察滑翔机怎么移动。
还没有输出。console.log 的内容会显示在这里。
运行中