HTML Canvas 어디까지 써봤니?
발표자 소개
- 의료 인공지능 스타트업 Lunit에서 Frontend Engineer로 재직중
- Radiology, Pathology 관련 데이터 수집 툴, 분석 결과 시각화 툴 등을 담당
- 좋아하는 것: 자율 출퇴근, 재택 근무, React, Redux, 맛있는 음식

HTML Canvas?
- 2D 그래픽을 다루기 위한 Web API
- 픽셀로 이루어진 Canvas 위에 도형, 텍스트, 이미지 등을 그릴 수 있다.
- SVG와 함께 웹 프론트엔드 시각화의 기초
Canvas Animation
- 지난 프레임 이후로 경과된 시간을 확인한다.
- 이미 그려진 화면을 지우고 시간에 따라 달라진 화면을 그린다.
- 다음 프레임이 올 때까지 기다려 1번으로 돌아간다.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
function draw(timestamp) {
const x = timestamp / 10 % 600;
ctx.clearRect(0, 0, 600, 50);
ctx.fillRect(x, 0, 50, 50);
window.requestAnimationFrame(draw);
}
window.requestAnimationFrame(draw);
requestAnimationFrame()
- 브라우저에서 다음 repaint 전에 콜백을 실행해주는 API
- 콜백 내에서 다시 requestAnimationFrame()을 호출하면 매 프레임을 새로 그려서 애니메이션을 만들 수 있다
- 인수로 1ms 정확도의 현재 시간(timestamp)을 전달해준다
- 백그라운드, 숨겨진 IFrame에서는 일시정지
- Canvas뿐 아니라 모든 Javascript 애니메이션의 친구
0.016초 안에 그려야 한다
60FPS == 0.016sec/frame
이렇게 하면 빠르다던데...
- 좌표에서 불필요한 소숫점 아래를 잘라 정수로 만들면 빠르다.
- drawImage()에서 크기를 조절하면 느리다.
- Text, Shadow 그리기는 느리다.
- Alpha를 끄면 빠르다.
- Canvas state(fillStyle, strokeStyle, etc.) 변경은 적을수록 좋다.
Don't Do That
"Premature optimization is the root of all evil."
- 렌더링 엔진의 발전으로 기존에 알려진 HTML5 Canvas 팁에서 성능 향상을 얻을 수 없는 경우가 많음
- 성능 문제가 나타난다면 화면을 그리는 부분의 알고리즘에 문제가 있는 경우가 대부분
- micro-optimization은 가장 마지막에 시도하고 프로파일링으로 검증
4 Lines vs. 1 Line
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const imageData = ctx.createImageData(1000, 1000)
const uint8Array = imageData.data
const dataView = new DataView(uint8Array.buffer)
for(let i = 0; i < uint8Array.length; i = i + 4) {
uint8Array[i] = 128
uint8Array[i + 1] = 128
uint8Array[i + 2] = 128
uint8Array[i + 3] = 255
}
for(let i = 0; i < uint8Array.length; i = i + 4) {
dataView.setUint32(i, 2155905279)
}
// https://jsperf.com/canvas-8bit-vs-32bit/1
4 Lines vs. 1 Line

(그럼에도 불구하고)
최적화가 필요한 순간은 온다
Lunit Scope
- 암 조직 슬라이드를 초고해상도 스캔하여 딥러닝으로 분석하는 프로젝트
- 가로 110,000px, 세로 250,000px 슬라이드 이미지 위에 16x16 사각 영역으로 분석 결과를 시각화
- 사각형 1억 7백만개만 그리면 됨
- 세포를 표시할 점 10만개는 덤
- 사용자는 확대, 축소, 이동을 자유롭게 할 수 있으며 모든 조작은 부드럽게 이루어져야 함


미리 그려두고 옮기기
- 아무리 최적화를 잘 한다 해도 1억 7백만번의 fillRect() 호출을 16ms 내에 할 수는 없다
- 전체 영역을 조직별로 미리 시각화하여 메모리에 저장
- 맥북 프로에서 약 3초 소요
- 사용자가 선택한 조직들을 병합
- 시점 이동에 따라 매 프레임마다 화면에 그려준다
- drawImage() 호출에 10ms

미리 그려두고 옮기기
const maskCanvas = document.createElement('canvas')
const maskCtx = maskCanvas.getContext('2d')
const imageData = maskCtx.getImageData(0, 0, maskImage.width, maskImage.height)
for (let i = 0; i < pixelData.length; i++) {
`(대충 시각화를 해주는 코드)`
}
maskCtx.putImageData(imageData, 0, 0)
const preRenderedImage = document.createElement('img')
preRenderedImage.src = maskCanvas.toDataURL('image/png')
const targetCanvas = document.getElementById('target')
const targetCtx = targetCanvas.getContext('2d')
targetCtx.drawImage(preRenderedImage, 0, 0)
화면에 보이는 영역만 그리기
- 세포는 워낙 작아서 일정 수준 이상 확대해야 눈에 보인다
- 확대할수록 한번에 보이는 영역은 좁아지는데, 화면 바깥을 그리는데 시간을 낭비할 필요가 있을까?
- Spatial Index를 활용해 보이는 영역의 데이터만 시각화하자
화면에 보이는 영역만 그리기
points.forEach(point => {
ctx.beginPath();
ctx.arc(point.x, point.y, pointSize, 0, 2 * Math.PI);
ctx.fill();
});
const indexedPoints = new KDBush(points);
indexedPoints.range(
bounds.x - pointSize * 2,
bounds.y - pointSize * 2,
bounds.x + bounds.width + pointSize * 2,
bounds.y + bounds.height + pointSize * 2
)
.forEach(idx => {
const point = points[idx];
ctx.beginPath();
ctx.arc(point.x, point.y, pointSize, 0, 2 * Math.PI);
ctx.fill();
});
전체 데이터(~76k) 그리기

화면에 보이는 데이터(~470) 그리기

최신 API 활용
feat. Google Chrome
ImageBitmap으로 더 빠르게 그리기
- drawImage()로 다양한 이미지 소스를 Canvas에 그릴 수 있다
- CSSImageValue, HTMLImageElement, SVGImageElement, HTMLVideoElement, HTMLCanvasElement, ImageBitmap, OffscreenCanvas
- 가장 빠르게 그릴 수 있는 소스는 ImageBitmap
- 미리 시각화한 이미지가 너무 커서 drawImage()가 느릴 때 활용
- Safari, IE, Edge에서 사용 불가
ImageBitmap으로 더 빠르게 그리기
console.time('HTMLCanvasElement')
for(let i = 0; i < 100000; i++) {
targetCtx.drawImage(preRenderedCanvas, 0, 0)
}
console.timeEnd('HTMLCanvasElement') // ~380ms
createImageBitmap(preRenderedCanvas)
.then(imageBitmap => {
console.time('ImageBitmap')
for(let i = 0; i < 100000; i++) {
targetCtx.drawImage(imageBitmap, 0, 0)
}
console.timeEnd('ImageBitmap') // ~280ms
})
OffscreenCanvas로 다른 스레드에서 그리기
- 메인 스레드에서 16ms를 다 쓸 수는 없다
- 스타일 계산, 레이아웃, 페인트 등 렌더링을 위한 다른 작업들
- 사용자 입력에 대응하는 다른 스크립트들
-
transferControlToOffscreen()을 이용하면 일반 Canvas와 한 쌍이 되는 OffscreenCanvas를 생성할 수 있다
- OffscreenCanvas에 그리는 모든 변경사항이 원본에 반영됨
- Chrome 69 이후부터 사용 가능
OffscreenCanvas로 다른 스레드에서 그리기
// Main Thread
const targetCanvas = document.getElementById("target")
const offscreen = targetCanvas.transferControlToOffscreen()
worker.postMessage({ canvas: offscreen },[offscreen])
// Worker
self.onmessage = event => {
self.mainCanvas = event.data.canvas
}
const mainCtx = self.mainCanvas.getContext("2d")
mainCtx.drawImage(preRenderedImage, 0, 0)
Q & A
Live Example: https://scope.lunit.io/
HTML Canvas 어디까지 써봤니?
By SangYeob Yu
HTML Canvas 어디까지 써봤니?
- 1,831