사전 고정 타임스텝
패턴

고정 타임스텝

gabury1고친 사람 github-actions[bot]

고정 타임스텝은 게임 속 세계를 언제나 같은 시간 폭만큼씩만 앞으로 움직입니다. 컴퓨터가 빠르든 느리든 한 번에 흘려보내는 게임 속 시간은 똑같습니다. 그래서 공이 튀는 높이나 물체가 부딪히는 순간이 컴퓨터마다 달라지지 않습니다. 화면을 그리는 박자는 이와 따로 돕니다.

쉽고 빠른 이해

무슨 일을 하나 — 게임 속 시간을 늘 같은 크기로 잘라서 한 조각씩 세계를 움직입니다. 1초를 60조각으로 잘랐다면 세계는 언제나 60분의 1초씩만 움직입니다.

왜 이렇게 하나 — 조각 크기가 컴퓨터마다 다르면 계산 결과도 달라집니다. 느린 컴퓨터에서는 같은 점프가 더 높이 뜨고, 빠른 총알이 벽을 건너뛰어 버립니다.

어떻게 도나 — 게임은 입력 읽기 · 세계 움직이기 · 화면 그리기를 한 바퀴로 삼아 계속 돕니다.

  1. 바퀴마다 흐른 실제 시간을 통에 모읍니다
  2. 통에 한 조각 이상 쌓였으면 세계를 한 조각만큼 움직입니다. 그만큼 통에서 덜어 냅니다. 모자랄 때까지 되풀이합니다
  3. 통에 남은 시간을 보고 직전 모습과 지금 모습의 중간쯤을 화면에 그립니다

언제 쓰나 — 결과가 다음 계산에 쌓이는 물리 · 충돌에 씁니다. 카메라 흔들림처럼 한 번 틀려도 다음에 흔적이 남지 않는 일에는 쓰지 않습니다.

대가 — 반복문 코드가 복잡해집니다. 화면은 늘 한 조각쯤 늦은 모습을 보여 줍니다. 한 조각을 계산하는 데 걸리는 실제 시간이 그 조각보다 길면 게임이 얼어붙을 수 있어서 막는 장치도 따로 둬야 합니다.

상세

이 절은 게임이 시간을 조각내어 계산한다는 데서 출발합니다. 조각 크기가 들쭉날쭉하면 무엇이 깨지는지를 떨어지는 공과 벽을 뚫는 총알로 보입니다.

이어서 조각 크기를 묶어 두는 반복문 코드를 한 바퀴 따라가 봅니다. 끝으로 이 방식이 밀릴 때 생기는 일과 쓰는 곳 · 안 쓰는 곳을 봅니다.

게임은 시간을 조각내어 계산한다

게임 속 세계는 게임 루프가 한 바퀴 돌 때마다 조금씩 움직입니다. 게임 루프는 게임이 켜져 있는 동안 입력 읽기, 세계 갱신, 화면 그리기를 되풀이하는 반복문입니다. 세계 갱신 한 번이 게임 속 시간을 얼마만큼 흘려보낼지는 누군가 정해 줘야 합니다.

갱신 한 번에 흘려보내는 시간의 폭을 타임스텝(time step)이라고 부릅니다. 타임스텝이 60분의 1초면 공은 갱신 한 번에 60분의 1초 동안 갈 거리만큼 옮겨 갑니다. 이어지는 시간을 이렇게 잘게 잘라 한 조각씩 계산하는 것이 시뮬레이션의 기본 방식입니다.

타임스텝을 정하는 방식은 크게 둘입니다. 바퀴마다 달라지게 두는 가변 타임스텝과, 늘 같은 값으로 묶어 두는 고정 타임스텝(fixed timestep)입니다.

가변 타임스텝과 델타 타임

가변 타임스텝은 지난 바퀴에 걸린 실제 시간을 타임스텝으로 씁니다. 이 시간을 델타 타임(delta time)이라고 합니다. 바퀴가 느리면 한 번에 많이 움직입니다. 바퀴가 빠르면 조금씩 움직입니다. 이렇게 해서 1초 동안 가는 거리를 맞춥니다.

가변 타임스텝은 코드가 단순합니다. 약점은 타임스텝이 컴퓨터 성능과 그 순간의 부하에 따라 흔들린다는 것입니다. 흔들리는 타임스텝은 아래 두 문제를 부릅니다.

조각 크기가 계산 결과를 바꾼다

세계 갱신은 대개 속도와 위치를 한 조각씩 계산합니다. 갱신마다 속도에 「중력 × 타임스텝」을 더합니다. 이어서 위치에 「속도 × 타임스텝」을 더합니다. 이렇게 한 걸음씩 더해 가며 움직임을 계산하는 방법을 수치 적분이라고 부릅니다.

수치 적분은 조각이 클수록 틀린 값을 냅니다. 한 조각 안에서는 속도가 변하지 않는다고 치고 계산하기 때문입니다.

멈춰 있던 공을 1초 동안 떨어뜨려 봅니다. 중력 가속도는 계산하기 쉽게 10으로 잡았습니다. 떨어진 거리는 「½ × 중력 가속도 × 시간²」이므로 참값은 ½ × 10 × 1² = 5미터입니다.

타임스텝 1초 동안 갱신 횟수 떨어진 거리
0.5초 2번 7.5미터
0.25초 4번 6.25미터
참값 — 5미터

같은 1초인데 조각 크기에 따라 떨어진 거리가 다릅니다. 가변 타임스텝에서는 느린 컴퓨터일수록 조각이 커지므로, 같은 점프도 컴퓨터마다 높이가 달라집니다.

고정 타임스텝은 조각 크기를 하나로 묶어 이 차이를 없앱니다. 오차가 사라지는 것은 아닙니다. 어느 컴퓨터에서나 같은 오차가 나므로 개발자가 본 움직임을 플레이어도 봅니다.

조각이 크면 충돌을 놓친다

둘째 문제는 충돌 감지에서 납니다. 충돌 감지는 갱신이 끝날 때마다 물체끼리 겹쳤는지 보는 일입니다. 겹친 순간이 한 번도 없으면 부딪히지 않은 것으로 칩니다.

빠른 총알이 한 조각 동안 벽 두께보다 멀리 가면, 벽 앞에 있던 총알이 다음 갱신에서는 벽 뒤에 있습니다. 겹친 순간이 없으니 총알은 벽을 뚫고 지나갑니다. 이렇게 충돌을 건너뛰는 일을 터널링이라고 부릅니다.

가변 타임스텝에서는 한 바퀴가 유난히 느려진 순간에 조각이 커집니다. 터널링은 바로 그때 납니다. 고정 타임스텝에서는 조각 크기가 늘 같습니다. 그래서 그 크기에 맞춰 벽 두께나 물체의 속도 상한을 미리 정해 둘 수 있습니다.

같은 입력이면 같은 계산

조각 크기가 같으면 같은 입력을 넣었을 때 같은 계산을 같은 순서로 거칩니다. 같은 입력에서 늘 같은 결과가 나오는 성질을 결정론적이라고 합니다. 고정 타임스텝은 게임을 결정론적으로 만드는 첫걸음입니다.

이 성질 덕에 입력만 기록해 두었다가 다시 돌려 경기를 재생하는 리플레이가 됩니다. 네트워크 게임에서 입력만 주고받고 계산은 각자 하는 락스텝 방식도 이 성질에 기댑니다.

고정 타임스텝만으로 결정론이 끝나지는 않습니다. 부동소수점 계산은 기계나 컴파일 설정에 따라 끝자리가 조금씩 다르게 나올 수 있습니다. 여러 기계가 같은 결과를 내야 하면 이 차이까지 따로 막아야 합니다.

흐른 시간을 모아 두었다가 나눠 쓴다

게임 루프의 한 바퀴는 조각 크기와 딱 맞게 끝나지 않습니다. 어떤 바퀴는 조각보다 짧고 어떤 바퀴는 조각 두 개보다 깁니다.

그래서 흐른 시간을 바로 쓰지 않고 변수 하나에 모아 둡니다. 이 변수를 누산기(accumulator)라고 부릅니다. 앞의 「쉽고 빠른 이해」에서 통이라 부른 것이 이것입니다.

아래는 고정 타임스텝을 쓰는 게임 루프의 뼈대입니다. DT 가 한 조각의 크기이고 acc 가 누산기입니다.

C
const double DT = 1.0 / 60;  // 한 조각
double acc = 0;              // 누산기
while (running) {
    acc += elapsed();  // 지난 바퀴 시간
    while (acc >= DT) {
        prev = curr;
        curr = update(curr, DT);
        acc -= DT;
    }
    render(prev, curr, acc / DT);
}

바깥 반복문은 한 바퀴에 한 번 화면을 그립니다. 안쪽 반복문은 누산기에 한 조각 이상 쌓여 있는 동안만 세계를 갱신합니다. update 에는 언제나 DT 만 넘어가므로, 세계는 한 조각보다 크게도 작게도 움직이지 않습니다.

prev 와 curr 은 직전 갱신의 세계와 방금 갱신한 세계입니다. render 에 넘기는 acc / DT 는 누산기에 남은 시간이 한 조각의 몇 분의 몇인지입니다. 이 셋을 어디에 쓰는지는 아래 「남은 시간으로 보간한다」에서 나옵니다.

한 바퀴를 따라가 본다

값을 넣어 한 바퀴를 계산해 봅니다. 시간은 밀리초 단위로 셉니다.

계산을 쉽게 하려고 조각을 10으로 잡았습니다. 누산기는 0에서 출발합니다. 지난 바퀴에는 25가 걸렸습니다.

acc += 25     // acc = 25
update(10)    // acc = 15
update(10)    // acc = 5
render(0.5)   // 5 / 10 = 0.5

이 바퀴에서 세계 갱신은 두 번 돌고 5가 남습니다. 남은 5는 버리지 않고 다음 바퀴로 넘어갑니다. 그래서 오래 돌려도 게임 속 시간이 실제 시간에서 밀려나지 않습니다.

바퀴가 조각보다 짧게 끝나면 안쪽 반복문은 한 번도 안 돕니다. 그 바퀴는 세계를 움직이지 않고 그리기만 합니다.

느린 컴퓨터는 한 바퀴에 갱신을 여러 번 몰아 돌립니다. 빠른 컴퓨터는 여러 바퀴에 한 번 갱신합니다. 1초 동안 도는 갱신 수는 두 컴퓨터가 같습니다.

틱과 프레임

고정 간격으로 도는 갱신 한 번을 틱(tick)이라고 부릅니다. 화면에 그린 한 장은 프레임(frame)입니다. 고정 타임스텝에서 1초당 틱 수는 늘 같습니다. 1초당 프레임 수인 프레임률은 컴퓨터마다 다릅니다.

게임 루프를 이렇게 틱과 프레임으로 가르는 것이 고정 타임스텝의 뼈대입니다. 세계의 규칙은 틱이 맡습니다. 매끄러운 움직임은 프레임이 맡습니다.

flowchart TD
    subgraph 틱["틱 · 늘 같은 간격"]
        U["세계 갱신 · 한 조각만큼"]
    end
    subgraph 누산기["누산기"]
        A["흐른 시간을 모은다"]
    end
    subgraph 프레임["프레임 · 컴퓨터가 되는 만큼"]
        R["화면 그리기"]
    end
    A -->|"한 조각 이상 쌓이면"| U
    U -->|"한 조각을 덜어 낸다"| A
    A -->|"남은 몫을 넘긴다"| R

프레임은 넘겨받은 몫으로 직전 틱과 방금 틱 사이 어디쯤을 그릴지 정합니다. 그 방법이 다음 소절입니다.

남은 시간으로 보간한다

누산기에 남은 시간은 한 조각이 채 안 되는 몫입니다. 이 몫은 버리지 않고 그리기에 씁니다.

보간(interpolation)은 알고 있는 두 값 사이의 중간값을 계산해 채우는 일입니다. 여기서 알고 있는 두 값은 직전 틱의 prev 와 방금 틱의 curr 입니다. 그 사이 어디쯤을 그릴지는 남은 몫이 정합니다.

위 예에서 남은 5는 조각 10의 절반, 곧 0.5입니다. 남은 몫을 한 조각에 대한 비율로 나타낸 이 값을 보간 비율이라고 부릅니다. 그리기는 prev 와 curr 사이에서 보간 비율만큼 간 위치에 물체를 그립니다. 비율이 0.5면 두 위치의 딱 중간입니다.

보간을 빼면 물체는 틱이 돌 때만 움직입니다. 프레임이 틱보다 잦으면 같은 그림이 여러 번 나오다가 한 번에 건너뛰어 움직임이 뚝뚝 끊겨 보입니다.

보간에도 값이 듭니다. 화면에 나오는 모습은 언제나 prev 와 curr 사이라서 가장 최근 틱보다 최대 한 조각 늦습니다. 키를 눌러도 그 결과는 화면에 최대 한 조각 늦게 뜹니다. 그만큼 입력 지연이 늘어납니다.

갱신이 조각보다 느리면

고정 타임스텝은 세계 갱신 한 번이 한 조각보다 빨리 끝난다고 믿습니다. 이 믿음이 깨지는 순간이 있습니다.

시간은 앞과 같이 밀리초로 셉니다. 조각은 10입니다. 이번에는 갱신 한 번에 15가 걸린다고 해 봅니다.

갱신 한 번에 실제 시간이 15 흐릅니다. 누산기에서 덜어 내는 것은 10뿐입니다. 그래서 갱신이 한 번 돌 때마다 누산기에 5가 남습니다. 누산기에 10이 든 채로 시작해 바퀴마다 따라가면 이렇습니다.

바퀴 바퀴 첫머리 누산기 갱신 횟수 그 바퀴에 걸린 시간
1 10 1번 15
2 15 1번 15
3 20 2번 30
4 30 3번 45
5 45 4번 60
6 65 6번 90
7 95 9번 135

바퀴 첫머리의 누산기가 10에서 95로 불어납니다. 누산기가 크면 다음 바퀴에 갱신을 더 돌려야 합니다. 그 갱신이 다시 시간을 써서 누산기를 더 키웁니다.

결국 루프는 갱신에 매달립니다. 그리기는 점점 드물어져 화면이 멈춘 것처럼 보입니다. 이 되먹임을 죽음의 나선(spiral of death)이라고 부릅니다.

그래서 한 바퀴에 돌릴 갱신 횟수나 한 번에 더할 흐른 시간에 상한을 둡니다. 상한을 넘은 몫은 버립니다. 게임 속 시간이 잠깐 느리게 흐르는 대신 화면은 계속 움직입니다.

조각 크기를 고르는 기준

조각 크기는 게임을 만들 때 한 번 정합니다. 게임이 돌아가는 동안에는 바꾸지 않습니다.

모니터가 1초에 화면을 새로 긋는 횟수를 주사율이라고 합니다. 조각 크기는 주사율이나 프레임률과 맞출 필요가 없습니다. 틱과 프레임이 갈라져 있기 때문입니다.

크기를 고를 때 걸리는 것은 정확도와 계산량입니다.

조각이 작을 때 조각이 클 때
수치 적분의 오차 작다 크다
터널링 덜 난다 잘 난다
1초에 도는 갱신 수 많다 적다
보간 때문에 늦는 폭 작다 크다

조각이 작으면 움직임이 참값에 가까워집니다. 보간 때문에 늦는 폭도 줄어듭니다.

대신 1초에 계산할 일이 늘어납니다. 그만큼 CPU(Central Processing Unit, 중앙 처리 장치)를 더 씁니다. 느린 컴퓨터가 죽음의 나선에 빠지기도 쉬워집니다.

쓰는 곳과 안 쓰는 곳

고정 타임스텝은 결과가 다음 계산에 쌓이는 일에 씁니다. 물체의 속도 · 위치 · 충돌을 계산하는 물리 엔진이 대표입니다. 많은 게임 엔진이 프레임마다 부르는 갱신 함수와 따로 고정 간격으로 부르는 갱신 함수를 둡니다.

네트워크 게임 서버도 세계를 틱 단위로 돌립니다. 1초에 몇 번 틱을 도는지를 틱레이트라고 부릅니다. 앞에서 본 리플레이와 락스텝도 고정 타임스텝 위에서 돕니다.

결과가 쌓이지 않는 일에는 굳이 쓰지 않습니다. 카메라 흔들림이나 메뉴 화면의 애니메이션은 한 프레임 틀려도 다음 프레임에 흔적이 남지 않습니다. 이런 일은 델타 타임으로 프레임마다 갱신하는 편이 코드가 단순합니다. 움직임도 더 부드럽습니다.

입력이 올 때만 화면이 바뀌는 턴제 게임에는 게임 루프 자체가 필요 없으니 고정 타임스텝도 필요 없습니다.

관련 항목

고정 타임스텝이 속하는 상위 분류

게임 개발 · 게임 루프 · 시뮬레이션 · 실시간 시스템 · 물리 엔진 · 게임 엔진

고정 타임스텝과 맞세워지는 시간 분할 방식

가변 타임스텝 · 델타 타임 · 세미 고정 타임스텝 · 프레임 제한

고정 타임스텝을 이루는 구성 요소

누산기 · 틱 · 물리 스텝 · 보간 · 외삽 · 프레임

고정 타임스텝이 막는 오류

터널링 (문제) · 충돌 감지 · 연속 충돌 감지 · 충돌 · 프레임률 의존 버그

고정 타임스텝이 계산에 쓰는 적분 방법

수치 적분 · 오일러 방법 · 반암시적 오일러 방법 · 베를레 적분 · 룽게-쿠타 방법

고정 타임스텝 위에 얹히는 기법

결정론적 시뮬레이션 · 리플레이 · 락스텝 · 틱레이트 · 클라이언트 측 예측 · 롤백 넷코드 · 부동소수점

고정 타임스텝이 밀릴 때 나는 장애

죽음의 나선 · 프레임 드롭 · 스터터링 · 입력 지연

고정 타임스텝의 박자를 재는 지표

프레임률 · 프레임 시간 · 프레임 예산 · 주사율

고정 타임스텝과 박자를 나누는 그리기 기법

렌더링 · 수직 동기화 · requestAnimationFrame · 더블 버퍼링

다른 이름: fixed timestep · fixed time step · fixed time-step · 고정 시간 간격