devicePixelContentBox를 사용하여 완벽하게 픽셀(pixel-perfect) 렌더링하기


원문 : https://web.dev/device-pixel-content-box/ 실제로 캔버스에는 몇 개의 픽셀이 있을까?

크롬 84부터, ResizeObserver는 물리적 픽셀 단위로 요소의 크기를 측정하는 새로운 박스 측정법인 device-pixel-content-box을 제공한다. 특히 고밀도 화면에서 픽셀 단위의 그래픽을 완벽하게 렌더링 할 수 있도록 한다.

배경: CSS 픽셀, 캔버스 픽셀과 물리적 픽셀

길이를 지정할 때 em, %, vh와 같은 추상적인 단위를 사용하여 작업할 때가 있는데, 이런 추상적인 값은 최종적으로 픽셀 단위로 변경된다. CSS에서 요소의 크기나 위치를 지정할 때마다 브라우저의 레이아웃 엔진은 결국 그 값을 픽셀(px)로 변환한다. "CSS 픽셀"은 많은 역사를 가지고 있으며 화면상의 픽셀과 느슨한 관계(loose relationship)를 맺는다.

오랫동안 96DPI(Dots per inch - 1인치당 점의 개수)로 화면 픽셀 밀도를 추정하는 것은 꽤 합리적이었데, 주어진 모니터가 1cm당 약 38픽셀을 가진다고 의미한다. 시간이 지나면서 모니터는 커지거나 축소되었고, 같은 표면적에 더 많은 픽셀을 가질 수도 있게 되었다. 웹에서 글꼴 크기를 포함하여 많은 콘텐츠가 크기를 px로 정의할 수 있는데, 이러한 개념과 결합되면 고밀도 화면("HiDPI")상에서 읽기 어려운 텍스트가 생기게 된다. 대응책으로, 브라우저는 모니터의 실제 픽셀 밀도를 숨기는 대신 사용자가 96DPI 디스플레이를 가지고 있다고 가장한다. CSS에서 px단위는 가상 96DPI 디스플레이에서 하나의 픽셀 크기를 나타내므로 "CSS 픽셀"이라는 이름이 붙여진다. "CSS 픽셀" 단위는 위치와 측정용으로 사용된다. 실제 렌더링이 일어나기 전에 물리적인 픽셀로 변환된다.

이렇게 가상 디스플레이에서 실제 사용자의 디스플레이로 어떻게 이동하는 것일까? devicePixelRatio를 입력해보자. devicePixelRatio는 전역 변수이며, 이 값은 CSS 픽셀을 구성하는데 필요한 물리적 픽셀 수를 나타낸다. 만약 devicePixelRatio(dPR)이 1이면, 약 96DPI 모니터로 작업 중인 게 된다. 만약 레티나 화면일 경우, dPR은 2일 것이다. 모바일에서는 2, 3 또는 2.65와 같이 더 높은 dPR 값이 나타날 수 도 있다. 이 값은 정밀한 값이긴 하지만, 모니터의 실제 DPI 값을 가져오지 못하도록 하는 것이 핵심이다. 2 dPR은 1CSS 픽셀이 실제로 물리적 픽셀 2개와 대응됨(mapping)을 의미한다.


예제

크롬 브라우저에 의하면, 필자의 모니터는 1dPR을 가진다고 한다. 디스플레이 영역 폭이 79cm이며 3440픽셀을 가진다. 이 화면은 110DPI의 해상도이다. 96에 가깝지만, 완전히 같지는 않다. 대부분의 디스플레이에서 <div style = "width : 1cm; height : 1cm">가 1cm 크기를 정확하게 잴 수 없는 이유이다.


결정적으로, dPR은 브라우저의 확대/축소 기능에 영향을 받을 수도 있다. 확대하면 브라우저가 dPR을 증가시켜 모든 것을 더 크게 렌더링한다. 브라우저를 확대/축소하는 동안 개발자 도구 콘솔에서 devicePixelRatio를 입력해보면 변경된 값이 보인다.

개발자 도구에서 브라우저를 줌하는 동안 'devicePixelRatio'를 확인해보면 분수형 값을 확인할 수 있다.

<canvas> 요소에 widthheight 속성을 사용하여 원하는 픽셀 수를 지정할 수 있다. <canvas width=40 height=30>은 40 x 30 픽셀의 캔버스가 될 것이다. 그러나 40 x 30 픽셀로 표시되는 것은 아니다. 기본적으로 캔버스는 widthheight 속성을 사용하여 고유한 크기를 정의하지만, CSS 속성을 사용하여 캔버스의 크기를 임의로 재조정할 수 있다. 지금까지 배운 모든 것을 가지고, 모든 시나리오에서 적합한 것은 아니다. 캔버스에서 하나의 픽셀이 여러 개의 물리적 픽셀을 덮을 수도 있으며 물리적인 픽셀을 일부만 덮을 수 있다. 이는 시각적으로 부드럽지 않은 결과물이 보여질 수 있다.

요약하면,

  • 캔버스 요소에는 그려질 수 있는 영역을 정의하기 위한 크기를 지정해 줄 수 있다.
  • 캔버스 픽셀 수는 CSS 픽셀로 지정된 캔버스의 디스플레이 크기와 완전히 독립적으로 동작한다.
  • CSS 픽셀 수는 물리적인 픽셀 수와 같지 않다.

픽셀 완성도

일부 시나리오에서는 캔버스 픽셀에서 물리적 픽셀로 정확하게 매핑하는 것이 바람직할 수도 있다. 이 매핑이 완벽하게 매칭되는 경우를 "픽셀-퍼펙트"라고 한다. 픽셀-퍼펙트 렌더링은 텍스트를 읽기 쉽게 렌더링하는데 중요하며, 특히 서브 픽셀 렌더링을 사용하거나 명도를 번갈아 바꿔가며 빽빽하게 정렬된 선을 표현할 때 더욱 중요하다.

아래 코드는 웹에서 픽셀-퍼펙트에 가까운 캔버스를 만들기 위해 자주 사용되는 방법 중에 하나이다.

<style>
  /* … styles that affect the canvas' size … */
</style>
<canvas id="myCanvas"></canvas>
<script>
  const cvs = document.querySelector('#myCanvas');

  // 캔버스의 CSS 픽셀 크기를 가져오기
  const rectangle = cvs.getBoundingClientRect();

  // 실제 픽셀로 변환
  cvs.width = rectangle.width * devicePixelRatio;
  cvs.height = rectangle.height * devicePixelRatio;

  // 그리기 시작...
</script>

똑똑한 독자들은 dPR이 정수가 아닐 때 어떻게 동작되는지 궁금할 것이다. 좋은 질문이며 전체 문제의 중요한 핵심이다. 요소의 위치나 크기를 지정할 때 %, vh 또는 정확한 픽셀값이 아닌 간접적으로 계산된 값을 사용하면 분수형 CSS 픽셀값이 설정될 수 있다. margin-left: 33%인 요소의 사각형 정보는 아래와 같다.

개발자 도구에서 'getBoundingClientRect()'을 호출한 결과이다. 분수형 픽셀 값들을 볼 수 있다.

CSS 픽셀은 가상의 값이기 때문에, 이론적으로 분수 값을 가져도 된다. 그러나 브라우저는 이에 대응하는 물리적 픽셀을 어떻게 알아낼까? 물리적 픽셀은 분수 값으로 존재할 수 없는데 말이다.

픽셀 스냅

물리적 픽셀로 엘리먼트의 정렬을 맞추기 위해 단위를 변환하는 프로세스를 "픽셀 스냅(pixel snapping)"이라고 하며, "픽셀 스냅"은 이름 그대로 분수형 픽셀값을 정수형 물리적 픽셀값으로 스냅 한다는 의미이다. 어떻게 정확히 이런 일이 일어나는지는 브라우저마다 다르다. dPR이 1인 디스플레이에 폭이 791.984px인 요소가 있으면 어떤 브라우저는 792px의 물리적 픽셀로 렌더링하고 다른 브라우저는 791px로 렌더링할 수 있다. 단지 1px 차이지만, 단일 픽셀이 완벽한 픽셀 렌더링(픽셀-퍼펙트)을 해야하는 상황에서 해로울 수 있다. 흐릿하게 보여지거나 무아레(Moiré) 효과같은 시각적인 결과물(visual artifact)이 나올 수 있다.

상단 이미지는 다른 색의 픽셀의 래스터다. 하단 이미지는 위와 동일하지만 양선형(*bilinear*) 스케일링을 이용해 너비와 높이가 1픽셀 줄었다. 이 새로운 패턴을 무아레 효과라고 한다. (이미지를 확장하지 않고 보려면 새 탭에서 열어야함)

devicePixelContentBox

devicePixelContentBox는 디바이스의 픽셀(예. 물리적인 픽셀) 단위에서 요소의 컨텐트 박스 정보를 준다. ResizeObserver에 포함되어 지원된다. 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;

  /* 캔버스 렌더링 */
});
observer.observe(canvas, {box: ['device-pixel-content-box']});

observer.observe()의 옵션 객체의 box 속성은 관찰하는 요소의 크기를 정의하도록 한다. 따라서 각 ResizeObserverEntry는 항상 borderBoxSize, contentBoxSizedevicePixelContentBoxSize(브라우저가 지원하는 경우)를 전달하며, 관찰중인(observed) 박스 매트릭스의 변경이 감지되었을 때만 콜백이 호출된다.


★ 모든 박스 매트릭스는 ResizeObserver가 향후 단편화(fragmentation)를 처리할 수 있도록 지원하는 배열이다. 이 글을 작성하는 시점에서 배열 길이는 항상 1이다.

새로운 속성 devicePixelContentBoxSize로 캔버스의 크기와 위치(효과적으로 분수형 픽셀값을 보장함)까지 애니메이션을 만들 수 있고 렌더링에 미치는 무아레 현상은 더 이상 볼 수 없다. getBoundingClientRect()를 사용하여 재현한 무아레 현상과 새로운 ResizeObserver 속성을 사용하여 이를 해결한 현상을 보고 싶다면 Chrome 84 이상에서 데모를 확인해보자!

기능 탐지

사용자의 브라우저에서 devicePixelContentBox를 지원하는지 확인하려면 요소를 관찰하여 ResizeObserverEntrydevicePixelContentBox 속성이 있는지 확인하자.

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())) {
  // 브라우저가 devicePixelContentBox를 지원하지 않음
}

결론

픽셀은 웹에서 의외로 복잡한 주제이며 이전에는 사용자 화면에서 요소가 차지하는 물리적 픽셀 수를 정확히 알 방법이 없었다. ResizeObserverEntry에 있는 새로운 devicePixelContentBox 속성을 통해 그 정보를 알 수 있으며, <canvas>를 사용하여 픽셀 단위까지 완벽한 렌더링을 할 수 있게 되었다. devicePixelContentBox는 크롬 84이상에서 지원한다.

조정은2020.07.28
Back to list