This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.
Compositing (cách các pixel mới được "trộn" vào các pixel đã có sẵn trên canvas) là một trong những
phần mạnh mẽ nhưng ít được khai thác nhất của Canvas 2D API. Khi nắm vững globalAlpha,
globalCompositeOperation, clip() cùng Path2D, bạn có thể tạo ra
hiệu ứng glow neon, mặt nạ (masking), tẩy xóa (erase), đèn pha (spotlight), khung avatar bo tròn và
viền chuyển động "marching ants" mà không cần bất kỳ thư viện nào. Bài học này đi từ nền tảng pixel
blending đến các thủ thuật chuyên nghiệp, kèm 2 demo tương tác trực tiếp ngay trong trang.
1. globalAlpha & Độ trong suốt
Có hai cách hoàn toàn khác nhau để tạo độ trong suốt trong Canvas, và việc nhầm lẫn giữa chúng là nguồn gốc của rất nhiều lỗi vẽ.
-
Alpha cục bộ qua
rgba()/hsla(): Độ trong suốt được "nung" trực tiếp vào màu của một thao tác vẽ cụ thể. Ví dụctx.fillStyle = 'rgba(225, 29, 72, 0.5)'chỉ ảnh hưởng đến hình được tô bằng màu đó. -
Alpha toàn cục qua
globalAlpha: Là một hệ số (0.0 → 1.0) nhân với độ mờ của mọi thao tác vẽ tiếp theo — kể cả ảnh (drawImage), gradient, hay path. Nó đóng vai trò như một "lớp kính mờ" áp lên toàn bộ context.
Điểm quan trọng: hai cơ chế này nhân chồng lên nhau. Nếu
globalAlpha = 0.5 và bạn tô bằng rgba(0,0,0,0.5), độ mờ hiệu dụng cuối cùng
là 0.5 × 0.5 = 0.25. Vì globalAlpha là một phần của trạng thái context, nó
được lưu/khôi phục bởi save()/restore() — đây là cách an toàn để thay đổi
tạm thời độ mờ rồi trả về giá trị cũ.
// Xếp lớp ba vòng tròn mờ chồng nhau để tạo cảm giác "kính"
ctx.save();
ctx.globalAlpha = 0.4; // Lớp kính mờ áp cho mọi hình bên dưới
ctx.fillStyle = '#e11d48';
ctx.beginPath(); ctx.arc(120, 100, 60, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#2563eb';
ctx.beginPath(); ctx.arc(170, 100, 60, 0, Math.PI * 2); ctx.fill();
ctx.restore(); // globalAlpha tự động trở về 1.0
// Vùng giao nhau sẽ tối/đậm hơn vì hai lớp alpha cộng dồn quang học.
Một ứng dụng kinh điển của globalAlpha là hiệu ứng fade in/out theo thời
gian: ta tăng dần giá trị alpha mỗi frame của animation loop, tạo cảm giác đối tượng hiện ra hoặc biến
mất mượt mà.
let opacity = 0;
function fadeIn() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.globalAlpha = Math.min(opacity, 1); // kẹp giá trị trong [0,1]
ctx.drawImage(logo, 100, 60);
ctx.globalAlpha = 1; // dọn dẹp để không "rò" sang frame sau
opacity += 0.02;
if (opacity < 1) requestAnimationFrame(fadeIn);
}
fadeIn();
2. globalCompositeOperation — 26 chế độ pha trộn
Thuộc tính ctx.globalCompositeOperation quyết định
công thức toán học trộn pixel nguồn (source — hình bạn sắp vẽ) với pixel đích
(destination — nội dung đã có trên canvas). Mặc định là source-over: hình mới đè lên hình
cũ theo alpha thông thường. Canvas hỗ trợ tổng cộng 26 chế độ, chia làm hai họ lớn:
nhóm Porter-Duff (quản lý vùng hiển thị dựa trên alpha) và nhóm
separable blend modes (trộn màu theo từng kênh, giống Photoshop).
Các chế độ Porter-Duff đặc biệt hữu ích cho masking và erase:
destination-out dùng hình nguồn để "khoét lỗ" vào nội dung cũ (cọ tẩy), còn
source-in chỉ giữ lại phần nguồn nằm trong vùng đích (cắt ảnh theo hình). Nhóm blend như
multiply (nhân, làm tối), screen (làm sáng), overlay (tăng
tương phản) và lighter (cộng màu — tạo glow rực rỡ) phục vụ hiệu ứng ánh sáng và phối màu
nghệ thuật.
| Nhóm | Chế độ | Tác dụng |
|---|---|---|
|
Porter-Duff (vùng/alpha) |
source-over (mặc định) |
Nguồn đè lên đích theo alpha |
source-in, source-out, source-atop |
Giữ nguồn trong/ngoài/chồng vùng đích | |
destination-over, destination-in, destination-out,
destination-atop
|
Đảo vai trò: vẽ sau lưng, tẩy xóa, mặt nạ ngược | |
copy, xor |
Thay thế hoàn toàn / loại trừ vùng chồng | |
|
Blend (màu/kênh) |
lighter, screen, lighten, color-dodge |
Làm sáng — lý tưởng cho glow, lửa, ánh sáng |
multiply, darken, color-burn |
Làm tối — bóng đổ, nhuộm màu, kính lọc | |
overlay, hard-light, soft-light, difference,
exclusion, hue, saturation, color,
luminosity
|
Tăng tương phản & pha trộn nghệ thuật theo HSL |
Hiệu ứng glow thường dùng lighter: vì chế độ này cộng giá trị
màu, các vùng chồng nhau càng nhiều sẽ càng sáng rực, mô phỏng cách photon thực sự cộng dồn năng lượng
ánh sáng.
ctx.globalCompositeOperation = 'lighter'; // cộng màu -> sáng rực
for (let i = 0; i < 12; i++) {
const g = ctx.createRadialGradient(300, 150, 0, 300, 150, 80);
g.addColorStop(0, 'rgba(56, 189, 248, 0.25)');
g.addColorStop(1, 'rgba(56, 189, 248, 0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.globalCompositeOperation = 'source-over'; // LUÔN reset sau khi xong
Ngược lại, destination-out biến thao tác vẽ thành một cọ tẩy: pixel
nguồn không thêm màu mà trừ alpha của đích, tạo lỗ trong suốt — nền tảng của tính năng
"eraser" và hiệu ứng cào thẻ cào (scratch card).
// Tô đầy một lớp phủ rồi cho người dùng "cào" lộ ảnh bên dưới
ctx.fillStyle = '#94a3b8';
ctx.fillRect(0, 0, canvas.width, canvas.height);
canvas.addEventListener('pointermove', (e) => {
ctx.globalCompositeOperation = 'destination-out'; // chế độ tẩy
ctx.beginPath();
ctx.arc(e.offsetX, e.offsetY, 24, 0, Math.PI * 2);
ctx.fill(); // không vẽ màu — chỉ khoét trong suốt
});
Một ứng dụng tinh tế khác là spotlight (đèn pha): tô một lớp tối phủ toàn màn rồi
dùng destination-out với gradient hình tròn để "soi" lộ phần nội dung phía dưới — chính
là kỹ thuật mà Demo 2 sẽ minh họa.
3. clip() & Vùng cắt
Hàm ctx.clip() biến path hiện tại thành một mặt nạ hình học: sau khi
gọi, mọi thao tác vẽ tiếp theo chỉ hiển thị trong vùng giới hạn bởi path đó, phần tràn ra ngoài bị cắt
bỏ hoàn toàn. Khác với fill() hay stroke(), clip() không vẽ gì
cả — nó chỉ thu hẹp "vùng vẽ hợp lệ".
Điểm mấu chốt khi làm việc với clip là không có hàm "unclip". Cách duy nhất để hủy
vùng cắt là dùng cặp ctx.save() trước khi clip và ctx.restore() sau khi vẽ
xong. Vì clip region là một phần của trạng thái context, restore() sẽ khôi phục lại vùng
vẽ rộng ban đầu. Nhiều lần clip lồng nhau sẽ tạo giao (intersection) của các vùng —
mỗi clip mới chỉ thu hẹp thêm chứ không bao giờ mở rộng.
// Cắt ảnh đại diện thành hình tròn (avatar bo tròn)
function drawCircularAvatar(img, cx, cy, r) {
ctx.save(); // lưu trạng thái không-clip
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip(); // chỉ vẽ trong hình tròn
ctx.drawImage(img, cx - r, cy - r, r * 2, r * 2);
ctx.restore(); // hủy clip, trả lại vùng vẽ đầy đủ
// viền tròn vẽ SAU restore nên không bị cắt
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = '#38bdf8';
ctx.lineWidth = 4;
ctx.stroke();
}
Từ phiên bản hiện đại, clip() chấp nhận một đối tượng Path2D làm tham số
trực tiếp: ctx.clip(myPath2D). Điều này cho phép tách bạch định nghĩa hình dạng khỏi
context, tái sử dụng cùng một mặt nạ ở nhiều nơi, và thậm chí truyền quy tắc lấp đầy
'evenodd' để tạo vùng cắt dạng "lỗ thủng".
const ring = new Path2D();
ring.arc(150, 150, 120, 0, Math.PI * 2); // vòng ngoài
ring.arc(150, 150, 60, 0, Math.PI * 2); // vòng trong (lỗ)
ctx.save();
ctx.clip(ring, 'evenodd'); // chỉ vẽ trong "vành khăn" giữa 2 vòng tròn
ctx.fillStyle = '#e11d48';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
Một ứng dụng cực kỳ phổ biến là reveal effect (hiệu ứng lộ dần): kết hợp
clip() với một hình tròn có bán kính tăng dần theo animation, ta tạo cảm giác nội dung
"nở ra" từ một điểm. Cũng có thể clip theo vùng chữ nhật trượt ngang để làm hiệu ứng "rèm kéo" (wipe
transition) giữa hai slide.
let r = 0;
function reveal() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.beginPath();
ctx.arc(300, 150, r, 0, Math.PI * 2);
ctx.clip();
drawScene(); // toàn cảnh, nhưng chỉ phần trong hình tròn hiện ra
ctx.restore();
r += 6;
if (r < 360) requestAnimationFrame(reveal);
}
reveal();
4. Path2D Object
Trước khi có Path2D, mọi đường vẽ phải được "kể lại" cho context bằng chuỗi lệnh
beginPath() → moveTo() → lineTo()… mỗi frame. Với một biểu đồ
có hàng trăm đối tượng lặp lại, điều này tốn rất nhiều lời gọi JavaScript. Path2D giải
quyết bằng cách cho phép
định nghĩa một hình dạng một lần, lưu vào đối tượng, rồi tái sử dụng qua
ctx.fill(path), ctx.stroke(path) hay ctx.clip(path).
Lợi ích hiệu năng đến từ việc trình duyệt có thể cache hình học nội bộ của một
Path2D: bạn dựng đường dẫn một lần, sau đó chỉ truyền tham chiếu thay vì phát lại toàn bộ
chuỗi lệnh vẽ mỗi khung hình. Ngoài ra, Path2D còn có thể gộp đường dẫn khác qua
path.addPath(other, matrix) kèm ma trận biến đổi.
// Định nghĩa hình ngôi sao MỘT lần, vẽ lại 200 lần
const star = new Path2D();
star.moveTo(0, -10);
for (let i = 1; i < 5; i++) {
const a = (i * 4 * Math.PI) / 5 - Math.PI / 2;
star.lineTo(Math.cos(a) * 10, Math.sin(a) * 10);
}
star.closePath();
for (let i = 0; i < 200; i++) {
ctx.save();
ctx.translate(Math.random() * canvas.width, Math.random() * canvas.height);
ctx.fillStyle = '#fbbf24';
ctx.fill(star); // tái dùng cùng một đối tượng path
ctx.restore();
}
Ấn tượng hơn nữa, hàm tạo Path2D chấp nhận trực tiếp
cú pháp SVG path data dưới dạng chuỗi. Điều này cho phép bạn lấy thuộc tính
d từ một file SVG (ví dụ icon) và render nó nguyên vẹn lên canvas mà không cần dịch tay
từng lệnh.
// Lấy nguyên chuỗi "d" của một icon SVG và vẽ lên canvas
const heart = new Path2D(
'M12 21s-7-4.35-9.5-8.5C1 9 3 5 6.5 5 8.5 5 10 6.5 12 9c2-2.5 3.5-4 5.5-4C21 5 23 9 21.5 12.5 19 16.65 12 21 12 21z'
);
ctx.translate(280, 120);
ctx.scale(6, 6);
ctx.fillStyle = '#e11d48';
ctx.fill(heart); // hỗ trợ đầy đủ M, L, C, Q, A, Z...
5. Line styling nâng cao
Canvas cung cấp nhiều thuộc tính tinh chỉnh cách một đường (stroke) được vẽ.
ctx.setLineDash([dài, trống]) định nghĩa mẫu nét đứt: mảng [10, 5] nghĩa là
10px nét rồi 5px khoảng trống, lặp lại. Bằng cách tăng dần ctx.lineDashOffset mỗi frame,
ta tạo hiệu ứng kinh điển "marching ants" (đàn kiến diễu hành) — viền chạy liên tục,
thường dùng cho vùng chọn (selection) trong các app đồ họa.
Hình dạng đầu mút và góc nối của đường được điều khiển bởi: lineCap ('butt'
cắt phẳng, 'round' bo tròn, 'square' vuông kéo dài), và
lineJoin ('miter' nhọn, 'round' tròn,
'bevel' vát). Khi dùng 'miter' ở các góc rất nhọn, mũi nhọn có thể vọt ra
rất dài — miterLimit đặt ngưỡng tối đa, vượt ngưỡng sẽ tự động chuyển sang
'bevel' để tránh gai nhọn xấu xí.
let offset = 0;
function marchingAnts() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setLineDash([12, 6]);
ctx.lineDashOffset = -offset; // dịch âm để nét chạy theo chiều kim đồng hồ
ctx.lineWidth = 2;
ctx.strokeStyle = '#38bdf8';
ctx.strokeRect(80, 60, 240, 140);
offset = (offset + 1) % 18; // 18 = tổng chu kỳ dash (12 + 6)
requestAnimationFrame(marchingAnts);
}
marchingAnts();
ctx.lineWidth = 18;
ctx.lineCap = 'round'; // đầu mút bo tròn, mềm mại
ctx.lineJoin = 'miter'; // góc nối nhọn
ctx.miterLimit = 4; // nếu mũi nhọn dài quá 4× lineWidth -> vát bevel
ctx.beginPath();
ctx.moveTo(60, 200);
ctx.lineTo(160, 60);
ctx.lineTo(260, 200); // góc nhọn ở đỉnh sẽ bị giới hạn bởi miterLimit
ctx.stroke();
ctx.setLineDash([]); // [] = xóa mẫu nét đứt, trở lại nét liền
6. Shadow & Glow
Canvas có bốn thuộc tính bóng đổ tích hợp sẵn: shadowColor (màu bóng, cần alpha > 0 để
hiển thị), shadowBlur (độ mờ nhòe của bóng), cùng shadowOffsetX /
shadowOffsetY (độ lệch của bóng so với hình). Khi đặt shadowBlur lớn nhưng
cả hai offset bằng 0, bóng tỏa đều quanh hình — đây chính là công thức tạo glow neon.
Để có hiệu ứng neon rực rỡ thực sự, ta thường kết hợp shadow với compositing: vẽ
chữ/hình nhiều lần với shadowBlur tăng dần dưới chế độ 'lighter', khiến các
lớp glow cộng dồn năng lượng ánh sáng. Lưu ý hiệu năng: shadowBlur rất tốn CPU/GPU, nên
hạn chế dùng trong animation loop dày đặc, hoặc render glow một lần ra canvas đệm (offscreen) rồi tái
sử dụng.
ctx.save();
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
ctx.shadowBlur = 12;
ctx.shadowOffsetX = 6;
ctx.shadowOffsetY = 6;
ctx.fillStyle = '#38bdf8';
ctx.fillRect(80, 80, 160, 100); // hình hộp có bóng đổ chéo phải-dưới
ctx.restore(); // tắt bóng cho các thao tác sau
ctx.save();
ctx.globalCompositeOperation = 'lighter'; // các lớp glow cộng dồn
ctx.shadowColor = '#f0abfc';
ctx.font = 'bold 64px system-ui';
ctx.fillStyle = '#f0abfc';
[6, 14, 28].forEach(blur => { // 3 lớp glow chồng nhau
ctx.shadowBlur = blur;
ctx.fillText('NEON', 120, 150);
});
ctx.restore();
7. Câu hỏi trắc nghiệm ôn tập
Trắc nghiệm 1: globalAlpha vs rgba()
Nếu globalAlpha = 0.5 và bạn tô bằng rgba(0,0,0,0.5), độ mờ hiệu dụng cuối
cùng là bao nhiêu?
Trắc nghiệm 2: Compositing để tẩy xóa
Chế độ globalCompositeOperation nào biến thao tác vẽ thành một "cọ tẩy" khoét lỗ trong
suốt vào nội dung đã có?
Trắc nghiệm 3: clip() và save/restore
Làm cách nào để hủy một vùng cắt (clip) và trả lại vùng vẽ đầy đủ cho canvas?
8. Demo: Spotlight Reveal & Marching Ants
Demo dưới đây gộp nhiều kỹ thuật vừa học: một lớp tối phủ toàn cảnh được khoét bằng
destination-out với gradient hình tròn để "soi" lộ mẫu hoa văn nhiều màu bên dưới
(spotlight). Vị trí đèn pha được điều khiển bằng thanh trượt, và viền vùng sáng là một đường
setLineDash chạy liên tục theo kiểu marching ants. Kéo thanh trượt để di chuyển đèn pha.
Tải file code thực hành minh họa bài học
File chứa toàn bộ ví dụ về compositing, globalAlpha, clip(), Path2D, line dash và shadow/glow:
Tải về canvas_compositing.js
Comments
Bình luận