🎨

HTML5 Canvas와 최적화

Created
Apr 24, 2024 05:58 AM
설명
HTML5 Canvas는 뭘까요? Canvas의 한계는 무엇이고 어떻게 해결할 수 있을까요?
Status
작성 완료
Tags
Optimization
FrontEnd
Select
Dev Log
👀
이 글은 제가 팀원들과 함께 운영하는 Snack Game에 최적화 진행 전 공부 및 공유를 위해 작성한 글입니다.

HTLM5 Canvas

HTML5 Canvas 는 JS를 통해 브라우저에 2D 그래픽을 동적으로 생성하고 조작할 수 있는 기능을 지원합니다. 애니메이션, 게임, 데이터 시각화(chart.js), 이미지 편집등 다양하게 활용이 가능합니다.
canvas에 그림을 그릴때는 보통 Canvas API 를 사용해 그리게 되는데요.
Canvas API 는 2D 그래픽에 중점을 두고있습니다. 고성능 3D 및 2D 그래픽을 위한 WebGL API도 존재하며 pixi.js, three.jsWebGL API에 기반한 엔진들이 존재합니다.
여기서는 Canvas API 에 관해 이야기해보겠습니다.

Canvas API로 그림을 그리는 방법

Canvas API 로 그림을 그리려면 CanvasRenderingContext2D 인터페이스를 사용해 그리게 되는데요.
CanvasRenderingContext2D 는 canvas에 그림을 그릴 수 있는 함수들을 제공하고 렌더링 컨텍스트는 getContext()로 가져올 수 있습니다.
 
const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); ctx.fillStyle = "green"; ctx.fillRect(10, 10, 150, 100);
notion image
이렇듯 보다시피 Canvas의 내용은 JS를 통해 그려주어야 합니다.

Canvas의 내용이 페이지에 그려지는 순서

우선 브라우저가 페이지를 보여주기 위한 도구들과 함께 우리의 애니메이션의 한 프레임이 그려지는 과정을 보겠습니다.
좌측부터 Javascript Engine, Rendering Engine, Graphics Library
좌측부터 Javascript Engine, Rendering Engine, Graphics Library
  • Rendering Engine(Blink)
    • HTML을 파싱해 어떤 모양으로 그릴지 결정(DOM)하고 CSS를 파싱해 어떤 모양을 그릴지 결정(CSSOM)합니다.
    • 이 둘읍 합쳐 위치와 사이즈를 계산하고 그려야할 순서를 결정해 렌더 트리를 만듭니다.
  • Graphics Library(skia)
    • 렌더링 엔진이 결정한 렌더 트리를 바탕으로 실제 픽셀을 화면에 그리는 역할을 합니다.
  • Javascript Engine(v8)
    • 자바스크립트를 해석하는 역할을 합니다.
이렇게 3개의 도구가 서로 상호작용 하며 Canvas를 렌더링하게 되는데요. 더 자세히 살펴보겠습니다.
notion image
Canvas는 JS를 통해 그림을 그리니 Rendering Engine은 파싱 중 JS 코드(<script>)를 마주하게 되고 자체적으로 JS를 해석할 수 없기 때문에 Javascript Engine에게 해석을 부탁합니다.
notion image
Javascript Engine JS를 해석하고 Canvas는 DOM 요소이기 때문에 정보를 가진 Rendering Engine에게 다시 요청하게 됩니다.
const canvas = document.getElementById("canvas");
notion image
이후 최종적으로 Rendering EngineGraphics Library 에게 렌더링을 요청해 우리의 결과물이 화면에 표시됩니다.
와우!
와우!
그렇다면 Canvas에서 애니메이션을 보여주려면 어떻게 해야할까요?

Canvas 애니메이션

애니메이션은 여러장의 이미지를 빠른 속도로 넘어가며 “연속적으로 보여주는 장면” 입니다.
notion image
하나의 이미지를 frame이라고 하고 frame per second 줄여서 fps 는 1초동안 보여지는 이미지의 단위인데요.
보통 게임에서는 60fps 이상부터 부드럽다고 느끼고 30fps 이하는 상당히 끊기는 느낌이 듭니다.
notion image
그럼 60fps를 목표로 게임을 만드는 게 좋은데요. 즉, 1초에 60번 16.7ms에 한 장씩 Canvas의 내용을 그리면 됩니다.

requestAnimationFrame()

requestAnimationFrame는 이런 브라우저 애니메이션을 도와주는 API입니다.
애니메이션 함수를 넘겨주고 애니메이션 함수는 requestAnimationFrame로 자기 자신을 재귀적으로 호출해 연속적인 애니메이션을 보여줍니다.
function draw(){ requestAnimationFrame(draw); // 연속적인 장면을 위해 재귀적으로 호출하는 모습 // 렌더링 로직 } requestAnimationFrame(draw);
requestAnimationFrame은 부드러운 애니메이션을 위한 기술을 지원하는데요.
브라우저의 프레임 주기에 맞추어 다음 프레임 렌더링 전 애니메이션 함수의 실행을 예약해 16.7ms 간격으로 애니메이션 함수의 호출을 보장하는 역할을 합니다.
👀
브라우저의 프레임 주기는 모니터의 주사율을 따릅니다. 60hz → 60fps, 144hz → 144fps. 즉, 모니터 주사율이 144hz 라면 1초에 144번 requestAnimationFrame이 호출됩니다.

Canvas 애니메이션의 문제점

문제는 이 16.7ms동안 우리의 Canvas 렌더링 코드와 브라우저 렌더링을 위한 작업들이 함께 돌아가야 합니다.
우리의 화면에는 Canvas만 존재하는게 아니기 때문에 나머지 DOM 요소들의 변경사항을 업데이트 하고 그리는 일도 Canvas의 렌더링과 함께 수행되게 됩니다.
notion image
그렇다면 60fps를 위해 16.7ms안에는 브라우저의 동작들과 Canvas의 한 프레임 렌더링이 끝나야 합니다.
만약 애니메이션 함수가 많이 무거워지면 어떻게 될까요?
function draw(){ requestAnimationFrame(draw); // 연속적인 장면을 위해 재귀적으로 호출하는 모습 // 렌더링 로직 for(let i = 0; i < 1000000000000; i++){ // 무거운 작업 } } requestAnimationFrame(draw);
예약된 함수가 브라우저의 한 렌더링 주기 동안 완료되지 않으면, 그 작업은 다음 렌더링 사이클로 이어집니다.
브라우저는 다음 requestAnimationFrame 호출 시점까지 기존 작업을 완료하려고 시도하고 이로인해 작업이 밀리게 되어 프레임 드랍이 일어나 성능이 저하됩니다.
notion image
그렇다면 이런 한계점을 해결할 수 있는 방법에는 무엇이 있을까요?

GPU야 해줘~ (WebGL)

메인스레드가 너무 바빠서 생기게 되는 현상이기 때문에 그림 그리는 부분을 전문가에게 넘기는 방식입니다.
맨 처음 소개드린 하드웨어 가속이 가능한 WebGL API 가 여기에 해당합니다.
 
notion image
하지만 그림을 보다시피 렌더링 부분만을 GPU에 위임할 뿐 WebGL API 는 JavaScript로 호출하기 때문에 복잡한 WebGL 애플리케이션은 메인 스레드에서 상당한 양의 JavaScript 계산을 수행할 수 있으며, 이는 성능 저하로 이어질 수 있습니다.
notion image
그래도 정말 복잡한 어플리케이션이 아니라면 대부분은 이정도 선에서 잘 작동하지 않을까 싶습니다.
만약 Snack Game에 최적화를 진행한다면 기존의 pixi.js 같은 WebGL을 훌륭히 지원하는 엔진으로 마이그레이션하는 선택지가 있겠네요.

다른 스레드에서 해버리자! (WebWorker)

웹 워커(Web worker)는 스크립트 연산을 웹 어플리케이션의 주 실행 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있는 기술입니다. 웹 워커를 통해 무거운 작업을 분리된 스레드에서 처리하면 주 스레드(보통 UI 스레드)가 멈추거나 느려지지 않고 동작할 수 있습니다. -MDN
설명을 보다시피 완전히 분리된 작업 환경에 무거운 작업을 분리할 수 있는 기술인데요.
하지만 Canvas는 DOM의 일부이고 WebWorker는 DOM에 직접적으로 접근할 수 없어 그 전까지는 WebWorker가 canvas에 렌더링할 방법이 없었습니다.
그래서 이문제를 해결하기 위해 OffscreenCanvas 가 나오게 되었습니다.
👀
WebWorker가 DOM에 직접 접근할 수 없는 이유는 동기화 이슈등 Javascript가 싱글 스레드인 이유랑 비슷하다고 추측합니다.

OffscreenCanvas

notion image
getContext()로 렌더링 컨텍스트를 가져왔던 것과 다르게 transferControlToOffscreen() 를 통해 OffscreenCanvas 객체를 가져오게 됩니다.
const canvas = document.getElementById("canvas"); const offscreen = canvas.transferControlToOffscreen();
이 OffscreenCanvas 객체는 메인 스레드와 워커 스레드를 왔다갔다 할 수 있는 객체 이고,
실제 DOM에 존재하는 canvas의 백 버퍼와 연결이되어 OffscreenCanvas에 그린 결과물이 canvas에 그려지게 됩니다.
👀
여기서 백 버퍼란 “더블 버퍼링” 기술과 관련이 있는데요. 컴퓨터 그래픽 분야에서 사용하는 용어입니다. 미처 다 그리지 못한 그림을 노출하지 않기 위해 보이지 않는 곳에서 다음 그림을 미리 그리고 다 그리면 기존 화면과 바꾸는 기술입니다. 리액트 가상 DOM을 위한 아키텍처 리액트 파이버도 이 기술을 사용합니다.
가상 DOM과 리액트 파이버
OffscreenCanvas는 메인 스레드에서만 생성할 수 있기 때문에 워커에 postMessage를 통해 OffscreenCanvas를 넘겨주어야 합니다.
이 후 워커 스레드는 OffscreenCanvas를 전달받고 렌더링 컨텍스트를 뽑아 그리기 시작합니다.
// 메인 스레드 const worker = new Worker('canvas-worker.js'); worker.postMessage({ canvas: offscreen }, [offscreen]) // 워커 스레드 self.onmessage = event = > { const offscreen = event.data.canvas; const context = canvas.getContext('2d'); function draw(){ requestAnimationFrame(draw); // 렌더링 로직 } requestAnimationFrame(draw); }

+ 추가

이 OffscreenCanvas는 WebGL과 함께 사용할 수 있고 다음과 같이 동작하게 됩니다.
notion image
pixi.jsthree.js 도 마찬가지로 OffscreenCanvas를 지원하고 이렇게까지 한다면 엄청난 성능 향상이될 것 같습니다.

Snack Game에서 생각해볼 점

이번 글은 미래의 Snack Game 최적화를 위해 작성하게 되었는데요.
나중에 문제가 생기기 전 미리 pixi.js 같은 엔진으로 마이그레이션을 해야하나.
여러모로 고민이 됩니다. 사실 아직 까지는 성능적인 측면에서는 큰 문제가 없었지만, Snack Game에 부족한 여러가지 풍푸한 애니메이션을 생각하면 pixi.jsphaser 같은 라이브러리가 자꾸 눈에 아른거리네요.
계속 2D Context로 제작하자니 성능의 걱정과 애니메이션 시스템을 직접 만드는게 부담이 되는것도 사실입니다.
아직 확정적인건 없지만 Snack Game의 미래를 위한 힌트가 되었으면 좋겠습니다.