1. Giới Thiệu & Cấu Trúc Dự Án
Ở Phần 1, chúng ta đã nắm vững nền tảng toán học của phương trình sóng 2D và các thuật toán rời rạc hóa. Trong bài viết này, chúng ta sẽ bắt tay vào viết mã nguồn thực tế (Hands-on Coding) cho cả hai phương pháp: CPU Canvas 2D và GPU WebGL Shader.
2. Hiện Thực Phương Pháp 1: Canvas 2D (Thuật Toán Hugo Elias)
2.1. Khởi Tạo Mảng Dữ Liệu & Kỹ Thuật Double-Buffering
Để đạt hiệu năng cao trên CPU, ta dùng hai mảng Int16Array đại diện cho độ cao mặt nước ở khung hình hiện tại và khung hình kế tiếp. Hai mảng này sẽ hoán đổi con trỏ qua từng frame (Double-Buffering):
const width = 320;
const height = 240;
const size = width * height;
let buffer1 = new Int16Array(size);
let buffer2 = new Int16Array(size);
let currentBuffer = buffer1;
let nextBuffer = buffer2;
// Hàm tạo điểm tác động sóng (Kích thích năng lượng ban đầu)
function dropAt(x, y, radius, strength) {
for (let j = -radius; j <= radius; j++) {
for (let i = -radius; i <= radius; i++) {
if (i * i + j * j <= radius * radius) {
const px = x + i;
const py = y + j;
if (px >= 0 && px < width && py >= 0 && py < height) {
currentBuffer[py * width + px] = strength;
}
}
}
}
}
2.2. Vòng Lặp Mô Phỏng Lan Truyền Sóng
Trong mỗi khung hình requestAnimationFrame, ta quét toàn bộ các điểm bên trong lưới pixel, tính trung bình 4 điểm lân cận (Bắc, Nam, Đông, Tây), trừ đi giá trị cũ ở nextBuffer và áp dụng hệ số suy giảm ma sát bằng thao tác Bitwise Shift >> 5:
function processRipples() {
for (let y = 1; y < height - 1; y++) {
let offset = y * width;
for (let x = 1; x < width - 1; x++) {
const i = offset + x;
// Công thức Hugo Elias: u_{t+1} = (1/2) * Σneighbors - u_{t-1}
let data = (
currentBuffer[i - 1] + // Trái (West)
currentBuffer[i + 1] + // Phải (East)
currentBuffer[i - width] + // Trên (North)
currentBuffer[i + width] // Dưới (South)
) >> 1; // Tương đương chia 2
data -= nextBuffer[i]; // Trừ u_{t-1}
data -= data >> 5; // Ma sát/Damping (~3.125% giảm năng lượng)
nextBuffer[i] = data;
}
}
// Hoán đổi 2 buffer
const temp = currentBuffer;
currentBuffer = nextBuffer;
nextBuffer = temp;
}
2.3. Khúc Xạ Ảnh Nền & Render Pixel
Sử dụng chênh lệch độ cao giữa các pixel lân cận để tính toán lượng dịch chuyển tọa độ UV khi đọc ImageData ảnh gốc:
function renderDisplacement(ctx, sourceImageData, outputImageData) {
const src = sourceImageData.data;
const dst = outputImageData.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
// Tính độ dốc (Gradient) bề mặt nước theo trục X và Y
const xOffset = (x > 0 && x < width - 1) ? currentBuffer[i - 1] - currentBuffer[i + 1] : 0;
const yOffset = (y > 0 && y < height - 1) ? currentBuffer[i - width] - currentBuffer[i + width] : 0;
// Tọa độ lấy mẫu ảnh gốc đã bị bẻ cong theo khúc xạ
let pixelX = Math.min(width - 1, Math.max(0, x + (xOffset >> 3)));
let pixelY = Math.min(height - 1, Math.max(0, y + (yOffset >> 3)));
const srcIdx = (pixelY * width + pixelX) * 4;
const dstIdx = i * 4;
dst[dstIdx] = src[srcIdx]; // Red
dst[dstIdx + 1] = src[srcIdx + 1]; // Green
dst[dstIdx + 2] = src[srcIdx + 2]; // Blue
dst[dstIdx + 3] = 255; // Alpha
}
}
ctx.putImageData(outputImageData, 0, 0);
}
3. Hiện Thực Phương Pháp 2: WebGL GLSL Shader (GPU-based)
3.1. Cấu Trúc Fragment Shader Năng Lượng Cao
Dưới đây là đoạn mã GLSL Fragment Shader nhận tọa độ và thời gian để tính toán gợn sóng trực tiếp trên card đồ họa:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
uniform sampler2D u_texture;
void main() {
// Đưa tọa độ màn hình về chuẩn NDC [-1, 1]
vec2 cPos = -1.0 + 2.0 * gl_FragCoord.xy / u_resolution.xy;
// Tính khoảng cách r tới tâm màn hình
float cLength = length(cPos);
// Nghiệm sóng chạy hình sin: A * cos(k*r - w*t)
// k = 12.0 (số sóng), w = 4.0 (tần số góc), A = 0.03 (biên độ khúc xạ)
vec2 uvOffset = (cPos / cLength) * cos(cLength * 12.0 - u_time * 4.0) * 0.03;
// Tọa độ UV lấy mẫu texture ban đầu
vec2 uv = (gl_FragCoord.xy / u_resolution.xy) + uvOffset;
// Lấy màu từ texture nền với tọa độ UV đã biến dạng
gl_FragColor = texture2D(u_texture, uv);
}
3.2. Mã Nguồn Khởi Tạo WebGL & Truyền Uniform
const canvas = document.getElementById('webgl-canvas');
const gl = canvas.getContext('webgl');
// Tạo Shader Program, Buffers và nạp Texture nền...
// Trong vòng lặp render animation:
function render(time) {
gl.useProgram(program);
// Truyền thời gian (tính bằng giây) và độ phân giải
gl.uniform1f(uTimeLocation, time * 0.001);
gl.uniform2f(uResolutionLocation, canvas.width, canvas.height);
// Vẽ Quad 2D bao phủ toàn màn hình
gl.drawArrays(gl.TRIANGLES, 0, 6);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
4. Kết Luận & Khuyên Dùng Cho Dự Án Thực Tế
- Khi nào chọn Canvas 2D CPU (Hugo Elias): Khi dự án đòi hỏi tương tác vật lý phong phú (nhiều hạt mưa rơi đồng thời, chuột tương tác tạo sóng giao thoa chân thực) và kích thước vùng chứa nhỏ (ví dụ: Widget 300x300px hoặc canvas độ phân giải thấp).
- Khi nào chọn WebGL GPU Shader: Khi xây dựng Background Hero Banner, Landing Page đòi hỏi khung hình mượt mà 60 FPS trên màn hình Retina 4K/8K mà không làm quá tải CPU của người dùng.
Chia sẻ