Idealne renderowanie dzięki urządzeniu devicePixelContentBox

Ile pikseli naprawdę mieści się na płótnie?

Od wersji 84 Chrome ResizeObserver obsługuje nowy pomiar wymiaru o nazwie devicePixelContentBox, który mierzy wymiary elementu w fizycznych pikselach. Umożliwia to renderowanie grafiki z dokładnością do piksela, zwłaszcza na ekranach o wysokiej gęstości pikseli.

Obsługa przeglądarek

  • Chrome: 84.
  • Edge: 84.
  • Firefox: 93.
  • Safari: nieobsługiwane.

Źródło

Tło: piksele CSS, piksele kanwy i fizyczne piksele

Często pracujemy z abstrakcyjnymi jednostkami długości, takimi jak em, % czy vh, ale 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ą sporą historię i luźno powiązane są tylko z pikselami na ekranie.

Przez długi czas rozsądnie było szacować gęstość pikseli ekranów wszystkich użytkowników na poziomie 96 DPI (punktów na cal), co oznacza, że każdy monitor miałby około 38 pikseli na cm. Z czasem monitory się powiększały lub zmniejszały albo zaczęły mieć więcej pikseli na tej samej powierzchni. Oprócz tego fakt, że znaczna ilość treści w internecie definiuje wymiary, w tym rozmiary czcionek, w języku px, powoduje, że na ekranach o dużej gęstości („HiDPI”) wyświetla się nieczytelny tekst. W ramach środków zaradczych przeglądarki ukrywają faktyczną gęstość pikseli na ekranie i zamiast tego udają, że użytkownik ma wyświetlacz 96 DPI. Jednostka px w CSS reprezentuje rozmiar jednego piksela na tym wirtualnym ekranie o rozdzielczości 96 DPI, stąd nazwa „piksel CSS”. Ta jednostka jest używana tylko do pomiarów i pozycjonowania. Zanim nastąpi rzeczywiste renderowanie, następuje konwersja na fizyczne piksele.

Jak przejść z wirtualnego wyświetlacza na rzeczywisty wyświetlacz użytkownika? Wpisz devicePixelRatio. Ta wartość globalna informuje, ile pikseli fizycznych potrzebujesz, by utworzyć jeden piksel CSS. Jeśli devicePixelRatio (dPR) to 1, pracujesz na monitorze o rozdzielczości 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, np. 2, 3 lub nawet 2.65. Trzeba pamiętać, że jest to dokładna wartość, która nie umożliwia uzyskania rzeczywistej wartości DPI monitora. dPR o wartości 2 oznacza, że 1 piksel CSS zostanie zmapowany na dokładnie 2 fizyczne piksele.

Przykład
Mój monitor ma wartość dPR 1 według Chrome…

Ma ona 3440 pikseli szerokości, a jej obszar wyświetlania ma 79 cm szerokości. Efektem jest rozdzielczość 110 DPI. Blisko 96, ale niezupełnie. Dlatego na większości wyświetlaczy <div style="width: 1cm; height: 1cm"> nie będzie dokładnie mierzył 1 cm.

Wreszcie, na dPR może mieć wpływ funkcja powiększania w przeglądarce. Jeśli powiększysz obraz, przeglądarka zwiększy zgłoszony parametr dPR, co spowoduje większe renderowanie wszystkich elementów. Jeśli podczas powiększania w konsoli DevTools zaznaczysz devicePixelRatio, zobaczysz wartości ułamkowe.

Narzędzia deweloperskie wyświetlają różne wartości ułamkowe devicePixelRatio z powodu powiększenia.

Dodajmy do tego element <canvas>. Za pomocą atrybutów width i height możesz określić, ile pikseli ma zawierać obszar roboczy. Wartość <canvas width=40 height=30> oznacza płótno o wymiarach 40 x 30 pikseli. Nie oznacza to jednak, że będzie wyświetlany w rozmiarze 40 x 30 pikseli. Domyślnie płótno używa atrybutów widthheight do określenia swojego rozmiaru, ale możesz dowolnie zmieniać jego rozmiar, korzystając ze wszystkich znanych i lubionych właściwości CSS. Biorąc pod uwagę wszystko, czego się do tej pory dowiedzieliśmy, może Ci się wydawać, że nie będzie to idealne rozwiązanie w każdym przypadku. Jeden piksel na kanwie może obejmować kilka fizycznych pikseli lub tylko ułamek fizycznego piksela. Może to powodować nieprzyjemne efekty wizualne.

Podsumowując: elementy płótna mają określony rozmiar, który określa obszar, na którym możesz rysować. Liczba pikseli na płótnie jest całkowicie niezależna od rozmiaru wyświetlania płótna określonego w pikselach CSS. Liczba pikseli CSS nie jest taka sama jak liczba fizycznych pikseli.

Pixel perfection

W niektórych sytuacjach jest wskazane dokładne mapowanie pikseli kanwy na fizyczne piksele. Jeśli takie mapowanie zostanie osiągnięte, określa się je jako „doskonały piksel”. Renderowanie perfekcyjne w pikselach ma kluczowe znaczenie dla czytelnego renderowania tekstu, zwłaszcza w przypadku renderowania subpikselowego lub wyświetlania grafiki z dopasowanymi do siebie liniami o zmiennej jasności.

Aby w internecie stworzyć coś, co jest jak najbardziej zbliżone do pikselowej płótna, należy wybrać jedną z najlepszych metod:

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

Bystry czytelnik może się zastanawiać, co się dzieje, gdy dPR nie jest liczbą całkowitą. To dobre pytanie i to jest sedno problemu. Jeśli dodatkowo określisz położenie lub rozmiar elementu za pomocą wartości procentowych, vh lub innych wartości pośrednich, może się zdarzyć, że będą one wyrażone w postaci ułamkowych wartości pikseli CSS. Element z atrybutem margin-left: 33% może zakończyć się prostokątem w następujący sposób:

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

Piksele CSS są całkowicie wirtualne, więc w teorii ułamki są dozwolone, ale jak przeglądarka interpretuje mapowanie na fizyczne piksele? Ułamkowe fizyczne piksele nie są niczym.

Przyciąganie pikseli

Część procesu konwersji jednostek, która zajmuje się dopasowywaniem elementów do fizycznych pikseli, nazywa się „przyciąganiem do piksela”. Działa ona zgodnie z nazwą: przyciąga wartości ułamkowe do wartości całkowitych, fizycznych pikseli. Sposób, w jaki to robisz, różni się w zależności od przeglądarki. Jeśli mamy element o szerokości 791.984px na wyświetlaczu, gdzie dPR wynosi 1, jedna przeglądarka może wyrenderować go z rozmiarem 792px pikseli fizycznych, a inna – z wartością 791px. To tylko jeden piksel, ale ten sam piksel może negatywnie wpłynąć na renderowanie, które musi wyglądać idealnie. Może to powodować rozmycie lub nawet bardziej widoczne artefakty, np. efekt Moiré.

Górny obraz to raster z różnokolorowych pikseli. Obraz na dole jest taki sam jak powyżej, ale jego szerokość i wysokość zostały zmniejszone o 1 piksel za pomocą skalowania dwuliniowego. Nowo powstały wzorzec nazywa się efektem mory.
(może być konieczne otwarcie tego zdjęcia w nowej karcie, aby wyświetlić je bez skalowania).

devicePixelContentBox

devicePixelContentBox podaje rozmiar pola treści elementu w pikselach urządzenia (czyli fizycznych pikselach). Należy do ResizeObserver. Chociaż od wersji Safari 13.1 obsługa ResizeObserver jest teraz obsługiwana we wszystkich popularnych przeglądarkach, właściwość devicePixelContentBox jest obecnie dostępna tylko w Chrome w wersji 84 i nowszych.

Jak wspomniano w ResizeObserver: jest to funkcja document.onresize dla elementów, funkcja wywołania zwrotnego ResizeObserver zostanie wywołana przed narysowaniem i po ułożeniu. Oznacza to, że parametr entries w wywołaniu zwrotnym zawiera rozmiary wszystkich obserwowanych elementów tuż przed ich wyrenderowaniem. W kontekście opisanego powyżej problemu z płótną możemy wykorzystać tę okazję do dostosowania liczby pikseli na naszym obszarze roboczym, tak aby uzyskać dokładne mapowanie między pikselami obszaru roboczego i fizycznymi.

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 dla observer.observe() pozwala określić, które rozmiary chcesz obserwować. Oznacza to, że każdy element ResizeObserverEntry będzie zawsze zawierał wartości borderBoxSize, contentBoxSizedevicePixelContentBoxSize (o ile przeglądarka obsługuje te dane), ale wywołanie zwrotne zostanie wywołane tylko wtedy, gdy zmieni się jakakolwiek wartość w polu obserwowane.

Dzięki tej nowej właściwości możemy nawet animować rozmiar i położenie kanwy (co skutecznie gwarantuje użycie wartości ułamkowych pikseli) i nie obserwować efektu moiré podczas renderowania. Jeśli chcesz zobaczyć, jak wygląda efekt mory przy zastosowaniu getBoundingClientRect() i jak nowa właściwość ResizeObserver pozwala Ci tego uniknąć, obejrzyj prezentację w Chrome w wersji 84 lub nowszej.

Wykrywanie cech

Aby sprawdzić, czy przeglądarka użytkownika obsługuje devicePixelContentBox, możemy obserwować dowolny element i sprawdzić, czy usługa jest obecna w 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

Piksel jest w internecie zaskakująco złożonym tematem. Do tej pory nie było sposobu na dokładne poznanie liczby fizycznych pikseli zajmowanych przez element na ekranie użytkownika. Nowa właściwość devicePixelContentBox dotycząca elementu ResizeObserverEntry zawiera te informacje i umożliwia tworzenie idealnej dla każdego piksela renderowania w interfejsie <canvas>. devicePixelContentBox jest obsługiwana w Chrome 84 i nowszych wersjach.