배속 버튼, 그냥 2를 곱하면 되잖아? (게임이 조용히 고장 나는 이유)

제가 만들던 Flutter 게임에 배속 버튼을 넣고 싶었습니다. 타워를 깔아두고 웨이브가 지나가는 걸 지켜보는 타워디펜스인데, 초반의 널널한 구간을 매번 실시간으로 보고 있자니 좀 답답하더라고요. x2, x4로 슥 감아버리고 싶었습니다.

머릿속에 떠오른 방법은 아주 단순했습니다.

"게임 시간을 그냥 2배로 곱하면 되잖아?"

한 프레임에 흐른 시간이 dt라면, x2일 때는 dt * 2를, x4일 때는 dt * 4를 시뮬레이션에 먹이면 되는 거 아닌가. 버튼 누르면 숫자에 곱셈 한 번. 세상에서 제일 쉬운 기능처럼 보였습니다.

그런데 이게, 게임을 조용히 망가뜨립니다. "조용히"가 핵심이에요. 에러도 안 나고, 크래시도 안 나고, 그냥 x4에서만 게임이 살짝 이상해집니다. (제일 잡기 싫은 종류의 버그죠.)

왜 곱하기가 틀리는가

이유를 알려면 시뮬레이션이 한 틱(tick)마다 실제로 뭘 하는지 봐야 합니다. 제 게임의 시뮬레이션은 넘겨받은 dt를 이렇게 씁니다.

void tick(double rawDt) {
  final dt = rawDt * timeScale;
  _tickSpawning(dt);
  _tickEnemies(dt);
  _tickTowers(dt);
  // ...
}

여기서 두 갈래가 갈립니다. 연속적인 것불연속적인 것.

적의 이동은 연속적입니다.

// _tickEnemies 안
enemy.dist += enemy.effectiveSpeed * dt;

경과 시간에 속도를 곱해서 위치에 더한다. 아주 정직한 적분이죠. dt가 2배가 되면 적도 2배 멀리 갑니다. 여기까지는 곱하기가 멀쩡하게 동작합니다.

문제는 타워입니다. 타워의 발사는 불연속적이에요.

// _tickTowers 안
tower.cooldown -= dt;
if (tower.cooldown > 0) continue;   // 아직 쿨타임이면 이번 틱은 안 쏨
// ... 타겟을 찾고
_fire(tower, target);
tower.cooldown = tower.fireInterval; // 쏘고 나면 쿨타임 리셋

보이시나요? 타워는 한 틱에 최대 한 발만 쏩니다. 쿨타임을 dt만큼 깎고, 0 이하로 내려갔으면 한 발 쏘고, 다시 쿨타임을 꽉 채웁니다.

이제 dt를 4배로 뻥튀기하면 무슨 일이 벌어질까요. 발사 간격이 0.02초인 빠른 타워가 있다고 칩시다. 원래대로면 초당 50발을 쏘는 녀석이죠. 한 프레임이 대충 1/60초(≈0.017초)인데, x4로 곱하면 이 한 틱에 게임 시간 0.067초가 흐른 걸로 칩니다. 그 0.067초 동안 이 타워는 3발을 쐈어야 합니다(0.067 ÷ 0.02 ≈ 3). 그런데 코드를 보면 여전히 한 발만 쏩니다. 쿨타임을 0.067초어치 몰아서 깎았어도, if문을 통과하는 순간 딱 한 번 쏘고 쿨타임을 다시 꽉 채워버리니까요. 나머지 두 발은 그냥 증발합니다.

배속을 올릴수록 이 손실이 커집니다. 스텝이 클수록 한 틱에 뭉개고 넘어가는 발사가 많아지거든요. 적은 dist += speed * dt 덕분에 4배 빨라져서 성큼성큼 다가오는데, 타워의 실질 화력은 오히려 뚝 떨어지는 겁니다. x1에서는 잘 막던 웨이브가 x4에서는 줄줄 새기 시작합니다.

이게 영상 배속과 시뮬레이션 배속의 차이예요. 유튜브 2배속은 이미 찍혀 있는 프레임을 두 배로 빨리 넘기는 겁니다. 내용은 그대로고 재생만 빨라지죠. 그런데 게임 시뮬레이션은 "이미 찍혀 있는" 게 아닙니다. 매 틱마다 그 자리에서 계산해서 만들어내는 거예요. 큰 dt로 한 번에 계산하면, 그 사이에 일어났어야 할 사건들(타워 발사, 충돌 판정)을 건너뛴 채로 결과만 툭 나옵니다. 재생 속도를 바꾼 게 아니라, 계산의 해상도를 떨어뜨린 거죠.

해결: 시간을 잘게 쪼개서 여러 번 돈다

정석은 곱하지 말고 쪼개는 겁니다. dt를 4배로 키우는 게 아니라, 원래 크기의 조각으로 나눠서 그 조각을 여러 번 돌립니다. 고정 타임스텝 서브스테핑(fixed-timestep sub-stepping)이라고 부르는 방법이에요.

제 게임에서 그 조각을 계산하는 게 이 함수입니다. (실제 코드 그대로예요.)

/// Splits a frame's real elapsed time into equal sub-steps whose game-time
/// (step × timeScale) never exceeds 1/60 s. Higher speeds therefore run
/// *more* sub-steps rather than coarser ones, so tower fire rates and enemy
/// motion stay accurate at 2×–4× instead of dropping shots at big steps.
/// Long gaps (backgrounding) are discarded, not fast-forwarded.
@visibleForTesting
static ({int count, double step}) framePlan(double realDt, double timeScale) {
  final capped = realDt > 0.25 ? 0.25 : realDt;
  if (capped <= 0) return (count: 0, step: 0);
  final maxStep = 1 / (60 * timeScale);
  final count = (capped / maxStep).ceil();
  return (count: count, step: capped / count);
}

핵심은 딱 한 줄, maxStep = 1 / (60 * timeScale)입니다. 한 서브스텝이 감당할 실제 시간의 상한을 정하는 거예요. 그리고 아까 tick 안에서 rawDt * timeScale을 했던 걸 떠올려 보면, 이 상한은 곧 게임 시간 기준 1/60초라는 불변식이 됩니다. step × timeScale이 절대 1/60초를 넘지 않도록요.

즉 배속이 올라가도 한 스텝이 커지지 않습니다. 대신 스텝의 개수가 늘어납니다. count = ceil(capped / maxStep)이 그 개수고요.

호출부는 이렇게 생겼습니다.

void _onTick(Duration elapsed) {
  final realDt = (elapsed - _lastElapsed).inMicroseconds / 1e6;
  _lastElapsed = elapsed;
  final plan = GamePage.framePlan(realDt, _sim.timeScale);
  for (var i = 0; i < plan.count; i++) {
    _sim.tick(plan.step);
  }
  _frame.value++;
}

plan.count번 만큼 _sim.tick을 돌립니다. 60Hz 화면에서 한 프레임이 대략 1/60초라고 치면, x1일 땐 서브스텝 1개, x2면 2개, x4면 4개를 이 한 프레임 안에서 연달아 돌리는 겁니다. 매 서브스텝의 게임 시간은 여전히 1/60초 이하. 그래서 타워는 x4에서도 쏴야 할 만큼 쏘고, 적은 성큼이 아니라 잘게 나눠서 다가옵니다.

여기서 짚고 갈 포인트 하나. 렌더링은 프레임당 한 번입니다. 위 코드에서 _frame.value++는 루프 밖에 있죠. 시뮬레이션을 네 번 돌려도 화면은 마지막 상태를 한 번만 그립니다. 시뮬레이션 스텝 수와 렌더 프레임 수는 별개예요. 배속은 "계산을 몇 번 하느냐"를 바꾸는 거지, "몇 번 그리느냐"를 바꾸는 게 아닙니다. (이걸 헷갈리면 배속 올렸는데 화면이 뚝뚝 끊기는 이상한 결과가 나옵니다.)

cap은 왜 있나 — 그리고 방어 코드 한 줄

함수 맨 윗줄, capped = realDt > 0.25 ? 0.25 : realDt도 그냥 있는 게 아닙니다. 앱을 백그라운드로 내렸다가 한참 뒤에 다시 열면, realDt가 몇 초씩 튈 수 있어요. 그 큰 값을 그대로 쪼개면 어떻게 될까요.

x4에서 realDt가 0.25초라고 해봅시다. maxStep = 1/240이니까 count = ceil(0.25 * 240) = 60. 한 프레임에 서브스텝 60개. cap이 없어서 0.25초가 아니라 5초짜리 갭이 들어왔다면 서브스텝이 1200개가 되고, 그 프레임 하나가 통째로 얼어붙습니다. 그래서 갭은 따라잡는(fast-forward) 게 아니라 그냥 버립니다. 주석에도 "Long gaps are discarded, not fast-forwarded"라고 적어뒀네요. (백그라운드 갔다 온 유저의 게임을 5초어치 순간이동시키는 것보다, 그냥 멈춘 자리에서 이어가는 게 훨씬 덜 억울하잖아요.)

그리고 바로 아랫줄.

if (capped <= 0) return (count: 0, step: 0);

경과 시간이 0 이하면 아예 스텝 0개를 돌려보냅니다. 이게 없으면 count = ceil(0 / maxStep) = 0이 되고, 마지막 capped / count에서 0으로 나누기가 터집니다. 걸음 수 세는 라이브러리 만들 때도 똑같은 걸 겪었는데, 0이 될 수 있는 분모 앞에는 이런 가드가 꼭 필요해요. "거의 안 일어난다"와 "절대 안 일어난다"는 프로덕션에서 하늘과 땅 차이거든요.

참고로 framePlan@visibleForTesting이 붙어 있는 게 보이시죠? 이런 순수 함수는 게임을 띄우지 않고도 테스트하기 딱 좋습니다. framePlan(1/60, 4)를 넣으면 개수가 몇이 나오나, cap이 제대로 걸리나, count가 0일 때 0으로 안 나눠지나. 위젯 트리 전체를 렌더링할 필요 없이 이 함수만 콕 집어서 검증할 수 있어요.

정리 — 배속은 "더 크게"가 아니라 "더 자주"

배속을 넣고 싶었을 뿐인데, 결국 배운 건 고정 타임스텝의 오래된 교훈이었습니다.

시뮬레이션을 빠르게 돌리고 싶으면, 스텝을 키우지 말고 스텝을 더 많이 돌려라.

dt로 한 번 적분한 결과는 작은 dt로 여러 번 적분한 결과와 다릅니다. 연속적인 값(적의 이동)은 그럭저럭 버텨도, 불연속적인 판정(타워 발사, 충돌)은 큰 스텝에서 조용히 사건을 빠뜨려요. 그게 x4에서만 게임이 이상해지던 이유였습니다.

그러니 혹시 게임이나 물리 시뮬레이션에 배속을 넣으신다면, 곱셈 한 줄로 끝내기 전에 한 번 의심해 보세요. "이 스텝 안에서 일어났어야 할 사건을, 내가 통째로 건너뛰고 있진 않나?" 십중팔구 그 답이 서브스테핑입니다.

그리고 위 숫자들 — 1/60초, cap 0.25초 — 은 제 게임의 선택이지 우주의 진리가 아닙니다. 60Hz를 기준으로 잡은 값이라, 120Hz 기기에서는 count가 또 다르게 나와요. 혹시 제 설명 중에 틀린 부분을 발견하셨다면, 주저 말고 지적해주세요. 저도 배속 버튼 하나 넣다가 여기까지 파고 들어간 거라, 더 나은 방법이 있다면 진심으로 궁금합니다.