devicePixelContentBox
를 사용하여 완벽하게 픽셀(pixel-perfect) 렌더링하기원문 : https://web.dev/device-pixel-content-box/ 실제로 캔버스에는 몇 개의 픽셀이 있을까?
크롬 84부터, ResizeObserver는 물리적 픽셀 단위로 요소의 크기를 측정하는 새로운 박스 측정법인 device-pixel-content-box
을 제공한다. 특히 고밀도 화면에서 픽셀 단위의 그래픽을 완벽하게 렌더링 할 수 있도록 한다.
길이를 지정할 때 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
를 입력해보면 변경된 값이 보인다.
<canvas>
요소에 width
와 height
속성을 사용하여 원하는 픽셀 수를 지정할 수 있다. <canvas width=40 height=30>
은 40 x 30 픽셀의 캔버스가 될 것이다. 그러나 40 x 30 픽셀로 표시되는 것은 아니다. 기본적으로 캔버스는 width
와 height
속성을 사용하여 고유한 크기를 정의하지만, 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%
인 요소의 사각형 정보는 아래와 같다.
CSS 픽셀은 가상의 값이기 때문에, 이론적으로 분수 값을 가져도 된다. 그러나 브라우저는 이에 대응하는 물리적 픽셀을 어떻게 알아낼까? 물리적 픽셀은 분수 값으로 존재할 수 없는데 말이다.
물리적 픽셀로 엘리먼트의 정렬을 맞추기 위해 단위를 변환하는 프로세스를 "픽셀 스냅(pixel snapping)"이라고 하며, "픽셀 스냅"은 이름 그대로 분수형 픽셀값을 정수형 물리적 픽셀값으로 스냅 한다는 의미이다. 어떻게 정확히 이런 일이 일어나는지는 브라우저마다 다르다. dPR이 1인 디스플레이에 폭이 791.984px
인 요소가 있으면 어떤 브라우저는 792px
의 물리적 픽셀로 렌더링하고 다른 브라우저는 791px
로 렌더링할 수 있다. 단지 1px 차이지만, 단일 픽셀이 완벽한 픽셀 렌더링(픽셀-퍼펙트)을 해야하는 상황에서 해로울 수 있다. 흐릿하게 보여지거나 무아레(Moiré) 효과같은 시각적인 결과물(visual artifact)이 나올 수 있다.
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
, contentBoxSize
및 devicePixelContentBoxSize
(브라우저가 지원하는 경우)를 전달하며, 관찰중인(observed) 박스 매트릭스의 변경이 감지되었을 때만 콜백이 호출된다.
★ 모든 박스 매트릭스는 ResizeObserver
가 향후 단편화(fragmentation)를 처리할 수 있도록 지원하는 배열이다. 이 글을 작성하는 시점에서 배열 길이는 항상 1이다.
새로운 속성 devicePixelContentBoxSize
로 캔버스의 크기와 위치(효과적으로 분수형 픽셀값을 보장함)까지 애니메이션을 만들 수 있고 렌더링에 미치는 무아레 현상은 더 이상 볼 수 없다. getBoundingClientRect()
를 사용하여 재현한 무아레 현상과 새로운 ResizeObserver
속성을 사용하여 이를 해결한 현상을 보고 싶다면 Chrome 84 이상에서 데모를 확인해보자!
사용자의 브라우저에서 devicePixelContentBox
를 지원하는지 확인하려면 요소를 관찰하여 ResizeObserverEntry
에 devicePixelContentBox
속성이 있는지 확인하자.
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이상에서 지원한다.