고정 시간 간격 루프와 타격감
"2배속" 버튼을 만드는 방법은 두 가지다. 하나는 맞고 하나는 틀렸다. 틀린 쪽은 처음엔 완벽하게 작동하는 것처럼 보이기 때문에 더 위험하다.
1. 왜 고정 간격인가
가장 단순한 게임 루프는 프레임 시간을 그대로 시뮬레이션에 넘긴다.
function frame(dt) {
world.tick(dt); // dt = 실제 경과 시간
render();
}
이건 두 가지 이유로 무너진다. 첫째, 결정성이 없다. 같은 입력을 줘도 프레임 타이밍이 다르면 결과가 달라진다. 시드 기반 재현이나 리플레이, 세이브 후 이어하기가 전부 불가능해진다.
둘째, 큰 dt에서 물리가 터진다.
탭을 잠깐 배경으로 돌렸다 돌아오면 dt가 2초가 되고,
유닛은 벽을 관통하고 발사체는 표적을 지나쳐 날아간다.
충돌 판정은 "이번 스텝에 겹쳤는가"를 보는데, 한 스텝에 60m를 이동하면
그 사이의 벽은 겹치는 순간이 없다.
그래서 시뮬레이션은 고정 간격으로 돌린다. 이 프로젝트는 20Hz다.
const TICK = 1 / 20;
let acc = 0;
function frame(dt) {
acc += dt;
while (acc >= TICK) {
world.tick(TICK); // 항상 같은 간격
acc -= TICK;
}
render(acc / TICK); // 남은 시간은 보간에 쓴다
}
렌더링은 프레임레이트 그대로, 시뮬레이션은 20Hz 고정.
화면이 144fps든 30fps든 게임 로직은 같은 결과를 낸다.
남은 누산값(acc)은 렌더 보간에 쓰면 20Hz 시뮬레이션이 부드럽게 보인다.
2. 속도 배율: 스텝 크기가 아니라 스텝 수
여기가 함정이다. "2배속"을 만들라고 하면 이렇게 쓰고 싶어진다.
world.tick(TICK * speed); // ❌
1배속에서는 완벽하게 작동한다. 2배속에서도 대충 작동한다. 3배속에서 유닛이 벽을 통과하기 시작한다. 왜냐하면 이건 고정 간격 루프를 만든 이유를 정확히 되돌리는 것이기 때문이다. 스텝 크기가 커지면 1절에서 말한 터널링이 그대로 돌아온다.
맞는 방법은 스텝을 더 많이 돌리는 것이다.
acc += dt * timeScale.value; // ✅ 시간이 빨리 흐른다
while (acc >= TICK && steps < maxSteps) {
world.tick(TICK); // 간격은 그대로
acc -= TICK;
steps++;
}
3배속은 프레임당 스텝을 3배 돌리는 것이다. 각 스텝의 물리는 1배속과 완전히 동일하고, 따라서 결정성도 충돌 정확도도 유지된다. 대가는 CPU 비용이 정직하게 3배가 되는 것이지만, 그건 정당한 대가다.
3. 스파이럴 오브 데스
위 코드에 steps < maxSteps가 있는 이유가 있다.
이 상한이 없으면 고전적인 실패 모드에 빠진다.
시뮬레이션이 무거워져서 한 스텝에 프레임 예산보다 오래 걸리기 시작하면, 누산기는 프레임마다 조금씩 더 쌓인다. 다음 프레임은 스텝을 더 많이 돌려야 하고, 그래서 더 느려지고, 누산기는 더 쌓인다. 한 번 뒤처지면 영원히 못 따라잡는다. 화면은 완전히 멈춘 것처럼 보이지만 CPU는 100%로 돌고 있다.
해결은 따라잡기를 포기하는 것이다. 프레임당 스텝 수에 상한을 두고, 넘치는 누산값은 버린다. 게임 시간이 실제 시간보다 느려지지만, 멈추는 것보다는 느려지는 게 낫다.
get maxSteps() { return Math.ceil(6 * this.value); }
상한이 속도 배율에 비례한다는 점이 중요하다. 3배속에서 상한이 1배속과 같으면, 3배속은 상한에 걸려서 실제로는 3배가 되지 않는다. 배율을 올렸는데 빨라지지 않는 버그가 되고, 원인이 상한이라는 걸 알아채기 어렵다.
4. 히트스톱: 프레임을 멈추지 말고 시뮬레이션을 멈춰라
타격감을 만드는 가장 저렴한 기법 중 하나가 히트스톱이다. 큰 타격이 들어간 순간 게임을 아주 짧게 정지시키면 그 타격이 무겁게 느껴진다. 격투 게임에서 오래 쓰여온 기법이다.
구현에서 흔한 오해는 렌더링까지 멈추는 것이다. 그러면 프레임이 진짜로 드롭되고, 플레이어는 타격감이 아니라 성능 문제로 인식한다. 멈춰야 하는 것은 시뮬레이션뿐이다 — 카메라도, 파티클도, UI도 계속 돌아야 한다.
while (acc >= TICK && steps < maxSteps && !hitStop.frozen) {
world.tick(TICK);
...
}
// render()는 조건과 무관하게 항상 호출된다
그런데 이렇게만 하면 새 버그가 생긴다.
정지 중에도 acc += dt는 계속 실행되므로 누산기가 쌓인다.
0.16초 정지 후 해제되는 순간, 쌓인 3~4스텝이 한꺼번에 처리된다.
화면상으로는 모든 유닛이 순간이동한다.
타격감을 살리려고 넣은 기능이 반대로 화면을 망가뜨린다.
해결은 해빙 시점에 누산기를 클램프하는 것이다. 정지 동안 흐른 시간은 게임 시간에서 없었던 것으로 처리한다. "정지"의 의미가 그것이므로 이게 개념적으로도 맞다.
5. 히트스톱 요청은 합이 아니라 최댓값
전장에 유닛이 300기 있으면 한 틱에 큰 타격이 여러 건 발생한다. 각 타격이 정지를 요청하면 어떻게 처리해야 하나?
요청 시간을 더하면 대규모 전투에서 게임이 반쯤 멈춘다. 0.1초 요청이 8건이면 0.8초 정지다. 그건 타격감이 아니라 프리즈다.
그래서 최댓값을 취한다. 그리고 두 개의 상수를 뒀다.
const MAX_HOLD = 0.16; // 이보다 길면 스터터로 읽힌다
const MIN_GAP = 0.22; // 정지를 연달아 붙이지 않는다
request(seconds) {
if (this.cooldown > 0) return;
this.remaining = Math.max(this.remaining, Math.min(MAX_HOLD, seconds));
this.cooldown = MIN_GAP;
}
MIN_GAP이 MAX_HOLD보다 큰 것이 의도적이다.
정지가 끝나고 최소 0.06초는 반드시 정상 속도로 흐른다.
이게 없으면 격전 중에 정지가 사슬처럼 이어져서 슬로모션이 된다.
6. 무엇을 기준으로 정지할지 잘못 골랐던 이야기
처음 구현에서는 피격 대상의 크기로 정지 여부를 판단했다. 거인이나 공성 유닛이 맞으면 정지, 잡몹이 맞으면 통과. 합리적으로 들린다.
실제로 재보니 한 판에서 정지가 0번 일어났다. 큰 유닛은 체력이 많아서 한 방에 큰 비율의 피해를 받는 일이 없고, 정지 조건에 걸릴 만한 타격은 애초에 발생하지 않았다. "합리적으로 보이는 조건"이 실제로는 절대 만족되지 않는 조건이었다.
기준을 타격의 심각도로 바꿨다 — 최대 체력 대비 피해 비율이 30% 이상이거나, 큰 유닛이면서 10% 이상일 때. 같은 판에서 정지가 6번 일어났다.
교훈: 연출 기능은 "발동했는가"를 세어봐야 한다. 코드가 문법적으로 맞고 리뷰를 통과해도, 조건이 실전에서 만족되지 않으면 그 기능은 존재하지 않는 것과 같다. 그리고 그 사실은 코드를 읽어서는 보이지 않는다.
7. 정리
- 시뮬레이션은 고정 간격, 렌더는 프레임레이트. 결정성과 충돌 정확도가 여기서 나온다.
- 속도 배율은 스텝 수로. 스텝 크기로 구현하면 고정 간격의 이유를 되돌린다.
- 프레임당 스텝에 상한을, 그리고 그 상한은 배율에 비례하게.
- 히트스톱은 시뮬레이션만 멈춘다. 그리고 해빙 시 누산기를 클램프하지 않으면 순간이동한다.
- 정지 요청은 최댓값으로 합산하고, 최소 간격을 강제한다.
- 연출은 발동 횟수를 세라. 0번 발동하는 기능은 흔하다.