HTML Canvas를 활용하여 인터랙티브 콘텐츠 만들기

Yechanny
Uniquegood
Published in
15 min readMay 3, 2021

안녕하세요 유니크굿컴퍼니의 책임개발자 이예찬입니다. 웹 프론트엔드와 백엔드 개발을 많이 하지만 요즘은 웹 프론트엔드 개발을 주로 하고 있습니다. 이 글에서 소개해 드릴 내용은 HTML Canvas입니다. JavaScript에 대한 전반적인 이해는 하고 있지만, HTML Canvas 기술은 처음인 분들을 대상으로 작성되었습니다.

HTML의 Canvas(캔버스) 기술은 일반적인 HTML 요소들로는 표현하기 힘든 다양한 그래픽 표현을 가능하게 해줍니다. 덕분에 웹 게임 같은 새로운 장르가 만들어지기도 했지요. 리얼월드 게임 ‘굿바이, 스노우볼’에 사용된 스노우볼을 깨부수는 인터랙티브 요소도 캔버스 기술을 사용해서 만들어졌는데요, 이러한 인터랙티브 요소는 어떻게 만들 수 있는지 그 기본적인 메커니즘을 간략하게 소개해 드립니다.

들어가며

유니크굿컴퍼니의 리얼월드는 창작자들에게는 폭넓은 창작의 자유를, 플레이어들에게는 다채로운 플레이 경험을 드릴 수 있도록 설계되어 있습니다. 하지만 원래 거의 모든 콘텐츠가 iOS/Android 네이티브 앱으로 제공되고 있던 리얼월드 플랫폼의 특성상, 창작의 자유를 제공해주기 위해서는 끊임없이 네이티브 앱에 기능을 추가하고 사용자에게 업데이트를 제공해주어야 한다는 문제가 있었습니다.

이러한 상황에서 저희가 선택한 것은 네이티브 앱의 일부를 웹뷰로 만들어서 웹 기술을 활용해 콘텐츠의 자유도를 확보하는 것이었습니다. 웹 기술을 사용하게 될 경우 HTML / CSS / JavaScript 코드만 있으면 어떤 기기에서든 별도의 네이티브 개발 및 배포 없이 자유롭게 업데이트가 가능하고, 창작자들의 크리에이티브를 반영하기도 훨씬 쉽다는 장점이 있습니다.

리얼월드에 있는 많은 게임이 웹뷰를 인터랙티브 요소로 활용하여 제작되었는데요, 이 글에서는 그중에서도 제가 가장 의욕적으로 제작에 참여했던 인터랙티브 콘텐츠를 만들었던 과정과 그 과정에서 HTML Canvas를 어떻게 활용했는지에 대해서 공유해보려고 합니다.

캔버스를 도입한 계기

먼저, 제가 만든 콘텐츠를 보여드릴게요. 리얼월드 게임 중 굿바이 스노우볼 게임에 사용된 웹뷰인데요, 나름 하이라이트에 들어간 요소라 스포일러를 피하고 싶으신 분께서는 실행하지 않고 넘어가시는 걸 추천해 드립니다!

어떤가요? 마치 게임을 플레이하는 듯 사용자가 인터랙티브하게 즐길 수 있을 만한 콘텐츠죠? 이런 콘텐츠를 구현하는 방법은 여러 가지가 있겠지만 저는 HTML Canvas를 사용해서 구현하고자 했습니다. 일반적으로 웹페이지를 구현하는 방법대로 HTML 요소들을 이용해서 만들 수도 있겠지만, HTML은 원래 문서를 만들기 위해 존재하는 것이니 이렇게 인터랙티브하고 시각적으로 동적인 요소는 캔버스의 존재 목적과 좀 더 닮아있다고 생각했기 때문입니다.

HTML Canvas에 대하여

우리는 Canvas API를 통해 HTML의 Canvas 요소에 그림을 그릴 수 있습니다. 여기서 그림이란 획을 긋거나, 원을 그리거나 사각형을 그리는 것은 물론이고 이미지를 그리는 것도 포함됩니다. 그것도 아주 다양한 방법으로 그릴 수 있는데, 그림을 회전시키거나 크기를 조절하거나 원본 이미지의 특정 부분만 그리는 것도 캔버스에서는 가능합니다.

그리고 캔버스에 이미지를 빠르게 여러 장 그리게 되면 그게 바로 애니메이션이 되는 것이지요. 여기서 사용자 입력에 따라 그릴 이미지에 변화를 주게 되면 게임과 같은 인터랙티브 콘텐츠가 되는 것입니다.

캔버스에 이미지를 그리는 기본적인 방법

먼저 캔버스에 이미지를 그리는 기본적인 방법을 살펴보겠습니다. 캔버스에 이미지를 그리는 과정은 제일 먼저 캔버스의 그리기 컨텍스트를 가져오는 것부터 시작됩니다. 그리기 컨텍스트란 캔버스에 그림을 그리는 데에 필요한 칠하기 색상, 채우기 색상, 변환(Transform) 등의 맥락(Context)을 저장하고, 그림을 그릴 수 있도록 여러 메소드를 제공해주는 객체입니다. 자세한 건 직접 써보면 이해가 될 거예요. 아래 코드에서는 캔버스 요소를 받아와서 그로부터 2D 그리기 컨텍스트를 가져옵니다.

캔버스 요소를 만드는 HTML
그리기 컨텍스트를 가져오는 JavaScript

이제 이 그리기 컨텍스트를 통해 이미지를 그려보겠습니다. 일단 그릴 이미지를 불러와야 할 텐데요, 이미지를 불러오는 데에는 여러 방법이 있지만 저는 HTML img 태그를 사용해서 이미지를 불러오고, 그걸 document.getElementById로 자바스크립트에 가져오는 방식을 사용했습니다. 그리고 drawImage 함수를 호출해서 이미지를 원하는 좌표에 그려주면 됩니다.

이미지 요소를 만드는 HTML
이미지를 캔버스에 그리는 JavaScript

위 코드에서는 ctx.drawImage함수를 호출해서 (0, 0) 좌표에 imgBall 이미지를 그렸습니다.

코드 실행의 결과 (캔버스 영역이 잘 보이도록 그림자를 추가했습니다)

여기서 캔버스의 좌표계에 대해 이해를 하고 가자면, 캔버스(를 비롯한 많은 컴퓨터 그래픽)의 좌표계는 수학 시간에 배웠던 좌표 평면의 1사분면을 위아래로 뒤집은 모양입니다. 좌상단이 (0, 0)이고 오른쪽으로 갈수록 x값이 커지며, 아래쪽으로 갈수록 y 값이 커지는 모양새입니다

캔버스에서 사용 되는 2D 좌표계

애니메이션 만들기

일반적으로 CSS를 이용하여 애니메이션을 만들 경우 트랜지션이나 키 프레임을 이용하여 만들게 됩니다. 그러나 캔버스를 사용할 경우 그것보다 더 낮은 수준(low-level)에서 애니메이션을 처리해주어야 합니다. 즉, 프레임마다 변화된 이미지를 화면에 직접 그려주어야 한다는 얘기입니다.

일반적으로 애니메이션은 1초에 30번 화면을 새로 그려줍니다. 혹은 조금 부드러운 영상에서는 1초에 60번을 그려주기도 합니다. 그러면 1/30초 혹은 1/60초마다 화면을 새로 그려주면 되겠지요? 가장 간단하게 접근하는 방법으로 window.setInterval함수를 이용해서 1/30초마다 반복되는 그리기 작업을 해주도록 하겠습니다.

y좌표가 1씩 증가하며 이미지가 캔버스에 그려집니다
코드 실행의 결과 — 잔상이 남습니다

만약 이대로 실행하게 되면 약 0.033초마다 이미지의 y 좌표가 1씩 증가하면서 이미지가 그려지는데요, 이미지가 자국을 남기면서 그려집니다. 기존에 그려졌던 이미지 위에 덧그려지기 때문에 이런 현상이 벌어지는 것이라 우리는 매번 이미지를 그릴 때마다 캔버스를 초기화해줄 필요가 있습니다. ctx.clearRect를 호출해서 캔버스의 일부 혹은 전부를 지워줄 수 있습니다.

위와 같이 호출해주면 캔버스를 전부 지워줄 수 있습니다. 이제 방금 작성했던 코드에 캔버스를 지워주는 코드를 함께 적어보도록 하겠습니다.

이번에는 이미지가 정상적으로 1픽셀씩 이동하며 잘 그려지는 것을 볼 수 있습니다.

코드 실행의 결과 — 잔상이 사라졌습니다

정밀하게 애니메이션 구현하기

위와 같은 방법으로 애니메이션을 구현하면 아래와 같은 두 가지 문제가 있습니다.

  1. window.setInterval이 실제로 호출되는 시점에 따라 1픽셀씩 이동하는 속도가 빨라지거나 느려질 수 있습니다: window.setInterval에 지정한 시간과 실제 호출되는 시점 간에는 다양한 이유로 오차가 생길 수 있어서 애니메이션이 더 빨라지거나 더 느려질 수 있습니다.
  2. 다양한 기기의 성능에 최적화되지 않았습니다: 어떤 기기에서는 초당 60번 그리는 부드러운 화면이 가능할 수도 있고, 어떤 기기에서는 초당 30번 그리는 화면만 가능할 수도 있는데, window.setInterval을 이용하면 일률적으로만 적용이 가능합니다.

1번을 해결하기 위해서는 draw함수가 호출될 때의 시각과 이전 호출 시각의 차이를 구해서 한 프레임 당 얼마나 움직여야 하는지 알아낼 수 있습니다.

위와 같이 하면 draw 함수가 호출되는 간격과 상관없이 일정한 속도로 이미지가 이동하는 것을 볼 수 있습니다.

2번을 해결하는 방법은 브라우저에서 제공하는 window.requestAnimationFrame 이라는 함수를 사용하는 것입니다.

window.requestAnimationFrame함수는 애니메이션의 다음 프레임을 그리는 시점을 브라우저에서 조절하기 때문에 일반적으로 모니터의 주사율(보통 60Hz)에 맞춰 호출됩니다. 그리고 탭이 백그라운드에 있는 등 화면이 보이지 않을 때는 성능 최적화를 위해 애니메이션이 실행되지 않도록 하는 기능도 합니다.

window.requestAnimationFrame함수를 쓰면 코드가 어떻게 바뀌는지 보겠습니다.

크게 두 가지가 바뀌었는데요, 하나는 new Date를 통해 현재 시각을 가져와 이전 프레임과의 시차를 구하던 게, 이제는 draw 함수의 파라미터로 현재의 타임스탬프를 받아와 이전 프레임과의 시차를 구하도록 바뀐 점입니다. 그리고 다른 하나는 window.setInterval에서는 지정한 시간마다 콜백 함수를 자동으로 호출해주던 게, 이제 window.requestAnimationFrame 함수를 프레임마다 직접 호출하여 다음 프레임을 그리도록 요청하는 것으로 바뀌었습니다

간단한 물리 적용하기

그냥 초당 50픽셀씩 아래로 이동하는 건 너무 재미가 없네요. 간단하게 가속도를 적용해서 중력의 영향을 받는 것처럼 만들어 보겠습니다. 아마 기억을 더듬어 보시면 거리 = 속도 * 시간, 속도 = 가속도 * 시간 같은 간단한 물리 공식들이 떠오를 거에요. 그럼 화면의 아래쪽까지만 공이 떨어지도록 중력을 간단하게 적용해볼까요?

코드 실행 결과 — 가속도가 붙지만 너무 느립니다

어떤가요? 아까보다는 훨씬 자연스럽게 떨어지는 모습을 볼 수 있습니다. 그런데 시간이 너무 느리게 가는 것 같네요. 이번에는 시간이 조금 빠르게 가도록 조정을 해볼까요? 시간이 빠르게 가도록 하는 건 timeDiff 변수에 원하는 시간 배수를 곱해주면 됩니다.

코드 실행 결과 — 적당한 속도로 떨어집니다

그런데 그냥 떨어지기만 하니까 너무 재미가 없습니다. 바닥에 닿을 때 튀어 오르게 만드는 건 어떨까요? 이미지가 캔버스를 넘어갈 경우 캔버스를 넘어가지 못하게 처리하면서 속도를 반대로 설정해주면 됩니다.

이렇게 하면 무한정 튀어 오를 테니 -speed에 적당한 탄성계수를 곱해서 탄성을 제한해 보겠습니다.

전체 코드는 아래와 같습니다

코드 실행 결과 — 탄성을 가지고 튀어 오릅니다

사용자 인터랙션

명색이 인터랙티브 콘텐츠인데 사용자가 개입할 여지가 없으면 안 되겠죠? 간단하게 사용자 인터랙션을 추가해봅시다. 일단 캔버스를 클릭했을 때 이미지가 튀어 오르게 만들어 보겠습니다. 원래 제대로 된 물리라면 힘을 가해주고 그 힘의 영향을 계산해야겠지만 그러기엔 너무 복잡해집니다. 그냥 결과적으로 적당한 속도를 음의 방향으로 주면 위로 튀어 오르는 효과가 손쉽게 완성됩니다.

코드 실행 결과 — 클릭할 때마다 튀어 오릅니다

위의 스노우볼 예시에서는 e.offsetXe.offsetY를 이용해서 사용자가 입력한 위치에 금이 간 효과 이미지를 추가하는 것이 적용되어 있습니다.

이미지 회전하기

이미지가 그냥 떨어지기만 하니까 재미가 없네요. 사용자가 클릭할 때마다 적당히 회전시켜보도록 하겠습니다. 사용자 클릭 시 적당한 각속도를 주고, draw가 호출될 때마다 각속도를 이미지의 회전에 더하는 방식으로 구현하면 됩니다.

먼저 필요한 변수를 선언해줍니다.

그리고 y 좌표를 속도가 변화시키듯 현재 회전 값에 각속도를 더합니다.

사용자가 입력할 때 각속도를 랜덤하게 설정해주겠습니다.

마지막으로 가장 중요한, 이미지를 그리는 코드입니다.

이 부분이 가장 핵심적인 부분인데요, 이미지를 정해진 좌표에 그리는 로직을 완전히 변경한 방식입니다. 일단 가장 이해하기 쉬운 ctx.translate(0, y) 부터 설명을 드리자면, translate는 옮긴다는 뜻입니다. 즉, ctx.translate(0, y)호출 이후 호출 되는 그리기 함수들은 전부 x축으로 0만큼, y축으로 y만큼 이동시켜 그려진다는 얘기입니다.
그리고 ctx.rotate(rotation)를 호출하면 ctx.rotate 이후 호출되는 그리기 함수들은 전부 rotation만큼 회전되어 그려진다는 얘기입니다. 참고로 rotation 라디안 각이어야 하고, 수학이 잘 기억 나지 않는 분을 위해 간략하게 흔히 쓰는 각도와 라디안 사이의 변환 방법만 알려 드리자면 라디안 = 각도 * 180 / π 입니다.

여기서 중요한 건, 그리는 축 자체가 회전해버린다는 것입니다. 그러니까, 우리가 ctx.rotate(Math.PI / 2) (90도 회전)을 호출해 두고 이전과 같이 ctx.drawImage(imgBall, 0, -400)처럼 호출을 한다면, 이미지만 90도 회전하는 게 아니라 축 자체가 회전해버려서 y축으로 -400이 된 결과가 아닌 x축으로 +400 되어버린 듯한 결과가 되어버립니다. 그러므로ctx.drawImage 함수에서 y 값을 반영한 대신 ctx.translate(0, y)를 추가로 호출해서 축 자체를 이동시켜버린 것입니다.

또 하나 중요한 사실이 있는데요, ctx.translatectx.rotate를 호출하는 순서입니다. 둘 다 축 자체를 바꿔버린다고 위에서 설명해 드렸는데요, 만약 rotate가 translate보다 먼저 온다면 어떻게 될까요? 축이 회전해버린 상태에서 이동을 하므로 우리가 원하는 방식으로 이동이 되지 않습니다. 그렇기 때문에 우리가 원하는 결과를 얻으려면 반드시 ctx.translate를 먼저 호출해서 축을 이동시키고, ctx.rotate를 호출해서 축을 회전시켜야 합니다.

위의 두 문단이 이해하는 데에 어려우셨다면 아래 그림을 보시면 더 명확하게 이해 되실거라 생각합니다.

회전(rotate) 먼저 하고 이동(translate)을 한 경우
이동(translate) 먼저 하고 회전(rotate)을 한 경우

비슷하게 scale이라는 함수도 있습니다만 이번에는 사용하지 않으니 생략하고 MDN링크를 드리도록 하겠습니다.

마지막으로 ctx.save()ctx.restore()를 짚고 가자면, 위에서 그리기 컨텍스트에 대해 설명할 때 아래와 같이 설명했었습니다.

그리기 컨텍스트란 캔버스에 그림을 그리는 데에 필요한 칠하기 색상, 채우기 색상, 변환(Transform)등의 맥락(Context)을 저장하고…

여기서 말하는 맥락은 draw함수가 여러 번 호출 되더라도 계속 유지 되는 맥락입니다. 즉, 우리가 위에서 ctx.translatectx.rotate를 호출했는데, 이건 draw함수가 다시 호출되는 다음 프레임에도, 그다음 프레임에도 유지가 되는 상태라는 얘기입니다. 다시 말해, translate와 rotate가 번갈아 가며 누적되어서 종잡을 수 없는 위치와 회전이 되어버립니다.

translate와 rotate를 초기화하지 않고 번갈아 4번 호출했을 때 그려지는 이미지

이를 방지하기 위해 우리는 하나의 물체를 그릴 때마다 이 컨텍스트를 초기화해줄 필요가 있습니다. 좀 더 코드와 유사하게 설명해 드리자면, ctx.translatectx.rotate등을 호출하기 전에, 컨텍스트가 깨끗한 상태를 저장하고, ctx.drawImage로 이미지를 그리고 나면 저장해둔 깨끗한 컨텍스트를 복원하는 것입니다. 그래서 ctx.save()ctx.restore() 사이에 모든 변환과 그리기 관련 함수 호출이 있는 것입니다.

코드 실행 결과 — 클릭 할 때 마다 이미지가 회전합니다

정리

이제 모든 코드를 한데 모아보겠습니다.

보통 이러한 콘텐츠를 만드는 건 게임엔진을 이용해서 하게 됩니다. 렌더링 시간에 대한 고민, 회전과 이동 변환에 대한 고민, 심지어 물리에 관한 고민 등을 전부 게임엔진에서 처리해 주죠.

하지만 리얼월드에 들어가는 인터랙티브 요소는 네이티브 앱으로 하는 게임 플레이 중간에 삽입되는 서브 콘텐츠이므로 로딩을 최소화해야 한다는 제약사항이 있었고, 게임엔진을 이용할 경우 게임엔진에 대한 학습을 별도로 해야 하므로 인터랙티브 콘텐츠 제작이 주된 업무가 아닌 프로덕트개발팀의 개발자가 이용하기에는 적합하지 않았습니다. (프로덕트개발팀의 주된 업무는 리얼월드 플랫폼을 만드는 것이며 콘텐츠 제작은 부가적인 업무랍니다)

그리고 무엇보다 이러한 콘텐츠를 만들기 위해 필요한 코드 양이 300여 줄 내외 정도밖에 되지 않았기 때문에 엔진이나 별도의 라이브러리를 학습하는 데에 들어가는 시간적인 비용 보다 그냥 개발하는 데에 들어가는 비용이 훨씬 적게 들어갈 것으로 생각했습니다. 거기다 코드와 프로그램의 흐름을 개발자가 온전히 제어한다는 것도 하나의 장점이 되기도 하고요.

글의 내용 자체가 어려운 내용은 아니지만, 캔버스를 처음 써보는 입장에서는 생소한 개념이 많이 등장할 수도 있겠다는 생각이 들었습니다. 글을 글로만 읽지 마시고 코드를 직접 작성해보시면서 값을 마음대로 이렇게 저렇게 바꿔보는 것을 추천해 드립니다. 글을 이해하는 데에 훨씬 큰 도움이 될 것입니다.

스노우볼 인터랙티브 콘텐츠를 만들면서 겪은 것이나 구현했던 것(파티클, 사운드 이펙트 등)을 더 소개해드리고 싶은 마음은 굴뚝같지만 지면 관계상 기초적인 부분만 작성할 수밖에 없어서 아쉽습니다. 기초적인 부분은 알려드렸으니 자유롭게 활용해서 멋진 콘텐츠를 만드셨으면 좋겠습니다

--

--