캔버스를 사용해서 차트를 만드는 업무를 맡게 되었다. 어떤 어려움이 있었고, 어떻게 해결했는지 정리해보자.
목차
펼치기
1. canvas란?
canvas는 HTML5에서 추가된 태그로, 그래픽을 그릴 수 있는 영역을 제공한다. 캔버스는 비트맵 그래픽을 그리는데 사용된다.
svg vs canvas
브라우저에서 그래픽을 그리는 방법은 크게 두 가지가 있다. svg와 canvas이다. svg는 벡터 그래픽을 그리는데 사용되고, canvas는 비트맵 그래픽을 그리는데 사용된다. 서로 장단점이 있으니 용도에 맞게 사용하면 된다.
svg | canvas |
---|---|
벡터 그래픽 | 비트맵 그래픽 |
크기 조절에 용이 | 크기 조절에 취약 |
복잡한 그래픽 성능 떨어짐 | 복잡한 그래픽 성능 우수 |
이벤트 다루기 쉬움 | 이벤트 다루기 어려움 |
아이콘, 로고 등 | 차트, 게임 등 |
2. canvas 사용법
선 그리기
선은 beginPath
, moveTo
, lineTo
, stroke
메소드를 사용해서 그릴 수 있다. beginPath
는 새로운 경로를 만들고, moveTo
는 시작점을 지정하고, lineTo
는 끝점을 지정하고, stroke
는 선을 그린다. 여기서 주의할 점은, 홀수 너비의 선을 그리면, 기준점에서 0.5만큼 옆 픽셀에 걸쳐지기 때문에, 가령 1px의 선을 그릴 때, 총 2px을 차지 해서, 선이 두껍고, 뿌옇게 보일 수 있다. 이때는 짝수 너비의 선을 그리거나, 홀수 너비의 선을 그릴 때 ctx.translate(0.5, 0.5)
식으로 선을 그리는 위치를 조정해주면 된다. 중요한 점은, stroke는 기준점에서 양 옆으로 각각 절반만큼 그려진다는 점이다.
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(200, 20);
ctx.stroke();
해상도 설정하기
캔버스 크기를 설정할 때, 알아야 할 것이 있다. canvas 태그의 너비와 높이에는 두가지 종류가 있다. 브라우저에 표시되는 크기(style
)와 캔버스의 해상도 크기이다. 브라우저에 표시되는 크기는 css로 조절할 수 있지만, 캔버스의 해상도 크기는 width
와 height
속성으로 조절할 수 있다.
그럼 만약 캔버스의 해상도(크기)가 상대적으로 높아지면 어떻게 될까? 브라우저에 표시되는 크기는 그대로 유지되지만, 캔버스의 해상도가 높아지면 선명하게 그려진다. 이렇게 더 높은 해상도에서 처리하고 원래 해상도로 줄이는 작업을 다운 샘플링
이라고 한다. 주로 이때 안티앨리어싱을 통해 이미지를 부드럽게 처리한다.
추가적으로 고려해야 할 점은 devicePixelRatio
이다. 이는 디바이스의 물리적 픽셀 수와 논리적 픽셀 수의 비율을 나타낸다. 만약 devicePixelRatio(dpr)
가 2라면, 논리적 픽셀 하나가 물리적 픽셀 두 개를 나타낸다는 뜻이다. 이는 브라우저의 표시되는 크기가 두 배로 커지는 효과를 준다.
const dpr = window.devicePixelRatio || 1;
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
canvas.width = 200 * dpr;
canvas.height = 100 * dpr;
canvas.style.width = '200px';
canvas.style.height = '100px';
ctx.scale(dpr, dpr); // dpr만큼 캔버스를 확대해줘야 원한는 사이즈로 그릴 수 있다.
여기서 dpr이상의 해상도로 그렸을때 더 선명해 보이는 이유는, 고 해상도 이미지 일수록 다운샘플링
시에 디테일이 더 잘 보존되고, 안티앨리어싱을 더 효과적으로 할 수 있기 때문이다. 하지만, 고 해상도의 이미지를 그릴 때는 그만큼 메모리 사용이 많아짐으로, 적절한 해상도를 선택해야 한다.
offscreen canvas
브라우저에서 여러 api를 제공해줘서 까먹곤 하지만, 자바스크립트는 기본적으로 싱글스레드이다. 그렇기 때문에 캔버스 작업을 하게 되면, 메인 스레드에서 그림을 그리는 작업을 하게 된다. 이때, 어떠한 이유로 메인스레드에 부하가 걸리거나, 블로킹이 발생하면, 캔버스 그리기에 영향이 생길 수 있다. 이때, OffscreenCanvas
를 사용하면, 메인 스레드와 별도의 워커 스레드에서 캔버스를 그릴 수 있게 해준다.
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('worker.js');
worker.postMessage({ canvas: offscreen }, [offscreen]);
더블 버퍼링
더블 버퍼링은 화면에 그릴 때, 먼저 메모리에 그린 후, 화면에 그리는 방식이다. 이를 통해 화면에 그리는 과정에서 깜빡임을 줄일 수 있다. 더블 버퍼링을 사용하려면, 두 개의 캔버스를 사용하면 된다. 하나는 메모리에 그리고, 다른 하나는 화면에 그리면 된다.
const os = new OffscreenCanvas(200, 100);
const osCtx = os.getContext('2d');
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
os.width = 200;
os.height = 100;
osCtx.beginPath();
osCtx.moveTo(20, 20);
osCtx.lineTo(200, 20);
osCtx.stroke();
ctx.drawImage(os, 0, 0);
애니메이션
애니메이션을 그리기 위해서는 requestAnimationFrame
을 사용하면 된다. 기본적으로 60fps의 속도로 애니메이션을 그릴 수 있다. requestAnimationFrame
은 브라우저가 다음 리프레시 타임에 애니메이션을 그리도록 요청하는 함수이다. 1초에 60번 호출되는 함수이다. 때문에 매 프레임 연산이 최대 16ms 이내에 끝나야 한다.
function draw(timestamp) { // requestAnimationFrame에서 넘어오는 timestamp
/// draw something
requestAnimationFrame(draw);
}
ImageBitmap
사용하기
이미지를 미리 준비해 놓고 drawImage를 사용해서 그릴 수 있다. 이때 ImageBitmap
을 사용하면, 이미지를 비트맵으로 변환해서 사용할 수 있다. 불필요한 메모리를 줄이고, 이미지를 더 빠르게 그릴 수 있다.
3. 어려웠던 점
원호 그리기
처음에는 원호를 그릴때 두 개의 부채꼴을 그려서 원호를 그리는 방법을 사용했다. 하지만, 계속해서 안쪽의 색상을 새로 그려줘야 해서, 항상 그리고 나면 안쪽의 이미지로 새로 그려줘야 하는 문제가 있었다.
// 큰 부채꼴
ctx.beginPath();
ctx.moveTo(100, 75);
ctx.arc(100, 75, 50, 0, 0.5 * Math.PI);
ctx.fillStyle = 'red';
ctx.fill();
// 안쪽 부채꼴
ctx.beginPath();
ctx.moveTo(100, 75);
ctx.arc(100, 75, 40, 0, 0.5 * Math.PI);
ctx.fillStyle = 'white'; // 혹은 배경색
ctx.fill();
fill
의 속성을 안다면 좀 더 깔끔하게 그릴 수 있다. 기본적으로 fill
을 사용하면, 그리는 영역을 채우는데, 선을 그을때 자동으로 이전 끝지점과 연결되는 선을 그린다. 그리고 마지막 점에서 처음 점으로 선을 그어준다고 생각하면 된다. 즉 이렇게만 그려도 된다.
ctx.beginPath();
ctx.arc(100, 75, 50, 0, 0.5 * Math.PI);
ctx.arc(100, 75, 40, 0.5 * Math.PI, 0, true);
ctx.fillStyle = 'red';
ctx.fill();
캔버스 깜빡임
처음부터 아무 생각 안하고 resize이벤트가 발생하는 경우와 데이터가 갱신되는 경우에 동일한 로직을 실행시켜도 될것 같아서 그렇게 했는데, 데이터가 갱신될때마다 깜빡이는 현상이 발생했다. 이는 캔버스를 초기화하고 다시 그리기 때문에 발생하는 현상이었다. 처음엔 이걸 모르고 이미지 그리는 로직이 오래 걸려서 그런가 싶어서 하나씩 알아보다가 더블 버퍼링, path 미리 그려놓기, 정수 사용하기, 변경된 부분만 다시 그리기, state 변경 최소화 하기 등 여러 방법을 사용할 수 있다는 것을 알게 되었다.
하지만 결국 해결은, resize 이벤트가 발생할때만, canvas의 크기를 변경하고, 그 외에는 데이터 갱신만 일어나도록 로직을 수정했다. 밑에 참고에 유투브를 보면, 엄청나게 고성능의 캔버스가 아닌 이상, 엄청난 최적화를 할 필요는 없다고 한다. 그리고 브라우저마다, 동작이 다를 수 있기 때문에 원하는 최적화의 효과는 또 직접 테스트해봐야 한다.
“Premature optimization is the root of all evil” - Donald Knuth
canvas.width = 400;
canvas.height = 200;
4. 추가적인 고려사항
css image-rendering 속성
image의 scale을 조절할 때, 경계에 선명도를 조정하기 위해 image-rendering
속성을 사용하는 것을 고려할 수 있다. 픽셀이 선명하게 보이도록 하거나, 선명도를 유지하거나 혹은 부드럽게 보이도록 설정할 수 있다.
line cap
line cap
은 선의 끝을 어떻게 처리할지 결정하는 속성이다.
line join
line join
은 선이 꺾이는 부분을 어떻게 처리할지 결정하는 속성이다. 이 두 속성을 사용해서 선의 끝과 꺾이는 부분을 조절할 수 있다.