This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.
Chào mừng bạn đến với bài học cuối cùng trong lộ trình lập trình Canvas 2D! Ở phần này, chúng ta sẽ không chỉ viết code để tạo ra nghệ thuật kỹ thuật số (Generative Art) mà còn đi sâu xuống tầng kiến trúc trình duyệt, phân tích cách tối ưu hóa phần cứng để đạt hiệu năng dựng hình 60 FPS mượt mà.
1. Perlin / Simplex Noise: Trái Tim Của Generative Art
Trong nghệ thuật sinh ngẫu nhiên, ta điều khiển chuyển động của hàng ngàn hạt bụi, địa hình, mây trời dựa trên một nguồn "ngẫu nhiên có trật tự". Cần phân biệt hai loại nhiễu:
- White Noise (Nhiễu trắng - Math.random): Trả về các giá trị ngẫu nhiên hoàn toàn độc lập, không liên quan gì đến các điểm xung quanh. Dùng để vẽ dòng chảy sẽ cho kết quả hỗn loạn, giật cục, rời rạc — như tuyết nhiễu trên TV cũ.
- Coherent Noise (Nhiễu liên tục - Perlin/Simplex): Giá trị thay đổi mượt mà, liên tục theo tọa độ. Nếu đầu vào \(x, y\) đổi một lượng cực nhỏ, giá trị trả về cũng chỉ lệch một lượng nhỏ tương ứng — tạo ra dòng chảy mềm mại mô phỏng gió, sóng nước, vân gỗ, mây.
Ken Perlin phát minh Perlin Noise năm 1983 cho phim Tron (và đoạt giải Oscar kỹ thuật). Có hai dòng chính cần nắm:
- Value Noise: gán một giá trị ngẫu nhiên cho mỗi đỉnh lưới nguyên, rồi nội suy (interpolate) mượt giữa các đỉnh. Đơn giản, nhanh, nhưng dễ lộ "khối vuông" theo lưới.
- Gradient Noise (Perlin): gán một vector gradient ngẫu nhiên cho mỗi đỉnh lưới, rồi nội suy dựa trên tích vô hướng (dot product) giữa gradient và vector khoảng cách. Cho kết quả tự nhiên hơn nhiều, ít artifact lưới. Simplex Noise (Perlin, 2001) là bản cải tiến dùng lưới tam giác/simplex thay vì lưới vuông, nhanh hơn ở số chiều cao và ít nhiễu hướng (directional artifact).
Bí quyết để mịn mượt là hàm làm mượt (fade/ease). Thay vì nội suy tuyến tính thô, Perlin dùng đa thức bậc 5 \(6t^5 - 15t^4 + 10t^3\) (có đạo hàm bậc 1 và bậc 2 đều bằng 0 tại hai đầu) để các đường đẳng trị liền mạch không gãy khúc. Dưới đây là một bản value noise 2D tự viết, hoàn toàn không cần thư viện:
// Value noise 2D tự viết — không phụ thuộc thư viện
function makeNoise2D(seed = 1) {
// Hash giả ngẫu nhiên từ toạ độ nguyên (i, j)
function hash(i, j) {
let n = i * 374761393 + j * 668265263 + seed * 69069;
n = (n ^ (n >> 13)) * 1274126177;
return ((n ^ (n >> 16)) >>> 0) / 4294967295; // 0..1
}
// Hàm làm mượt bậc 5 của Perlin
const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10);
const lerp = (a, b, t) => a + (b - a) * t;
return function (x, y) {
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const u = fade(xf), v = fade(yf);
// Nội suy song tuyến giữa 4 đỉnh lưới
const x1 = lerp(hash(xi, yi), hash(xi + 1, yi), u);
const x2 = lerp(hash(xi, yi + 1), hash(xi + 1, yi + 1), u);
return lerp(x1, x2, v); // 0..1
};
}
Một mẫu noise đơn lẻ trông khá "mịn phẳng". Để có chi tiết phong phú như địa hình thật, ta cộng dồn nhiều lớp (octave) noise với tần số tăng dần và biên độ giảm dần — kỹ thuật gọi là fractal Brownian Motion (fBm). Mỗi octave thêm chi tiết nhỏ hơn: octave thấp tạo dáng núi lớn, octave cao tạo đá sỏi gồ ghề. Hai tham số điều khiển: lacunarity (tần số nhân mỗi octave, thường ×2) và persistence (biên độ nhân mỗi octave, thường ×0.5):
// Cộng dồn nhiều octave noise → fractal Brownian Motion
function fbm(noise2D, x, y, octaves = 5, lacunarity = 2, persistence = 0.5) {
let value = 0, amplitude = 1, frequency = 1, max = 0;
for (let o = 0; o < octaves; o++) {
value += noise2D(x * frequency, y * frequency) * amplitude;
max += amplitude;
frequency *= lacunarity; // octave sau: tần số cao hơn (chi tiết nhỏ)
amplitude *= persistence; // octave sau: biên độ thấp hơn (đóng góp ít)
}
return value / max; // chuẩn hoá về 0..1
}
// Ứng dụng terrain: map giá trị fbm thành độ cao → màu
function terrainColor(h) {
if (h < 0.4) return '#1e3a8a'; // biển sâu
if (h < 0.5) return '#3b82f6'; // nước nông
if (h < 0.55) return '#fcd34d'; // cát
if (h < 0.75) return '#16a34a'; // rừng
if (h < 0.9) return '#78716c'; // núi đá
return '#f8fafc'; // tuyết đỉnh
}
Cũng chính nhờ tính liên tục theo thời gian: nếu thêm chiều thứ ba \(z = time\) (hoặc đơn giản dịch chuyển toạ độ lấy mẫu theo thời gian), ta được noise cuộn động dùng cho mây bay, khói, lửa. Demo flow field ở Mục 2 sẽ dùng chính bản noise này để định hướng hàng trăm hạt.
2. Flow Fields: Hàng Trăm Hạt Trôi Theo Trường Vector
Flow field (trường dòng chảy) là một lưới vector phủ kín màn hình: tại mỗi điểm \((x, y)\) ta gán một góc hướng, và các hạt di chuyển sẽ "đọc" góc tại vị trí của mình để biết nên trôi về đâu. Nếu góc đó được sinh từ Perlin noise, các hạt sẽ uốn lượn thành những dòng chảy mượt mà tuyệt đẹp — đây là một trong những kỹ thuật generative art kinh điển nhất.
Công thức cốt lõi: angle = noise(x * scale, y * scale) * TAU * k. Tham số
scale quyết định trường "xoáy mịn" hay "rối": scale nhỏ → các dòng chảy lớn, thoải; scale lớn
→ nhiều xoáy nhỏ chi chít. Mỗi frame, hạt cập nhật vận tốc theo hướng vector tại vị trí của nó:
const noise2D = makeNoise2D(42);
const TAU = Math.PI * 2;
const particles = Array.from({ length: 400 }, () => ({
x: Math.random() * W, y: Math.random() * H, px: 0, py: 0
}));
function step(scale, speed) {
for (const p of particles) {
p.px = p.x; p.py = p.y;
// Đọc góc hướng từ noise tại vị trí hạt
const angle = noise2D(p.x * scale, p.y * scale) * TAU * 2;
p.x += Math.cos(angle) * speed;
p.y += Math.sin(angle) * speed;
// Quấn vòng quanh mép (wrap-around) để hạt không biến mất
if (p.x < 0) p.x = W; if (p.x > W) p.x = 0;
if (p.y < 0) p.y = H; if (p.y > H) p.y = 0;
}
}
Bí quyết tạo vệt mờ (trail) đẹp: thay vì clearRect xoá sạch mỗi frame,
ta phủ một lớp màu nền bán trong suốt lên trên (ví dụ rgba(10,15,30,0.06)). Các
nét vẽ cũ mờ dần qua nhiều frame thay vì biến mất ngay, tạo cảm giác chuyển động lưu luyến như sơn
dầu. Sau đó chỉ cần nối (px, py) → (x, y) bằng lineTo:
function render() {
// KHÔNG clearRect — phủ lớp nền mờ để tạo trail mượt
ctx.fillStyle = 'rgba(10, 15, 30, 0.06)';
ctx.fillRect(0, 0, W, H);
ctx.lineWidth = 1.2;
ctx.beginPath();
for (const p of particles) {
ctx.moveTo(p.px, p.py);
ctx.lineTo(p.x, p.y);
}
ctx.strokeStyle = 'hsla(200, 80%, 65%, 0.5)';
ctx.stroke();
requestAnimationFrame(render);
}
Hãy thử nghiệm trực tiếp trường dòng chảy Perlin dưới đây — kéo các thanh trượt để đổi độ mịn của trường và số lượng hạt:
3. L-Systems & Fractals: Ngữ Pháp Sinh Hình
L-System (Lindenmayer System, do nhà sinh học Aristid Lindenmayer đề xuất năm 1968 để mô hình hóa sự phát triển của thực vật) là một ngữ pháp viết lại chuỗi (string rewriting grammar). Bắt đầu từ một chuỗi gốc (axiom), ta áp dụng các luật thay thế (production rules) lặp đi lặp lại; mỗi vòng lặp khiến chuỗi dài ra theo cấp số nhân, mã hóa một cấu trúc fractal tự tương tự (self-similar).
Ví dụ kinh điển cây nhị phân: axiom F, luật F → FF+[+F-F-F]-[-F+F+F]. Sau
khi sinh chuỗi, ta "đọc" nó bằng turtle graphics: mỗi ký tự là một lệnh cho con rùa
vẽ. F = tiến tới và vẽ, + = quay phải một góc, - = quay trái,
[ = lưu trạng thái (vị trí + góc) vào stack, ] = khôi phục trạng thái. Chính
cặp [ ] tạo ra các nhánh rẽ:
// Sinh chuỗi L-System sau n lần viết lại
function generateLSystem(axiom, rules, iterations) {
let s = axiom;
for (let i = 0; i < iterations; i++) {
let next = '';
for (const ch of s) next += rules[ch] || ch;
s = next;
}
return s;
}
// Cây thực vật
const tree = generateLSystem('X', {
'X': 'F+[[X]-X]-F[-FX]+X',
'F': 'FF'
}, 4);
// Bông tuyết Koch: axiom 'F', luật F → F+F--F+F, góc 60°
const koch = generateLSystem('F', { 'F': 'F+F--F+F' }, 4);
Bước vẽ dùng turtle graphics với một stack để xử lý nhánh. Lưu ý dùng
ctx.save()/restore() hoặc tự quản lý mảng stack — ở đây ta tự quản lý để minh họa rõ cơ
chế:
function drawTurtle(ctx, str, { x, y, angle, len, turn }) {
const stack = [];
for (const ch of str) {
if (ch === 'F') {
const nx = x + Math.cos(angle) * len;
const ny = y + Math.sin(angle) * len;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(nx, ny); ctx.stroke();
x = nx; y = ny;
} else if (ch === '+') angle += turn;
else if (ch === '-') angle -= turn;
else if (ch === '[') stack.push({ x, y, angle }); // rẽ nhánh
else if (ch === ']') { ({ x, y, angle } = stack.pop()); } // quay về thân
}
}
drawTurtle(ctx, tree, { x: 300, y: 280, angle: -Math.PI / 2,
len: 6, turn: 25 * Math.PI / 180 });
Thử nghiệm trực tiếp cây fractal L-System mọc dần dưới đây — kéo thanh trượt độ sâu đệ quy và góc rẽ nhánh để thấy hình dạng cây biến đổi:
4. requestAnimationFrame & Profiling: Ngân Sách 16.6ms
requestAnimationFrame (rAF) đồng bộ vòng vẽ với chu kỳ làm tươi màn hình (thường 60Hz). Ở
60 FPS, bạn có đúng 16.6ms cho mỗi frame để làm tất cả: cập nhật logic, vật
lý, vẽ. Vượt quá ngân sách này → trình duyệt bỏ frame (dropped frame) → giật (jank). Trên màn 120Hz,
ngân sách còn khắc nghiệt hơn: chỉ 8.3ms.
Hai kẻ thù lớn nhất của hiệu năng: (1) Layout thrashing — đọc thuộc tính layout
(offsetWidth, getBoundingClientRect) xen kẽ với ghi style trong vòng lặp,
buộc trình duyệt tính lại layout đồng bộ nhiều lần; hãy gom tất cả lần đọc trước, rồi mới
ghi. (2) Cấp phát object trong hot loop gây GC pause giật cục. Dùng tab
Performance của Chrome DevTools để ghi lại (record) vài giây, tìm các thanh dài màu
vàng (Scripting) hoặc tím (Rendering) vượt 16.6ms. Luôn tách rõ update và render, và
đo FPS bằng delta thời gian:
let last = performance.now();
let fps = 60, frames = 0, acc = 0;
function loop(now) {
const dt = now - last; // ms trôi qua từ frame trước
last = now;
// Đo FPS trung bình mỗi 0.5s
frames++; acc += dt;
if (acc >= 500) { fps = Math.round(frames * 1000 / acc); frames = 0; acc = 0; }
// Đặt mốc đo để xem trên DevTools Performance
performance.mark('update-start');
update(dt / 1000); // truyền dt → animation độc lập tốc độ máy
performance.mark('update-end');
performance.measure('update', 'update-start', 'update-end');
render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
5. Offscreen Canvas: Cơ Chế Bit Blitting Của Trình Duyệt
Khi bạn vẽ 1000 đối tượng phức tạp chứa bóng đổ shadowBlur và gradient lên canvas hiển
thị, CPU sẽ bị tắc nghẽn nặng nề do phải tính toán rasterization (chuyển đổi vectơ sang mảng pixel)
lại từ đầu cho từng đối tượng trong mỗi frame.
Giải pháp tối ưu phần cứng: Ta render hình vẽ phức tạp đó 1 lần duy nhất lên một
Offscreen Canvas (canvas đệm ẩn). Trong loop vẽ chính, ta sử dụng lệnh
ctx.drawImage(offscreenCanvas, x, y).
[Dưới góc độ công nghệ]: Phương pháp này gọi là Bit Blitting (Bit Boundary Block Transfer). Trình duyệt chỉ cần thực hiện sao chép vùng nhớ đệm pixel có sẵn từ RAM đệm sang GPU Texture trực tiếp mà không cần giải mã vector lại, tốc độ nhanh hơn từ 10 đến 50 lần.
// 1. Khởi tạo canvas ảo
const cacheCanvas = document.createElement('canvas');
cacheCanvas.width = 30;
cacheCanvas.height = 30;
const cctx = cacheCanvas.getContext('2d');
// 2. Vẽ hình phức tạp lên canvas ảo một lần duy nhất
const grad = cctx.createRadialGradient(15, 15, 2, 15, 15, 13);
grad.addColorStop(0, '#fff');
grad.addColorStop(1, '#3b82f6');
cctx.fillStyle = grad;
cctx.shadowBlur = 8;
cctx.shadowColor = '#3b82f6';
cctx.beginPath(); cctx.arc(15, 15, 12, 0, Math.PI * 2); cctx.fill();
// 3. Sử dụng drawImage để copy bộ đệm cực nhanh
function renderLoop() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach(p => {
// Blitting hình tròn đệm lên canvas thật
ctx.drawImage(cacheCanvas, p.x - 15, p.y - 15);
});
requestAnimationFrame(renderLoop);
}
6. Web Workers: Đa Luồng Song Song (Multithreading)
Mặc định, JavaScript chạy đơn luồng (Single-threaded) trên Main Thread (luồng giao diện người dùng). Nếu bạn có 10,000 hạt bụi cần tính toán va chạm và cập nhật tọa độ vật lý, luồng chính sẽ bị nghẽn, giao diện người dùng bị đơ cứng (UI Freeze).
Giải pháp: Đưa toàn bộ tính toán toán học xuống chạy dưới Web Worker chạy ở luồng nền độc lập của hệ điều hành.
Khi giao tiếp giữa Main Thread và Web Worker, ta cần truyền dữ liệu mảng nhị phân có định dạng Transferable Objects (như ArrayBuffer) thay vì object thông thường (Structured Clone). Transferable truyền vùng nhớ vật lý trực tiếp mà không cần copy bộ nhớ, giúp tốc độ truyền dữ liệu gần như tức thời (0ms overhead).
7. WebGL Context: Sức Mạnh Song Song Của GPU
Khi số lượng hạt lên tới 100,000 hạt, CPU của máy tính sẽ chạm giới hạn tốc độ xử lý đơn luồng. Lúc này, ta cần chuyển sang sử dụng WebGL context.
| Tính chất | Canvas 2D API | WebGL API |
|---|---|---|
| Xử lý bởi | CPU (Đơn luồng phần lớn) | GPU (Xử lý song song đa luồng) |
| Kiểu vẽ | Immediate Mode (Vẽ vẽ ngay lập tức) | Retained / Shader Pipeline (GLSL code) |
| Giới hạn hạt | Dưới ~5,000 hạt ở 60 FPS | Hơn ~200,000 hạt ở 60 FPS |
WebGL nạp toàn bộ toạ độ hạt vào VBO (Vertex Buffer Object) trên bộ nhớ của Card đồ họa một lần duy nhất, sau đó GPU sử dụng hàng ngàn nhân xử lý đồ họa nhỏ để chạy mã Shader (GLSL) đồng thời, mang lại hiệu năng mô phỏng cực hạn.
8. Xuất Tác Phẩm: PNG, Data URL & Frame Capture
Sau khi tạo ra tác phẩm generative art, người dùng sẽ muốn lưu lại. Canvas cung cấp hai API xuất ảnh:
canvas.toDataURL('image/png') trả về một chuỗi base64 (đồng bộ, tiện nhúng vào
<img> nhưng tốn RAM với ảnh lớn), và
canvas.toBlob(callback, type, quality) trả về một Blob nhị phân (bất đồng
bộ, hiệu quả hơn nhiều cho việc tải xuống). Cách thực hành tốt nhất là dùng toBlob kết
hợp URL.createObjectURL rồi kích hoạt một thẻ <a download> ảo:
// Tải canvas hiện tại thành file PNG
function downloadPNG(canvas, filename = 'artwork.png') {
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url); // giải phóng bộ nhớ ngay sau khi tải
}, 'image/png');
}
// Frame capture cho GIF/video: lưu lại từng frame để ghép sau
const frames = [];
function captureFrame() {
// ImageData thô — đưa vào gif.js / WebCodecs / MediaRecorder để ghép
frames.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
}
// Quay video trực tiếp từ canvas (cách hiện đại nhất cho "GIF động"):
// const stream = canvas.captureStream(30); // 30 FPS
// const rec = new MediaRecorder(stream, { mimeType: 'video/webm' });
Lưu ý quan trọng về bảo mật: nếu canvas đã vẽ ảnh từ domain khác mà không có header CORS phù
hợp, canvas trở thành "tainted" (nhiễm bẩn) và mọi lời gọi toDataURL/toBlob
sẽ ném lỗi SecurityError. Để xuất GIF động thật sự, dùng thư viện như
gif.js (chạy trong Web Worker) ghép mảng frames ở trên, hoặc dùng
MediaRecorder + canvas.captureStream() để quay thẳng ra video WebM — nhẹ và
mượt hơn GIF rất nhiều.
9. Câu hỏi trắc nghiệm ôn tập
Trắc nghiệm 1: Perlin Noise vs Math.random
Tại sao nhiễu Perlin lại được ưa chuộng hơn Math.random trong mô phỏng mây bay hay sóng nước?
Trắc nghiệm 2: Bit Blitting
Tại sao dùng drawImage() copy từ Offscreen Canvas lại nhanh hơn vẽ arc/shadow trực tiếp?
Trắc nghiệm 3: Transferables
Tại sao nên dùng Transferable Objects (ArrayBuffer) khi gửi dữ liệu giữa Main Thread và Web Worker?
Tải file code thực hành minh họa bài học
File code mẫu hoàn chỉnh vẽ flowfield bằng thuật toán lượng giác lượng tử và xuất ảnh PNG:
Tải về canvas_creative.js
Comments
Bình luận