HTML5 캔버스 성능 개선

보리스 스무스
보리스 스무스

소개

Apple에서 실험으로 시작한 HTML5 캔버스는 웹에서 가장 널리 지원되는 2D 즉시 모드 그래픽 표준입니다. 현재 많은 개발자가 다양한 멀티미디어 프로젝트, 시각화, 게임에 이 기능을 사용합니다. 그러나 Google에서 빌드하는 애플리케이션이 점점 복잡해짐에 따라 개발자가 의도치 않게 성능 장벽에 부딪히게 됩니다. 캔버스 성능 최적화에 관해서는 단절된 지혜가 많이 있습니다. 이 문서는 개발자가 보다 쉽게 이해할 수 있는 리소스로 이 본문의 일부를 통합하는 것을 목표로 합니다. 이 문서에는 모든 컴퓨터 그래픽 환경에 적용되는 기본 최적화뿐만 아니라 캔버스 구현이 개선됨에 따라 변경될 수 있는 캔버스별 기법이 포함되어 있습니다. 특히 브라우저 공급업체에서 캔버스 GPU 가속을 구현하면 설명된 성능 기술 중 일부가 영향을 덜 받게 될 가능성이 높습니다. 필요한 경우 이 점을 명시합니다. 이 도움말에서는 HTML5 캔버스 사용에 대해 다루지 않습니다. 자세히 알아보려면 HTML5Rocks의 캔버스 관련 도움말, HTML5 사이트 살펴보기 또는 MDN 캔버스 튜토리얼을 참고하세요.

성능 테스트

빠르게 변화하는 HTML5 캔버스의 환경에 대응하기 위해 JSPerf(jsperf.com) 테스트는 제안된 모든 최적화가 여전히 작동하는지 확인합니다. JSPerf는 개발자가 JavaScript 성능 테스트를 작성할 수 있게 해주는 웹 애플리케이션입니다. 각 테스트는 달성하려는 결과에 초점을 맞추며 (예: 캔버스 지우기) 동일한 결과를 얻는 여러 접근 방식을 포함합니다. JSPerf는 각 접근 방식을 짧은 기간 동안 가능한 한 많이 실행하고 통계적으로 유의미한 초당 반복 횟수를 제공합니다. 점수는 높을수록 항상 좋습니다. JSPerf 성능 테스트 페이지의 방문자는 자신의 브라우저에서 테스트를 실행할 수 있으며 JSPerf가 정규화된 테스트 결과를 Browserscope (browserscope.org)에 저장하도록 허용할 수 있습니다. 이 문서의 최적화 기법은 JSPerf 결과로 백업되므로 이 기법이 여전히 적용되는지에 관한 최신 정보를 확인할 수 있습니다. 이 문서 전체에 포함된 이러한 결과를 그래프로 렌더링하는 작은 도우미 애플리케이션을 작성했습니다.

이 문서의 모든 성능 결과는 브라우저 버전을 기반으로 합니다. 이는 브라우저가 어떤 OS에서 실행되었는지 또는 더 중요한 것은 성능 테스트를 실행할 때 HTML5 캔버스에 하드웨어 가속이 사용되었는지 여부를 알 수 없기 때문에 한계로 판명되었습니다. 주소 표시줄에서 about:gpu를 방문하여 Chrome의 HTML5 캔버스에서 하드웨어 가속이 사용되는지 확인할 수 있습니다.

화면 밖 캔버스에 사전 렌더링

게임을 작성할 때와 마찬가지로 여러 프레임에 걸쳐 화면과 유사한 프리미티브를 다시 그릴 경우 장면의 많은 부분을 사전 렌더링하여 성능을 크게 높일 수 있습니다. 사전 렌더링은 별도의 화면 밖 캔버스 (또는 캔버스)를 사용하여 임시 이미지를 렌더링한 다음 화면 밖 캔버스를 표시된 캔버스에 다시 렌더링하는 것을 의미합니다. 예를 들어 초당 60프레임으로 실행되는 Mario를 다시 그리는 경우 각 프레임에서 모자, 콧수염, 'M'을 다시 그리거나 애니메이션을 실행하기 전에 Mario를 사전 렌더링할 수 있습니다. 사전 렌더링 없음:

// canvas, context are defined
function render() {
  drawMario(context);
  requestAnimationFrame(render);
}

사전 렌더링:

var m_canvas = document.createElement('canvas');
m_canvas.width = 64;
m_canvas.height = 64;
var m_context = m_canvas.getContext('2d');
drawMario(m_context);

function render() {
  context.drawImage(m_canvas, 0, 0);
  requestAnimationFrame(render);
}

requestAnimationFrame의 사용에 유의하세요. 이 내용은 이후 섹션에서 자세히 설명합니다.

이 기법은 렌더링 작업(위의 예에서는 drawMario) 비용이 많이 들 때 특히 효과적입니다. 이에 대한 좋은 예로는 비용이 많이 드는 작업인 텍스트 렌더링이 있습니다.

그러나 '사전 렌더링된 느슨한' 테스트 사례는 성능이 저하됩니다. 사전 렌더링할 때 임시 캔버스가 그리는 이미지 주위에 잘 맞도록 하는 것이 중요합니다. 그렇지 않으면 큰 캔버스 하나를 다른 캔버스에 복사할 때 (소스 타겟 크기에 따라 다름) 성능 손실로 인해 화면 밖 렌더링의 성능 손실이 평등하게 유지됩니다. 위 테스트에서 아늑한 캔버스는 다음과 같이 더 작습니다.

can2.width = 100;
can2.height = 40;

실적이 저조한 일반 배포와 비교했을 때

can3.width = 300;
can3.height = 100;

캔버스 호출 일괄 호출

그리기는 비용이 많이 드는 작업이므로 긴 명령어 집합을 사용하여 그리기 상태 머신을 로드한 다음 모든 동영상 버퍼에 덤프하는 것이 더 효율적입니다.

예를 들어 여러 선을 그릴 때는 그 안에 모든 선이 포함된 하나의 경로를 만들고 단일 그리기 호출로 그리는 것이 더 효율적입니다. 즉, 별도의 선을 그리지 말고 다음과 같이 하세요.

for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.beginPath();
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
  context.stroke();
}

단일 다중선을 그리면 성능이 향상됩니다.

context.beginPath();
for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
}
context.stroke();

이는 HTML5 캔버스에도 적용됩니다. 예를 들어 복잡한 경로를 그릴 때는 세그먼트를 개별적으로 렌더링하는 것보다 모든 지점을 경로에 넣는 것이 좋습니다 (jsperf).

그러나 캔버스의 경우 이 규칙에는 중요한 예외가 있습니다. 원하는 객체를 그리는 데 관련된 프리미티브에 작은 경계 상자 (예: 가로선 및 세로선)가 있는 경우 실제로 별도로 렌더링하는 것이 더 효율적일 수 있습니다(jsperf).

불필요한 캔버스 상태 변경 방지

HTML5 캔버스 요소는 채우기 및 획 스타일과 같은 항목과 현재 경로를 구성하는 이전 지점을 추적하는 상태 시스템 위에 구현됩니다. 그래픽 성능을 최적화하려고 할 때는 그래픽 렌더링에만 집중하고 싶을 수 있습니다. 그러나 상태 머신을 조작해도 성능 오버헤드가 발생할 수 있습니다. 예를 들어 여러 채우기 색상을 사용하여 장면을 렌더링하는 경우 캔버스에 배치하는 것보다 색상으로 렌더링하는 것이 더 저렴합니다. 핀스트라이프 패턴을 렌더링하기 위해 스트라이프를 렌더링하거나 색상을 변경하거나 다음 스트라이프를 렌더링할 수 있습니다.

for (var i = 0; i < STRIPES; i++) {
  context.fillStyle = (i % 2 ? COLOR1 : COLOR2);
  context.fillRect(i * GAP, 0, GAP, 480);
}

또는 홀수 스트라이프를 모두 렌더링한 다음 짝수 스트라이프를 모두 렌더링합니다.

context.fillStyle = COLOR1;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2) * GAP, 0, GAP, 480);
}
context.fillStyle = COLOR2;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2+1) * GAP, 0, GAP, 480);
}

예상대로 인터레이스 방식은 상태 머신을 변경하는 비용이 많이 들기 때문에 속도가 느립니다.

완전히 새로운 상태가 아닌 화면 차이만 렌더링

예상하셨겠지만, 화면에서 더 적게 렌더링하는 것이 더 많이 렌더링하는 것보다 저렴합니다. 다시 그리기 간에 점진적인 차이만 있다면 차이를 그리기만 해도 성능을 크게 향상할 수 있습니다. 즉, 그리기 전에 전체 화면을 지우는 대신 다음을 따르세요.

context.fillRect(0, 0, canvas.width, canvas.height);

그려진 경계 상자를 추적하고 이 내용만 삭제합니다.

context.fillRect(last.x, last.y, last.width, last.height);

컴퓨터 그래픽에 익숙하다면 이 기법을 '영역 다시 그리기'로 알 수도 있습니다. 영역 다시 그리기에서는 이전에 렌더링된 경계 상자가 저장된 후 렌더링될 때마다 지워집니다. 이 기법은 JavaScript Nintendo 에뮬레이터 토크에서 알 수 있듯이 픽셀 기반 렌더링 컨텍스트에도 적용됩니다.

복잡한 장면에는 여러 레이어로 이루어진 캔버스 사용하기

앞서 언급했듯이 큰 이미지를 그리는 것은 비용이 많이 들므로 가능하면 피해야 합니다. 사전 렌더링 섹션에서 설명한 것처럼 화면 밖에서 렌더링하는 데 다른 캔버스를 사용하는 것 외에 캔버스를 레이어로 쌓아서 사용할 수도 있습니다. 포그라운드 캔버스에서 투명도를 사용하면 GPU를 통해 렌더링 시 알파를 함께 합성할 수 있습니다. 절대적으로 배치된 두 개의 캔버스를 서로 겹치도록 다음과 같이 설정할 수 있습니다.

<canvas id="bg" width="640" height="480" style="position: absolute; z-index: 0">
</canvas>
<canvas id="fg" width="640" height="480" style="position: absolute; z-index: 1">
</canvas>

여기에 캔버스를 하나만 사용할 때보다 장점은 포그라운드 캔버스를 그리거나 지울 때 배경을 수정하지 않는다는 것입니다. 게임 또는 멀티미디어 앱을 포그라운드와 백그라운드로 분할할 수 있는 경우 별도의 캔버스에서 렌더링하여 성능을 크게 향상하는 것이 좋습니다.

종종 인간의 불완전한 인식을 활용하여 배경을 한 번 또는 포그라운드에 비해 느린 속도로 렌더링할 수 있습니다(사용자의 관심을 가장 많이 끌 수 있음). 예를 들어 렌더링할 때마다 포그라운드를 렌더링하고 N 번째 프레임마다 백그라운드만 렌더링할 수 있습니다. 또한 애플리케이션이 이러한 유형의 구조에서 더 잘 작동하는 경우 이 접근 방식은 모든 합성 캔버스에 잘 일반화됩니다.

shadowBlur 방지

다른 많은 그래픽 환경과 마찬가지로 HTML5 캔버스를 사용하면 개발자가 프리미티브를 블러 처리할 수 있지만 이 작업은 비용이 많이 들 수 있습니다.

context.shadowOffsetX = 5;
context.shadowOffsetY = 5;
context.shadowBlur = 4;
context.shadowColor = 'rgba(255, 0, 0, 0.5)';
context.fillRect(20, 20, 150, 100);

캔버스를 지우는 다양한 방법

HTML5 캔버스는 즉시 모드 그리기 패러다임이므로 각 프레임에서 장면을 명시적으로 다시 그려야 합니다. 따라서 캔버스 지우기는 HTML5 캔버스 앱과 게임에서 근본적으로 중요한 작업입니다. 캔버스 상태 변경 방지 섹션에서 언급했듯이 전체 캔버스를 지우는 것은 바람직하지 않은 경우가 많지만 반드시 그렇게 해야 하는 경우 다음과 같은 두 가지 옵션이 있습니다. context.clearRect(0, 0, width, height)를 호출하거나 캔버스별 해킹을 사용하여 삭제할 수 있습니다. 이 문서 작성 시점에는 일반적으로 clearRect가 너비 재설정 버전보다 성능이 우수하지만 Chrome에서는 canvas.width 해킹 1을 사용하는 것이 훨씬 더 빠릅니다.canvas.width = canvas.width

이 팁은 기본 캔버스 구현에 따라 크게 달라지고 변경될 수 있으므로 주의해야 합니다. 자세한 내용은 캔버스 지우기에 관한 사이먼 사리스의 도움말을 참고하세요.

부동 소수점 좌표 피하기

HTML5 캔버스는 하위 픽셀 렌더링을 지원하며 사용 중지할 방법이 없습니다. 정수가 아닌 좌표로 그리면 자동으로 앤티앨리어싱을 사용하여 선을 부드럽게 처리하려고 합니다. 다음은 seb Lee-Delisle의 이 하위 픽셀 캔버스 성능 문서에서 가져온 시각 효과입니다.

서브 픽셀

평활화된 스프라이트가 원하는 효과가 아니라면 Math.floor 또는 Math.round (jsperf)를 사용하여 좌표를 정수로 변환하는 것이 훨씬 빠를 수 있습니다.

부동 소수점 좌표를 정수로 변환하려면 몇 가지 현명한 기법을 사용할 수 있습니다. 가장 효과적인 기법은 대상 숫자에 절반을 더한 후 결과에 비트 연산을 수행하여 소수 부분을 제거하는 것입니다.

// With a bitwise or.
rounded = (0.5 + somenum) | 0;
// A double bitwise not.
rounded = ~~ (0.5 + somenum);
// Finally, a left bitwise shift.
rounded = (0.5 + somenum) << 0;

전체 성능 분석은 여기(jsperf)에서 확인할 수 있습니다.

캔버스 구현에 GPU 가속이 적용되어 정수가 아닌 좌표를 빠르게 렌더링할 수 있게 되면 이러한 종류의 최적화는 더 이상 중요하지 않습니다.

requestAnimationFrame로 애니메이션 최적화

비교적 새로운 requestAnimationFrame API는 브라우저에서 대화형 애플리케이션을 구현하는 데 권장되는 방법입니다. 특정 고정 틱 속도로 브라우저에 렌더링하도록 지시하는 대신 브라우저에 렌더링 루틴을 호출하고 브라우저를 사용할 수 있을 때 호출되도록 정중하게 요청합니다. 좋은 부작용으로, 페이지가 포그라운드에 있지 않으면 브라우저가 렌더링되지 않을 만큼 지능적입니다. requestAnimationFrame 콜백은 60FPS 콜백 속도를 목표로 하지만 이를 보장하지 않으므로 마지막 렌더링 이후 경과된 시간을 추적해야 합니다. 다음과 같을 수 있습니다.

var x = 100;
var y = 100;
var lastRender = Date.now();
function render() {
  var delta = Date.now() - lastRender;
  x += delta;
  y += delta;
  context.fillRect(x, y, W, H);
  requestAnimationFrame(render);
}
render();

이렇게 requestAnimationFrame를 사용하면 캔버스는 물론 WebGL과 같은 기타 렌더링 기술에도 적용됩니다. 현재 이 API는 Chrome, Safari, Firefox에서만 사용할 수 있으므로 이 shim을 사용해야 합니다.

대부분의 모바일 캔버스 구현이 느립니다.

모바일에 대해 이야기해 보겠습니다. 이 문서를 작성 당시에는 Safari 5.1을 실행하는 iOS 5.0 베타에만 GPU 가속 모바일 캔버스 구현이 포함되어 있습니다. GPU 가속이 없으면 일반적으로 모바일 브라우저의 CPU는 최신 캔버스 기반 애플리케이션에 충분히 강력하지 않습니다. 위에서 설명한 여러 JSPerf 테스트는 데스크톱에 비해 모바일에서 훨씬 더 나쁜 성능을 보이므로 성공적으로 실행할 수 있는 교차 기기 앱의 종류가 크게 제한됩니다.

결론

요약하자면 이 문서에서는 성능이 뛰어난 HTML5 캔버스 기반 프로젝트를 개발하는 데 도움이 되는 유용한 최적화 기법을 포괄적으로 다루었습니다. 이제 여기에서 새로운 내용을 배웠으므로 멋진 창작물을 최적화해 보세요. 현재 최적화할 게임이나 애플리케이션이 없다면 Chrome 실험Creative JS에서 아이디어를 얻으세요.

참조