Explore particle systems simulated entirely on the GPU. Learn about compute update cycles, storage buffer layouts, eliminating PCIe bus bottlenecks, and drawing millions of vertices efficiently using Instanced Rendering in WebGPU.

This lesson is currently only available in Vietnamese. Please switch the language toggle in the menu to Vietnamese to read the full guide and take the interactive quiz.

Mô phỏng hệ thống hạt (Particle Systems) là một kỹ thuật kinh điển để tạo ra các hiệu ứng hình ảnh động như khói, lửa, bụi bặm, vụ nổ hoặc bão cát. Tuy nhiên, nếu chúng ta tính toán tọa độ vật lý của hàng vạn hạt trên CPU bằng JavaScript rồi đẩy dữ liệu đó lên card đồ họa ở mỗi khung hình, chương trình sẽ nhanh chóng bị nghẽn và sụt giảm FPS trầm trọng.

Nguyên nhân chính là do nghẽn băng thông truyền tải PCIe (PCIe Bus Bottleneck). Giải pháp tối ưu nhất là giữ toàn bộ dữ liệu trạng thái hạt trực tiếp trên bộ nhớ video (VRAM) của GPU thông qua Storage Buffer, sử dụng Compute Shader để cập nhật trạng thái vật lý của hạt và Render Pipeline vẽ chúng tức thời bằng kỹ thuật Instanced Rendering.

1. Khắc phục điểm nghẽn PCIe bằng Zero-Copy

Trong kiến trúc máy tính truyền thống, CPU và GPU sở hữu các vùng bộ nhớ độc lập. Khi CPU cập nhật vị trí hạt, nó phải đóng gói dữ liệu và gửi thông qua giao tiếp Bus PCIe lên GPU. Khi số lượng hạt tăng từ $1,000$ lên $100,000$, dung lượng dữ liệu truyền tải tăng vọt gây quá tải băng thông PCIe, khiến GPU phải liên tục nhàn rỗi chờ đợi CPU (CPU-bound).

Bằng cách ứng dụng Zero-Copy Simulation trên WebGPU, ta khởi tạo các hạt một lần duy nhất trên VRAM. Ở mỗi khung hình:

  1. Compute Shader đọc tọa độ cũ từ Storage Buffer, cập nhật chuyển động vật lý mới và ghi đè trực tiếp kết quả vào Storage Buffer đó.
  2. Render Shader đọc thẳng dữ liệu từ Storage Buffer đó để vẽ ra màn hình.

Dữ liệu được duy trì hoàn toàn trên GPU mà không cần bất kỳ chuyến hành trình khứ hồi (round-trip) nào qua CPU, giúp giải phóng băng thông tối đa và đạt tốc độ render lên tới hàng trăm ngàn hạt mượt mà.

2. Khai báo hạt trong Storage Buffer

Mỗi hạt (Particle) thường có các thuộc tính: Vị trí (Position), Vận tốc (Velocity), Màu sắc (Color), và Tuổi thọ (Life). Ta định nghĩa cấu trúc hạt trong WGSL như sau:

struct Particle {
  pos : vec2<f32>,
  vel : vec2<f32>,
  color : vec4<f32>,
  life : f32,
}

@group(0) @binding(1) var<storage, read_write> particles : array<Particle>;

3. Vẽ hàng loạt bằng Instanced Rendering

Instanced Rendering (Vẽ hàng loạt đối tượng) cho phép chúng ta vẽ cùng một mô hình lưới cơ sở (Mesh) nhiều lần bằng cách chỉ gửi 1 lệnh vẽ duy nhất lên GPU, nhưng mỗi phiên bản (Instance) lại sở hữu một biến đổi riêng (như vị trí, màu sắc).

Để vẽ hàng vạn hạt hình vuông, ta không cần nạp 6 đỉnh cho mỗi hạt vuông đó vào Vertex Buffer. Thay vào đó, ta chỉ cần định nghĩa 1 hình vuông mẫu 6 đỉnh duy nhất (Quad). Trong Vertex Shader, ta sử dụng biến hệ thống:

  • @builtin(vertex_index): Để xác định đỉnh nào của hình vuông mẫu đang được xử lý (từ 0 đến 5) nhằm tạo ra tọa độ cục bộ của hình vuông.
  • @builtin(instance_index): Để xác định ID của hạt đang vẽ, dùng ID này truy cập vào Storage Buffer lấy vị trí trung tâm thực tế của hạt đó.
\[\text{vị trí đỉnh tuyệt đối} = \text{tọa độ trung tâm hạt} + (\text{tọa độ đỉnh cục bộ} \times \text{kích thước hạt})\]

Sân chơi tương tác: 100k GPU Particles Sandbox

Hãy chạy thử nghiệm mô phỏng hạt WebGPU dưới đây. Bạn có thể chọn số lượng hạt lên tới 100,000, điều chỉnh các thông số trọng lực, gió xoáy và di chuột trực tiếp trên khung vẽ để cuốn các hạt chuyển động theo con trỏ chuột:

✨ Mô Phỏng Hạt Bằng Compute Shader

Cấu hình mô phỏng

60 FPS
particles.wgsl
struct Particle {
  pos : vec2<f32>,
  vel : vec2<f32>,
  color : vec4<f32>,
  life : f32,
};

struct Params {
  gravity : f32,
  wind : f32,
  mousePos : vec2<f32>,
  mouseActive : u32,
  deltaTime : f32,
  particleSize : f32,
};

@group(0) @binding(0) var<uniform> u : Params;
@group(0) @binding(1) var<storage, read_write> particles : array<Particle>;

// Compute Shader: Cập nhật vị trí hạt
@compute @workgroup_size(64)
fn compMain(@builtin(global_invocation_id) id : vec3<u32>) {
  let idx = id.x;
  if (idx >= arrayLength(&particles)) {
    return;
  }

  var p = particles[idx];

  // Giảm tuổi thọ hạt
  p.life = p.life - u.deltaTime;

  // Hồi sinh hạt khi hết tuổi thọ
  if (p.life <= 0.0) {
    p.pos = vec2<f32>(0.0, 0.0);
    p.life = 1.0 + fract(sin(f32(idx)) * 43758.5453);
    p.vel = vec2<f32>(
      (fract(sin(f32(idx) * 1.5) * 43758.5453) - 0.5) * 1.5,
      (fract(cos(f32(idx) * 2.3) * 43758.5453) - 0.5) * 1.5
    );
  } else {
    // Áp dụng trọng lực hướng xuống
    p.vel.y = p.vel.y - u.gravity * u.deltaTime;

    // Áp dụng gió xoáy tâm
    let toCenter = -p.pos;
    let dist = length(toCenter);
    if (dist > 0.01) {
      let dir = toCenter / dist;
      let tangent = vec2<f32>(-dir.y, dir.x);
      p.vel = p.vel + tangent * u.wind * u.deltaTime;
    }

    // Tương tác với chuột
    if (u.mouseActive == 1u) {
      let toMouse = u.mousePos - p.pos;
      let mouseDist = length(toMouse);
      if (mouseDist < 0.8 && mouseDist > 0.01) {
        let mouseDir = toMouse / mouseDist;
        p.vel = p.vel + mouseDir * (0.8 - mouseDist) * u.deltaTime * 3.0;
      }
    }

    // Cập nhật vị trí theo vận tốc
    p.pos = p.pos + p.vel * u.deltaTime;
    
    // Ma sát chậm hạt lại nhẹ
    p.vel = p.vel * 0.99;
  }

  particles[idx] = p;
}
particles-setup.js
// Khai báo lệnh vẽ Instanced Rendering
const commandEncoder = device.createCommandEncoder();

// 1. Chạy Compute Shader cập nhật hạt
const compPass = commandEncoder.beginComputePass();
compPass.setPipeline(computePipeline);
compPass.setBindGroup(0, bindGroup);
compPass.dispatchWorkgroups(Math.ceil(particleCount / 64));
compPass.end();

// 2. Chạy Render Shader vẽ hạt
const renderPass = commandEncoder.beginRenderPass(renderPassDesc);
renderPass.setPipeline(renderPipeline);
renderPass.setBindGroup(0, bindGroup);
// Vẽ 6 đỉnh cho mỗi hạt, lặp lại particleCount lần
renderPass.draw(6, particleCount, 0, 0);
renderPass.end();

device.queue.submit([commandEncoder.finish()]);

Tài liệu tham khảo chuyên sâu & Trích dẫn Học thuật:

Trắc nghiệm ôn tập

Câu 1: Tại sao việc cập nhật vị trí của 100,000 hạt trên CPU rồi đẩy lên GPU lại gây giảm FPS nghiêm trọng?

Trắc nghiệm ôn tập

Câu 2: Trong Vertex Shader của Instanced Rendering, biến `@builtin(instance_index)` có vai trò gì?