Kết xuất pixel hoàn hảo bằng devicePixelContentBox

Thực sự có bao nhiêu pixel trong một canvas?

Kể từ Chrome 84, ResizeObserver hỗ trợ một phép đo hộp mới có tên là devicePixelContentBox. Phép đo này đo kích thước của phần tử theo pixel thực. Điều này cho phép kết xuất đồ hoạ pixel hoàn hảo, đặc biệt là trong bối cảnh màn hình có mật độ điểm ảnh cao.

Hỗ trợ trình duyệt

  • Chrome: 84.
  • Cạnh: 84.
  • Firefox: 93.
  • Safari: không được hỗ trợ.

Nguồn

Nền: Pixel CSS, pixel canvas và pixel thực

Mặc dù chúng ta thường làm việc với các đơn vị độ dài trừu tượng như em, % hoặc vh, nhưng tất cả đều đơn giản đến pixel. Bất cứ khi nào chúng ta chỉ định kích thước hoặc vị trí của một phần tử trong CSS, cuối cùng công cụ bố cục của trình duyệt sẽ chuyển đổi giá trị đó thành pixel (px). Đây là "Pixel CSS", có nhiều giá trị trước đó và chỉ có mối quan hệ lỏng lẻo với các pixel bạn có trên màn hình.

Trong một thời gian dài, việc ước tính mật độ pixel trên màn hình của mọi người bằng 96DPI ("điểm trên mỗi inch") là khá hợp lý, nghĩa là bất kỳ màn hình nào cũng có khoảng 38 pixel trên mỗi cm. Theo thời gian, màn hình đã tăng và/hoặc giảm kích thước hoặc bắt đầu có nhiều pixel hơn trên cùng một diện tích bề mặt. Kết hợp với thực tế là nhiều nội dung trên web xác định kích thước, bao gồm cả cỡ chữ, trong px, chúng ta sẽ thấy văn bản không đọc được trên các màn hình có mật độ điểm ảnh cao ("HiDPI") này. Để đối phó, trình duyệt sẽ ẩn mật độ điểm ảnh thực tế của màn hình và giả vờ rằng người dùng có màn hình 96 DPI. Đơn vị px trong CSS biểu thị kích thước của một pixel trên màn hình ảo 96 DPI này, do đó có tên là "Pixel CSS". Đơn vị này chỉ dùng để đo lường và định vị. Trước khi quá trình kết xuất thực tế diễn ra, quá trình chuyển đổi sang pixel thực sẽ diễn ra.

Làm cách nào để chuyển từ màn hình ảo này sang màn hình thực của người dùng? Nhập devicePixelRatio. Giá trị chung này cho bạn biết số pixel thực cần thiết để tạo một pixel CSS. Nếu devicePixelRatio (dPR) là 1, tức là bạn đang làm việc trên màn hình có khoảng 96DPI. Nếu bạn có màn hình retina, dPR có thể là 2. Trên điện thoại, bạn sẽ thường gặp các giá trị dPR cao hơn (và kỳ lạ hơn) như 2, 3 hoặc thậm chí 2.65. Cần lưu ý rằng giá trị này là chính xác nhưng không cho phép bạn lấy được giá trị DPI thực tế của màn hình. dPR là 2 có nghĩa là 1 pixel CSS sẽ ánh xạ chính xác 2 pixel vật lý.

Ví dụ:
Theo Chrome, màn hình của tôi có dPR là 1

Màn hình này có chiều rộng 3440 pixel và diện tích hiển thị rộng 79 cm. Độ phân giải này sẽ là 110 DPI. Gần như là 96, nhưng chưa chính xác. Đó cũng là lý do tại sao <div style="width: 1cm; height: 1cm"> sẽ không đo chính xác kích thước 1cm trên hầu hết màn hình.

Cuối cùng, dPR cũng có thể chịu ảnh hưởng của tính năng thu phóng của trình duyệt. Nếu bạn phóng to, trình duyệt sẽ tăng dPR được báo cáo, khiến mọi thứ hiển thị lớn hơn. Nếu kiểm tra devicePixelRatio trong Bảng điều khiển Công cụ cho nhà phát triển khi thu phóng, bạn có thể thấy các giá trị phân số xuất hiện.

Công cụ cho nhà phát triển hiển thị nhiều devicePixelRatio phân đoạn do phóng to.

Hãy thêm phần tử <canvas> vào danh sách kết hợp. Bạn có thể chỉ định số pixel mà bạn muốn canvas có bằng cách sử dụng thuộc tính widthheight. Vì vậy, <canvas width=40 height=30> sẽ là một canvas có 40 x 30 pixel. Tuy nhiên, điều này không có nghĩa là hình ảnh sẽ hiển thị ở kích thước 40x30 pixel. Theo mặc định, canvas sẽ sử dụng thuộc tính widthheight để xác định kích thước nội tại, nhưng bạn có thể tuỳ ý đổi kích thước canvas bằng tất cả các thuộc tính CSS mà bạn biết và yêu thích. Với mọi điều chúng tôi đã tìm hiểu được cho đến hiện tại, có thể bạn sẽ nhận thấy rằng đây không phải là phương án lý tưởng trong mọi tình huống. Một pixel trên canvas có thể bao phủ nhiều pixel thực hoặc chỉ một phần của pixel thực. Điều này có thể khiến các thành phần hình ảnh không hài lòng.

Tóm lại: Các phần tử Canvas có kích thước nhất định để xác định khu vực mà bạn có thể vẽ. Số lượng pixel canvas hoàn toàn độc lập với kích thước hiển thị của canvas, được chỉ định bằng pixel CSS. Số lượng pixel CSS không giống với số lượng pixel vật lý.

Sự hoàn hảo của Pixel

Trong một số trường hợp, bạn nên có được ánh xạ chính xác từ pixel canvas đến pixel thực. Nếu đạt được việc liên kết này, thì đó được gọi là "pixel-perfect" (chính xác đến từng pixel). Khả năng hiển thị điểm ảnh hoàn hảo đóng vai trò quan trọng trong việc hiển thị văn bản dễ đọc, đặc biệt là khi sử dụng tính năng kết xuất pixel phụ hoặc khi hiển thị hình ảnh đồ họa với các đường liên kết độ sáng xen kẽ được căn chỉnh chặt chẽ.

Để đạt được một canvas gần với pixel hoàn hảo nhất có thể trên web, đây là phương pháp ít nhiều được áp dụng:

<style>
  /* … styles that affect the canvas' size … */
</style>
<canvas id="myCanvas"></canvas>
<script>
  const cvs = document.querySelector('#myCanvas');
  // Get the canvas' size in CSS pixels
  const rectangle = cvs.getBoundingClientRect();
  // Convert it to real pixels. Ish.
  cvs.width = rectangle.width * devicePixelRatio;
  cvs.height = rectangle.height * devicePixelRatio;
  // Start drawing…
</script>

Người đọc tinh ý có thể thắc mắc điều gì sẽ xảy ra khi dPR không phải là giá trị số nguyên. Đó là một câu hỏi hay và chính xác là mấu chốt của toàn bộ vấn đề này nằm ở đâu. Ngoài ra, nếu bạn chỉ định vị trí hoặc kích thước của một phần tử bằng cách sử dụng tỷ lệ phần trăm, vh hoặc các giá trị gián tiếp khác, thì có thể các phần tử này sẽ phân giải thành các giá trị pixel CSS phân số. Một phần tử có margin-left: 33% có thể kết thúc bằng một hình chữ nhật như sau:

Công cụ cho nhà phát triển hiển thị các giá trị pixel phân đoạn do lệnh gọi getBoundingClientRect().

Pixel CSS hoàn toàn là ảo, vì vậy, về lý thuyết, việc có các phân số pixel là không sao, nhưng trình duyệt sẽ xác định mối liên kết với pixel thực như thế nào? Vì pixel thực không có giá trị phân đoạn.

Chụp nhanh pixel

Phần của quá trình chuyển đổi đơn vị đảm nhiệm việc căn chỉnh các phần tử với pixel vật lý được gọi là "chụp nhanh pixel" và thực hiện những gì được nêu trên hộp thiếc: Gắn giá trị pixel phân số với giá trị pixel vật lý, số nguyên. Cách thức diễn ra chính xác của việc này còn tuỳ thuộc vào trình duyệt. Nếu chúng ta có một phần tử có chiều rộng là 791.984px trên màn hình, trong đó dPR là 1, thì một trình duyệt có thể hiển thị phần tử ở mức 792px pixel vật lý, trong khi một trình duyệt khác có thể hiển thị phần tử đó ở 791px. Đó chỉ là một pixel bị tắt, nhưng một pixel đơn lẻ cũng có thể gây bất lợi cho việc kết xuất cần phải hoàn hảo về điểm ảnh. Điều này có thể dẫn đến tình trạng mờ hoặc thậm chí là các hiện tượng rõ ràng hơn như hiệu ứng Moiré.

Hình ảnh trên cùng là một đường quét gồm các pixel có màu khác nhau. Hình ảnh dưới cùng giống như hình ảnh ở trên, nhưng chiều rộng và chiều cao đã giảm đi 1 pixel bằng cách sử dụng tỷ lệ nội suy song tuyến tính. Mẫu hình xuất hiện được gọi là hiệu ứng Moiré.
(Bạn có thể phải mở hình ảnh này trong một thẻ mới để xem hình ảnh mà không cần áp dụng tỷ lệ nào.)

devicePixelContentBox

devicePixelContentBox cung cấp cho bạn hộp nội dung của một phần tử theo đơn vị pixel thiết bị (tức là pixel thực). Thuộc tính ResizeObserver. Mặc dù ResizeObserver hiện đã được hỗ trợ trong tất cả các trình duyệt chính kể từ Safari 13.1, nhưng thuộc tính devicePixelContentBox hiện chỉ có trong Chrome 84 trở lên.

Như đã đề cập trong phần ResizeObserver: giống như document.onresize cho các phần tử, hàm gọi lại của ResizeObserver sẽ được gọi trước khi vẽ và sau khi bố cục. Điều đó có nghĩa là tham số entries cho lệnh gọi lại sẽ chứa kích thước của tất cả các phần tử quan sát được ngay trước khi chúng được vẽ. Trong bối cảnh vấn đề về canvas được nêu ở trên, chúng ta có thể tận dụng cơ hội này để điều chỉnh số lượng pixel trên canvas, đảm bảo rằng chúng ta sẽ có được ánh xạ chính xác từng pixel giữa pixel canvas và pixel thực.

const observer = new ResizeObserver((entries) => {
  const entry = entries.find((entry) => entry.target === canvas);
  canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
  canvas.height = entry.devicePixelContentBoxSize[0].blockSize;

  /* … render to canvas … */
});
observer.observe(canvas, {box: ['device-pixel-content-box']});

Thuộc tính box trong đối tượng tuỳ chọn cho observer.observe() cho phép bạn xác định kích thước mà bạn muốn quan sát. Vì vậy, mặc dù mỗi ResizeObserverEntry sẽ luôn cung cấp borderBoxSize, contentBoxSizedevicePixelContentBoxSize (miễn là trình duyệt hỗ trợ), nhưng lệnh gọi lại sẽ chỉ được gọi nếu bất kỳ chỉ số hộp đã quan sát nào thay đổi.

Với thuộc tính mới này, chúng ta thậm chí có thể tạo ảnh động cho kích thước và vị trí canvas của mình (đảm bảo hiệu quả các giá trị pixel phân số) và không nhìn thấy bất kỳ hiệu ứng Moiré nào trên kết xuất. Nếu bạn muốn xem hiệu ứng Moiré trên phương pháp sử dụng getBoundingClientRect() và cách thuộc tính ResizeObserver mới cho phép bạn tránh hiệu ứng này, hãy xem bản minh hoạ trong Chrome 84 trở lên!

Phát hiện tính năng

Để kiểm tra xem trình duyệt của người dùng có hỗ trợ devicePixelContentBox hay không, chúng ta có thể quan sát bất kỳ phần tử nào và kiểm tra xem thuộc tính có xuất hiện trên ResizeObserverEntry hay không:

function hasDevicePixelContentBox() {
  return new Promise((resolve) => {
    const ro = new ResizeObserver((entries) => {
      resolve(entries.every((entry) => 'devicePixelContentBoxSize' in entry));
      ro.disconnect();
    });
    ro.observe(document.body, {box: ['device-pixel-content-box']});
  }).catch(() => false);
}

if (!(await hasDevicePixelContentBox())) {
  // The browser does NOT support devicePixelContentBox
}

Kết luận

Pixel là một chủ đề phức tạp đáng ngạc nhiên trên web và cho đến nay, bạn không có cách nào để biết chính xác số pixel thực tế mà một phần tử chiếm trên màn hình của người dùng. Thuộc tính devicePixelContentBox mới trên ResizeObserverEntry cung cấp cho bạn thông tin đó và cho phép bạn kết xuất pixel hoàn hảo bằng <canvas>. devicePixelContentBox được hỗ trợ trong Chrome phiên bản 84 trở lên.