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ặc width: 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 widthheight trê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):

Không scale (DPR bỏ qua) pixel to → mờ
Backing store 600×280 bị kéo lên 1200×560 → nội suy
Scale DPR đúng
Backing store 1200×560 đúng bằ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:

  1. Đặt canvas.width = cssWidth × dprcanvas.height = cssHeight × dpr để buffer có đủ độ phân giải vật lý.
  2. Giữ CSS size cố định: canvas.style.width = cssWidth + 'px' (để layout không bị phình to gấp đôi).
  3. 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:

hidpi_basic.js
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:

setupHiDPI.js
/**
 * 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:

canvas_state_preservation.js
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.

resize_observer.js
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:

letterbox_fit.js
// 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.

✨ Demo tương tác: Aspect-ratio & Letterbox

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:

coordinate_mapping_advanced.js
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ẽ 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:

✨ Demo tương tác: So sánh DPR Scaling

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.

canvas_responsive.js
// 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ể:

mobile_dpr_cap.css.js
/* 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

Related Articles

Bài viết liên quan trong series

Lesson 11: Bài 9: Interaction: Mouse, Touch & Hit Detection Bài 11: Interaction: Mouse, Touch & Hit Detection Lesson 9: Sprite Animation & Spritesheets in Canvas Bài 9: Sprite Animation & Spritesheet trong Canvas Back to Canvas Series Overview Quay lại Lộ trình Canvas Series