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

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

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

Hỗ trợ trình duyệt

  • 84
  • 84
  • 93
  • x

Nền: pixel CSS, pixel canvas và pixel vật lý

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 sẽ rút gọn thành pixel. Bất cứ khi nào chúng tôi chỉ định kích thước hoặc vị trí của một phần tử trong CSS, công cụ bố cục của trình duyệt cuối cùng sẽ chuyển đổi giá trị đó thành pixel (px). Đây là "CSS Pixel" có rất nhiều lịch sử và chỉ có mối quan hệ không chặt chẽ với pixel bạn có trên màn hình.

Trong một thời gian dài, khá hợp lý khi ước tính mật độ pixel trên màn hình của bất kỳ ai với 96DPI ("số điểm trên mỗi inch"), nghĩa là bất kỳ màn hình nào cũng sẽ có khoảng 38 pixel trên cm. Theo thời gian, các màn hình lớn dần và/hoặc thu nhỏ lại 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 điều đó 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, nên chúng ta sẽ có văn bản khó đọc trên màn hình mật độ cao ("HiDPI"). Như một biện pháp đối phó, các trình duyệt sẽ ẩn mật độ pixel thực tế của màn hình và thay vào đó sẽ giả vờ rằng người dùng có màn hình 96 DPI. Đơn vị px trong CSS đại diện cho 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à "CSS Pixel". Đơn vị này chỉ dùng để đo lường và định vị. Trước khi hiển thị thực tế, một lượt chuyển đổi thành pixel thực sẽ xảy 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ố lượng pixel thực tế mà bạn cần để tạo thành 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 dùng màn hình retina, thì dPR của bạn có thể là 2. Trên điện thoại, không phải lúc nào cũng gặp các giá trị dPR cao hơn (và kỳ lạ hơn) như 2, 3 hoặc thậm chí là 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 giá trị DPI thực tế của màn hình. dPR của 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...

Nó có chiều rộng 3440 pixel và khu vực hiển thị rộng 79 cm. Điều đó dẫn đến độ phân giải là 110 DPI. Gần với 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 1 cm trên hầu hết các màn hình.

Cuối cùng, dPR cũng có thể bị ảnh hưởng bởi 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.

DevTools cho thấy nhiều devicePixelRatio phân số do thu phóng.

Hãy thêm phần tử <canvas> vào danh sách kết hợp. Bạn có thể chỉ định số lượng pixel bạn muốn cho canvas bằng cách sử dụng các thuộc tính widthheight. Vì vậy, <canvas width=40 height=30> sẽ là một canvas có kích thướ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 40 x 30 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 của nó, nhưng bạn có thể tuỳ ý đổi kích thước canvas bằng cách sử dụng tất cả các thuộc tính CSS mà bạn biết và yêu thích. Với tất cả những gì chúng tôi tìm hiểu được từ đầu đến giờ, có thể bạn thấy rằng điều này không phải là lý tưởng trong mọi tình huống. Một pixel trên canvas có thể che phủ nhiều pixel vật lý hoặc chỉ một phần nhỏ của một pixel thực. Điều này có thể dẫn đến các thành phần hình ảnh không vừa ý.

Tóm tắt: Các phần tử Canvas có kích thước nhất định để xác định vùng 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ý.

Độ hoàn hảo của pixel

Trong một số trường hợp, bạn nên có ánh xạ chính xác từ pixel canvas đến pixel thực. Nếu ánh xạ này đạt được, nó được gọi là "pixel hoàn hảo". Độ chính xác hoàn hảo đến pixel rất 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ọa có các đường căn chỉnh chặt chẽ với độ sáng xen kẽ.

Để đạt được thứ gì đó giống nhất có thể với một canvas có điểm ảnh hoàn hảo trên web, đây ít nhiều là phương pháp nên 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 khéo léo có thể đang tự hỏi điều gì sẽ xảy ra khi dPR không phải là một giá trị số nguyên. Đó là một câu hỏi hay và chính xác nơi cốt lõi 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ể chúng sẽ phân giải thành các giá trị pixel CSS dạng phân số. Phần tử có margin-left: 33% có thể kết thúc bằng một hình chữ nhật như sau:

DevTools cho thấy các giá trị pixel dạng phân số do lệnh gọi getBoundingClientRect() tạo ra.

Pixel CSS hoàn toàn là ảo, vì vậy về mặt lý thuyết, việc có các phân số của một pixel là chấp nhận được, nhưng trình duyệt làm cách nào để tìm ra ánh xạ tới pixel vật lý? Vì các pixel thực tế phân số không phải là một thứ.

Chụp ảnh Pixel

Phần của quá trình chuyển đổi đơn vị đảm nhận 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ư sau: Nó chụp các giá trị pixel phân số thành các giá trị pixel thực, số nguyên. Chính xác thì điều này xảy ra như thế nào giữa các 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 mà dPR là 1, thì một trình duyệt có thể hiển thị phần tử đó ở 792px pixel thực, trong khi một trình duyệt khác có thể hiển thị phần tử đó ở 791px. Đó chỉ là một pixel tắt, nhưng một pixel duy nhất có thể gây hạ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 hiện tượng mờ hoặc thậm chí thấy nhiều hiện tượ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ư trên, nhưng chiều rộng và chiều cao đã được giảm đi một pixel bằng cách sử dụng tỷ lệ song tuyến tính. Hoa văn mới 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 thẻ mới để xem mà không á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 của thiết bị (tức là pixel thực). Thuộc tính này thuộc 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 hiện tại, thuộc tính devicePixelContentBox chỉ có trong Chrome 84 trở lên.

Như đã đề cập trong ResizeObserver: nó giống như document.onresize cho các phần tử, hàm callback của ResizeObserver sẽ được gọi trước khi vẽ và sau 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ử được quan sát ngay trước khi các phần tử đó được vẽ. Trong trường hợp sự cố canvas được nêu ở trên, chúng ta có thể sử dụng cơ hội này để điều chỉnh số pixel trên canvas, đảm bảo rằng chúng ta kết thúc mối liên kết một với một chính xác giữa các 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ợ), lệnh gọi lại sẽ chỉ được gọi nếu bất kỳ chỉ số nào trong hộp quan sát được 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í của canvas (đảm bảo hiệu quả các giá trị pixel theo tỷ lệ) và không thấy bất kỳ hiệu ứng Moiré nào khi kết xuất. Nếu bạn muốn xem hiệu ứng Moiré trong cách sử dụng getBoundingClientRect() và cách thuộc tính ResizeObserver mới giúp bạn tránh gặp phải vấn đề này, hãy xem bản minh hoạ trên 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 này 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 đến bất ngờ trên web và cho đến nay vẫn chưa có cách nào để bạn biết được chính xác số lượng pixel vật lý 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 điểm ảnh một cách hoàn hảo bằng <canvas>. devicePixelContentBox được hỗ trợ trong Chrome 84 trở lên.