This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.
Trong các ứng dụng đồ họa thực tế, việc giữ cho canvas co giãn theo khung nhìn (responsive), đồng thời
giữ độ sắc nét trên màn hình Retina/HiDPI là vô cùng quan trọng. Bài học này sẽ phân tích chi tiết bản
chất kỹ thuật của hệ thống hiển thị pixel, cách browser xử lý canvas buffer, kỹ thuật scale HiDPI đúng
chuẩn, ResizeObserver, letterboxing giữ tỉ lệ, và những góc tối trong việc ánh xạ tọa độ
touch/mouse cũng như tối ưu hiệu năng trên thiết bị di động.
1. Bản Chất Pixel: CSS Pixel vs Physical Pixel
Một thẻ Canvas có hai lớp lưới pixel hoàn toàn độc lập mà bạn cần phân biệt rõ ràng:
-
Logic Size (CSS Pixels): Được định nghĩa bởi CSS (ví dụ
width: 100%hoặcwidth: 600px). Đây là kích thước hộp layout mà trình duyệt sử dụng để sắp xếp giao diện. -
Backing Store Size (Hardware Pixels): Được định nghĩa bởi thuộc tính attribute
widthvàheighttrên thẻ canvas HTML. Đây là bộ nhớ đệm bitmap (bitmap buffer) chứa dữ liệu màu RGBA thực tế mà GPU sẽ vẽ lên màn hình.
Mối quan hệ giữa hai kích thước này được minh họa qua sơ đồ ống dẫn (pipeline) dưới đây:
[CSS Layout Viewport] (Kích thước hộp logic CSS hiển thị)
│
▼ (Nhân với window.devicePixelRatio)
[Backing Store Bitmap] (Mảng pixel thực tế trong RAM đồ họa)
│
▼ (Trình duyệt blit đè bộ nhớ đệm lên màn hình)
[GPU Texture Buffer] (Card đồ họa xử lý rasterization)
│
▼
[Physical Screen] (Các bóng LED hiển thị vật lý trên thiết bị)
Nếu tỉ lệ giữa Backing Store / CSS Size không khớp với mật độ pixel vật lý của màn
hình (chính là window.devicePixelRatio), card đồ họa buộc phải thực hiện thuật toán nội
suy (Interpolation) phóng to/thu nhỏ các pixel đệm khiến đồ họa bị
mờ nhòe, vỡ hạt hoặc răng cưa nặng nề.
Hãy tưởng tượng cụ thể: trên một chiếc MacBook Retina có devicePixelRatio = 2, mỗi điểm
CSS (1 CSS px) tương ứng với một lưới 2×2 = 4 điểm sáng vật lý trên màn hình. Nếu bạn tạo một canvas
<canvas width="600" height="280"> rồi để CSS kéo giãn nó lên
width: 600px, thì backing store chỉ có 600×280 pixel thật, trong khi màn hình lại đang
yêu cầu 1200×560 điểm vật lý để lấp đầy. Trình duyệt buộc phải "đoán" màu cho các pixel còn thiếu bằng
phép nội suy song tuyến (bilinear) — kết quả là các đường nét sắc, chữ và viền tròn đều bị nhòe đi một
lớp mờ mỏng. Đây chính là hiện tượng retina blur kinh điển mà mọi lập trình viên canvas đều
gặp.
Sơ đồ dưới đây minh họa trực quan sự khác biệt giữa canvas không scale DPR (1 pixel logic bị kéo giãn ra phủ 4 pixel vật lý → mờ) và canvas scale DPR đúng (mỗi pixel logic được vẽ chi tiết tới từng pixel vật lý → sắc nét):
Bạn có thể kiểm tra giá trị này ngay trong console: window.devicePixelRatio trả về
1 trên màn hình thường, 2 trên hầu hết màn hình Retina/laptop hiện đại, và
3 trên nhiều điện thoại cao cấp. Lưu ý giá trị này không cố định — nó
thay đổi khi người dùng zoom trang (Ctrl/Cmd +/−) hoặc kéo cửa sổ giữa hai màn hình có mật độ khác
nhau, nên ta cần lắng nghe và scale lại động (xem Mục 4).
2. HiDPI Scaling Đúng Cách: width = cssW × dpr
Nguyên tắc vàng để canvas luôn sắc nét:
tách rời kích thước hiển thị (CSS) khỏi kích thước backing store (attribute). CSS quy
định canvas to bao nhiêu trên màn hình; attribute width/height quy định
buffer chứa bao nhiêu pixel thật. Công thức chuẩn gồm 3 bước:
-
Đặt
canvas.width = cssWidth × dprvàcanvas.height = cssHeight × dprđể buffer có đủ độ phân giải vật lý. -
Giữ CSS size cố định:
canvas.style.width = cssWidth + 'px'(để layout không bị phình to gấp đôi). -
Gọi
ctx.scale(dpr, dpr)một lần — từ đó về sau bạn vẽ bằng tọa độ CSS bình thường (ví dụ vẽ tại x=300 là tâm của canvas 600px logic), còn trình duyệt tự nhân lên cho buffer vật lý.
Nhờ ctx.scale(dpr, dpr), toàn bộ code vẽ của bạn không cần biết tới dpr
— bạn luôn làm việc trong hệ tọa độ CSS logic, code sạch và di động giữa các thiết bị. Đây là ví dụ
setup cơ bản:
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
const cssWidth = 600; // kích thước hiển thị logic mong muốn
const cssHeight = 280;
const dpr = window.devicePixelRatio || 1;
// 1. Buffer vật lý = CSS × dpr
canvas.width = Math.round(cssWidth * dpr);
canvas.height = Math.round(cssHeight * dpr);
// 2. Khóa kích thước hiển thị bằng CSS
canvas.style.width = cssWidth + 'px';
canvas.style.height = cssHeight + 'px';
// 3. Scale context — từ giờ vẽ bằng tọa độ CSS logic
ctx.scale(dpr, dpr);
// Vẽ tại tâm 300,140 — sắc nét trên mọi màn hình
ctx.fillStyle = '#3b82f6';
ctx.beginPath();
ctx.arc(300, 140, 60, 0, Math.PI * 2);
ctx.fill();
Trong dự án thực tế, bạn nên gói logic này vào một hàm helper tái sử dụng được. Hàm
dưới đây nhận một canvas, tự đọc kích thước CSS hiện tại của nó qua
getBoundingClientRect(), set buffer đúng theo dpr và trả về context đã scale sẵn. Quan
trọng: ta dùng setTransform thay vì scale để khi gọi lại nhiều lần (lúc
resize) ma trận không bị nhân chồng lên nhau:
/**
* Cấu hình canvas sắc nét trên màn hình HiDPI.
* Trả về context đã scale theo dpr — vẽ bằng tọa độ CSS logic.
*/
function setupHiDPI(canvas, maxDpr = 2) {
const ctx = canvas.getContext('2d');
// Giới hạn dpr để tiết kiệm bộ nhớ/pin (xem Mục 7)
const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
// setTransform: reset ma trận rồi scale — an toàn khi gọi lại nhiều lần
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Trả kèm kích thước logic để code vẽ dùng
return { ctx, dpr, width: rect.width, height: rect.height };
}
// Dùng:
const { ctx, width, height } = setupHiDPI(canvas);
ctx.strokeRect(0, 0, width, height); // viền vẫn 1px sắc nét
3. Góc Tối Khi Thay Đổi Kích Thước: Hủy Bỏ Drawing State
Khi bạn thay đổi thuộc tính `width` hoặc `height` của canvas bằng Javascript (ngay cả khi giá trị mới bằng giá trị cũ), trình duyệt sẽ thực hiện một tác vụ phần cứng ngầm:
[QUAN TRỌNG] Trình duyệt sẽ giải phóng vùng nhớ đệm cũ (Backing Store), cấp phát một vùng nhớ bitmap mới tinh, và reset hoàn toàn trạng thái context (Context State Stack) về mặc định. Mọi cài đặt như
fillStyle,strokeStyle, ma trận biến đổi (translate, rotate, scale), độ dày nét vẽ, font chữ... đều bị xóa sạch.
Để khắc phục lỗi này, bạn cần lưu trữ cấu trúc vẽ hoặc thực hiện thiết lập lại trạng thái context ngay sau khi resize:
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
// Trạng thái vẽ muốn duy trì
const drawState = {
fillColor: '#e11d48',
translation: { x: 50, y: 50 }
};
function resize() {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
// Thay đổi size của attribute -> Reset context state ngầm!
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
// Tái thiết lập trạng thái context sau khi bị reset
ctx.scale(dpr, dpr);
ctx.fillStyle = drawState.fillColor;
ctx.translate(drawState.translation.x, drawState.translation.y);
// Vẽ lại toàn bộ nội dung
draw();
}
4. ResizeObserver & Canvas Responsive Đúng Chuẩn
Cách cũ là lắng nghe window.addEventListener('resize', ...). Nhưng cách này có khuyết
điểm lớn: nó chỉ kích hoạt khi cửa sổ trình duyệt đổi kích thước, mà
không phát hiện được khi container của canvas đổi kích thước vì lý do khác — ví dụ
sidebar mở/đóng, flexbox co giãn, hay một panel CSS grid thay đổi. Giải pháp hiện đại là
ResizeObserver: nó quan sát trực tiếp element chứa canvas và gọi callback mỗi
khi kích thước box của element đó thay đổi, bất kể nguyên nhân.
Tuy nhiên resize/observe có thể bắn ra hàng chục lần liên tiếp khi người dùng kéo cửa sổ. Việc cấp
phát lại buffer canvas (đổi width/height) là tác vụ nặng, nên ta cần
debounce (gom nhiều sự kiện thành một lần xử lý cuối). Mẫu phổ biến là dùng
requestAnimationFrame làm hàng rào: nhiều callback trong cùng một frame chỉ dẫn tới đúng
một lần vẽ lại.
let rafId = 0;
const ro = new ResizeObserver(() => {
// Debounce qua rAF: gom nhiều callback trong 1 frame thành 1 lần resize
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
const { ctx, width, height } = setupHiDPI(canvas); // gọi lại helper Mục 2
draw(ctx, width, height); // canvas.width đổi → phải vẽ lại toàn bộ
});
});
// Quan sát container, không phải window
ro.observe(canvas.parentElement);
// Nhớ dọn dẹp khi component bị huỷ
// ro.disconnect();
Một yêu cầu thường gặp khác là khóa tỉ lệ khung hình (aspect-ratio lock) — ví dụ một game luôn phải giữ tỉ lệ 16:9 dù container rộng/hẹp ra sao. Kỹ thuật letterboxing (giống viền đen trên/dưới khi xem phim) tính ra vùng vẽ lớn nhất vừa khít tỉ lệ mong muốn bên trong container, rồi căn giữa và chừa lề. Công thức: so sánh tỉ lệ container với tỉ lệ đích — nếu container "quá rộng" thì khóa theo chiều cao và chừa lề trái/phải; ngược lại khóa theo chiều rộng và chừa lề trên/dưới:
// Tính vùng vẽ giữ tỉ lệ targetRatio bên trong container (cw × ch)
function fitWithLetterbox(cw, ch, targetRatio /* = 16/9 */) {
const containerRatio = cw / ch;
let w, h;
if (containerRatio > targetRatio) {
// Container quá rộng → khóa chiều cao, chừa lề ngang
h = ch;
w = ch * targetRatio;
} else {
// Container quá cao → khóa chiều rộng, chừa lề dọc
w = cw;
h = cw / targetRatio;
}
return {
width: w,
height: h,
offsetX: (cw - w) / 2, // lề trái (letterbox)
offsetY: (ch - h) / 2 // lề trên (pillarbox)
};
}
const fit = fitWithLetterbox(rect.width, rect.height, 16 / 9);
ctx.translate(fit.offsetX, fit.offsetY); // dịch vùng vẽ vào giữa
Hãy thử nghiệm trực tiếp với demo dưới đây: kéo thanh trượt để thu hẹp container và quan sát canvas tự re-fit, giữ tỉ lệ và thêm viền letterbox tự động.
5. Công Thức Toán Học Ánh Xạ Tọa Độ Chuột Nâng Cao
Để ánh xạ chính xác tọa độ chuột từ màn hình vào tọa độ vẽ trên canvas, ta không chỉ trừ đơn thuần góc lề `rect.left` mà phải tính đến CSS borders, paddings và sự chênh lệch tỉ lệ phân giải hệ số zoom.
Công thức tổng quát để tính tọa độ local thực tế \(x_{local}\) và \(y_{local}\) từ sự kiện chuột:
\[x_{local} = (x_{client} - rect.left - borderLeft - paddingLeft) \times \frac{canvas.width}{rect.width - borderLeft - borderRight - paddingLeft - paddingRight}\] \[y_{local} = (y_{client} - rect.top - borderTop - paddingTop) \times \frac{canvas.height}{rect.height - borderTop - borderBottom - paddingTop - paddingBottom}\]Dưới đây là hàm Javascript triển khai công thức trên để đảm bảo click chuột luôn chính xác pixel-perfect bất kể CSS padding/border được set thế nào:
function getCanvasCoords(canvas, clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const style = window.getComputedStyle(canvas);
// Đọc các giá trị border và padding vật lý từ CSS
const borderLeft = parseFloat(style.borderLeftWidth) || 0;
const borderTop = parseFloat(style.borderTopWidth) || 0;
const paddingLeft = parseFloat(style.paddingLeft) || 0;
const paddingTop = parseFloat(style.paddingTop) || 0;
// Tính chiều rộng và cao khả dụng hiển thị bên trong (CSS box-sizing)
const clientWidth = rect.width - borderLeft - (parseFloat(style.borderRightWidth) || 0) - paddingLeft - (parseFloat(style.paddingRight) || 0);
const clientHeight = rect.height - borderTop - (parseFloat(style.borderBottomWidth) || 0) - paddingTop - (parseFloat(style.paddingBottom) || 0);
// Ánh xạ tỉ lệ giữa backing store resolution và CSS box size
const x = (clientX - rect.left - borderLeft - paddingLeft) * (canvas.width / clientWidth);
const y = (clientY - rect.top - borderTop - paddingTop) * (canvas.height / clientHeight);
return { x, y };
}
Để cảm nhận rõ tác động của việc scale DPR, hãy thử demo dưới đây. Nửa trái được vẽ
không scale theo dpr (mờ trên màn Retina), nửa phải được vẽ có scale
dpr (sắc nét). Bấm nút để bật/tắt, đồng thời quan sát devicePixelRatio hiện tại của thiết
bị bạn:
6. Sử dụng Fullscreen API & Touch Events di động
Khi vào chế độ Fullscreen, trình duyệt sẽ phóng to canvas ra toàn màn hình thiết bị. Canvas cần lắng nghe sự thay đổi này thông qua `ResizeObserver` để tự động điều chỉnh lại `devicePixelRatio` giúp game hoặc đồ họa không bị méo lệch.
Đối với Touch Events trên thiết bị di động, mảng `e.touches` chứa danh sách tất cả các ngón tay đang chạm vào màn hình. Ta cần lặp qua mảng này và map tọa độ cho từng ngón tay độc lập để xử lý multi-touch.
// Chặn hành vi scroll mặc định khi touch trên màn hình canvas
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
Array.from(e.changedTouches).forEach(touch => {
const coords = getCanvasCoords(canvas, touch.clientX, touch.clientY);
console.log(`Touch ID ${touch.identifier} chạm tại: X=${coords.x}, Y=${coords.y}`);
});
}, { passive: false });
7. Hiệu Năng & Viewport Trên Thiết Bị Di Động
Trên mobile, ba yếu tố quyết định trải nghiệm canvas. Thứ nhất, meta viewport phải
khai báo đúng để trang không bị thu nhỏ kỳ lạ và để 1 CSS px ánh xạ chuẩn:
<meta name="viewport" content="width=device-width, initial-scale=1">. Thứ hai, CSS
touch-action giúp loại bỏ độ trễ và ngăn các cử chỉ mặc định (scroll, pinch-zoom) tranh
chấp với thao tác vẽ — đặt touch-action: none cho canvas tương tác để toàn quyền xử lý
touch, mượt hơn nhiều so với gọi preventDefault() trong từng event.
Thứ ba — và quan trọng nhất cho pin: nhiều điện thoại có devicePixelRatio = 3, nghĩa là
buffer canvas full-screen có thể lên tới hơn 3 triệu pixel, đốt RAM và GPU khủng khiếp. Hãy
giới hạn (cap) dpr, thường ở 2 hoặc thậm chí 1.5 cho các
hiệu ứng động nặng — mắt người gần như không phân biệt được dpr 2 và 3 ở khoảng cách cầm điện thoại,
nhưng số pixel phải xử lý giảm hơn một nửa, giúp giữ 60 FPS và tiết kiệm pin đáng kể:
/* CSS: cho phép canvas tự xử lý mọi cử chỉ touch */
/*
.game-canvas {
touch-action: none; // tắt scroll/zoom mặc định
-webkit-user-select: none;
user-select: none;
}
*/
// JS: cap dpr để tiết kiệm pin & giữ FPS trên mobile
const rawDpr = window.devicePixelRatio || 1;
const dpr = Math.min(rawDpr, 2); // không bao giờ vượt 2
canvas.width = Math.round(cssWidth * dpr);
canvas.height = Math.round(cssHeight * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Tip: với animation rất nặng, hạ tiếp xuống 1.5 trên máy yếu
// const dpr = navigator.hardwareConcurrency < 4 ? 1.5 : Math.min(rawDpr, 2);
8. Câu hỏi trắc nghiệm ôn tập
Trắc nghiệm 1: devicePixelRatio
Tại sao canvas thường bị mờ (blurry) trên màn hình Retina khi không xử lý HiDPI?
Trắc nghiệm 2: Resize Edge Case
Điều gì xảy ra với trạng thái context (fillStyle, translate, font...) khi thay đổi canvas.width?
Trắc nghiệm 3: Coordinate Mapping
Tại sao công thức ánh xạ tọa độ nâng cao cần lấy borderLeft và paddingLeft ra khỏi clientX?
Tải file code thực hành minh họa bài học
File chứa toàn bộ setup co giãn màn hình, DPI scaling và xử lý touch events:
Tải về canvas_responsive.js
Comments
Bình luận