HTML5 캔버스 성능 개선

소개

Apple의 실험으로 시작된 HTML5 캔버스는 웹에서 2D 즉시 모드 그래픽을 위한 가장 널리 지원되는 표준입니다. 이제 많은 개발자가 다양한 멀티미디어 프로젝트, 시각화, 게임에 이 플랫폼을 사용하고 있습니다. 하지만 빌드하는 애플리케이션의 복잡성이 가중됨에 따라 개발자들은 의도치 않게 성능 장벽에 부딪히게 됩니다. 캔버스 성능 최적화에 관한 잘못된 정보가 많이 있습니다. 이 도움말에서는 이 문서의 일부를 개발자가 더 쉽게 소화할 수 있는 리소스로 통합하는 것을 목표로 합니다. 이 도움말에는 모든 컴퓨터 그래픽 환경에 적용되는 기본 최적화와 캔버스 구현이 개선됨에 따라 변경될 수 있는 캔버스별 기술이 포함되어 있습니다. 특히 브라우저 공급업체에서 캔버스 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프레임으로 달리는 모습을 다시 그린다고 가정해 보겠습니다. 각 프레임에서 모자, 콧수염, 'M'을 다시 그리거나 애니메이션을 실행하기 전에 마리오를 미리 렌더링할 수 있습니다. 사전 렌더링 없음:

// 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).

그러나 Canvas의 경우 이 규칙에 중요한 예외가 있습니다. 원하는 객체를 그리기에 관련된 원시 요소에 작은 경계 상자가 있는 경우 (예: 가로선 및 세로선) 별도로 렌더링하는 것이 더 효율적일 수 있습니다(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>

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

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

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)를 호출하거나 캔버스 전용 해킹을 사용하면 canvas.width = canvas.width입니다. 이 글을 작성하는 시점에 clearRect는 일반적으로 너비 재설정 버전보다 성능이 우수하지만 경우에 따라 Chrome 재설정 핵 14를 사용하는 것이 훨씬 더 빠릅니다.canvas.width

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

부동 소수점 좌표 피하기

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

하위 픽셀

부드럽게 처리된 스프라이트가 원하는 효과가 아닌 경우 Math.floor 또는 Math.round (jsperf)를 사용하여 좌표를 정수로 변환하는 것이 훨씬 빠를 수 있습니다.

부동 소수점 좌표를 정수로 변환하려면 여러 가지 영리한 기법을 사용할 수 있습니다. 가장 성능이 우수한 기법은 타겟 숫자에 1/2을 더한 다음 결과에 비트 연산을 실행하여 소수 자릿수를 삭제하는 것입니다.

// 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에서 아이디어를 얻어 보세요.

참조