건반형 리듬게임(VSRG)에서 몇 밀리초 차이는 체감이 큽니다. 화면 주사율 한 프레임(약 16ms)만 어긋나거나 사운드 버퍼가 조금만 밀려도, 올 퍼펙트를 노리던 플레이어 손끝에는 바로 미세한 불쾌감이 전해집니다.
설치 없이 브라우저에서 바로 열리는 Rhythmio를 만들기 시작했을 때 가장 많이 들었던 질문도 하나였습니다. “웹 브라우저에서 진짜 딜레이 없이 리듬게임을 칠 수 있어?”
결론부터 말하면 HTML5 기본 오디오 태그로는 불가능하고, Web Audio API의 하드웨어 클록을 직접 다뤄야 가능합니다. 실제로 겪었던 문제들과 이를 해결한 구조를 정리해 보았습니다.
기존 HTML5 <audio>가 안 통하는 이유
웹에서 소리를 낼 때 가장 먼저 떠올리는 방법은 <audio> 태그나 HTMLAudioElement.play()입니다. 스트리밍 음악이나 간단한 효과음에는 문제없지만, 리듬게임에서는 쓸 수 없는 치명적인 이유 세 가지가 있습니다.
- 시작 지연을 예측할 수 없음:
audio.play()를 호출하면 브라우저 내부에서 비동기 처리를 거칩니다. OS와 CPU 상황에 따라 소리가 스피커로 나가기까지 30ms에서 150ms까지 들쭉날쭉 밀립니다. - 시간 단위가 너무 큼:
currentTime프로퍼티는 약 15Hz60Hz(16ms66ms 간격)로만 갱신됩니다. 밀리초 단위로 파고들어야 하는 리듬게임 판정에는 오차가 너무 큽니다. - 화면과 소리의 시계가 다름: 자바스크립트 메인 스레드와 브라우저 오디오 렌더러는 서로 다른 시계를 봅니다. 메인 스레드에서 잠깐 프레임 드랍이 생겨도 오디오는 계속 흘러가버려, 판정선과 음악이 순식간에 어긋납니다.
Web Audio API: 하드웨어 클록으로 앞서 스케줄링하기
서브 밀리초 수준의 정밀도를 얻기 위해 Rhythmio는 전적으로 Web Audio API(AudioContext)를 씁니다.
핵심은 AudioContext.currentTime입니다. CPU 메인 스레드 경과 시간을 재는 performance.now()와 달리, 사운드카드(DAC) 하드웨어의 샘플 카운터에서 시간을 직접 가져옵니다.
// Rhythmio 오디오 엔진의 선행 스케줄링(Lookahead) 패턴
const scheduleAheadTime = 0.1; // 100ms 선행 윈도우
let nextNoteIndex = 0;
function scheduler() {
const currentAudioTime = audioContext.currentTime;
while (nextNoteIndex < chart.notes.length) {
const note = chart.notes[nextNoteIndex];
const noteTargetTime = songStartTime + (note.time / 1000);
// 목표 시점이 100ms 이내로 다가온 경우 오디오 스레드에 사전 예약
if (noteTargetTime < currentAudioTime + scheduleAheadTime) {
scheduleHitSound(note.soundId, noteTargetTime);
nextNoteIndex++;
} else {
break;
}
}
}
매 프레임마다 소리를 낼지 검사하는 대신, 하드웨어 오디오 클록에 100ms 앞서 예약(Lookahead Scheduling)을 걸어둡니다. 덕분에 자바스크립트 메인 스레드에 잠깐 부하가 걸리더라도 소리가 끊기거나 밀리지 않습니다.
사전 디코딩과 메모리 할당 제로화
4K~7K 고난도 패턴에서는 1초에 30타 넘는 동시 입력이 쏟아집니다. 키를 누를 때마다 새 오디오 노드나 객체를 만들면, 머지않아 V8 엔진의 가비지 컬렉션(GC Pause)이 돌면서 게임 화면이 뚝 끊기게 됩니다.
키 입력 즉시 반응하면서도 끊김을 없애기 위해 세 가지 원칙을 지켰습니다.
- PCM 오디오 사전 디코딩: 곡을 불러오는 단계에서 모든 키음과 효과음 샘플을 어레이 버퍼로 받아
audioContext.decodeAudioData()로 미리 풀어둡니다. - 오디오 노드 재사용(풀링): 이미 만들어둔 소스 노드를 링 버퍼 형태로 돌려 씁니다. 인게임 플레이 중에는 새로운 메모리 할당이 전혀 일어나지 않습니다.
- 오디오 워클릿(Audio Worklet): 브라우저가 지원하는 경우 실시간 오디오 스레드에서 직접 신호를 처리해, 메인 스레드가 아무리 바빠도 무지연으로 타격음을 울립니다.
모니터 주사율과 사운드 싱크 맞추기
하드웨어 오디오 클록을 써도 모니터 주사율(60Hz, 144Hz, 240Hz, 360Hz)과 사운드카드 샘플링 레이트(44.1kHz, 48kHz) 사이에는 미세한 시계 차이가 생길 수 있습니다. 3분이 넘는 곡을 플레이하다 보면 눈과 귀의 싱크가 서서히 어긋나게 됩니다.
Rhythmio는 오디오 하드웨어 클록을 기준 시계로 삼아 이 문제를 풀었습니다.
- 매 프레임(
requestAnimationFrame)마다 화면 속 노트의 위치는 다음 공식으로만 계산합니다: $$\text{노트 위치} = (\text{audioContext.currentTime} - \text{곡 시작 시점}) \times \text{스크롤 배속}$$ - 블루투스 이어폰을 연결하거나 외장 DAC로 장치가 바뀌어 오디오 클록에 갑작스러운 건너뜀이 생기면, 엔진이 3프레임에 걸쳐 부드럽게 위치를 보간해 노트가 갑자기 튀지 않게 처리합니다.
정리하며
Web Audio API의 하드웨어 클록, 선행 스케줄링, 그리고 사전 디코딩 버퍼 풀링을 조합하면서 웹 브라우저에서도 스팀 설치형 리듬게임 못지않은 쫀득한 손맛을 낼 수 있었습니다.
다음 글에서는 Rhythmio의 ±16ms 밀리초 판정 기준과 정확도 계산 공식을 다룹니다.
