Time.deltaTime 을 곱하는 이유
유니티로 캐릭터를 처음 움직여 보면 이런 일을 겪습니다. 내 컴퓨터에서는 알맞은 속도였는데, 친구 컴퓨터에서는 두 배로 빨리 달리고 휴대폰에서는 느릿느릿 걷습니다. 코드는 한 글자도 안 바꿨습니다.
원인은 Update 가 1초에 몇 번 불릴지 정해져 있지 않다는 데 있습니다.
어떤 기계는 1초에 120번 부르고, 어떤 기계는 30번 부릅니다.
“불릴 때마다 0.1 씩 가라” 고 적으면 많이 불리는 쪽이 멀리 갑니다.
Time.deltaTime은 지난 프레임이 몇 초 걸렸는지입니다. 여기에 “1초에 얼마” 를 곱하면 이번 프레임에 갈 거리가 나오고, 몇 번 불리든 1초 동안 가는 거리가 같아집니다.
Update 는 1초에 몇 번 불리나
프레임은 화면에 그리는 그림 한 장입니다.
유니티는 그림 한 장을 그릴 때마다, 그리기 직전에 모든 스크립트의 Update 를 한 번씩 부릅니다.
그래서 Update 가 불리는 횟수 = 그린 그림 장수입니다.
1초에 그린 장수를 fps(frames per second)라고 부릅니다.
이 fps 는 기계마다, 순간마다 다릅니다. 모니터가 1초에 화면을 몇 번 바꾸는지를 주사율이라 하고 Hz 로 적습니다. 60Hz 면 1초에 60번입니다.
| 상황 | fps |
|---|---|
| 수직 동기화(모니터 주사율에 맞춰 그리는 설정)를 켜고 60Hz 모니터 | 60 |
| 같은 설정으로 144Hz 모니터 | 144 |
| 수직 동기화를 끄고 상한도 안 정함 | 그릴 수 있는 만큼. 수백 |
| 휴대폰에서 따로 정하지 않음 | 30 |
| 무거운 장면, 로딩 중 | 그만큼 떨어짐 |
같은 코드가 어떤 기계에서는 1초에 30번, 어떤 기계에서는 120번 돕니다.
Update 안에 적은 한 줄은 “1초에 한 번” 도 “1초에 60번” 도 아닙니다. “그림 한 장마다 한 번” 입니다.
한 번에 0.1 씩 가면
캐릭터를 오른쪽으로 보내려고 이렇게 썼다고 합시다.
void Update()
{
// 불릴 때마다 오른쪽으로 0.1
transform.position += new Vector3(0.1f, 0f, 0f);
}
1초 동안 몇 번 불렸는지만 세면 1초 뒤 위치가 나옵니다.
| fps | 1초 동안 불린 횟수 | 한 번에 | 1초 뒤 위치 |
|---|---|---|---|
| 30 | 30 | 0.1 | 0.1 × 30 = 3 |
| 60 | 60 | 0.1 | 0.1 × 60 = 6 |
| 120 | 120 | 0.1 | 0.1 × 120 = 12 |
같은 코드인데 120fps 기계가 30fps 기계보다 네 배 멀리 갑니다.
아래 블록은 게임 시간 1초 동안 세 기계에서 캐릭터가 간 거리입니다. 줄 위의 눈금 하나가 Update 한 번입니다.
문제는 0.1 이라는 숫자의 뜻입니다. 이 0.1 은 “프레임 한 장 동안 갈 거리” 입니다. 그런데 프레임 한 장의 길이가 기계마다 다릅니다. 30fps 의 한 장은 1/30초, 120fps 의 한 장은 1/120초입니다. 길이가 다른 자로 “한 칸씩” 재고 있는 셈입니다.
deltaTime 은 지난 프레임이 걸린 시간이다
Time.deltaTime 은 바로 전 프레임부터 지금 프레임까지 걸린 시간을 초로 돌려줍니다.
60fps 로 고르게 돌고 있다면 매번 1/60 ≈ 0.0167 입니다.
여기서 쓰는 것은 학교에서 배운 식 하나입니다.
거리 = 속도 × 시간
1초에 6 만큼 가고 싶다면, 이번 프레임 동안 갈 거리는 6 × 이번 프레임이 걸린 시간 입니다.
그 시간이 Time.deltaTime 입니다.
public float speed = 6f; // 1초에 6
void Update()
{
// 1초에 6 × 이번 프레임이 걸린 초 = 이번 프레임에 갈 거리
float step = speed * Time.deltaTime;
transform.position += new Vector3(step, 0f, 0f);
}
세 기계에서 한 단계씩 계산하면 이렇게 됩니다.
| fps | deltaTime | 한 번에 (6 × deltaTime) | 1초 동안 불린 횟수 | 1초 뒤 위치 |
|---|---|---|---|---|
| 30 | 1/30 | 0.2 | 30 | 0.2 × 30 = 6 |
| 60 | 1/60 | 0.1 | 60 | 0.1 × 60 = 6 |
| 120 | 1/120 | 0.05 | 120 | 0.05 × 120 = 6 |
자주 불리는 기계는 조금씩, 드물게 불리는 기계는 한 번에 많이 갑니다. 그래서 1초 뒤에는 모두 같은 자리에 있습니다. 위 블록의 「deltaTime 곱하기」 코드에서는 눈금 간격이 줄마다 달라도 점이 나란히 갑니다.
왜 항상 맞아떨어지는지는 덧셈으로 보면 바로 보입니다. 1초 동안의 deltaTime 을 전부 더하면, 몇 번으로 나눴든 1초입니다. 30fps 는 1/30 을 30번, 120fps 는 1/120 을 120번 더하는데 둘 다 1 입니다.
간 거리 = 6 × dt₁ + 6 × dt₂ + ... + 6 × dtₙ
= 6 × (dt₁ + dt₂ + ... + dtₙ)
= 6 × 1초
= 6
곱하기 전과 후에 달라진 것은 speed 의 뜻입니다.
| 숫자 | 뜻 | 기계마다 | |
|---|---|---|---|
| 곱하기 전 | 0.1 | 한 프레임에 0.1 | 달라짐 |
| 곱한 뒤 | 6 | 1초에 6 | 같음 |
단위로 읽어도 됩니다. 유니티에서 1 은 보통 1m 로 봅니다.
6 (m/초) × 0.0167 (초) = 0.1 (m) 이라 초가 지워지고 거리만 남습니다.
deltaTime 을 곱한다는 것은 “프레임” 이라는 단위를 “초” 로 바꾸는 일입니다.
프레임 길이가 들쭉날쭉해도 맞는다
실제 게임은 fps 가 고르지 않습니다. 가비지 컬렉션(쓰지 않는 메모리를 치우는 작업)이나 로딩 때문에 한 프레임만 0.3초씩 걸리는 일이 생깁니다. 화면이 순간 멈칫하는 그 현상입니다.
deltaTime 은 평균이 아니라 매 프레임 그 프레임의 길이를 다시 잽니다. 그래서 긴 프레임 뒤에는 그만큼 큰 값이 들어옵니다. 아래는 0.02초짜리 프레임 사이에 0.3초짜리 프레임 하나가 끼어 있는 기계입니다.
아래 줄은 0.3초 동안 멈춰 있다가 한 번에 1.8 을 건너뜁니다. 대신 그 뒤로는 위 줄과 같은 자리에 있습니다. deltaTime 을 곱하지 않았다면 멈춘 0.3초만큼 영영 뒤처졌을 것입니다.
이것이 deltaTime 의 대가이기도 합니다.
- 한 번에 멀리 건너뛸 수 있습니다. 그 사이에 얇은 벽이 있으면 벽 반대편으로 나가 버립니다
- 그래서 유니티는 deltaTime 에 상한을 둡니다.
Time.maximumDeltaTime이고 기본값은 1/3초입니다. 한 프레임이 1초 걸려도 deltaTime 은 0.333 으로 잘려 들어오고, 그 순간에는 게임 시간이 실제 시간보다 느리게 흐릅니다 - deltaTime 은 이미 지나간 프레임의 길이입니다. 이번 프레임이 얼마나 걸릴지는 모르므로, 끊긴 직후 한 프레임이 늘 크게 튑니다
어디에 곱하고 어디에 안 곱하나
기준은 하나입니다. “매 프레임 조금씩 쌓이는 변화” 면 곱하고, 그렇지 않으면 안 곱합니다.
표의 rb 는 물리 엔진으로 움직이는 오브젝트에 붙이는 Rigidbody 컴포넌트입니다.
| 코드 | 곱하나 | 왜 |
|---|---|---|
transform.position 을 직접 옮김 |
곱함 | 프레임마다 쌓이는 이동 |
transform.Rotate(0, 90 * Time.deltaTime, 0) |
곱함 | 1초에 90도 |
timer += Time.deltaTime |
더함 | 지난 시간을 모음 |
rb.linearVelocity = dir * speed |
안 곱함 | 속도는 이미 “1초에 얼마”. 물리 엔진이 시간을 곱해 옮김 |
FixedUpdate 안의 rb.AddForce(force) |
안 곱함 | 기본 방식(ForceMode.Force)이 물리 한 걸음의 시간(0.02초)을 알아서 곱함 |
Input.GetAxis("Horizontal") 로 키보드 이동 |
곱함 | −1 ~ 1 은 “얼마나 눌렀나” 일 뿐. 속도와 deltaTime 을 곱해야 거리 |
Input.GetAxis("Mouse X") |
안 곱함 | 이미 “이번 프레임 동안 움직인 양” |
| 점프 순간 속도를 한 번 넣음 | 안 곱함 | 쌓이지 않고 한 번만 일어남 |
자주 틀리는 것은 아래 셋입니다.
- 물리 속도에 곱하기.
rb.linearVelocity(Unity 6 이전 이름은velocity)는 “1초에 얼마” 입니다. 여기에 deltaTime 을 곱하면 속도가 1/60 로 줄어 거의 안 움직이고, fps 에 따라 속도까지 달라집니다 - 마우스 이동량에 곱하기. 마우스 값은 그 프레임 동안 손이 움직인 거리라서, 프레임이 길면 이미 큰 값이 들어옵니다. 여기에 deltaTime 을 또 곱하면 시간을 두 번 곱한 것이라 fps 가 낮을수록 시점이 더 많이 돌아갑니다
FixedUpdate에서 착각하기. 물리 계산용FixedUpdate는 fps 와 상관없이 정해진 간격(기본 0.02초)으로 불립니다. 그 안에서Time.deltaTime을 읽으면 자동으로 그 간격인 0.02 가 나옵니다. 값이 늘 같을 뿐, 곱하는 규칙은 같습니다.AddForce도 여기서 부릅니다.Update에서 부르면 그림을 많이 그리는 기계일수록 힘이 여러 번 쌓여 더 세게 밀립니다
곱해 두면 일시정지가 공짜다
Time.deltaTime 에는 Time.timeScale 이 이미 곱해져 있습니다.
| timeScale | deltaTime (60fps) | deltaTime 을 곱한 움직임 |
|---|---|---|
| 1 | 0.0167 | 그대로 |
| 0.5 | 0.0083 | 절반 속도. 슬로 모션 |
| 0 | 0 | 멈춤. 일시정지 |
Time.timeScale = 0 한 줄로 deltaTime 을 곱한 것은 전부 멈춥니다. 이때도 Update 는 계속 불리고, deltaTime 이 0 이라 움직임만 0 이 됩니다.
곱하지 않은 코드는 일시정지 중에도 계속 움직입니다. 이동에 deltaTime 을 빠뜨린 캐릭터가 일시정지 화면 뒤에서 걸어가는 것이 그 경우입니다.
반대로 일시정지 메뉴의 애니메이션처럼 멈추면 안 되는 것은 Time.unscaledDeltaTime 을 씁니다. timeScale 을 곱하지 않은 실제 프레임 시간입니다.
정리
Update는 그림 한 장마다 한 번 불립니다. 1초에 몇 번인지는 기계와 순간마다 다릅니다- “한 번에 0.1” 처럼 적으면 30fps 는 3, 120fps 는 12 를 갑니다
Time.deltaTime은 지난 프레임이 걸린 초입니다.속도 × deltaTime이 이번 프레임에 갈 거리입니다- 1초 동안의 deltaTime 을 다 더하면 1초라서, 몇 번 불리든 1초 동안 간 거리가 같습니다
- 매 프레임 쌓이는 변화에는 곱하고, 이미 “1초에 얼마” 인 물리 속도, 이미 “프레임 동안의 양” 인 마우스 값, 한 번만 일어나는 일에는 곱하지 않습니다
- 긴 프레임 뒤에는 한 번에 멀리 건너뜁니다. 상한은
Time.maximumDeltaTime입니다 - deltaTime 을 곱해 두면
Time.timeScale로 멈추거나 느리게 할 수 있습니다