基于 WebGPU 的 GPGPU 实践:在浏览器端进行百万级粒子物理模拟
大家好,我是今天的主讲人。今天我们要深入探讨一个非常前沿且极具实用价值的话题:如何使用 WebGPU 在浏览器中实现百万级粒子的物理模拟。
如果你曾尝试过在网页上跑复杂的计算任务(比如流体动力学、碰撞检测或粒子系统),你可能已经遇到过性能瓶颈——JavaScript 单线程执行效率低、内存访问慢、无法充分利用现代 GPU 的并行能力。而 WebGPU 正是为解决这些问题应运而生的新一代图形和计算 API。
一、为什么选择 WebGPU?
1.1 现有技术局限
- WebGL:虽然广泛支持,但仅限于图形渲染,不支持通用计算(GPGPU)。
- WebAssembly + JavaScript:虽可提升性能,但仍受限于 CPU 并行度,难以处理大规模并行运算。
- Worker 线程:可以多线程,但数据同步复杂,且仍受制于 CPU 核心数。
1.2 WebGPU 的优势
| 特性 | WebGL | WebAssembly | WebGPU |
|---|---|---|---|
| 支持 GPGPU | ❌ | ❌ | ✅ |
| 并行计算能力 | 有限 | 有限 | 强大(数千个线程) |
| 内存模型 | 浮点纹理/缓冲区 | C/C++ 导出 | 显式控制资源(Buffer, Texture) |
| 跨平台兼容性 | 高 | 中 | 中(Chrome/Firefox/Safari 支持中) |
📌 关键点:WebGPU 提供了类似 CUDA 或 Vulkan 的编程模型,让你可以在浏览器中直接调用 GPU 进行大规模数值计算,非常适合粒子系统这类“每个粒子独立更新”的场景。
二、项目目标:百万级粒子物理模拟器
我们设计一个简单的粒子系统:
- 每个粒子具有位置 (x, y, z)、速度 (vx, vy, vz) 和质量 m。
- 模拟重力、空气阻力、碰撞检测(边界反射)。
- 使用 WebGPU 编写着色器代码,在 GPU 上并行更新所有粒子状态。
- 最终通过 WebGL 渲染粒子(或用 Canvas 2D 绘制点)。
✅ 目标:单帧内完成 100万粒子的状态更新 + 渲染,保持流畅帧率(>30 FPS)。
三、核心架构设计
整个流程分为三个阶段:
| 阶段 | 描述 | 技术栈 |
|---|---|---|
| 初始化 | 创建 GPU 设备、缓冲区、着色器模块 | WebGPU API |
| 计算 | 在 compute pass 中执行粒子更新逻辑 | WGSL(WebGPU Shading Language) |
| 渲染 | 将粒子数据传给 fragment shader 渲染到 canvas | WebGL / Canvas 2D |
💡 注意:我们不会用 WebGL 渲染粒子,而是用 CanvasRenderingContext2D 来简化演示。实际项目中可用 Instanced Rendering 或 Geometry Shader。
四、完整代码实现(含详细注释)
4.1 初始化 WebGPU 设备与缓冲区
async function initWebGPU() {
if (!navigator.gpu) {
alert("Your browser does not support WebGPU.");
return null;
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) return null;
const device = await adapter.requestDevice();
const canvas = document.getElementById("canvas");
const context = canvas.getContext("webgpu");
// 设置渲染格式
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device,
format: presentationFormat,
alphaMode: "premultiplied",
});
// 创建粒子数据缓冲区(每粒子 16 字节:pos(3f) + vel(3f) + mass(1f))
const particleCount = 1_000_000; // 百万粒子!
const particleBufferSize = particleCount * 16; // 16 bytes per particle
const particleBuffer = device.createBuffer({
size: particleBufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
// 填充初始数据(随机分布 + 初始速度)
const particleData = new Float32Array(particleBufferSize / 4);
for (let i = 0; i < particleCount; ++i) {
const offset = i * 4; // 每粒子占 4 个 float
particleData[offset] = Math.random() * 2 - 1; // x
particleData[offset + 1] = Math.random() * 2 - 1; // y
particleData[offset + 2] = Math.random() * 2 - 1; // z
particleData[offset + 3] = Math.random() * 0.5; // mass
}
new Float32Array(particleBuffer.getMappedRange()).set(particleData);
particleBuffer.unmap();
return { device, canvas, context, particleBuffer };
}
📌 关键点:
- 使用
GPUBufferUsage.STORAGE表示这是用于 GPGPU 的共享内存。 - 数据结构紧凑(16 字节/粒子),适合 GPU 批量读取。
- 初始值随机生成,模拟真实粒子分布。
4.2 编写 WGSL 计算着色器(compute shader)
创建一个 .wgsl 文件内容如下(保存为 particle_sim.wgsl):
struct Particle {
pos: vec3<f32>,
vel: vec3<f32>,
mass: f32,
};
@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
@compute @workgroup_size(256)
fn updateParticles(@builtin(global_invocation_id) global_id: u32) {
let idx = global_id;
if (idx >= 1000000) return;
var p = particles[idx];
// 重力加速度(向下)
const gravity = vec3<f32>(0.0, -9.8, 0.0);
p.vel += gravity * 0.016; // dt = 16ms
// 空气阻力(简单模型)
p.vel *= 0.99;
// 更新位置
p.pos += p.vel * 0.016;
// 边界碰撞检测(假设 [-1,1] 区间)
if (p.pos.x < -1.0 || p.pos.x > 1.0) {
p.vel.x *= -0.8;
p.pos.x = p.pos.x < -1.0 ? -1.0 : 1.0;
}
if (p.pos.y < -1.0 || p.pos.y > 1.0) {
p.vel.y *= -0.8;
p.pos.y = p.pos.y < -1.0 ? -1.0 : 1.0;
}
if (p.pos.z < -1.0 || p.pos.z > 1.0) {
p.vel.z *= -0.8;
p.pos.z = p.pos.z < -1.0 ? -1.0 : 1.0;
}
particles[idx] = p;
}
📌 解析:
@compute表示这是一个计算着色器。@workgroup_size(256):每次启动 256 个线程并行执行。- 共需运行
ceil(1e6 / 256) ≈ 3907个工作组(Workgroup)。 - 每个线程负责一个粒子,完全并行化!
4.3 在 JS 中加载并执行计算着色器
async function runComputePass(device, particleBuffer) {
// 加载 WGSL 模块
const shaderModule = device.createShaderModule({
code: `
struct Particle {
pos: vec3<f32>,
vel: vec3<f32>,
mass: f32,
};
@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
@compute @workgroup_size(256)
fn updateParticles(@builtin(global_invocation_id) global_id: u32) {
let idx = global_id;
if (idx >= 1000000) return;
var p = particles[idx];
const gravity = vec3<f32>(0.0, -9.8, 0.0);
p.vel += gravity * 0.016;
p.vel *= 0.99;
p.pos += p.vel * 0.016;
if (p.pos.x < -1.0 || p.pos.x > 1.0) {
p.vel.x *= -0.8;
p.pos.x = p.pos.x < -1.0 ? -1.0 : 1.0;
}
if (p.pos.y < -1.0 || p.pos.y > 1.0) {
p.vel.y *= -0.8;
p.pos.y = p.pos.y < -1.0 ? -1.0 : 1.0;
}
if (p.pos.z < -1.0 || p.pos.z > 1.0) {
p.vel.z *= -0.8;
p.pos.z = p.pos.z < -1.0 ? -1.0 : 1.0;
}
particles[idx] = p;
}
`,
});
// 创建绑定组(将 buffer 绑定到 shader)
const bindGroup = device.createBindGroup({
layout: device.createBindGroupLayout({
entries: [{
binding: 0,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: "storage" },
}],
}),
entries: [{
binding: 0,
resource: { buffer: particleBuffer },
}],
});
// 创建 compute pipeline
const pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [bindGroup.layout],
}),
compute: {
module: shaderModule,
entryPoint: "updateParticles",
},
});
// 执行 compute pass
const commandEncoder = device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(pipeline);
passEncoder.setBindGroup(0, bindGroup);
passEncoder.dispatchWorkgroups(Math.ceil(1_000_000 / 256));
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
}
📌 性能亮点:
- 不需要复制回 CPU,直接在 GPU 上完成计算。
- 利用
dispatchWorkgroups()自动调度线程池。 - 整个过程可在 10~20ms 内完成(取决于设备性能)。
五、渲染粒子(Canvas 2D 示例)
由于 WebGPU 主要用于计算,渲染部分可以用 Canvas 2D 快速实现:
function renderParticles(device, particleBuffer, canvas) {
const commandEncoder = device.createCommandEncoder();
const stagingBuffer = device.createBuffer({
size: 1_000_000 * 16, // 同样大小
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
// 复制 GPU 数据到 CPU(用于绘制)
commandEncoder.copyBufferToBuffer(
particleBuffer,
0,
stagingBuffer,
0,
1_000_000 * 16
);
device.queue.submit([commandEncoder.finish()]);
// 等待映射完成
stagingBuffer.mapAsync(GPUMapMode.READ).then(() => {
const data = new Float32Array(stagingBuffer.getMappedRange());
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制粒子(简化版:只画前 10k 个)
for (let i = 0; i < Math.min(10000, data.length / 4); ++i) {
const x = (data[i * 4] + 1) * canvas.width / 2;
const y = (data[i * 4 + 1] + 1) * canvas.height / 2;
ctx.fillStyle = "#ffffff";
ctx.fillRect(x, y, 1, 1);
}
stagingBuffer.unmap();
});
}
📌 注意事项:
- 如果你想渲染全部粒子,请改用 WebGL instanced rendering 或使用
GPUTexture+copyTextureToBuffer。 - 当前版本限制:不能直接从 GPU Buffer 绘制到 Canvas,必须先拷贝到 CPU 再绘图(开销略高)。
六、性能测试与优化建议
| 场景 | FPS(估算) | 说明 |
|---|---|---|
| 10万粒子 | ~60 FPS | 可流畅运行 |
| 50万粒子 | ~30 FPS | GPU 负载适中 |
| 100万粒子 | ~20–25 FPS | 几乎达到极限(依赖显卡) |
| 200万粒子 | <15 FPS | 开始出现卡顿(建议分批处理) |
🎯 优化方向:
- 减少每帧粒子数量:动态管理粒子生命周期(出生/死亡)。
- 使用更高效的着色器:避免分支预测失败(如条件语句过多)。
- 异步传输:用
mapAsync+transferSize分批读取数据。 - 多阶段计算:将模拟拆分为多个小任务(例如:先更新位置,再做碰撞检测)。
七、总结与展望
今天我们完成了以下工作:
✅ 成功在浏览器中构建了一个基于 WebGPU 的百万级粒子物理模拟器。
✅ 掌握了 WebGPU 的基本流程:初始化 → 计算 → 渲染。
✅ 实现了完整的 GPGPU 工作流,无需服务器即可在客户端运行复杂科学计算。
未来发展方向:
- 结合 WebGPU + WebXR 实现 AR/VR 中的实时粒子特效。
- 构建跨平台桌面应用(Electron + WebGPU)。
- 用于教育、可视化、游戏开发等场景。
📌 最后提醒:WebGPU 是未来的趋势,但它目前仍在发展中(Chrome 115+ 支持良好)。建议你在生产环境中逐步引入,同时保留 fallback 方案(如 WebAssembly + WASM SIMD)。
如果你对这个项目感兴趣,欢迎访问 GitHub 示例仓库(可提供链接)。谢谢大家!