Build a complete 3D ambient aquarium simulation using the WebGPU compute and graphics pipeline. Learn how to simulate flocking behavior (Boids algorithm) and optimize draw counts using instanced rendering.

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.

Chào mừng các bạn đến với bài học cuối cùng trong chuỗi học trình WebGPU cơ bản! Lần này, chúng ta sẽ ứng dụng tất cả kiến thức đã tích lũy từ các bài học trước – bao gồm thiết lập chiều sâu depth stencil, bộ đệm lưu trữ Compute Storage Buffer, truyền tham số Uniform và nạp chất liệu – để cùng xây dựng một dự án 3D hoàn chỉnh: ColorQuarium 3D (phiên bản WebGPU bể cá Ambient nổi tiếng).

Bài học này sẽ hướng dẫn cách hiện thực hóa thuật toán di chuyển mô phỏng hành vi sinh học của đàn cá song song trên GPU và cách tối ưu vẽ hàng loạt thực thể đa giác bằng Instanced Rendering.

1. Thuật toán đàn cá Boids trên Compute Shader

Để mô phỏng đàn cá bơi lội tự nhiên không đâm vào nhau, chúng ta sử dụng thuật toán Boids (Craig Reynolds, 1987). Thuật toán này định nghĩa hành vi di chuyển của mỗi cá thể dựa trên ba quy tắc vector cục bộ:

\[ \vec{v}_{\text{steering}} = w_s \cdot \vec{f}_{\text{separation}} + w_c \cdot \vec{f}_{\text{cohesion}} + w_a \cdot \vec{f}_{\text{alignment}} \]

2. Instanced Rendering & Căn hướng đối tượng trên GPU

Để vẽ hàng trăm chú cá, ta chỉ truyền đúng 1 mô hình lưới đa giác (mesh) của cá vào đệm đỉnh. Sau đó dùng lệnh gọi drawIndexed(indicesCount, fishCount) để yêu cầu GPU nhân bản mesh này lên $N$ lần.

Trong Vertex Shader, ta đọc thuộc tính vị trí và hướng bay của thực thể cá hiện tại thông qua chỉ số @builtin(instance_index) từ Storage Buffer nhị phân, tự động xoay lưới cá trùng khớp với hướng bơi hiện tại:

let p = fishes[instanceIdx];
let zAxis = normalize(p.vel); // Trục tiến hướng bơi
let xAxis = normalize(cross(vec3<f32>(0.0, 1.0, 0.0), zAxis));
let yAxis = cross(zAxis, xAxis);

let rotMatrix = mat3x3<f32>(xAxis, yAxis, zAxis);
let rotatedPos = rotMatrix * localVertexPos;
let worldPos = rotatedPos + p.pos.xyz; // .xyz: p.pos là vec4 (padding ở w)

3. Hiệu ứng động quẫy đuôi (Tail Waving) trên Vertex Shader

Để mô phỏng đàn cá sống động như bơi thật thay vì di chuyển cứng ngắc như tàu vũ trụ, ta uốn cong đuôi cá (tọa độ $z$ âm) qua hàm sóng lượng giác hình sin ngay trong Vertex Shader:

// Uốn cong tọa độ x của các đỉnh nằm ở đuôi cá theo thời gian
let wave = sin(u.time * 14.0 - localVertexPos.z * 18.0) * 0.07;
if (localVertexPos.z < -0.05) {
  let factor = (-0.05 - localVertexPos.z) / 0.13;
  localVertexPos.x += wave * factor;
}

Bể cá Ambient: ColorQuarium 3D Sandbox

Dưới đây là bể cá 3D hoàn chỉnh được tăng tốc phần cứng WebGPU. 200 thực thể cá neon được mô phỏng hành vi bầy đàn động học thời gian thực, với đuôi quẫy uốn lượn uốn khúc và ánh sáng khúc xạ mờ dưới đại dương.

*Hãy điều chỉnh thanh trượt để thay đổi hành vi và tốc độ bơi của đàn cá.*

🐳 ColorQuarium 3D Viewport

Điều khiển đàn cá

60 FPS Số lượng cá: 200 thực thể
coloraquarium.wgsl
struct Params {
  cohesion : f32,
  separation : f32,
  alignment : f32,
  maxSpeed : f32,
  deltaTime : f32,
  fishCount : u32,
  time : f32,
};

struct Fish {
  pos : vec3<f32>,
  vel : vec3<f32>,
  color : vec3<f32>,
};

@group(0) @binding(0) var<uniform> u : Params;
@group(0) @binding(1) var<storage, read> srcFish : array<Fish>;
@group(0) @binding(2) var<storage, read_write> dstFish : array<Fish>;

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id : vec3<u32>) {
  let idx = id.x;
  if (idx >= u.fishCount) { return; }

  var f = srcFish[idx];
  var pos = f.pos;
  var vel = f.vel;

  var forceCohesion = vec3<f32>(0.0);
  var forceSeparation = vec3<f32>(0.0);
  var forceAlignment = vec3<f32>(0.0);

  var countCohesion = 0.0;
  var countSeparation = 0.0;
  var countAlignment = 0.0;

  // Lặp tìm các lân cận để lái hành vi bầy đàn
  for (var i = 0u; i < u.fishCount; i = i + 1u) {
    if (i == idx) { continue; }
    let other = srcFish[i];
    let diff = pos - other.pos;
    let dist = length(diff);

    if (dist < 0.18 && dist > 0.001) {
      forceSeparation += (diff / dist) * (0.18 - dist);
      countSeparation += 1.0;
    }
  }

  // Tích hợp di chuyển Euler và giới hạn tốc độ tối đa...
  dstFish[idx].pos = pos + vel * u.deltaTime;
  dstFish[idx].vel = vel;
  dstFish[idx].color = f.color;
}
aquarium-loop.js
// Quy trình Double-Buffering chuyển đổi GPU Buffers qua các khung hình
let srcIndex = 0;

function frame() {
  const commandEncoder = device.createCommandEncoder();

  // 1. Compute Pass mô phỏng Boids đàn cá
  const compPass = commandEncoder.beginComputePass();
  compPass.setPipeline(computePipeline);
  compPass.setBindGroup(0, computeBindGroups[srcIndex]);
  compPass.dispatchWorkgroups(Math.ceil(fishCount / 64));
  compPass.end();

  // 2. Render Pass vẽ Instanced Rendering 200 con cá chỉ bằng 1 lệnh gọi
  const renderPass = commandEncoder.beginRenderPass(renderPassDesc);
  renderPass.setPipeline(renderPipeline);
  renderPass.setVertexBuffer(0, vertexBuffer);
  renderPass.setIndexBuffer(indexBuffer, 'uint16');
  renderPass.setBindGroup(0, renderBindGroups[1 - srcIndex]); // Đọc đầu ra của compute
  renderPass.drawIndexed(indicesLength, fishCount); // Instanced
  renderPass.end();

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

  // Hoán đổi buffer nguồn/đích
  srcIndex = 1 - srcIndex;
  requestAnimationFrame(frame);
}

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: Kỹ thuật Instanced Rendering mang lại lợi ích lớn nhất nào cho hiệu năng đồ họa 3D?

Trắc nghiệm ôn tập

Câu 2: Tại sao trong dự án bể cá, chúng ta cần cơ chế Double-Buffering (2 đệm lưu trữ luân phiên) cho Compute Shader?