此示例需要 WebGPU(Chrome / Edge 113+、Safari 26+)。
数据从不离开 GPU
每个粒子是 16 字节的结构体:位置和速度各一个 vec2f。所有粒子放在一个存储缓冲里。每一帧:
- 计算 pass:每个线程负责一个粒子,读出、积分、写回;
- 渲染 pass:顶点着色器按
instance_index从同一个缓冲读取位置,画一个小方块。
CPU 只负责每帧写几十字节的 uniform(鼠标位置、时间步长等),十几万粒子的状态从不往返。
一个线程一个粒子
wgsl
@compute @workgroup_size(256)
fn simulate(@builtin(workgroup_id) wg : vec3u,
@builtin(local_invocation_index) lid : u32) {
let i = wg.x * 256u + lid; // 等价于 global_invocation_id.x
if (i >= S.count) { return; }
var p = particles[i];
p.vel = (p.vel + acc * S.dt) * pow(S.damping, S.dt * 60.0);
p.pos += p.vel * S.dt;
particles[i] = p;
}wg.x * 256u + lid 就是全局线程编号,和 @builtin(global_invocation_id) 的 .x 一模一样。因为每个线程只读写自己的元素,原地更新是安全的;如果要读邻居(比如粒子间碰撞),就需要两个缓冲轮流读写。
同一个缓冲,两种视图
计算管线把缓冲声明为 read_write,渲染管线声明为 read。同一块内存创建两个绑定组即可,WebGPU 会在两个 pass 之间自动插入必要的同步。
实例化绘制
pass.draw(6, count):每个实例 6 个顶点(两个三角形),共 count 个实例。顶点着色器用 vertex_index 选方块的角,用 instance_index 选粒子。一次绘制调用就画出所有粒子。
加法混合
片元输出很暗的颜色,混合模式设为 src + dst。粒子密集处颜色叠加发光,而且与绘制顺序无关,不需要排序。颜色由速度决定:慢的偏靛蓝,快的偏琥珀到白色。
节奏控制
时间步长 dt 限制在 1/30 秒以内,防止卡顿后粒子“飞出去”。在途帧数用 onSubmittedWorkDone 限制在 2 帧,避免 GPU 跟不上时命令无限堆积、延迟越来越大。
核心 API
var<storage, read_write>@builtin(instance_index)pass.dispatchWorkgroupspass.draw(6, count)device.createComputePipelineblend: one + onedevice.queue.writeBufferdevice.queue.onSubmittedWorkDone动手试试
- 1把 count 调到 1M,看帧率能否保持;再把 point size 调大,观察填充率的影响。
- 2把 swirl 设为负数,粒子会反向旋转;按住鼠标试试更强的吸引。
- 3在计算着色器里改用 @builtin(global_invocation_id) gid,用 gid.x 作为粒子下标。
还没有输出。console.log 的内容会显示在这里。
运行中