델타 타임
고친 사람 github-actions[bot]
델타 타임은 게임이 화면을 한 번 그린 뒤 다음번을 그리기까지 흐른 시간을 알려 줍니다. 게임은 물체의 속도에 이 시간을 곱해서 이번에 옮길 거리를 정합니다. 덕분에 성능이 높은 컴퓨터에서도 낮은 컴퓨터에서도 물체가 1초에 같은 거리를 움직입니다.
쉽고 빠른 이해
델타 타임은 게임이 그림 한 장을 그린 뒤 다음 장까지 흐른 시간입니다. 1초에 60장을 그리는 게임이면 약 0.017초입니다.
이 값이 없으면 게임의 빠르기가 컴퓨터 성능에 묶입니다. 한 장마다 5픽셀씩 옮기면 1초에 60장 그리는 컴퓨터와 30장 그리는 컴퓨터에서 캐릭터가 가는 거리가 두 배 차이 납니다.
어떻게 도는가:
- 한 장을 시작할 때 시계를 읽고 지난번에 읽은 시각을 뺍니다
- 속도를 「1초에 몇 픽셀」로 정해 두고 뺀 값을 곱합니다
- 곱한 만큼 물체를 옮긴 뒤 화면을 그립니다
대가는 들쭉날쭉함입니다. 한 장이 오래 걸리면 물체가 한 번에 멀리 건너뛰어 벽을 뚫고 지나갈 수 있습니다. 같은 입력을 넣어도 컴퓨터마다 결과가 조금씩 달라집니다. 그래서 정확해야 하는 게임은 흐른 시간을 쌓아 두고 정해진 간격으로만 계산합니다.
상세
두 사람이 같은 길을 같은 빠르기로 걷습니다. 한 사람은 10초마다, 다른 사람은 30초마다 지도에 지금 선 곳을 점으로 찍습니다. 찍은 점의 수는 세 배 차이 나지만 한 시간 뒤 두 사람이 선 곳은 같습니다. 점을 찍을 때마다 몇 초가 지났는지 알면 점 사이를 얼마나 걸었는지 알 수 있습니다.
게임은 같은 일을 끝없이 되풀이하는 반복문 위에서 돕니다. 한 번 돌 때마다 입력을 읽습니다. 그다음 게임 속 상태를 새로 계산합니다. 끝으로 화면을 그립니다. 이 반복문을 게임 루프(game loop)라고 부릅니다.
루프를 한 번 돌 때마다 화면에는 새 그림이 한 장 나옵니다. 이 한 번을 프레임이라고 부릅니다. 1초에 프레임을 몇 번 도는지를 프레임률(frame rate)이라고 합니다. 단위는 FPS(frames per second, 초당 프레임 수)입니다.
델타 타임(delta time)은 지난 프레임이 시작한 때부터 이번 프레임이 시작한 때까지 흐른 시간입니다. 대개 초 단위로 적고 코드에서는 줄여서 dt 라고 씁니다. 60 FPS 로 도는 게임이면 델타 타임은 60분의 1초, 약 0.017초입니다. 30 FPS 면 약 0.033초입니다.
델타는 그리스 문자 Δ 의 이름입니다. 수학과 물리에서 Δ 는 두 값의 차이를 뜻합니다. 두 시각의 차이라서 델타 타임이라는 이름이 붙었습니다.
델타 타임은 프레임마다 다릅니다. 그릴 것이 많은 프레임은 오래 걸리고 적은 프레임은 빨리 끝납니다. 아래 그림은 프레임 넷이 시작한 시각과 각 프레임이 받는 델타 타임입니다.
flowchart TD
A["1번 프레임 시작 · 0.000초"] -->|"2번이 받는 dt = 0.017초"| B["2번 프레임 시작 · 0.017초"]
B -->|"3번이 받는 dt = 0.033초"| C["3번 프레임 시작 · 0.050초"]
C -->|"4번이 받는 dt = 0.016초"| D["4번 프레임 시작 · 0.066초"]
2번 프레임이 다른 프레임의 두 배쯤 걸렸습니다. 그래서 3번 프레임이 받는 델타 타임이 0.033초로 커집니다. 델타 타임은 이렇게 지난 프레임이 걸린 시간을 한 프레임 늦게 알려 줍니다.
프레임마다 같은 양을 옮기는 방식의 문제
델타 타임을 안 쓰는 방식부터 봅니다. 캐릭터가 한 프레임에 5픽셀씩 오른쪽으로 간다고 정해 두는 방식입니다.
이 게임을 60 FPS 로 도는 컴퓨터에서 돌리면 캐릭터는 1초에 300픽셀을 갑니다. 30 FPS 로 도는 컴퓨터에서는 150픽셀만 갑니다. 같은 게임이 성능이 높은 컴퓨터에서 두 배 빨리 돕니다.
한 컴퓨터 안에서도 문제가 됩니다. 폭발 장면처럼 그릴 것이 많아지면 프레임률이 떨어집니다. 그 순간 캐릭터도 함께 느려집니다. 화면이 무거워질 때마다 게임 속 시간이 느리게 흐릅니다.
초당 속도와 델타 타임의 곱
델타 타임을 쓰면 속도를 「한 프레임에 몇 픽셀」이 아니라 「1초에 몇 픽셀」로 정합니다. 그리고 프레임마다 속도에 델타 타임을 곱해 이번 프레임에 옮길 거리를 계산합니다. 거리는 속도 곱하기 시간이라는 계산을 프레임마다 합니다.
아래 코드는 초속 300픽셀짜리 캐릭터를 두 컴퓨터에서 한 프레임 옮깁니다. 오른쪽 주석이 그 프레임에 옮긴 거리입니다.
// 60 FPS 컴퓨터: dt = 1/60초
x += 300 * (1 / 60); // 5픽셀 전진
// 30 FPS 컴퓨터: dt = 1/30초
x += 300 * (1 / 30); // 10픽셀 전진
30 FPS 컴퓨터는 한 프레임에 두 배를 옮깁니다. 대신 1초에 도는 프레임 수가 절반입니다. 그래서 1초 동안 가는 거리는 60 × 5 와 30 × 10 으로 둘 다 300픽셀입니다.
속도만 이렇게 계산하지 않습니다. 1초에 몇 도 도는 회전, 1초에 얼마씩 줄어드는 타이머, 1초에 얼마씩 붙는 가속도도 델타 타임을 곱합니다. 「1초에 얼마」로 정해 둔 값이면 무엇이든 같은 방식을 씁니다.
델타 타임을 재는 방법
델타 타임은 프레임이 시작할 때마다 시계를 읽어 구합니다. 이번에 읽은 시각에서 지난번에 읽은 시각을 빼면 됩니다. 뺀 뒤에는 이번 시각을 다음 프레임을 위해 남겨 둡니다.
아래는 한 프레임의 뼈대입니다. 게임을 켠 지 10초쯤 된 프레임이고 지난 프레임에 읽은 시각은 10.033초였습니다. 오른쪽 주석이 이 프레임에서 나온 값입니다.
now = clock(); // 10.050
dt = now - prev; // 0.017
prev = now; // 10.050
update(dt);
render();
update 는 게임 속 상태를 새로 계산하는 함수입니다. render 는 그 상태를 화면에 그리는 함수입니다. 물체를 옮기는 곱셈은 update 안에서 일어나므로 델타 타임을 update 에 넘깁니다.
시계는 단조 시계(monotonic clock)를 씁니다. 단조 시계는 뒤로 가지 않고 한 방향으로만 늘어나는 시계입니다. 어느 시점부터 흐른 시간을 셀 뿐 달력 날짜와는 상관이 없습니다.
달력 시각을 알려 주는 벽시계(wall clock)는 델타 타임을 재는 데 안 맞습니다. 벽시계는 NTP(Network Time Protocol, 네트워크 시간 프로토콜)로 시각을 맞추거나 사용자가 시각을 고치면 앞뒤로 건너뜁니다. 그 순간 델타 타임이 음수가 되거나 몇 분으로 부풉니다. 백엔드에서 경과 시간을 잴 때 단조 시계를 쓰는 것과 같은 까닭입니다.
델타 타임이 튀는 때
델타 타임은 대개 몇십 밀리초 안에서 움직입니다. 그런데 한 프레임이 몇 초씩 걸리는 때가 있습니다. 디버거 중단점에서 멈췄다가 다시 풀 때나 다음 스테이지의 맵 파일을 디스크에서 읽느라 루프가 멈췄을 때가 그렇습니다.
그 뒤 첫 프레임은 델타 타임이 몇 초로 나옵니다. 속도에 이 값을 곱하면 캐릭터가 한 번에 화면 밖까지 건너뜁니다. 멈춰 있던 동안의 움직임이 한 프레임에 몰려서 나타납니다.
그래서 델타 타임에 상한을 두는 경우가 많습니다. 아래 한 줄은 0.1초보다 큰 델타 타임을 0.1초로 깎습니다. 오른쪽 주석은 디버거에서 2.5초 멈췄다 풀린 프레임의 값입니다.
dt = min(now - prev, 0.1); // 2.5 → 0.1
깎인 몫만큼 게임 속 시간은 잠깐 느리게 흐릅니다. 대신 물체가 순간이동하지는 않습니다.
델타 타임이 클수록 커지는 오차
델타 타임이 크면 속도가 큰 물체가 한 번에 멀리 건너뜁니다. 이번 프레임에 벽 앞에 있던 총알이 다음 프레임에 벽 뒤로 옮겨지면 벽과 겹친 프레임이 하나도 없습니다. 그러면 부딪히지 않은 것으로 판정됩니다. 이 현상을 터널링(tunneling)이라고 부릅니다.
block-beta columns 3 a["이번 프레임의 총알"] b["벽"] c["다음 프레임의 총알"]
속도가 계속 바뀌는 움직임에서는 다른 오차도 생깁니다. 중력을 받아 떨어지는 공이나 점프하는 캐릭터가 그렇습니다. 곱하기로 옮기는 계산은 한 프레임 동안 속도가 안 변한다고 보기 때문입니다.
점프를 예로 봅니다. 캐릭터가 위로 초속 10미터로 뛰어오른다고 합시다. 중력은 속도를 1초에 10미터씩 깎습니다. 프레임마다 높이에 「속도 × dt」를 더한 다음 속도에서 「10 × dt」를 뺍니다. 계산을 쉽게 하려고 dt 를 크게 잡았습니다.
아래 표는 dt 만 바꿔 같은 점프를 계산한 결과입니다.
| dt | 가장 높이 올라간 높이 |
|---|---|
| 1초 | 10미터 |
| 0.5초 | 7.5미터 |
| 0.25초 | 6.25미터 |
같은 점프인데 dt 가 작을수록 낮게 뜹니다. dt 를 끝없이 줄이면 5미터에 다가갑니다. 이 5미터가 물리 공식으로 구한 참값입니다.
이처럼 짧은 간격 동안 속도가 안 변한다고 보고 한 스텝씩 계산하는 방법을 오일러 방법(Euler method)이라고 부릅니다. 스텝 크기(step size)가 클수록 참값에서 멀어집니다. 게임에서는 델타 타임이 그 스텝 크기입니다.
델타 타임은 컴퓨터마다, 프레임마다 다릅니다. 그래서 같은 입력을 넣어도 점프 높이가 컴퓨터마다 조금씩 달라집니다. 이 표의 계산 방식에서는 프레임률이 낮은 컴퓨터일수록 높이 뜹니다.
델타 타임과 고정 타임스텝
상태 갱신 한 번에 넘기는 시간 간격을 타임스텝(timestep)이라고 부릅니다. 앞에서 본 스텝 크기가 이것입니다. 이 소절에서는 타임스텝을 정하는 두 방식을 봅니다.
프레임마다 다른 델타 타임을 그대로 타임스텝으로 쓰는 방식을 가변 타임스텝(variable timestep)이라고 부릅니다. 지금까지 본 방식이 이것입니다. 코드가 짧은 대신 터널링과 컴퓨터마다 달라지는 결과를 안고 갑니다.
고정 타임스텝(fixed timestep)은 타임스텝을 늘 같은 값으로 둡니다. 예를 들어 60분의 1초입니다. 어느 컴퓨터든 같은 타임스텝으로 계산하므로 델타 타임 탓에 생기던 결과 차이가 사라집니다. 타임스텝이 늘 작으니 한 번에 멀리 건너뛰지도 않습니다.
오차가 0이 되지는 않습니다. 60분의 1초로 계산해도 오일러 방법의 오차는 남습니다. 대신 모든 컴퓨터가 같은 오차를 냅니다.
고정 타임스텝에서도 델타 타임은 쓰입니다. 프레임마다 잰 델타 타임을 누적 변수(accumulator)에 더해 둡니다. 누적 변수가 타임스텝보다 크거나 같으면 갱신을 한 번 돌리고 누적 변수에서 타임스텝만큼 덜어 냅니다.
이 일을 누적 변수가 타임스텝보다 작아질 때까지 되풀이합니다. 남은 몫은 버리지 않고 다음 프레임으로 넘깁니다. 그래서 한 프레임 안에서 갱신이 한 번도 안 돌 수도 있고 두세 번 돌 수도 있습니다. 아래 그림은 타임스텝이 60분의 1초일 때 한 프레임의 흐름입니다.
flowchart TD
A["시계 읽고 dt 계산"] --> B["누적 변수에 dt 더하기"]
B --> C{"누적 변수 ≥ 1/60초?"}
C -->|"예"| D["update(1/60초)"]
D --> E["누적 변수에서 1/60초 덜어 내기"]
E --> C
C -->|"아니요"| F["render"]
F -->|"다음 프레임"| A
예를 들어 누적 변수에 0.010초가 남아 있었고 이번 프레임의 델타 타임이 0.030초면 합은 0.040초입니다. 60분의 1초(약 0.0167초)를 두 번 덜어 내면 약 0.0067초가 남습니다. 이 프레임은 갱신을 두 번 돕니다. 남은 0.0067초는 다음 프레임으로 넘어갑니다.
흐른 시간을 재는 일은 여전히 델타 타임이 맡습니다. 갱신 한 번에 넘기는 값만 고정 타임스텝으로 바뀝니다.
델타 타임에 거는 시간 배율
델타 타임에 배율을 곱해 게임 속 시간의 빠르기를 바꾸기도 합니다. 이 배율을 시간 배율(time scale)이라고 부릅니다. 0.5를 곱하면 모든 움직임이 절반 빠르기로 흐르는 슬로 모션이 됩니다. 0을 곱하면 모든 것이 멈춰 일시 정지가 됩니다.
일시 정지 중에도 메뉴 화면은 움직여야 합니다. 그래서 메뉴처럼 멈추면 안 되는 것은 배율을 곱하지 않은 델타 타임을 따로 받아 움직입니다.
게임 밖의 델타 타임
델타 타임은 게임만의 것이 아닙니다. 웹 페이지의 애니메이션도 같습니다. 브라우저는 화면을 새로 그리기 전에 requestAnimationFrame 으로 등록한 함수를 부르면서 지금 시각을 넘겨 줍니다. 코드는 지난번 시각과의 차이만큼 진행률을 올립니다. 그래야 기기 성능과 상관없이 정해 둔 시간 안에 움직임이 끝납니다.
시간에 따라 상태가 바뀌는 물리 시뮬레이션도 같은 계산을 합니다. 시뮬레이션은 시간을 짧은 간격으로 잘라 한 스텝씩 계산합니다. 이 간격을 흔히 Δt 로 적습니다. 게임의 델타 타임은 이 간격을 프레임마다 시계로 재서 정하는 경우입니다.
관련 항목
델타 타임을 재고 넘기는 게임 루프의 구성 요소
델타 타임과 함께 게임 속 시간을 나아가게 하는 방식
타임스텝 · 가변 타임스텝 · 고정 타임스텝 · 누적 변수 · 물리 스텝 · 보간 · 죽음의 나선 · 시간 배율
델타 타임과 맞물리는 화면 표시 지표
프레임률 · 프레임 시간 · 프레임 예산 · 주사율 · 프레임 드롭
델타 타임을 재는 시계
단조 시계 · 벽시계 · NTP · 시스템 시계 · 고해상도 타이머
델타 타임이 클 때 터지는 오류
터널링 · 연속 충돌 감지 · 부동소수점 · 결정론적 시뮬레이션 · 충돌
델타 타임을 곱해 움직임을 계산하는 방법
수치 적분 · 오일러 방법 · 반암시적 오일러 방법 · 베를레 적분 · 물리 엔진
델타 타임을 쓰는 상위 분야
다른 이름: delta time · dt · deltaTime · Δt · 델타타임 · 프레임 간 시간