삽질하기 싫으면 꼭 읽어봐야 할 Canvas 트러블 슈팅


2021년 1월, 우리는 TOAST UI CHART 4.0을 공개했다. 큰 변화 중 하나는 기존에 SVG를 사용해서 차트를 생성했다면 4.0에서는 Canvas를 이용해 개발했다는 점이다. 이 글에서는 Canvas 프로젝트에서 흔히 겪을 수 있는 문제들에 관해 설명한다.

안티 앨리어싱(Anti-Aliasing)

스크린샷 2021-05-25 21 49 59

앨리어싱은 컴퓨터 그래픽에서 해상도의 한계로 선 등이 우둘투둘하게 되는 현상을 의미하는 것으로, 위 그림처럼 결과물이 깨져 보이며 '계단 현상'이라 부르기도 한다. 안티 앨리어싱은 앨리어싱에 방지하는 뜻의 안티(Anti-)가 붙여져 이러한 계단 현상을 방지하는 것을 의미한다.

스크린샷 2021-05-25 21 50 11

차트4에서 축 영역에 텍스트 라벨을 표시하는 데 해상도가 높은 모니터에서 텍스트가 흐릿하게 보이는 현상(text blurry)이 발생했다. 원인을 밝히기에 앞서 devicePixelRatio(이하 DPR)에 대해 알아야 한다. Canvas 크기와 화면에 표시(디스플레이)되는 크기는 다르며, 디스플레이 크기는 DPR의 영향을 받는다. DPR는 웹 브라우저에서 전역변수 window.devicePixelRatio로 확인해볼 수 있는데, CSS 픽셀을 구성하는데 필요한 물리적 픽셀 수를 의미한다. 예를 들어 DPR의 기본값은 1이며 레티나 디스플레이일 경우 2가 되는데, 2DPR은 CSS 1픽셀이 물리적으로 실제 2픽셀과 대응됨을 의미한다. 이러한 이유로 디스플레이마다 CSS 1픽셀을 그리는 데 사용되는 실제 픽셀 수가 다르기 때문에 Canvas 크기를 DPR에 따라 보정해야 한다. Canvas 요소의 width, height속성을 DPR 배율로 계산해주고 디스플레이 크기(CSS에서 지정해준 width, height 값)과 맞춰주기 위해 scale을 조정했다.

// 디스플레이 크기 설정 (css 픽셀)
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;

// 메모리에 실제 크기 설정 (픽셀 밀도를 고려하여 크기 조정)
const dpr = window.devicePixelRatio;

canvas.width =  width * dpr;
canvas.height = height * dpr;

const ctx = canvas.getContext('2d');

// CSS에서 설정한 크기와 맞춰주기 위한 scale 조정
ctx.scale(dpr, dpr);

...

X축과 Y축 영역, 라인 시리즈가 보정 전보다 선명해진 것을 느낄 수 있다.

보정 전

aliasing

보정 후

anti-aliasing

두께가 1px(홀수 값)인 라인 선명하게 표현하기

스크린샷 2021-05-25 23 02 43
(위 이미지는 이해를 돕기위해 확대하였다)

Canvas에서 두께가 1px인 라인을 표시할 때 흐릿하면서 1px보다 두껍게 표시되는 것 이상한(?) 현상이 나타났다. 두께가 1 ~ 10px의 라인을 그려보았더니 두께가 짝수일 때는 라인이 뚜렷하게 보이지만 홀수일 때는 흐릿하면서 설정해준 두께 값보다 더 두꺼워 보였다.

이러한 현상은 Canvas에서 좌표와 두께를 기준으로 라인을 그리는 방법에서 원인을 찾았다. (참고 : MDN-lineWidth 예제)

먼저 Canvas에서 사각형을 그리는 방법에 대해 알아보자.

스크린샷 2021-05-21 10 54 18
위의 이미지는 그리드 라인을 기준으로 네모 한 칸은 화면 픽셀을 의미한다. 좌표 (2, 1)에서 (5, 5)로 3 x 4 크기 사각형을 그렸다. 이미지는 픽셀 그리드 라인으로 사각형이 딱 맞아떨어지는 것을 확인할 수 있다.

다음은 (3, 1)에서 (3, 5)로 두께가 1인 라인을 그렸다.
스크린샷 2021-05-21 10 58 28
라인은 중심이 되는 지점부터 그려지기 때문에 두께가 1이지만 그리드에서 2칸을 차지하는 것을 볼 수 있다. 이것이 두께가 1px인 라인이 2px을 차지하는 것처럼 보이며 흐릿하게 보이는 이유 이다.

중심이 되는 지점을 두께의 절반만큼 이동하여 그리드의 한 칸을 차지하도록 수정하였다.
스크린샷 2021-05-21 11 02 34
좌표를 두께 1의 절반인 0.5만큼 이동해 (3.5, 1)에서 (3.5, 5)로 설정했다. 깔끔하게 그려지는 것을 확인할 수 있다.

실제로 차트4에서는 픽셀 위치 값을 보정하기 위해 다음과 같은 crispPixel 유틸성 함수를 정의해서 사용한다.

function crispPixel(pixel, thickness = 1) {
  const halfThickness = thickness / 2;

  return thickness % 2
    ? (isInteger(pixel) ? pixel : Math.round(pixel - halfThickness)) + halfThickness
    : Math.round(pixel);
}

crispPixel을 사용해 픽셀을 보정한 라인이 보정 전보다 더 선명하게 표시되는 것을 알 수 있다.

1px 3px 5px
스크린샷 2021-05-25 23 20 41 스크린샷 2021-05-25 23 20 48 스크린샷 2021-05-25 23 20 57
(위 이미지는 이해를 돕기위해 확대하였다)

<canvas> 요소의 width, height 속성값을 직접 변경하면 이미 그려진 Canvas가 초기화된다.

차트4에서는 차트의 크기가 변경되면 자동으로 변경된 크기에 맞게 자동으로 차트를 다시 그려주는 기능이 새롭게 추가되었다. 반응형(responsive) 동작 도중에 차트 Canvas의 크기가 변경되면서 차트가 흰 화면으로 아주 잠깐 깜빡이는 현상(flicker) 현상이 나타났다. 이러한 동작은 좋지 않은 사용자 경험을 제공한다. 개발할 당시에는 애니메이션 동작의 문제일까 생각했는데 원인은 Canvas 요소의 width, height 속성값이 변경되면 이미 그려져 있는 Canvas가 초기화되기 때문이었다. Canvas 크기를 변경한 직후에 원래 표시되었던 드로잉 모델을 다시 그려줌으로써 좀 더 자연스러운 반응형 동작이 되도록 수정하였다.

수정 전

수정 후

테두리가 있는 사각형 표현하기

두께가 20px인 파란색 테두리를 가진 사각형이 있다고 가정해보자. 크기는 100x100이며 배경색은 투명도를 0.5로 조절한 빨간색이다.

스크린샷 2021-05-24 12 43 54

만약 CSS로 스타일 값을 지정해준다면 아래와 같이 간단히 작성할 것이다.

<div class="rect"></div>
div.rect {
  width: 100px;
  height: 100px;
  border: 20px solid blue;
  background-color: rgba(255, 0, 0, 0.5);
}

이 사각형을 Canvas에 그리기 위해서는 strokeRect()fillRect() API를 사용해야 한다. 배경색을 채운 사각형은 fillRect()를 사용하고, 테두리가 있는 사각형은 strokeRect()를 사용한다. Canvas에서 라인을 그릴 때 주의해야 할 점은 두께의 절반인 중심이 되는 부분이 기준 좌표가 된다는 점이다. 그려질 사각형의 좌표와 크기, 라인 두께를 인자로 받는 drawBorderRect함수를 정의하였다.

function drawBorderRect(x, y, width, height, borderWidth) {
  ctx.lineWidth = borderWidth;
  ctx.strokeStyle = 'blue';
  ctx.strokeRect(
    x + borderWidth/2,
    y + borderWidth/2,
    width + borderWidth,
    height + borderWidth
  );

  ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
  ctx.fillRect(x + borderWidth, y + borderWidth, width, height);
}

drawBorderRect(0, 0, 100, 100, 20);

storkeRect는 두께의 절반만큼 좌표를 이동하였고 두께를 포함한 크기로 재계산하였다. fillRect에서 x, y의 좌표도 두께만큼 이동하였다.

추가로, storkeRect, fillRect 호출 순서에 따라 결과값이 달라질 수 있으니 주의해서 사용해야 한다.

function drawBorderRect1(x, y, width, height, borderWidth) {
  // # 1. strokeRect 후
  ctx.lineWidth = borderWidth;
  ctx.strokeStyle = 'blue';
  ctx.strokeRect(x, y, width, height);

  // # 2. rect 그리기
  ctx.fillStyle = 'pink';
  ctx.fillRect(x, y, width, height);
}

function drawBorderRect2(x, y, width, height, borderWidth) {
  // # 1. rect 후
  ctx.fillStyle = 'pink';
  ctx.fillRect(x, y, width, height);

  // # 2. strokeRect 그리기
  ctx.lineWidth = borderWidth;
  ctx.strokeStyle = 'blue';
  ctx.strokeRect(x, y, width, height);
}
drawBorderRect1 drawBorderRect2
스크린샷 2021-05-24 13 03 16 스크린샷 2021-05-24 13 04 18

drawBorderRect1()drawBorderRect2()의 결과물이 다른데, storkeRect가 그려질 때 두께의 절반을 기준 좌표가 된다는 점과 캔버스에서 개체가 그려질 때 나중에 그려진 것이 상위에 표시되는 것을 알 수 있다. (만약 새롭게 그려진 개체가 상위에 배치되는 것이 아닌 다른 합성 방법으로 설정하고 싶다면 globalCompositeOperation을 참고한다) drawBorderRect1()strokeRect를 먼저 그리고 난 후 fillRect를 그리기 때문에 이미 그려진 strokeRect는 나머지 두께가 절반인 영역만 보이는 것이고, drawBorderRect2()fillRect가 그려진 후 strokeRect를 그리기 때문에 storkeRect의 모든 영역이 보이며 이전에 그려졌던 fillRectstrokeRect영역에 가려져 작아 보이는 것이다.

이해를 돕기위해 fillRectstrokeRect가 호출될 때 넘겨준 x, y, width, height이 어떻게 적용되는지 표시했다. x, y값은 빨간색 점 (x, y)이고, width, height값은 노란색 점선이 차지하는 크기이다.

스크린샷 2021-05-26 00 22 21 스크린샷 2021-05-26 00 22 13

마무리

HTML Canvas를 사용해 프로젝트를 진행할 때 한 번쯤은 겪어보았을 내용을 정리해보았다. 당연히 되어야 한다고 생각했던 것들이 Canvas 프로젝트 초기에는 생각대로 동작하지 않아 당황했었다. 프로젝트에서 Canvas를 사용해야 한다면 시작하기에 앞서 조금이나마 도움이 되었길 바라며 이 글을 마무리 짓겠다.

참고

조정은2021.05.26
Back to list