渲染
计算着色器与粒子
直觉问题:为什么 GPU 能处理数百万个粒子?
Q1: CPU 处理 10 万个粒子会很卡,GPU 处理 100 万个粒子却很流畅,为什么?
Q2: GPU 如何同时运行成千上万个线程,它们如何协调?
核心概念白话讲
计算着色器
计算着色器是一种通用计算 Shader,不依赖图形管线,可以直接操作内存。
与传统 Shader 的区别:
- Vertex/Fragment Shader:输入 → GPU 管线 → 输出
- Compute Shader:输入 → 自定义计算 → 输出
TIP
- WebGL 2.0 使用 Transform Feedback 模拟计算着色器
- WebGPU 原生支持 Compute Shader
Dispatch 与线程组
Dispatch(分发):告诉 GPU 启动多少个工作组
graph LR
A[CPU<br/>dispatchWorkgroups] --> B[GPU<br/>Workgroup Grid]
B --> C[Workgroup 0]
B --> D[Workgroup 1]
C --> E[Thread 0]
C --> F[Thread 1]
D --> G[Thread 0]
D --> H[Thread 1]
Workgroup(工作组):
- 3D 网格
(x, y, z) - 每个工作组包含多个线程
- 工作组内线程可以共享内存
Thread Group(线程组):
- WGSL 使用
@workgroup_size(x, y, z)定义 - 典型配置:
@workgroup_size(16, 16, 1)或@workgroup_size(8, 8, 1)
总线程数计算:
全局线程 ID 计算:
粒子系统
CPU 粒子 vs GPU 粒子:
| 特性 | CPU 粒子 | GPU 粒子 |
|---|---|---|
| 线程数 | 1-8 线程 | 数千线程 |
| 粒子数 | < 10,000 | > 100,000 |
| 物理模拟 | 复杂 | 简单 |
| 内存访问 | 顺序 | 并行 |
GPU 粒子系统流程:
- 初始化:生成随机位置、速度、颜色
- 更新:计算新位置、速度、生命周期
- 渲染:使用 GPU Instancing 绘制粒子
GPU Instancing
传统渲染:每个物体一个 Draw Call Instanced 渲染:一个 Draw Call 渲染多个实例
优势:
- 减少 CPU-GPU 通信
- 降低 Draw Call 开销
- 支持 > 10,000 实例
Instanced 绘制:
- 传入实例数据(位置、旋转、缩放)
- 使用
gl_InstanceID或@builtin(instance_index)区分实例
原理与数学机制
粒子运动方程
位置更新:
速度更新(重力):
阻力:
碰撞检测
边界反弹:
球体碰撞:
线程组内共享内存
Shared Memory(共享内存):
- 工作组内线程共享
- 比全局内存快 10-100 倍
- 使用
var<workgroup>声明
粒子排序示例:
1. 加载粒子数据到共享内存
2. 工作组内排序
3. 写回全局内存
GLSL vs WGSL 代码对照
计算着色器:粒子更新
GLSL 版本(使用 Transform Feedback 模拟)
#version 300 es
precision highp float;
in vec3 a_position;
in vec3 a_velocity;
in float a_life;
out vec3 v_position;
out vec3 v_velocity;
out float v_life;
uniform float u_delta_time;
uniform vec3 u_gravity;
void main() {
vec3 position = a_position;
vec3 velocity = a_velocity;
float life = a_life;
velocity += u_gravity * u_delta_time;
position += velocity * u_delta_time;
life -= u_delta_time;
if (life <= 0.0) {
position = vec3(0.0, 0.0, 0.0);
velocity = normalize(vec3(
fract(sin(gl_VertexID * 12.9898 + 78.233) * 43758.5453),
fract(sin(gl_VertexID * 67.9898 + 23.233) * 43758.5453),
fract(sin(gl_VertexID * 34.9898 + 45.233) * 43758.5453)
)) * 10.0;
life = 1.0;
}
v_position = position;
v_velocity = velocity;
v_life = life;
}
WGSL 版本(原生 Compute Shader)
struct Particle {
position: vec3<f32>,
velocity: vec3<f32>,
life: f32,
}
@group(0) @binding(0)
var<storage, read> input_particles: array<Particle>;
@group(0) @binding(1)
var<storage, read_write> output_particles: array<Particle>;
@group(0) @binding(2)
var<uniform> delta_time: f32;
@group(0) @binding(3)
var<uniform> gravity: vec3<f32>;
fn hash(n: u32) -> f32 {
return fract(sin(f32(n)) * 43758.5453);
}
fn rand(seed: u32) -> vec3<f32> {
return vec3<f32>(hash(seed), hash(seed + 1u), hash(seed + 2u));
}
@compute @workgroup_size(64)
fn update_particles(@builtin(global_invocation_id) global_id: vec3<u32>) {
let index = global_id.x;
if (index >= arrayLength(&input_particles)) {
return;
}
var particle = input_particles[index];
particle.velocity = particle.velocity + gravity * delta_time;
particle.position = particle.position + particle.velocity * delta_time;
particle.life = particle.life - delta_time;
if (particle.life <= 0.0) {
particle.position = vec3<f32>(0.0, 0.0, 0.0);
particle.velocity = normalize(rand(index)) * 10.0;
particle.life = 1.0;
}
output_particles[index] = particle;
}
差异点:
- WGSL 使用
@compute替代 GLSL 的 Transform Feedback - WGSL 使用
@builtin(global_invocation_id)获取线程 ID - WGSL 使用
var<storage, read>声明只读缓冲区 - WGSL 的
@workgroup_size(64)定义线程组大小
GPU Instancing 渲染
GLSL 版本
Vertex Shader:
#version 300 es
precision highp float;
in vec3 a_position;
in vec3 a_normal;
in mat4 a_instance_matrix;
uniform mat4 u_view;
uniform mat4 u_projection;
out vec3 v_normal;
void main() {
vec4 world_position = a_instance_matrix * vec4(a_position, 1.0);
vec4 view_position = u_view * world_position;
gl_Position = u_projection * view_position;
vec3 world_normal = mat3(transpose(inverse(a_instance_matrix))) * a_normal;
v_normal = world_normal;
}
CPU 端代码:
const instanceMatrix = new Float32Array(instanceCount * 16);
for (let i = 0; i < instanceCount; i++) {
const matrix = instanceMatrices[i];
instanceMatrix.set(matrix, i * 16);
}
const instanceBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer);
gl.bufferData(gl.ARRAY_BUFFER, instanceMatrix, gl.DYNAMIC_DRAW);
const instanceLocation = 3; // 起始 attribute 位置
for (let i = 0; i < 4; i++) {
const location = instanceLocation + i;
gl.enableVertexAttribArray(location);
gl.vertexAttribPointer(location, 4, gl.FLOAT, false, 64, i * 16);
gl.vertexAttribDivisor(location, 1);
}
gl.drawArraysInstanced(gl.TRIANGLES, 0, vertexCount, instanceCount);
WGSL 版本
Vertex Shader:
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
}
struct InstanceInput {
@location(2) instance_matrix_0: vec4<f32>,
@location(3) instance_matrix_1: vec4<f32>,
@location(4) instance_matrix_2: vec4<f32>,
@location(5) instance_matrix_3: vec4<f32>,
}
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) normal: vec3<f32>,
}
@group(0) @binding(0)
var<uniform> view: mat4x4<f32>;
@group(0) @binding(1)
var<uniform> projection: mat4x4<f32>;
@vertex
fn vs_main(
input: VertexInput,
instance: InstanceInput,
@builtin(instance_index) instance_index: u32
) -> VertexOutput {
var output: VertexOutput;
let instance_matrix = mat4x4<f32>(
instance.instance_matrix_0,
instance.instance_matrix_1,
instance.instance_matrix_2,
instance.instance_matrix_3
);
let world_position = instance_matrix * vec4<f32>(input.position, 1.0);
let view_position = view * world_position;
output.position = projection * view_position;
let world_normal = mat3x3<f32>(instance_matrix) * input.normal;
output.normal = world_normal;
return output;
}
CPU 端代码:
const instanceBuffer = device.createBuffer({
size: instanceCount * 64, // 4 vec4 per matrix
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(instanceBuffer, 0, instanceData);
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: uniformBuffer.viewBuffer } },
{ binding: 1, resource: { buffer: uniformBuffer.projectionBuffer } },
],
});
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.setVertexBuffer(0, vertexBuffer);
pass.setVertexBuffer(1, instanceBuffer);
pass.draw(vertexCount, instanceCount);
pass.end();
差异点:
- WGSL 矩阵需拆分为 4 个
vec4传递 - WGSL 使用
@builtin(instance_index)获取实例 ID - WGSL 使用
draw(vertexCount, instanceCount)语法
常见误区与陷阱
-
线程组大小不当
- 陷阱:使用
@workgroup_size(1, 1, 1)导致严重性能下降 - 解决:根据 GPU warp/wavefront 大小调整(通常 32 或 64)
- 陷阱:使用
-
共享内存竞争
- 陷阱:多个线程同时写入共享内存导致数据错误
- 解决:使用原子操作
atomicAdd或atomicExchange
-
边界检查缺失
- 陷阱:线程索引越界导致崩溃
- 解决:
if (global_id.x < particle_count) return;
-
内存访问不连续
- 陷阱:随机访问内存导致缓存命中率低
- 解决:确保线程访问模式连续(SoA 布局)
-
GPU Instancing 矩阵传递错误
- 陷阱:矩阵行列式为 0 导致渲染异常
- 解决:确保矩阵正交化或使用正交矩阵
-
Transform Feedback 兼容性
- 陷阱:WebGL 1.0 不支持 Transform Feedback
- 解决:使用 WebGL 2.0 或 WebGPU
-
粒子生命周期未重置
- 陷阱:粒子消失后不重生,粒子数逐渐减少
- 解决:
if (life <= 0.0) respawn();
-
Workgroup 数量计算错误
- 陷阱:Dispatch 数量不足导致部分粒子未处理
- 解决:
dispatch((particle_count + workgroup_size - 1) / workgroup_size)
延伸阅读与自测
权威资料
- WebGPU Compute Shaders - WebGPU 计算着色器教程
- Vulkan Compute Shader - Vulkan 计算着色器指南
- GPU Instancing - LearnOpenGL - OpenGL 实例渲染教程
- Particle System in WebGPU - WebGPU 粒子系统论文
开源实现参考
- WebGPU-Samples - WebGPU 官方示例(包含 Compute Shader)
- Three.js InstancedMesh - Three.js 实例化网格
- GPU Gems - Chapter 31 - GPU 粒子动力学
自测题
-
思考题: 为什么 GPU 能同时处理数百万个粒子,而 CPU 只能处理数千个?
-
对比题: Compute Shader 与 Transform Feedback 的区别是什么?为什么 WebGPU 原生支持 Compute Shader?
-
实践题: 如何实现一个支持碰撞检测的 GPU 粒子系统?如何处理粒子间碰撞?
-
扩展题: GPU Instancing 如何实现阴影渲染?如何处理不同材质的实例?
-
优化题: 如何优化粒子系统的内存布局?SoA(Structure of Arrays) vs AoS(Array of Structures)哪个更适合 GPU?
参考资料获取时间: 2026-07-06,通过 web-search-prime_web_search_prime 工具检索。