devicePixelContentBox로 완벽한 픽셀 렌더링

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

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

브라우저 지원

  • Chrome: 84.
  • Edge: 84.
  • Firefox: 93.
  • Safari: 지원되지 않음

소스

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

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

오랫동안 모든 사용자의 화면 픽셀 밀도를 96DPI('인치당 도트 수')로 추정하는 것이 상당히 합리적이었습니다. 즉, 모든 모니터의 픽셀 밀도는 센티미터당 약 38개였습니다. 시간이 지남에 따라 모니터가 커지거나 작아지거나 동일한 표면적에 더 많은 픽셀이 포함되기 시작했습니다. 웹의 많은 콘텐츠가 글꼴 크기를 비롯한 크기를 px에서 정의한다는 사실과 결합하면 이러한 고밀도('HiDPI') 화면에서 읽을 수 없는 텍스트가 표시됩니다. 이에 대응하기 위해 브라우저는 모니터의 실제 픽셀 밀도를 숨기고 대신 사용자가 96DPI 디스플레이를 사용하는 것처럼 가장합니다. CSS의 px 단위는 이 가상 96DPI 디스플레이에서 1픽셀의 크기를 나타내므로 'CSS 픽셀'이라고 합니다. 이 단위는 측정 및 위치 지정용으로만 사용됩니다. 실제 렌더링이 실행되기 전에 실제 픽셀로 변환됩니다.

이 가상 디스플레이에서 사용자의 실제 디스플레이로 이동하려면 어떻게 해야 할까요? devicePixelRatio를 입력합니다. 이 전역 값은 단일 CSS 픽셀을 형성하는 데 필요한 실제 픽셀 수를 나타냅니다. devicePixelRatio (dPR)가 1이면 약 96DPI의 모니터로 작업하고 있는 것입니다. 레티나 화면을 사용하는 경우 dPR은 2일 수 있습니다. 휴대전화에서는 더 높고 이상한 dPR 값 (예: 2, 3, 심지어 2.65)이 발생하는 경우가 많습니다. 이 값은 정확하지만 모니터의 실제 DPI 값을 가져올 수 없다는 점에 유의해야 합니다. dPR이 2이면 CSS 픽셀 1개가 물리적 픽셀 2개에 정확하게 매핑됩니다.

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

너비가 3440픽셀이고 디스플레이 영역의 너비는 79cm입니다. 따라서 해상도는 110DPI가 됩니다. 96에 가깝지만 정답은 아닙니다. 또한 <div style="width: 1cm; height: 1cm">가 대부분의 디스플레이에서 1cm를 정확하게 측정하지 않는 이유이기도 합니다.

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

확대/축소로 인해 DevTools에서 다양한 부분 devicePixelRatio을 표시합니다.

<canvas> 요소를 믹스에 추가해 보겠습니다. widthheight 속성을 사용하여 캔버스의 픽셀 수를 지정할 수 있습니다. 따라서 <canvas width=40 height=30>는 40x30픽셀의 캔버스입니다. 하지만 40x30픽셀로 표시되는 것은 아닙니다. 기본적으로 캔버스는 widthheight 속성을 사용하여 고유한 크기를 정의하지만, 알고 있고 좋아하는 모든 CSS 속성을 사용하여 캔버스의 크기를 임의로 조정할 수 있습니다. 지금까지 알아본 내용을 토대로 생각해 보면 모든 시나리오에 이 방법이 이상적이지 않을 수 있습니다. 캔버스의 한 픽셀이 여러 실제 픽셀 또는 실제 픽셀의 일부만 덮을 수 있습니다. 이로 인해 불쾌감을 주는 시각적 아티팩트가 발생할 수 있습니다.

요약하면 캔버스 요소는 그릴 수 있는 영역을 정의하는 지정된 크기를 갖습니다. 캔버스 픽셀 수는 CSS 픽셀로 지정된 캔버스의 디스플레이 크기와 완전히 별개입니다. CSS 픽셀 수는 실제 픽셀 수와 다릅니다.

Pixel Perfect

경우에 따라 캔버스 픽셀을 실제 픽셀에 정확하게 매핑하는 것이 좋습니다. 이러한 매핑이 달성되면 '픽셀퍼펙트'라고 합니다. 특히 하위 픽셀 렌더링을 사용하거나 밝기가 번갈아 표시되는 선이 촘촘하게 정렬된 그래픽을 표시할 때 텍스트를 읽기 쉽게 렌더링하려면 픽셀 정밀 렌더링이 중요합니다.

웹에서 최대한 픽셀 완벽 캔버스에 가까운 결과를 얻기 위해 다음과 같은 접근 방식이 주로 사용되었습니다.

<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 이후 크기 조절 관찰자가 모든 주요 브라우저에서 지원되지만, 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 이상에서 지원됩니다.