hyunsu blog

홈노트

웹게임 개발 기초: 엔진 전에 짜 볼 루프·캔버스·입력·오디오

목차 · 6
  1. 01루프는 프레임 수가 아니라 경과 시간으로 움직인다
  2. 02탭을 숨기면 루프가 멈춘다
  3. 032D 는 Canvas 로 시작하고, 3D 가 필요할 때 넘어간다
  4. 04이동 키는 key 가 아니라 code 로 읽는다
  5. 05소리는 첫 클릭 안에서 연다
  6. 06새 프로젝트는 이 순서로 뼈대를 세운다

웹게임의 첫 뼈대는 네 가지면 된다. 경과 시간으로 움직이는 requestAnimationFrame 루프, 화면 밀도에 맞춘 캔버스, 물리 키 기준 입력, 그리고 첫 클릭 안에서 여는 오디오다. 엔진을 고르기 전에 브라우저 API 만으로 이 넷을 짜 보면 엔진이 무엇을 대신해 주는지 보인다.

01 루프는 프레임 수가 아니라 경과 시간으로 움직인다

requestAnimationFrame() 은 다음 화면 갱신 직전에 콜백을 한 번 부르는 브라우저 API 다. 호출 빈도는 보통 모니터 주사율을 따른다. 60Hz 가 가장 흔하지만 그보다 빠른 화면도 널리 쓰인다. 그래서 프레임마다 위치를 고정값만큼 더하면 고주사율 화면에서 게임이 빨라진다. MDN 은 콜백이 받는 첫 인자인 타임스탬프로 진행량을 계산하라고 적어 두었다.

js
let last;

function frame(now) {
  requestAnimationFrame(frame); // 다음 프레임을 먼저 예약
  if (last === undefined) last = now;
  const dt = Math.min((now - last) / 1000, 0.1); // 초 단위, 긴 공백은 자른다
  last = now;

  update(dt); // 위치 += 속도 * dt
  render();
}

requestAnimationFrame(frame);

다음 프레임을 먼저 예약하는 순서는 MDN 「Anatomy of a video game」 이 권한다. 이번 프레임이 수직 동기화(VSync) 시점을 놓쳐도 브라우저가 다음 호출을 제때 받기 때문이다. 갱신 주기를 화면과 떼고 싶다면 같은 문서의 고정 주기 방식(예제는 50ms 간격)을 보면 된다.

02 탭을 숨기면 루프가 멈춘다

대부분의 브라우저는 백그라운드 탭과 숨겨진 iframe 에서 requestAnimationFrame() 호출을 멈춘다. 성능과 배터리를 아끼려는 동작이다. setTimeout() 같은 타이머도 비활성 탭에서는 제한되니, 타이머로 바꿔도 공백은 생긴다. 돌아왔을 때의 공백을 MDN 은 세 갈래로 다룬다. 멈춘 시간으로 보고 건너뛰기, 밀린 만큼 따라잡기, 서버나 상대에게서 상태를 다시 받기다.

서버가 없는 1인용 게임이라면 첫째가 현실적이다. 따라잡기는 공백이 길면 버겁고, 상태 복구는 받아 올 서버가 없기 때문이다. 위 루프의 Math.min(..., 0.1) 이 건너뛰기 역할을 하고, 탭이 숨겨지는 순간은 visibilitychange 이벤트(탭이 보이거나 숨겨질 때 오는 이벤트)로 잡아 일시정지를 건다.

js
document.addEventListener("visibilitychange", () => {
  if (document.hidden) pauseGame(); // 일시정지 화면을 띄운다
});

03 2D 는 Canvas 로 시작하고, 3D 가 필요할 때 넘어간다

Canvas API 는 자바스크립트로 canvas 요소에 그리는 API 로, 주로 2D 그래픽을 다룬다. MDN 이 꼽는 용도에도 게임 그래픽이 들어 있다. 하드웨어 가속 2D·3D 가 필요하면 같은 canvas 요소를 쓰는 WebGL 로 넘어간다. WebGL 의 후속인 WebGPU 는 범용 GPU 연산까지 지원하지만 HTTPS 에서만 동작하고, MDN 기준으로 널리 쓰이는 일부 브라우저에서는 아직 동작하지 않는다.

고해상도 화면에서는 캔버스가 흐려 보일 수 있다. 보이는 크기는 CSS 로 잡고, 실제 그리기 버퍼는 devicePixelRatio(CSS 픽셀 대비 물리 픽셀 비율)만큼 키워 따로 잡으면 된다. MDN 예제를 줄이면 이렇다.

js
const scale = window.devicePixelRatio;
canvas.style.width = `${w}px`; // 보이는 크기(CSS 픽셀)
canvas.style.height = `${h}px`;
canvas.width = Math.floor(w * scale); // 실제 그리기 버퍼
canvas.height = Math.floor(h * scale);
ctx.scale(scale, scale); // 이후 좌표는 CSS 픽셀 기준

04 이동 키는 key 가 아니라 code 로 읽는다

KeyboardEvent.code 는 눌린 물리 키를 가리키며, 키보드 배열이나 보조키 상태에 따라 바뀌지 않는다. 그래서 KeyW·KeyA·KeyS·KeyD 로 이동을 짜면 AZERTY 배열 사용자도 같은 자리의 키로 움직인다. 이름 입력처럼 눌린 글자 자체가 필요할 때만 KeyboardEvent.key 를 쓴다. 마우스·펜·터치는 포인터 이벤트(Pointer Events)가 하나의 이벤트 모델로 묶어 주니 기기별 분기가 줄어든다.

05 소리는 첫 클릭 안에서 연다

브라우저는 사용자가 아직 상호작용하지 않은 탭에서 코드로 시작한 소리 재생을 대체로 막는다. 브라우저 오디오 처리 API 인 Web Audio API 도 예외가 아니다. MDN 「Web Audio API best practices」 는 이 정책을 "사용자 제스처 안에서 컨텍스트를 만들거나 재개하라" 로 요약한다.

js
const audioCtx = new AudioContext();

startButton.addEventListener("click", () => {
  if (audioCtx.state === "suspended") audioCtx.resume();
  startGame();
});

클릭 밖에서 만든 AudioContext 는 suspended 상태로 시작하므로, 위처럼 시작 버튼 핸들러에서 resume() 을 부른다. 클릭 핸들러 안에서 처음 만들면 바로 running 이 된다고 MDN 은 적는다. 타이틀 화면의 시작 버튼은 장식이 아니라 오디오를 여는 자리인 셈이다.

06 새 프로젝트는 이 순서로 뼈대를 세운다

엔진을 고르기 전에 빈 HTML 문서 하나에서 아래 순서로 짜 본다. 단계마다 위에서 본 문제 하나씩을 막는다. 이 뼈대가 손에 익으면 엔진 문서를 읽을 때 어느 기능이 무엇을 대신하는지 바로 짚인다.

  1. 경과 시간으로 움직이는 requestAnimationFrame 루프를 만들고, 큰 공백은 잘라 낸다.
  2. 캔버스를 devicePixelRatio 에 맞추고 2D 로 그린다.
  3. 이동은 code 로, 마우스·터치는 포인터 이벤트로 받는다.
  4. 시작 버튼에서 AudioContext 를 재개하고, visibilitychange 로 일시정지를 건다.

3D 가 필요하면 WebGL 이나 WebGPU 로, 갱신 주기를 화면과 떼야 하면 고정 주기 갱신으로 넘어가면 된다. WebGPU 를 고른다면 HTTPS 와 브라우저 지원부터 확인한다. 어느 쪽으로 가든 루프·입력·오디오를 다루는 방식은 그대로 남는다.