devicePixelContentBox로 완벽한 픽셀 렌더링

캔버스에는 실제로 몇 개의 픽셀이 있나요?

Chrome 84부터 ResizeObserver실제 픽셀로 요소의 크기를 측정하는 devicePixelContentBox라는 새로운 상자 측정을 지원합니다. 이를 통해, 특히 고밀도 화면의 컨텍스트에서 픽셀 단위의 그래픽을 완벽히 렌더링할 수 있습니다.

브라우저 지원

  • 84
  • 84
  • 93
  • x

배경: CSS 픽셀, 캔버스 픽셀, 실제 픽셀

em, %, vh와 같은 추상적인 길이 단위를 사용하는 경우가 많지만 모두 픽셀로 축소됩니다. CSS에서 요소의 크기나 위치를 지정할 때마다 브라우저의 레이아웃 엔진은 결국 해당 값을 픽셀 (px)로 변환합니다. 이들은 'CSS 픽셀'이며 많은 기록이 있으며 화면에 있는 픽셀과 느슨한 관계를 갖습니다.

오랫동안 모든 사람의 화면 픽셀 밀도는 96DPI ('인치당 도트 수')를 추정하는 것이 합리적이었습니다. 즉, 어떤 모니터가 cm당 약 38 픽셀이 될 것입니다. 시간이 지남에 따라 모니터가 증가 또는 축소되거나 동일한 표면 영역에 더 많은 픽셀이 있기 시작했습니다. 여기에 글꼴 크기 등 글꼴 크기를 비롯한 웹 콘텐츠의 크기가 px로 정의되는 경우가 많다는 점과 고밀도 ('HiDPI') 화면에서 읽을 수 없는 텍스트가 생성됩니다. 이에 대한 조치로 브라우저는 모니터의 실제 픽셀 밀도를 숨기고 대신 사용자의 디스플레이가 96DPI인 것으로 가장합니다. CSS의 px 단위는 이 가상 96 DPI 디스플레이의 1픽셀 크기를 나타내므로 'CSS Pixel'이라고 합니다. 이 단위는 측정 및 위치 지정에만 사용됩니다. 실제 렌더링이 발생하기 전에 실제 픽셀로 변환됩니다.

이 가상 디스플레이에서 사용자의 실제 디스플레이로 어떻게 전환할 수 있을까요? devicePixelRatio를 입력합니다. 이 전역 값은 단일 CSS 픽셀을 구성하는 데 필요한 실제 픽셀 수를 알려줍니다. devicePixelRatio (dPR)이 1이면 약 96DPI의 모니터에서 작업 중인 것입니다. 레티나 화면의 경우 dPR이 2일 수 있습니다. 휴대전화에서는 2, 3 또는 2.65와 같이 더 높고 더 이상한 dPR 값이 표시되는 경우가 많습니다. 이 값은 정확하지만 모니터의 실제 DPI 값을 가져올 수는 없습니다. dPR이 2이면 1개의 CSS 픽셀이 정확히 2개의 실제 픽셀에 매핑된다는 의미입니다.

Chrome에 따르면 내 모니터의 dPR은 1입니다.

너비가 3440픽셀이고 디스플레이 영역의 너비는 79cm입니다. 그러면 해상도가 110DPI가 됩니다. 96에 가깝지만 정답이 아닙니다. 이 때문에 <div style="width: 1cm; height: 1cm">는 대부분의 디스플레이에서 정확히 1cm 크기를 측정하지 않습니다.

마지막으로 dPR은 브라우저의 확대/축소 기능도 영향을 받을 수 있습니다. 확대하면 브라우저는 보고된 dPR을 높여 모든 항목이 더 크게 렌더링됩니다. 확대/축소하는 동안 DevTools 콘솔에서 devicePixelRatio를 선택하면 소수 값이 표시되는 것을 볼 수 있습니다.

확대/축소로 인해 다양한 부분 devicePixelRatio를 표시하는 DevTools

<canvas> 요소를 믹스에 추가해 보겠습니다. widthheight 속성을 사용하여 캔버스에 포함할 픽셀 수를 지정할 수 있습니다. 따라서 <canvas width=40 height=30>는 40x30픽셀 크기의 캔버스가 됩니다. 하지만 40x30픽셀로 표시되는 것은 아닙니다. 기본적으로 캔버스는 widthheight 속성을 사용하여 고유한 크기를 정의하지만, 알고 있고 좋아하는 모든 CSS 속성을 사용하여 캔버스의 크기를 임의로 조절할 수 있습니다. 지금까지 배운 내용을 살펴봤을 때, 이러한 접근 방식이 모든 시나리오에 적합하지는 않을 수도 있습니다. 캔버스의 한 픽셀이 여러 실제 픽셀을 덮거나 실제 픽셀의 일부만 덮을 수도 있습니다. 이로 인해 시각적 아티팩트가 마음에 들지 않을 수 있습니다.

요약: 캔버스 요소에는 그릴 수 있는 영역을 정의하는 지정된 크기가 있습니다. 캔버스 픽셀의 수는 CSS 픽셀로 지정된 캔버스의 표시 크기와 완전히 별개입니다. CSS 픽셀 수는 실제 픽셀 수와 다릅니다.

픽셀의 완성도

일부 시나리오에서는 캔버스 픽셀에서 실제 픽셀로 정확하게 매핑하는 것이 좋습니다. 이렇게 매핑하면 이를 '완벽한 픽셀'이라고 합니다. 완벽한 픽셀의 렌더링은 텍스트를 읽기 쉽게 렌더링하는 데 매우 중요합니다. 특히 하위 픽셀 렌더링을 사용하거나 밝기의 교차 선으로 그래픽을 표시하는 경우 더욱 그렇습니다.

웹에서 가능한 한 픽셀의 완벽한 캔버스에 근접하는 목표를 달성하기 위해 다음과 같은 접근 방식을 취했습니다.

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

예리한 독자는 dPR이 정수 값이 아니면 어떻게 되는지 궁금해할 수 있습니다. 이것은 좋은 질문이며 이 전체 문제의 핵심이 정확히 어디에 있는가입니다. 또한 비율, vh 또는 기타 간접 값을 사용하여 요소의 위치나 크기를 지정하면 이러한 값이 소수 CSS 픽셀 값으로 확인될 수 있습니다. margin-left: 33%가 있는 요소는 다음과 같은 직사각형이 될 수 있습니다.

getBoundingClientRect() 호출의 결과로 소수 픽셀 값을 보여주는 DevTools

CSS 픽셀은 순전히 가상이므로 이론적으로는 픽셀의 일부가 있어도 괜찮지만, 브라우저는 실제 픽셀에 대한 매핑을 어떻게 알아낼까요? 분수의 물리적 픽셀은 문제가 되지 않기 때문입니다.

픽셀 맞추기

단위 변환 프로세스에서 요소를 물리적 픽셀과 정렬하는 부분을 '픽셀 맞추기'라고 하며, 주석에 표시된 대로 작동합니다. 즉, 소수 픽셀 값을 정수의 실제 픽셀 값에 맞춥니다. 이것이 정확히 어떻게 되는지는 브라우저마다 다릅니다. dPR이 1인 디스플레이에 너비가 791.984px인 요소가 있는 경우 한 브라우저는 요소를 792px 물리적 픽셀로 렌더링하고 다른 브라우저는 791px에서 요소를 렌더링할 수 있습니다. 이는 단 하나의 픽셀에 불과하지만, 단일 픽셀은 완벽한 픽셀이어야 하는 렌더링에 해가 될 수 있습니다. 이렇게 하면 흐릿하게 처리되거나 무아레 효과와 같은 아티팩트가 더 많이 보일 수 있습니다.

상단 이미지는 서로 다른 색상의 픽셀로 구성된 래스터입니다. 하단 이미지는 위와 동일하지만 이중 선형 배율을 사용하여 너비와 높이를 1픽셀 줄였습니다. 새로운 패턴을 모아레 효과라고 합니다.
(크기 조절이 적용되지 않은 이미지를 보려면 새 탭에서 이미지를 열어야 할 수 있습니다.)

devicePixelContentBox

devicePixelContentBox는 기기 픽셀 (즉, 물리적 픽셀) 단위로 된 요소의 콘텐츠 상자를 제공합니다. ResizeObserver의 일부입니다. Safari 13.1 이후 ResizeObserver는 이제 모든 주요 브라우저에서 지원되지만, devicePixelContentBox 속성은 현재 Chrome 84 이상에서만 사용할 수 있습니다.

ResizeObserver에서 언급했듯이 이는 요소의 document.onresize와 같습니다. ResizeObserver의 콜백 함수는 페인트 전과 레이아웃 후에 호출됩니다. 즉, 콜백의 entries 매개변수에는 페인트 직전에 관찰된 모든 요소의 크기가 포함됩니다. 위에서 설명한 캔버스 문제의 맥락에서 이 기회를 사용하여 캔버스의 픽셀 수를 조정하여 캔버스 픽셀과 실제 픽셀 간에 정확히 일대일로 매핑되도록 할 수 있습니다.

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

observer.observe()의 옵션 객체에 있는 box 속성을 사용하면 관찰하려는 크기를 정의할 수 있습니다. 따라서 각 ResizeObserverEntry은 항상 borderBoxSize, contentBoxSize, devicePixelContentBoxSize (브라우저에서 지원하는 경우)를 제공하지만 콜백은 관찰된 상자 측정항목 중 하나라도 변경될 때만 호출됩니다.

이 새로운 속성을 사용하면 캔버스의 크기와 위치에 애니메이션을 적용할 수 있으며 (단수 픽셀 값을 효과적으로 보장함) 렌더링 시 모아레 효과는 보지 못합니다. getBoundingClientRect()를 사용하는 접근 방식에 모아레 효과를 확인하고 새 ResizeObserver 속성으로 어떻게 이를 방지할 수 있는지 알아보려면 Chrome 84 이상에서 데모를 참고하세요.

기능 감지

사용자의 브라우저가 devicePixelContentBox를 지원하는지 확인하려면 모든 요소를 관찰하고 속성이 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
}

결론

픽셀은 웹에서 놀라울 정도로 복잡한 주제이며 지금까지는 사용자의 화면에서 요소가 차지하는 실제 픽셀 수를 정확히 알 수 있는 방법이 없었습니다. ResizeObserverEntry의 새로운 devicePixelContentBox 속성은 이러한 정보를 제공하고 <canvas>를 사용하여 완벽한 픽셀 렌더링을 수행할 수 있게 해 줍니다. devicePixelContentBox 버전은 Chrome 84 이상에서 지원됩니다.