HTML5 Canvas 고정 델타 타임(Fixed Timestep) 루프와 Web Audio 절차적 효과음 합성HTML5 Canvas Fixed Timestep Loop and Procedural Web Audio Synthesis
144Hz 이상 고주사율 모니터에서도 물리 연산 속도를 일정하게 유지하는 requestAnimationFrame 누적기 루프와 외부 오디오 파일 없이 Web Audio API로 효과음을 합성하는 기법입니다.Maintaining deterministic physics across 144Hz+ high-refresh-rate monitors with a requestAnimationFrame accumulator loop and synthesizing sound effects via the Web Audio API without external audio files.
requestAnimationFrame은 사용자의 모니터 주사율(60Hz, 120Hz, 144Hz, 240Hz)에 맞춰 호출됩니다. 프레임마다 단순 위치 덧셈으로 물리를 갱신하면 고주사율 모니터에서 게임 속도가 2~4배 빨라집니다. 시간 누적기(Accumulator)를 두어 고정 시간 간격($dt = 1/60\text{s}$)으로만 물리 상태를 전진시키고, 사운드는 Web Audio API의 OscillatorNode와 GainNode 주파수 스윕으로 합성하면 외부 에셋 로드 없이 경량 웹 게임을 구현할 수 있습니다.requestAnimationFrame fires at the user's native display refresh rate (60Hz, 120Hz, 144Hz, 240Hz). Updating physics directly per frame causes the game to run 2x to 4x faster on high-refresh monitors. Using a time Accumulator advances physics strictly in fixed increments ($dt = 1/60\text{s}$), while synthesizing sound effects through Web Audio API OscillatorNode and GainNode frequency sweeps eliminates external audio asset dependencies.1. 고정 타임스텝 메인 루프 및 Web Audio 신디사이저 구현1. Implementing Fixed Timestep Main Loop and Web Audio Synthesizer
performance.now()로 측정한 프레임 간 경과 시간을 accumulator에 누적한 뒤 고정 틱(FIXED_DT) 단위로 물리 루프를 소화하고, 사용자 입력 이벤트 발생 시 오디오 컨텍스트에서 주파수 감쇠 엔벨로프를 스케줄링합니다.
Accumulate elapsed frame time measured via performance.now() and step the physics simulation in fixed FIXED_DT intervals. When user input occurs, schedule frequency and amplitude decay envelopes directly on the Web Audio graph.
// --- [1. 고정 델타 타임(Fixed Timestep) 캔버스 루프] ---
const FIXED_DT = 1 / 60; // 60Hz 고정 물리 스텝 (약 16.67ms)
const MAX_FRAME_TIME = 0.25; // 탭 전환 복귀 시 나선형 사망(Spiral of Death) 방지 상한
let lastTime = performance.now();
let accumulator = 0;
function gameLoop(now) {
let frameTime = (now - lastTime) / 1000;
lastTime = now;
if (frameTime > MAX_FRAME_TIME) frameTime = MAX_FRAME_TIME;
accumulator += frameTime;
while (accumulator >= FIXED_DT) {
updatePhysics(FIXED_DT); // 주사율과 무관하게 항상 동일한 dt로 적분
accumulator -= FIXED_DT;
}
const alpha = accumulator / FIXED_DT; // 렌더링 보간 계수 (0.0 ~ 1.0)
renderCanvas(alpha);
requestAnimationFrame(gameLoop);
}
// --- [2. Web Audio API 절차적 효과음(점프/타격) 합성] ---
let audioCtx = null;
function playJumpSfx() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') audioCtx.resume();
const t0 = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
// 구형파(Square)로 레트로 아케이드 질감 생성 및 주파수 상향 스윕
osc.type = 'square';
osc.frequency.setValueAtTime(180, t0);
osc.frequency.exponentialRampToValueAtTime(620, t0 + 0.14);
// 클릭 노이즈 방지를 위한 지수 감쇠 볼륨 엔벨로프
gain.gain.setValueAtTime(0.18, t0);
gain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.15);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(t0);
osc.stop(t0 + 0.15);
}
// --- [1. Fixed Timestep Canvas Loop] ---
const FIXED_DT = 1 / 60; // 60Hz fixed physics step (~16.67ms)
const MAX_FRAME_TIME = 0.25; // Upper bound to prevent Spiral of Death after tab switching
let lastTime = performance.now();
let accumulator = 0;
function gameLoop(now) {
let frameTime = (now - lastTime) / 1000;
lastTime = now;
if (frameTime > MAX_FRAME_TIME) frameTime = MAX_FRAME_TIME;
accumulator += frameTime;
while (accumulator >= FIXED_DT) {
updatePhysics(FIXED_DT); // Integrate with identical dt regardless of refresh rate
accumulator -= FIXED_DT;
}
const alpha = accumulator / FIXED_DT; // Render interpolation factor (0.0 ~ 1.0)
renderCanvas(alpha);
requestAnimationFrame(gameLoop);
}
// --- [2. Web Audio API Procedural Sound Effect (Jump/Hit) Synthesis] ---
let audioCtx = null;
function playJumpSfx() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') audioCtx.resume();
const t0 = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
// Square wave for retro arcade timbre with upward frequency sweep
osc.type = 'square';
osc.frequency.setValueAtTime(180, t0);
osc.frequency.exponentialRampToValueAtTime(620, t0 + 0.14);
// Exponential decay volume envelope to prevent click artifacts
gain.gain.setValueAtTime(0.18, t0);
gain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.15);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(t0);
osc.stop(t0 + 0.15);
}
2. 핵심 타이밍 및 오디오 파라미터 명세2. Core Timing and Audio Parameter Specification
| 파라미터 / API | 설정값 / 범위 | 역할 및 동작 특성 |
|---|---|---|
FIXED_DT | 1 / 60 (0.01667s) | 모니터 주사율(60Hz~240Hz)에 상관없이 동일한 점프 포물선과 충돌 결과를 보장 |
MAX_FRAME_TIME | 0.25 (250ms) | 브라우저 백그라운드 탭 이동 후 복귀 시 수백 번의 물리 루프가 한꺼번에 도는 현상 차단 |
exponentialRampToValueAtTime | > 0.0001 (양수 필수) | 주파수 피치 스윕 및 볼륨 감쇠 곡선 생성 (0을 전달하면 RangeError 예외 발생) |
devicePixelRatio | window.devicePixelRatio || 1 | 레티나/고해상도 디스플레이에서 캔버스 픽셀이 흐려지지 않도록 내부 버퍼 해상도 배율 조정 |
| Parameter / API | Value / Range | Role and Behavior |
|---|---|---|
FIXED_DT | 1 / 60 (0.01667s) | Guarantees identical jump arcs and collision outcomes across 60Hz to 240Hz displays |
MAX_FRAME_TIME | 0.25 (250ms) | Prevents hundreds of physics steps from executing all at once when returning from a background tab |
exponentialRampToValueAtTime | > 0.0001 (must be positive) | Shapes pitch sweeps and volume decay curves (passing 0 throws a RangeError) |
devicePixelRatio | window.devicePixelRatio || 1 | Scales internal canvas buffer resolution to keep graphics sharp on Retina/HiDPI screens |
3. 실무 적용 및 주의사항3. Practical Considerations and Caveats
- 브라우저 자동 재생 정책(Autoplay Policy) 준수: 크롬과 사파리는 사용자의 명시적인 클릭(
pointerdown)이나 키 입력(keydown) 이전에 생성·재생된AudioContext를suspended상태로 차단하므로, 첫 입력 핸들러 내부에서audioCtx.resume()을 호출해야 합니다. - 지수 감쇠 함수의 0값 전달 금지:
GainNode.gain.exponentialRampToValueAtTime(value, endTime)은 수학적으로 로그 스케일을 사용하므로 목표값에0을 넣으면 오류가 발생합니다. 무음 처리는0.001이하의 작은 양수로 감쇠시킨 뒤osc.stop()으로 노드를 종료해야 합니다. - 백그라운드 탭 복귀 방어:
requestAnimationFrame은 비활성 탭에서 일시 정지되었다가 탭 활성화 시 수 초의now - lastTime을 한 번에 전달하므로MAX_FRAME_TIME클램핑이 필수입니다.
- Comply with Browser Autoplay Policies: Chrome and Safari suspend any
AudioContextcreated before an explicit user gesture (pointerdownorkeydown), so always callaudioCtx.resume()inside the input handler. - Never Pass Zero to Exponential Ramps:
GainNode.gain.exponentialRampToValueAtTime(value, endTime)uses a logarithmic curve and throws an error if the target value is0. Ramp down to0.001instead and terminate the node withosc.stop(). - Clamp Delta Time After Tab Switching:
requestAnimationFramepauses in background tabs and delivers a multi-second delta upon resume, makingMAX_FRAME_TIMEclamping essential.
어큐뮬레이터 기반 고정 델타 타임 루프와 Web Audio 절차적 신디사이저를 결합하면, 네트워크 오디오 에셋 로딩 지연 없이 어떤 주사율의 디바이스에서도 일관된 조작감을 제공하는 웹 게임을 제작할 수 있습니다.
Pairing an accumulator-based fixed timestep loop with procedural Web Audio synthesis delivers consistent gameplay physics and zero-latency sound effects across devices of any refresh rate.