FixedUpdate 가 따로 도는 이유
점프 입력을 FixedUpdate 안에서 Input.GetKeyDown 으로 받았더니, 고사양 PC 에서는 점프가 가끔 씹히고 저사양 노트북에서는 가끔 두 번 뜁니다.
유니티의 물리는 화면 프레임(화면 한 장)과 따로, 정해진 간격(기본 0.02초)마다 한 번씩 계산합니다. 1초에 그리는 프레임 수가 fps 라, 60fps 면 1초에 60장입니다.
Update 는 프레임마다 한 번, FixedUpdate 는 물리가 계산할 때마다 한 번 불리는 함수입니다.
매 프레임 지난 프레임이 걸린 시간(
Time.deltaTime)을 통에 붓고, 통에 0.02초 이상 있으면 0.02 씩 덜어 내며 FixedUpdate 를 부릅니다. fps 가 높으면 FixedUpdate 가 안 불리는 프레임이 생기고, 낮으면 한 프레임에 여러 번 불립니다. 그래서 한 프레임에만 참인 입력(GetKeyDown)은 Update 에서 받아 두고 FixedUpdate 에서 씁니다.
물리는 왜 간격을 고정하나
물리 엔진은 시간을 조금씩 앞으로 돌리며 계산합니다. 한 번 돌리는 계산을 물리 한 걸음(step)이라고 하고, 걸음마다 물체를 옮기고 부딪힌 것을 찾아 밀어냅니다. 한 걸음이 맡는 시간이 간격입니다.
물리 엔진은 걸음마다 속도 += 가속도 × 간격, 위치 += 속도 × 간격 을 계산합니다. 간격 동안 속도가 조금씩 바뀌는데, 계산은 한 가지 속도로 한 걸음을 다 간 것으로 칩니다.
이런 어림셈이라 간격이 달라지면 결과가 달라집니다. 처음 속도 8 로 위로 뛰고 중력이 10 이면 정확한 꼭대기는 3.2 입니다. 0.2초 간격으로 계산하면 2.4, 0.05초 간격이면 3.0 입니다. 간격이 클수록 많이 모자랍니다.
간격을 화면 프레임에 맞추면 이 간격이 기계마다 달라집니다. 그래서 떼어 놓습니다.
| 간격이 화면 프레임을 따라가면 | 간격을 고정하면 |
|---|---|
| 30fps 기계(간격 0.033초)와 144fps 기계(간격 0.007초)의 점프 높이가 다름 | 어느 기계든 0.02초 간격이라 같은 높이 |
| 끊긴 프레임 하나가 0.3초면 한 걸음에 크게 이동해 벽을 지나치거나 깊이 박힘 | 0.3초를 0.02초 열다섯 걸음으로 나눠 계산 |
| 같은 입력을 넣어도 프레임 시간에 따라 결과가 조금씩 다름 | 걸음의 크기가 같아 결과를 되풀이하기 쉬움. 리플레이와 네트워크 동기화의 첫 조건 |
그래서 화면은 기계가 그릴 수 있는 만큼 그리고, 물리는 늘 같은 간격으로 걷게 둘을 떼어 놓습니다. 이렇게 늘 같은 값으로 정해 둔 간격을 고정 간격(fixed timestep)이라고 부릅니다.
이 간격이 Time.fixedDeltaTime 이고, Project Settings 의 Time 에서 바꿀 수 있습니다. 기본값 0.02초는 1초에 50번입니다.
프레임마다 몇 번 도나: 쌓아 두고 덜어 낸다
화면 프레임은 제멋대로 길고 짧은데 물리는 0.02초씩 걸어야 합니다. 그래서 유니티는 지난 시간을 통에 모아 두고, 0.02초가 찰 때마다 한 걸음씩 덜어 냅니다. 이 통을 흔히 누산기(accumulator, 쌓아 두는 곳)라고 부르고, Glenn Fiedler 의 「Fix Your Timestep!」 이 이 방식을 정리한 대표 글입니다. 유니티는 프레임이 올 때마다 이렇게 합니다.
쌓인 시간 += 이번 프레임의 deltaTime
while (쌓인 시간 >= 0.02)
{
FixedUpdate 와 물리 한 걸음
쌓인 시간 -= 0.02
}
Update
화면 그리기
FixedUpdate 들이 먼저 돌고, 그다음에 그 프레임의 Update 가 한 번 돕니다. 0.02 를 덜어 내고 남은 자투리는 다음 프레임으로 넘깁니다. 그래서 길게 보면 1초에 정확히 50번 물리가 돕니다.
아래 그림은 30fps 와 120fps 에서 프레임마다 FixedUpdate 가 몇 번 도는지입니다. 가로가 시간이고, 한 칸이 0.01초입니다.
| fps | 한 프레임 | 프레임마다 FixedUpdate |
|---|---|---|
| 30 | 0.033초 | 1번, 2번, 2번, 1번, 2번, 2번 … 평균 1.67번 |
| 50 | 0.02초 | 늘 1번 |
| 120 | 0.0083초 | 0번, 0번, 1번, 0번, 1번 … 평균 0.42번 |
어느 쪽이든 1초 동안 물리가 도는 횟수는 50번으로 같습니다. 달라지는 것은 그 50번이 프레임 사이에 어떻게 흩어지느냐입니다.
FixedUpdate 안에서 Time.deltaTime 을 읽으면 자동으로 Time.fixedDeltaTime, 곧 0.02 가 나옵니다. 그래서 같은 이동 코드를 어느 쪽에 두든 Time.deltaTime 을 곱하는 규칙은 같습니다.
그래서 입력이 씹히거나 두 번 들어간다
Input.GetKeyDown 은 키를 누른 그 프레임 한 번만 참입니다. 다음 프레임이면 다시 거짓입니다.
| 상황 | 누른 프레임에 FixedUpdate 가 | 결과 |
|---|---|---|
| 120fps | 0번 불림 | FixedUpdate 는 참인 순간을 못 봄. 점프가 씹힘 |
| 50fps | 1번 불림 | 정상 |
| 30fps | 2번 불림 | 그 프레임 동안 GetKeyDown 이 계속 참. 두 번 뜀 |
그래서 입력은 Update 에서 받아 표시만 해 두고, 물리를 건드리는 일은 FixedUpdate 에서 그 표시를 보고 한 번만 합니다.
public float jumpPower = 5f;
Rigidbody rb;
bool jumpPressed; // Update 가 받아 두고 FixedUpdate 가 쓰는 표시
void Start()
{
rb = GetComponent<Rigidbody>();
}
void Update()
{
// Update 는 프레임마다 꼭 한 번 불리니 누른 순간을 놓치지 않는다
if (Input.GetKeyDown(KeyCode.Space))
{
jumpPressed = true;
}
}
void FixedUpdate()
{
if (jumpPressed)
{
// Impulse 는 한 번에 속도를 바꾸는 순간적인 힘
rb.AddForce(Vector3.up * jumpPower, ForceMode.Impulse);
jumpPressed = false; // 한 번 쓰고 지운다. 두 번 불려도 두 번 뛰지 않는다
}
}
누르고 있는 동안 계속 참인 값(Input.GetKey, GetAxis)은 어느 쪽에서 읽어도 괜찮습니다.
반대로 Rigidbody 에 힘을 주거나 속도를 바꾸는 코드는 FixedUpdate 에 둡니다. Update 에서 매 프레임 힘을 주면 물리 한 걸음 사이에 힘이 몇 번 쌓이느냐가 fps 를 따라갑니다.
무거운 프레임이 이어지면
한 프레임이 0.3초 걸리면 쌓인 시간이 0.3 이라 FixedUpdate 를 15번 불러야 합니다. 그 15번이 또 시간을 먹어 다음 프레임이 더 길어지면 다음엔 더 많이 불러야 합니다. 쌓인 시간이 점점 불어나 게임이 멈추듯 느려집니다. 이 악순환을 흔히 죽음의 소용돌이(spiral of death)라고 부릅니다.
이를 막으려고 유니티는 한 프레임에 흐를 수 있는 게임 시간에 상한을 둡니다. Project Settings 의 Time 에 있는 Maximum Allowed Timestep, 코드로는 Time.maximumDeltaTime 이고 기본값은 약 0.333초(1/3초)입니다.
한 프레임이 1초 걸려도 게임 시간은 0.333초만 흐른 것으로 칩니다. 그래서 FixedUpdate 도 많아야 0.333 ÷ 0.02 ≈ 16번까지만 불립니다.
넘는 시간은 버려지니, 그런 순간에는 게임 시간이 실제 시간보다 느리게 갑니다.
간격을 바꾸면
fixedDeltaTime |
1초에 | 얻는 것 | 치르는 것 |
|---|---|---|---|
| 0.01 | 100번 | 정확해지고, 빠른 물체가 덜 뚫고 지나감 | 물리 계산이 두 배 |
| 0.02 (기본) | 50번 | ||
| 0.0333 | 30번 | 물리 계산이 줄어 모바일에 유리 | 오차가 커지고 빠른 물체가 잘 뚫음 |
간격을 화면 fps 와 맞출 필요는 없습니다. 둘은 일부러 떼어 놓은 것입니다.
다만 물리 간격이 화면 프레임보다 길면 물리로 움직이는 물체가 떨려 보입니다. 120fps 화면에서는 프레임 두세 장마다 한 번만 물리가 돌아서, 몇 장 동안 같은 자리에 있다가 한 번에 옮겨지기 때문입니다.
Rigidbody 의 Interpolate 설정을 켜면 두 물리 걸음 사이의 자리를 화면 프레임마다 채워 그려 이 떨림을 메웁니다.
정리
- 물리는 화면 프레임과 따로
Time.fixedDeltaTime(기본 0.02초)마다 한 번씩 계산합니다 - 간격을 고정하는 이유는 물리 계산이 간격에 따라 결과가 달라지는 어림셈이라서입니다. 고정하면 기계가 달라도 같은 결과를 내고, 긴 프레임에도 한 걸음이 커지지 않습니다
- 프레임마다 deltaTime 을 쌓고 0.02 씩 덜어 내며 FixedUpdate 를 부릅니다. 자투리는 다음 프레임으로 넘어갑니다
- fps 가 높으면 FixedUpdate 가 0번인 프레임이, 낮으면 여러 번인 프레임이 생깁니다. 1초에 50번인 것은 같습니다
- 한 프레임에만 참인 입력은 Update 에서 받아 두고 FixedUpdate 에서 한 번 쓰고 지웁니다
- Rigidbody 에 힘을 주는 코드는 FixedUpdate 에 둡니다
- 한 프레임에 흐를 수 있는 게임 시간은
Time.maximumDeltaTime(기본 약 0.333초)까지라, 무거운 프레임이 이어져도 FixedUpdate 가 끝없이 불어나지 않습니다