返回知识库

渲染

计算着色器与粒子

计算着色器与粒子 封面
WebGPU/WebGLShaderGLSL/WGSLCompute ShaderGPU 粒子GPU Instancing

直觉问题:为什么 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)

总线程数计算

TotalThreads=GridSize×WorkgroupSize\text{TotalThreads} = \text{GridSize} \times \text{WorkgroupSize}

全局线程 ID 计算

globalID=workgroupID×workgroupSize+localID\text{globalID} = \text{workgroupID} \times \text{workgroupSize} + \text{localID}

粒子系统

CPU 粒子 vs GPU 粒子

特性CPU 粒子GPU 粒子
线程数1-8 线程数千线程
粒子数< 10,000> 100,000
物理模拟复杂简单
内存访问顺序并行

GPU 粒子系统流程

  1. 初始化:生成随机位置、速度、颜色
  2. 更新:计算新位置、速度、生命周期
  3. 渲染:使用 GPU Instancing 绘制粒子

GPU Instancing

传统渲染:每个物体一个 Draw Call Instanced 渲染:一个 Draw Call 渲染多个实例

优势

  • 减少 CPU-GPU 通信
  • 降低 Draw Call 开销
  • 支持 > 10,000 实例

Instanced 绘制

  • 传入实例数据(位置、旋转、缩放)
  • 使用 gl_InstanceID@builtin(instance_index) 区分实例

原理与数学机制

粒子运动方程

位置更新

pt+1=pt+vtΔtp_{t+1} = p_t + v_t \cdot \Delta t

速度更新(重力)

vt+1=vt+gΔtv_{t+1} = v_t + g \cdot \Delta t

阻力

vt+1=vt(1drag)v_{t+1} = v_t \cdot (1 - \text{drag})

碰撞检测

边界反弹

if pt>boundary:\text{if } |p_t| > \text{boundary}: vt+1=vtrestitutionv_{t+1} = -v_t \cdot \text{restitution}

球体碰撞

dist=p1p2\text{dist} = \|p_1 - p_2\| if dist<r1+r2:\text{if } \text{dist} < r_1 + r_2: v1,v2=elasticCollision(v1,v2)v_1, v_2 = \text{elasticCollision}(v_1, v_2)

线程组内共享内存

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) 语法

常见误区与陷阱

  1. 线程组大小不当

    • 陷阱:使用 @workgroup_size(1, 1, 1) 导致严重性能下降
    • 解决:根据 GPU warp/wavefront 大小调整(通常 32 或 64)
  2. 共享内存竞争

    • 陷阱:多个线程同时写入共享内存导致数据错误
    • 解决:使用原子操作 atomicAddatomicExchange
  3. 边界检查缺失

    • 陷阱:线程索引越界导致崩溃
    • 解决:if (global_id.x < particle_count) return;
  4. 内存访问不连续

    • 陷阱:随机访问内存导致缓存命中率低
    • 解决:确保线程访问模式连续(SoA 布局)
  5. GPU Instancing 矩阵传递错误

    • 陷阱:矩阵行列式为 0 导致渲染异常
    • 解决:确保矩阵正交化或使用正交矩阵
  6. Transform Feedback 兼容性

    • 陷阱:WebGL 1.0 不支持 Transform Feedback
    • 解决:使用 WebGL 2.0 或 WebGPU
  7. 粒子生命周期未重置

    • 陷阱:粒子消失后不重生,粒子数逐渐减少
    • 解决:if (life <= 0.0) respawn();
  8. Workgroup 数量计算错误

    • 陷阱:Dispatch 数量不足导致部分粒子未处理
    • 解决:dispatch((particle_count + workgroup_size - 1) / workgroup_size)

延伸阅读与自测

权威资料

开源实现参考

自测题

  1. 思考题: 为什么 GPU 能同时处理数百万个粒子,而 CPU 只能处理数千个?

  2. 对比题: Compute Shader 与 Transform Feedback 的区别是什么?为什么 WebGPU 原生支持 Compute Shader?

  3. 实践题: 如何实现一个支持碰撞检测的 GPU 粒子系统?如何处理粒子间碰撞?

  4. 扩展题: GPU Instancing 如何实现阴影渲染?如何处理不同材质的实例?

  5. 优化题: 如何优化粒子系统的内存布局?SoA(Structure of Arrays) vs AoS(Array of Structures)哪个更适合 GPU?


参考资料获取时间: 2026-07-06,通过 web-search-prime_web_search_prime 工具检索。