FixedUpdate 가 따로 도는 이유
Esc

    FixedUpdate 가 따로 도는 이유

    –Unity기초

    점프 입력을 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 가 끝없이 불어나지 않습니다