Idealne renderowanie dzięki urządzeniu devicePixelContentBox

Ile pikseli jest faktycznie w odbitce na płótnie?

Od wersji Chrome 84 usługa ResizeObserver obsługuje nową funkcję pomiaru pola o nazwie devicePixelContentBox, która mierzy wymiary elementu w pikselach fizycznych. Umożliwia to renderowanie grafiki o idealnej jakości pikseli, zwłaszcza na ekranach o dużej gęstości.

Obsługa przeglądarek

  • 84
  • 84
  • 93
  • x

Tło: piksele CSS, piksele canvas i piksele fizyczne.

Choć często używamy abstrakcyjnych jednostek długości, takich jak em, % czy vh, wszystko sprowadza się do pikseli. Gdy określamy rozmiar lub pozycję elementu w CSS, mechanizm układu przeglądarki przekonwertuje tę wartość na piksele (px). Są to „piksele CSS”, które mają długą historię i są luźno powiązane z pikselami widocznymi na ekranie.

Przez długi czas można było dość rozsądnie oszacować gęstość pikseli na ekranie u użytkowników wynoszącą 96 DPI („punkty na cal”), co oznacza, że na każdym monitorze będzie to około 38 pikseli na cm. Z czasem monitory powiększały się, zmniejszały lub zaczynały mieć więcej pikseli na tej samej powierzchni. W połączeniu z tym, że wiele treści w internecie określa ich wymiary, w tym rozmiar czcionki, w px, w efekcie na ekranach o dużej gęstości („HiDPI”) otrzymujemy nieczytelny tekst. W ramach działań zaradczych przeglądarki ukrywają faktyczną gęstość pikseli na monitorze i zamiast tego udawaj, że użytkownik ma wyświetlacz o rozdzielczości 96 dpi. Jednostka px w CSS odpowiada rozmiarowi 1 piksela na tym wirtualnym wyświetlaczu 96 DPI, stąd nazwa „CSS Pixel”. Ta jednostka służy tylko do pomiarów i pozycjonowania. Przed renderowaniem następuje konwersja na fizyczne piksele.

Jak przejść od wirtualnego wyświetlacza do rzeczywistego? Wypełnij pole devicePixelRatio. Ta wartość globalna informuje, ile pikseli fizycznych potrzebujesz do utworzenia pojedynczego piksela CSS. Jeśli wartość devicePixelRatio (dPR) wynosi 1, pracujesz na monitorze o około 96 DPI. Jeśli masz ekran Retina, dPR wynosi prawdopodobnie 2. Na telefonach często występują wyższe (i dziwniejsze) wartości dPR, takie jak 2, 3 czy nawet 2.65. Pamiętaj, że jest to wartość dokładna, ale nie pozwala na ustalenie rzeczywistej wartości DPI na monitorze. dPR równy 2 oznacza, że 1 piksel CSS zostanie zmapowany na dokładnie 2 piksele fizyczne.

Przykład
Według Chrome mój monitor ma dPR na poziomie 1...

Ma 3440 pikseli szerokości, a obszar wyświetlania – 79 cm. Daje to rozdzielczość 110 DPI. Blisko 96, ale niezupełnie. Dlatego też <div style="width: 1cm; height: 1cm"> nie mierzy dokładnie 1 cm na większości wyświetlaczy.

Na dPR może też wpływać funkcja powiększenia w przeglądarce. Po powiększeniu widok w przeglądarce zwiększa dPR, co powoduje zwiększenie renderowania. Jeśli podczas powiększania wyświetlisz pole devicePixelRatio w konsoli Narzędzi deweloperskich, zobaczysz wartości ułamkowe.

Narzędzia deweloperskie wyświetlają różne ułamkowe wartości devicePixelRatio ze względu na powiększenie.

Dodajmy do składanki element <canvas>. Możesz określić, ile pikseli ma mieć obszar roboczy, używając atrybutów width i height. <canvas width=40 height=30> będzie więc obszarem roboczym o wymiarach 40 x 30 pikseli. Nie oznacza to jednak, że będzie wyświetlany w rozmiarze 40 x 30 pikseli. Domyślnie obszar roboczy używa atrybutów width i height do określenia jego wewnętrznego rozmiaru, ale możesz dowolnie zmieniać rozmiar obszaru roboczego, korzystając ze wszystkich znanych i lubianych właściwości CSS. Biorąc pod uwagę wszystko, czego się do tej pory dowiedzieliśmy, może się okazać, że nie w każdej sytuacji będzie to idealne rozwiązanie. Jeden piksel na obszarze roboczym może zająć wiele fizycznych pikseli lub tylko ich część. Może to prowadzić do niepokojących artefaktów wizualnych.

Podsumujmy: elementy Canvas mają określony rozmiar określający obszar, na którym możesz rysować. Liczba pikseli obszaru roboczego jest całkowicie niezależna od rozmiaru wyświetlanego obszaru roboczego określonego w pikselach CSS. Liczba pikseli CSS nie jest równa liczbie fizycznych pikseli.

Pikselowa perfekcja

W niektórych sytuacjach pożądane jest dokładne mapowanie z pikseli obszaru roboczego na fizyczne. Mapowanie to tzw. „pixel-perfect”. Idealne renderowanie tekstu ma kluczowe znaczenie dla czytelnego renderowania tekstu, zwłaszcza w przypadku korzystania z renderowania podpikselowego lub wyświetlania grafiki z ściśle wyrównanymi liniami o zmiennej jasności.

Jeśli chcesz osiągnąć w internecie obraz o idealnej doskonałości na płótnie, wygląda mniej lub spójnie:

<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>

Sprytny czytelnik może zastanawiać się, co się stanie, gdy dPR nie jest liczbą całkowitą. Dobre pytanie i dokładne miejsce, w którym tkwi sedno całego problemu. Poza tym, jeśli określisz położenie lub rozmiar elementu za pomocą procentów, vh lub innych wartości pośrednich, mogą one przyjmować ułamkowe wartości pikseli CSS. Element z atrybutem margin-left: 33% może skończyć się prostokątem takim jak:

Narzędzia deweloperskie pokazujące ułamkowe wartości pikseli w wyniku wywołania getBoundingClientRect().

Piksele CSS są w pełni wirtualne, więc teoretycznie ułamki są dopuszczalne, ale jak przeglądarka może odwzorować piksele fizyczne? Ponieważ ułamkowe piksele fizyczne są czymś.

Przyciąganie pikseli

Etap procesu przeliczania jednostek, który zajmuje się wyrównywaniem elementów z fizycznymi pikselami, nosi nazwę „przyciągania pikseli”. Działa tak, jak podano na cynie: ułamkowe wartości pikseli są przyciągane do liczb całkowitych, czyli wartości w pikselach. Sposób, w jaki to się dzieje, różni się w zależności od przeglądarki. Jeśli na wyświetlaczu mamy element o szerokości 791.984px, przy czym wartość dPR wynosi 1, jedna przeglądarka może wyrenderować go w pikselach fizycznych o 792px, a inna – na 791px. To tylko jeden piksel zbędny, ale ten pojedynczy piksel może mieć negatywny wpływ na renderowanie, które wymaga perfekcyjnego renderowania. Może to spowodować rozmycie lub uzyskanie jeszcze bardziej widocznych artefaktów, takich jak efekt mory.

Górny obraz to rastra z pikselami o różnych kolorach. Dolny obraz jest taki sam jak powyżej, ale szerokość i wysokość zostały zmniejszone o 1 piksel za pomocą skalowania dwuliniowego. Nowy wzór to efekt mory.
(Aby zobaczyć ten obraz bez skalowania, konieczne może być otwarcie tego obrazu w nowej karcie).

devicePixelContentBox

devicePixelContentBox udostępnia pole treści elementu w pikselach urządzenia (pikselach fizycznych). Jest częścią usługi ResizeObserver. Chociaż usługa ResizeObserver jest teraz obsługiwana we wszystkich najpopularniejszych przeglądarkach od Safari w wersji 13.1, właściwość devicePixelContentBox jest obecnie dostępna tylko w Chrome 84 i nowszych wersjach.

Jak wspomnieliśmy w sekcji ResizeObserver: jest to typ document.onresize dla elementów, więc funkcja wywołania zwrotnego elementu ResizeObserver zostanie wywołana przed wyrenderowaniem i po układem. Oznacza to, że parametr entries wywołania zwrotnego będzie zawierał rozmiary wszystkich obserwowanych elementów tuż przed ich wyrenderowaniem. W kontekście opisanego powyżej problemu z obszarem roboczym możemy wykorzystać tę możliwość, aby dostosować liczbę pikseli na płótnie, aby uzyskać dokładne mapowanie pikseli obszaru roboczego i pikseli fizycznych.

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']});

Właściwość box w obiekcie opcji observer.observe() pozwala określić, które rozmiary chcesz obserwować. Każdy element ResizeObserverEntry zawsze zawiera parametry borderBoxSize, contentBoxSize i devicePixelContentBoxSize (o ile przeglądarka je obsługuje), jednak wywołanie zwrotne jest wywoływane tylko w przypadku zmiany którejkolwiek ze wskaźników obserwowanego pola.

Dzięki tej nowej właściwości można nawet animować rozmiar i położenie naszego płótna (co gwarantuje ułamkową wartość pikseli) bez stosowania efektu mory na renderowaniu. Jeśli chcesz zobaczyć efekt mory w sposobie używania getBoundingClientRect() i zobaczyć, jak nowa właściwość ResizeObserver pozwala tego uniknąć, obejrzyj prezentację w Chrome 84 lub nowszej.

Wykrywanie funkcji

Aby sprawdzić, czy przeglądarka użytkownika obsługuje devicePixelContentBox, możemy obserwować dowolny element i sprawdzać, czy właściwość znajduje się w elemencie ResizeObserverEntry:

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
}

Podsumowanie

Piksele to zaskakująco złożony temat w internecie. Do tej pory nie było w stanie określić dokładnej liczby pikseli fizycznych zajmujących dany element na ekranie użytkownika. Nowa właściwość devicePixelContentBox w elemencie ResizeObserverEntry dostarcza tych informacji i umożliwia uzyskanie perfekcyjnego renderowania obrazu za pomocą <canvas>. Usługa devicePixelContentBox jest obsługiwana w Chrome 84 i nowszych wersjach.