HTML Canvas 어디까지 써봤니?

발표자 소개

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

HTML Canvas?

  • 2D 그래픽을 다루기 위한 Web API  
  • 픽셀로 이루어진 Canvas 위에 도형, 텍스트, 이미지 등을 그릴 수 있다. 
  • SVG와 함께 웹 프론트엔드 시각화의 기초

Canvas Animation

  1. 지난 프레임 이후로 경과된 시간을 확인한다.
  2. 이미 그려진 화면을 지우고 시간에 따라 달라진 화면을 그린다.
  3. 다음 프레임이 올 때까지 기다려 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/

Made with Slides.com