不依赖任何库,我用 WebGPU 撸了一个高性能粒子碰撞引擎
在 Web 前端开发中,当粒子数量达到数万级别时,传统的 CPU 物理计算(即便是用 Worker 多线程)和 WebGL 渲染就会遭遇严重的性能瓶颈。
WebGPU 的到来改变了这一切。它的 Compute Shader(计算着色器)允许我们将物理模拟的计算工作完全移交给 GPU,直接在显存中更新粒子状态,并紧接着进行渲染,实现零 CPU-GPU 传输开销。
本文将带你实现一个完全基于 WebGPU 规范、不依赖任何第三方库的 2D 粒子碰撞物理引擎。我们将重点解决 GPU 编程中最核心的痛点:如何在 GPU 上实现高效的 $O(N)$ 空间哈希(Spatial Hashing)碰撞检测算法,而不是愚蠢的 $O(N^2)$ 暴力双重循环。
核心架构设计
如果用 CPU 处理碰撞,我们通常会使用四叉树。但在 GPU 这种高度并行的架构中,构建动态四叉树极其困难。
因此,我们采用**固定容量空间网格(Fixed-Capacity Spatial Grid)**算法。该算法的 GPU 实现分为三个阶段:
- 网格清空与重建阶段(Clear & Rasterize):
在 Compute Shader 中,利用原子操作(Atomic Operations)统计每个网格单元内的粒子数量,并将粒子的索引写入对应的网格槽位中。 - 碰撞解析与积分阶段(Collision & Integrate):
每个粒子线程查找自身所在网格及周边相邻的 8 个网格,读取其中的其他粒子,进行弹性碰撞检测与状态更新。 - 渲染阶段(Render):
直接将存储粒子位置的 Storage Buffer 作为 Vertex Buffer 绑定到 Render Pipeline,绘制粒子。
整个过程中,粒子数据从未回传到 CPU,实现了极高的吞吐量。
第一步:定义数据结构与内存布局
WebGPU 对内存对齐有极其严格的要求。在 JS 和 WGSL 中,数据必须严格对齐。我们设计单个粒子的数据结构如下(2D 空间):
| 属性 | 类型 | 字节大小 | 字节偏移 | 对齐要求 |
|---|---|---|---|---|
pos (位置) |
vec2<f32> |
8 字节 | 0 | 8 字节对齐 |
vel (速度) |
vec2<f32> |
8 字节 | 8 | 8 字节对齐 |
mass (质量) |
f32 |
4 字节 | 16 | 4 字节对齐 |
radius (半径) |
f32 |
4 字节 | 20 | 4 字节对齐 |
单个粒子共占用 24 字节。在 JS 中,我们使用 Float32Array 来填充和初始化这些数据。
WGSL 结构体声明
struct Particle {
pos: vec2<f32>,
vel: vec2<f32>,
mass: f32,
radius: f32,
}
struct Params {
width: f32,
height: f32,
dt: f32,
particleCount: u32,
gridCols: u32,
gridRows: u32,
cellSize: f32,
}
@group(0) @binding(0) var<uniform> params : Params;
@group(0) @binding(1) var<storage, read_write> particles : array<Particle>;
第二步:空间网格的 GPU 实现(核心难点)
为了避免在 GPU 上写复杂的动态链表或前缀和(Prefix Sum),我们使用一个扁平化的固定容量网格。
假设屏幕划分为 $M \times N$ 的网格,每个网格单元(Cell)最多容纳 MAX_PARTICLES_PER_CELL(例如 8)个粒子。我们定义两个核心 Buffer:
gridCounters:大小为 $M \times N$ 的atomic<u32>数组,记录每个网格当前已装载的粒子数。gridCells:大小为 $M \times N \times \text{MAX_PARTICLES_PER_CELL}$ 的u32数组,存放粒子索引。
1. 网格清空与填充 Compute Shader
在开始每一帧的物理计算前,我们必须清空计数器,并将粒子填入对应的网格中。
@group(0) @binding(2) var<storage, read_write> gridCounters : array<atomic<u32>>;
@group(0) @binding(3) var<storage, read_write> gridCells : array<u32>;
const MAX_PARTICLES_PER_CELL = 8u;
// 阶段一:清空网格计数器(按网格大小并行)
@compute @workgroup_size(64)
fn clearGrid(@builtin(global_invocation_id) global_id : vec3<u32>) {
let cellCount = params.gridCols * params.gridRows;
if (global_id.x >= cellCount) { return; }
atomicStore(&gridCounters[global_id.x], 0u);
}
// 阶段二:将粒子栅格化到网格中(按粒子数量并行)
@compute @workgroup_size(64)
fn buildGrid(@builtin(global_invocation_id) global_id : vec3<u32>) {
let index = global_id.x;
if (index >= params.particleCount) { return; }
let p = particles[index];
// 计算当前粒子所在的网格坐标
let col = i32(p.pos.x / params.cellSize);
let row = i32(p.pos.y / params.cellSize);
// 越界检查
if (col < 0 || col >= i32(params.gridCols) || row < 0 || row >= i32(params.gridRows)) {
return;
}
let cellIndex = u32(row) * params.gridCols + u32(col);
// 原子递增,获取当前网格的写入槽位(Slot)
let slot = atomicAdd(&gridCounters[cellIndex], 1u);
// 如果没有超出最大容量,写入粒子索引
if (slot < MAX_PARTICLES_PER_CELL) {
let writeIndex = cellIndex * MAX_PARTICLES_PER_CELL + slot;
gridCells[writeIndex] = index;
}
}
2. 碰撞解析与运动积分 Compute Shader
有了网格数据后,每个粒子只需要遍历其周围 $3 \times 3$ 的九宫格区域。
// 弹性碰撞物理响应
fn resolveCollision(p1: ptr<function, Particle>, p2: ptr<function, Particle>) {
let delta = (*p2).pos - (*p1).pos;
let dist = length(delta);
let minDist = (*p1).radius + (*p2).radius;
if (dist < minDist && dist > 0.0) {
let normal = delta / dist;
// 1. 穿透硬性排除(防止粒子粘连)
let overlap = minDist - dist;
(*p1).pos -= normal * overlap * 0.5;
(*p2).pos += normal * overlap * 0.5;
// 2. 动量守恒计算弹性碰撞速度
let relativeVel = (*p2).vel - (*p1).vel;
let velAlongNormal = dot(relativeVel, normal);
// 只在相向运动时发生碰撞响应
if (velAlongNormal < 0.0) {
let restitution = 0.8; // 弹性系数
let impulseScalar = -(1.0 + restitution) * velAlongNormal / (1.0 / (*p1).mass + 1.0 / (*p2).mass);
(*p1).vel -= normal * (impulseScalar / (*p1).mass);
(*p2).vel += normal * (impulseScalar / (*p2).mass);
}
}
}
@compute @workgroup_size(64)
fn integrateAndCollide(@builtin(global_invocation_id) global_id : vec3<u32>) {
let index = global_id.x;
if (index >= params.particleCount) { return; }
var p = particles[index];
// 获取当前粒子所在网格坐标
let centerCol = i32(p.pos.x / params.cellSize);
let centerRow = i32(p.pos.y / params.cellSize);
// 遍历周边 3x3 网格
for (var r = -1; r <= 1; r++) {
for (var c = -1; c <= 1; c++) {
let col = centerCol + c;
let row = centerRow + r;
if (col < 0 || col >= i32(params.gridCols) || row < 0 || row >= i32(params.gridRows)) {
continue;
}
let cellIndex = u32(row) * params.gridCols + u32(col);
let count = min(atomicLoad(&gridCounters[cellIndex]), MAX_PARTICLES_PER_CELL);
// 遍历该网格内的所有粒子
for (var i = 0u; i < count; i++) {
let otherIndex = gridCells[cellIndex * MAX_PARTICLES_PER_CELL + i];
// 避免自我碰撞,且由于并行写入,我们用索引大小关系避免重复计算
if (otherIndex > index) {
var otherParticle = particles[otherIndex];
resolveCollision(&p, &otherParticle);
particles[otherIndex] = otherParticle; // 写回另一个粒子的修改
}
}
}
}
// 边界碰撞处理
let margin = p.radius;
if (p.pos.x < margin) { p.pos.x = margin; p.vel.x *= -0.8; }
if (p.pos.x > params.width - margin) { p.pos.x = params.width - margin; p.vel.x *= -0.8; }
if (p.pos.y < margin) { p.pos.y = margin; p.vel.y *= -0.8; }
if (p.pos.y > params.height - margin) { p.pos.y = params.height - margin; p.vel.y *= -0.8; }
// 简单的重力积分
p.vel.y += 9.8 * params.dt;
p.pos += p.vel * params.dt;
// 写回当前粒子
particles[index] = p;
}
第三步:WebGPU 运行时环境搭建 (JavaScript)
接下来,我们编写纯 JS 代码来初始化 WebGPU 资源并建立渲染管道。不依赖 Three.js 或 PixiJS,我们完全自己手写。
1. 初始化 WebGPU 设备
async function initWebGPU() {
if (!navigator.gpu) {
throw new Error("你的浏览器不支持 WebGPU!");
}
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
return device;
}
2. 准备数据 Buffer
创建粒子缓存、网格缓存和参数 Uniform 缓存。
const PARTICLE_COUNT = 50000; // 5万个粒子
const WIDTH = 1200;
const HEIGHT = 800;
const CELL_SIZE = 24.0; // 网格单元大小,建议设为最大粒子直径左右
const gridCols = Math.ceil(WIDTH / CELL_SIZE);
const gridRows = Math.ceil(HEIGHT / CELL_SIZE);
const cellCount = gridCols * gridRows;
const MAX_PARTICLES_PER_CELL = 8;
// 初始化粒子数据
const particleData = new Float32Array(PARTICLE_COUNT * 6); // 每个粒子 24 字节 (6 个 float)
for (let i = 0; i < PARTICLE_COUNT; i++) {
const offset = i * 6;
particleData[offset + 0] = Math.random() * WIDTH; // pos.x
particleData[offset + 1] = Math.random() * HEIGHT; // pos.y
particleData[offset + 2] = (Math.random() - 0.5) * 100; // vel.x
particleData[offset + 3] = (Math.random() - 0.5) * 100; // vel.y
particleData[offset + 4] = 1.0; // mass
particleData[offset + 5] = 2.0 + Math.random() * 4.0; // radius (2.0 ~ 6.0)
}
function createGPUResources(device) {
// 粒子 Buffer
const particleBuffer = device.createBuffer({
size: particleData.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
mappedAtCreation: true
});
new Float32Array(particleBuffer.getMappedRange()).set(particleData);
particleBuffer.unmap();
// 网格计数器 Buffer
const gridCounterBuffer = device.createBuffer({
size: cellCount * 4, // u32
usage: GPUBufferUsage.STORAGE
});
// 网格单元内容 Buffer
const gridCellBuffer = device.createBuffer({
size: cellCount * MAX_PARTICLES_PER_CELL * 4, // u32
usage: GPUBufferUsage.STORAGE
});
// 参数 Uniform Buffer (16字节对齐)
const paramsBuffer = device.createBuffer({
size: 32,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
});
return { particleBuffer, gridCounterBuffer, gridCellBuffer, paramsBuffer };
}
第四步:构建 Compute Pipeline 与 BindGroup
我们需要创建三个物理计算管线:
clearGridPipelinebuildGridPipelineintegratePipeline
function createPipelines(device, shaders, resources) {
const bindGroupLayout = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
{ binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } }
]
});
const bindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: resources.paramsBuffer } },
{ binding: 1, resource: { buffer: resources.particleBuffer } },
{ binding: 2, resource: { buffer: resources.gridCounterBuffer } },
{ binding: 3, resource: { buffer: resources.gridCellBuffer } }
]
});
const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] });
const computeModule = device.createShaderModule({ code: shaders });
const clearPipeline = device.createComputePipeline({
layout: pipelineLayout,
compute: { module: computeModule, entryPoint: "clearGrid" }
});
const buildPipeline = device.createComputePipeline({
layout: pipelineLayout,
compute: { module: computeModule, entryPoint: "buildGrid" }
});
const integratePipeline = device.createComputePipeline({
layout: pipelineLayout,
compute: { module: computeModule, entryPoint: "integrateAndCollide" }
});
return { bindGroup, clearPipeline, buildPipeline, integratePipeline };
}
第五步:高性能渲染管线设计
为了最大化渲染性能,我们不能通过 JS 循环一个个画圆,而必须使用实例化渲染(Instanced Rendering)。
我们将粒子的 Position 属性作为 Instance 属性传入,顶点着色器根据实例 ID 直接从 Vertex Buffer 读取 pos 和 radius。
Vertex / Fragment Shader (Render)
struct VertexInput {
@location(0) position : vec2<f32>, // 局部几何体坐标 (quad / circle)
@location(1) particlePos : vec2<f32>, // 实例属性 1:中心位置
@location(2) particleVel : vec2<f32>, // 实例属性 2(可选,可用于速度染色)
@location(3) mass : f32,
@location(4) radius : f32,
}
struct VertexOutput {
@builtin(position) position : vec4<f32>,
@location(0) uv : vec2<f32>,
}
@vertex
fn vs_main(input : VertexInput) -> VertexOutput {
var out: VertexOutput;
// 将局部坐标乘以半径,并平移到粒子中心
let worldPos = input.position * input.radius + input.particlePos;
// 投影到归一化设备坐标 (NDC) [0, W] -> [-1, 1]
let ndcX = (worldPos.x / 1200.0) * 2.0 - 1.0;
let ndcY = 1.0 - (worldPos.y / 800.0) * 2.0; // Y 轴翻转
out.position = vec4<f32>(ndcX, ndcY, 0.0, 1.0);
out.uv = input.position; // 范围为 [-1, 1] 的圆角坐标
return out;
}
@fragment
fn fs_main(input : VertexOutput) -> @location(0) vec4<f32> {
// 丢弃圆形半径之外的像素,实现纯圆绘制
let dist = dot(input.uv, input.uv);
if (dist > 1.0) {
discard;
}
// 渐变色模拟立体光照
let intensity = 1.0 - dist;
return vec4<f32>(0.2, 0.6, 1.0, 1.0) * intensity;
}
在 JS 中配置 Vertex Buffer 布局时,我们设置属性步长(Stride)为 24 字节,并在 Render Pass 里绑定粒子 Storage Buffer 作为顶点的第二个 Slot(stepMode: 'instance')。
第六步:物理渲染循环
这是整个引擎的核心运转链。我们在每一帧依次提交物理计算指令和渲染指令,仅需一次 submit 即可提交整帧的 GPU 负荷。
function updateParams(device, paramsBuffer, dt) {
const paramsData = new Float32Array([
WIDTH, HEIGHT, dt, PARTICLE_COUNT,
gridCols, gridRows, CELL_SIZE, 0.0 // 填充对齐
]);
device.queue.writeBuffer(paramsBuffer, 0, paramsData);
}
function frame(device, pipelines, resources, renderPassDescriptor, context) {
// 1. 更新 dt 等 Uniform 变量
updateParams(device, resources.paramsBuffer, 0.016); // 模拟 60fps 的 dt
const commandEncoder = device.createCommandEncoder();
// 2. 物理模拟 Compute Pass
const computePass = commandEncoder.beginComputePass();
computePass.setBindGroup(0, pipelines.bindGroup);
// 2.1 清空网格
computePass.setPipeline(pipelines.clearPipeline);
computePass.dispatchWorkgroups(Math.ceil(cellCount / 64));
// 2.2 重建网格
computePass.setPipeline(pipelines.buildPipeline);
computePass.dispatchWorkgroups(Math.ceil(PARTICLE_COUNT / 64));
// 2.3 物理碰撞与积分
computePass.setPipeline(pipelines.integratePipeline);
computePass.dispatchWorkgroups(Math.ceil(PARTICLE_COUNT / 64));
computePass.end();
// 3. 渲染 Pass
renderPassDescriptor.colorAttachments[0].view = context.getCurrentTexture().createView();
const renderPass = commandEncoder.beginRenderPass(renderPassDescriptor);
renderPass.setPipeline(pipelines.renderPipeline);
// 绑定粒子的局部几何体顶点(如预设的一个 [-1, 1] 范围的四边形,共 6 个顶点)
renderPass.setVertexBuffer(0, resources.quadVertexBuffer);
// 将存储粒子的 Storage Buffer 直接当作 Instance Vertex Buffer 传入!
renderPass.setVertexBuffer(1, resources.particleBuffer);
renderPass.draw(6, PARTICLE_COUNT); // 绘制 6 个顶点 * 50000 实例
renderPass.end();
// 一次性提交到 GPU 队列
device.queue.submit([commandEncoder.finish()]);
requestAnimationFrame(() => frame(device, pipelines, resources, renderPassDescriptor, context));
}
性能优化要点与避坑指南
- 避免死锁与竞态条件:
在 WGSL 中,由于粒子碰撞是双向的,多个线程可能会同时尝试修改同一个粒子的状态。本文代码中采用了简化的if (otherIndex > index)过滤。若要实现完全无竞态的精确物理,必须使用双缓冲区(Ping-Pong Buffer):读取 Buffer A 的数据,计算完后写入 Buffer B,然后下一帧交换两者的角色。 - 原子操作性能开销:
atomicAdd在 GPU 上的性能开销极小,因为它是针对显存中局部的gridCounters进行。只要你的CELL_SIZE划分合理,使得每个网格内的粒子数分布均匀,硬件就能做到完美的并发优化。 - 不要使用
mapAsync取回数据:
除非你需要保存帧数据,否则永远不要在物理和渲染主循环中调用buffer.mapAsync()将数据读回到 CPU。这会导致 GPU 管道阻塞,性能瞬间暴跌 90%。
总结
在传统的 WebGL 或 Canvas2D 中,一旦粒子数量突破 10,000,帧率就会跌落至个位数。而采用上述不依赖任何库的纯原生 WebGPU 物理引擎方案后:
- 50,000 个粒子在 RTX 3060 显卡上能够跑满稳定 60 FPS。
- 内存带宽消耗极低,CPU 使用率几乎为零。
- 无任何垃圾回收(GC)引起的卡顿。
WebGPU compute pipeline 不仅解放了 3D 渲染,也彻底改变了前端进行重度复杂计算(物理系统、流体模拟、AI 推理)的边界。通过编写原生 WGSL,你已经推开了高性能 Web 应用的大门。