[Unity] 완벽한 CharacterController 기반 Player를 위해

박지인·2023년 2월 3일
2

Unity

목록 보기
3/3
post-thumbnail

미안하다

제목 어그로 한번 끌어봤다. 절대적으로 완벽한게 어디 있겠는가. 다 상황마다 다른거지.

그럼에도 내가 이러한 제목으로 글을 쓰는 이유는, 나같은 초보 개발자들이 놓치기 쉬운 것들을 더 널리 공유하고 싶어서이다. 이동 구현은 나같은 초짜가 보기엔 정말 별것 아닌 것 같다. 그야 대부분의 게임에서 볼 수 있는 기본적인 기능이니까. 하루 안에 이동 기능 구현을 끝내겠다고 다짐한 나. 그러나 그날 저녁 남아있는건 묘하게 조작감이 구린 툭툭 끊기며 이동하는 무언가였다.

이 글은 말했듯이 CharacterController 기반 캐릭터의 이동 구현에서 초보자가 구현에 애를 먹는 것들과 놓치기 쉬운 것들을 공유하기 위해 쓰인 글이다. 나의 경우는 일인칭 캐릭터를 구현중이라, 삼인칭 기반은 나와 사뭇 다른 구현을 요할 수도 있다. 그러나 내가 이번 글에서 쓰는 것들의 핵심은 동일하게 적용될 것이라 생각한다.

대각선 이동

뭐지 대각선으로 이동하면 좀 더 빠른 것 같은데??

별다른 처리를 하지 않고 플레이어의 input vector을 바로 이동에 사용하면 수직/수평으로 이동할때와 대각선으로 이동할 때 속도가 다르기 쉽다. 이에 대해선 저번에 쓴 글에서 다룬 바 있다. 여기서는 간략하게 다루겠다.

    [SerializeField] private Transform movementOrientation;

    void Update()
    {
        horizontalInput = Input.GetAxis("Horizontal");
        verticalInput = Input.GetAxis("Vertical");
        
        // 생략...
    }
    
    private Vector3 GetXZVelocity(float horizontalInput, float verticalInput)
    {
        Vector3 moveVelocity = movementOrientation.forward * verticalInput + movementOrientation.right * horizontalInput;
        Vector3 moveDirection = moveVelocity.normalized;
        float moveSpeed = Mathf.Min(moveVelocity.magnitude, 1.0f) * speed;

        return moveDirection * moveSpeed;
    }

플레이어의 input(수직 수평 각 0.0~1.0)을 받아, 해당 프레임의 속도를 구하는 메서드를 만들었다. 이후 저 메서드를 통해 구한 속도에 시간 변화량을 곱해 위치 변화량을 얻어 이동할 것이다.

movementOrientation은 플레이어 이동 뱡향의 기준이 되는 transform이다. 나의 경우 일인칭 캐릭터를 구현하고 있기에 플레이어 헤드에 부착한 카메라 원점으로 설정해주었다. 시점과 이동 방향이 일치하지 않을 경우, 보통은 자기 자신의 transform이나 플레이어의 메쉬의 transform으로 설정해주면 될 것이다.

위의 코드에서 주의해서 볼 것은 사용자의 input을 바로 이동 방향에 곱해서 속도 vector를 구하지 않았다는 것이다. 수직/수평 입력을 동시에 하면, vector는 변의 길이가 1인 정사각형의 대각선 길이(2\sqrt{2})만큼의 norm을 가지게 된다.

이를 해결하기 위해서는 input vector의 norm과 1.0을 비교하여 더 작은 값을 normalize한 vector에 곱하면 된다. 그냥 1.0을 곱하지 않는 것은 Unity Input System이 0.0에서 1.0까지 부드럽게 보간해주는 값을 사용하기 위해서이다.

중력과 점프

아래는 CharacterController에 대한 Unity 공식 문서의 설명이다.

캐릭터 컨트롤러(Character Controller) 는 Rigidbody 물리를 활용하지 않는 3인 또는 1인 플레이어에 주로 사용됩니다.

CharacterController는 이동의 세세한 부분들을 프로그래머가 직접 설정할 수 있다는 점에서 강력하지만, 프로그래머가 많은 부분을 제어해야 한다는 것은 때로는 엔진에서 제공하는 몇 기능들을 사용하지 못한다는 뜻이기도 하다. 큰 힘에는 큰 책임이 따르는 법. CharacterController를 사용하며 생기는 책임 중 하나는 Unity의 RigidBody 물리의 도움을 받기 힘들다는 것이다.

수평 방향의 이동은 위에서 처리했지만, 캐릭터의 수직 이동에 관여하는 중력과 점프 기능 역시 프로그래머가 직접 개발해야 한다.

Ground Check

나의 경우는 플레이어가 땅에 닿아있는지 체크하기 위한 간단한 GroundChecker 스크립트를 만들어 캐릭터에 부착하였다. CharacterController에 내장된 isGrounded를 사용하지 않는 이유는, 해당 기능의 정밀도가 그냥은 못써먹을 정도로 떨어지기 때문이다. 플레이어가 땅에 닿아있는지 확인할 수만 있다면, 꼭 BoxCast가 아니라 다른 방법을 사용해도 된다.

Ground check를 위해 많이들 사용하는 방법에는 RayCast와 BoxCast 두 가지가 있다. 만약 RayCast를 사용한다면 주의할 점은, 모서리 부분에서 플레이어는 모서리에 서있는데 공중에 있는 것으로 판정되는 경우가 있다는 것이다. 이런 경우 주로 두 가지 문제가 발생한다. 첫번째론 플레이어가 공중에 떠있는 판정이기에, 플레이어는 분명 바닥에 닿아있는데도 점프를 하지 못한다. 두번째론 플레이어가 낙하중인 상태로 인식되어 중력으로 인해 음의 방향으로 속도가 계속 증가한다. 그 경우 플레이어가 더 이동하여 실재로 낙하를 시작하게 되면 갑자기 엄청난 속도로 플레이어가 떨어지게 된다.

BoxCast를 사용하여 BoxCast의 크기만 잘 조정해주면 위와 같은 문제를 해결할 수 있다.

/* GroundChecker.cs */
public class GroundChecker : MonoBehaviour
{
    [Header("Boxcast Property")]
    [SerializeField] private Vector3 boxSize;
    [SerializeField] private float maxDistance;
    [SerializeField] private LayerMask groundLayer;

    [Header("Debug")]
    [SerializeField] private bool drawGizmo;

    private void OnDrawGizmos()
    {
        if (!drawGizmo) return;

        Gizmos.color = Color.cyan;
        Gizmos.DrawCube(transform.position - transform.up * maxDistance, boxSize);
    }

    public bool IsGrounded()
    {
        return Physics.BoxCast(transform.position, boxSize, -transform.up, transform.rotation, maxDistance, groundLayer);
    }

}

번외로 내장된 isGrounded의 판정 정확도를 올리기 위해, 해당 기능이 정확한 판정을 내릴 때까지 값의 변동을 무시하는 방법도 있다. 시도해보지는 않았지만 해당 내용이 담긴 링크를 남겨둔다.

중력과 점프 구현

/* PlayerMovement.cs */
    [SerializeField] private float gravitationalAcceleration;
    [SerializeField] private float jumpForce;
    
    private GroundChecker m_groundChecker;
    private Vector3 velocity;
    private bool jumpFlag;

    void Update()
    {
    	// 생략...
        if (Input.GetKeyDown(KeyCode.Space)) jumpFlag = true;
        // 생략...
    }

    private float GetYVelocity()
    {
        if (!m_groundChecker.IsGrounded())
        {
            return velocity.y - gravitationalAcceleration * Time.fixedDeltaTime;
        }

        if (jumpFlag)
        {
            jumpFlag = false;
            return velocity.y + jumpForce;
        }
        else
        {
            return Mathf.Max(0.0f, velocity.y);
        };
    }

플레이어의 수직방향 속도를 계산하는 GetYVelocity() 메서드를 제작하였다. 이후 수평방향 속도, 수직방향 속도를 더해 해당 프레임의 최종 속도를 구하고, 시간 변화량을 곱해 위치 변화량을 구해 이동할 것이다. 즉 수직방향 속도가 양수일 경우 플레이어는 상승하고, 음수일경우 플레이어는 낙하한다.

해당 메서드는 플레이어가 땅에 닿아있지 않을 경우 현재 속도에서 (중력가속도 * 시간변화량)만큼 감소한 속도를 반환한다. 공기저항으로 인한 낙하 속도 상한도 원한다면 Max() 함수로 설정해줄 수 있다.

플레이어의 점프 input 처리는 Update()에서 처리한다. 점프키(여기서는 Space) 입력시 jumpFlag를 true로 설정한다. GetYVelocity()에서는 jumpFlag가 true일 경우 이를 false로 설정한 후 현재 속도에 jumpForce를 더한다. 그러면 플레이어가 상승하기 시작하고, IsGrounded()가 false를 반환하며 지속적으로 속도를 감소시킨다. 속도가 0이 되는 순간 플레이어의 위치는 최고점을 찍게 되며, 속도가 음수가 되면 플레이어는 낙하하기 시작한다.

플레이어가 땅에 닿아있고, 점프도 하지 않을 경우 0과 현재 수직 가속도 중 큰 수를 반환한다. 땅에 닿아있다고 단순히 0을 반환하지 않는 것은, GroudChecker가 점프 직후 바로 false를 반환하지 않기 때문이다. RayCast 방식이건 BoxCast 방식이건, GroundChecker가 플레이어와 땅 사이의 거리를 측정하는 방법을 기초로 하는 이상 점프 후 일정 고도 이상으로 상승하기 전까지는 땅에 닿아있는 판정을 내리게 된다.

만약 0과 현재 수직 가속도 중 큰 수를 반환하지 않고 단순히 0을 반환할 경우, 점프 직후 다시 속도가 0으로 설정되어 점프가 끊기게 된다. 이를 방지하기 위해서는 0과 현재 속도중 큰 값을 반환해야 한다.

RigidBody와의 충돌 처리

아래는 Unity 공식 문서의 설명이다.

캐릭터 컨트롤러를 이용하여 리지드바디나 오브젝트를 푸시하고 싶을 경우, 스크립팅을 통해 OnControllerColliderHit() 함수를 사용하여 컨트롤러와 충돌하는 모든 오브젝트에 힘을 적용할 수 있습니다.

간단한 메서드를 추가하여 플레이어가 RigidBody 물체를 밀칠 수 있도록 설정하였다. 필요에 따라 추가적인 기능을 넣도록 하자.

    private void OnControllerColliderHit(ControllerColliderHit hit)
    {
        if (hit.rigidbody)
        {
            hit.rigidbody.AddForce(velocity / hit.rigidbody.mass);
        }
    }

Update와 FixedUpdate의 장점만 골라 적용하기

아마 이 파트가 이번 글에서 가장 이해하기 어렵고 그나마 고급진 기술에 속하지 않으련가 싶다. 여기서 다룰 내용과 관련된 Update()와 FixedUpdate()의 장단점에 대해 짚고 넘어가보자.

Update()

각 프레임을 새로 그릴때마다 실행되는 함수.

Pros

  1. (보통은) FixedUpdate보다 더 빨리, 자주 실행되어 부드러운 이동을 처리할 수 있다.
  2. 플레이어의 Input에 대한 반응속도가 빠르고 키 씹힘이 적다.

Cons

  1. 각 클라이언트마다 실행 주기, 초당 실행 횟수가 다르기에 실행 환경에 따른 오차가 발생한다. (아래에서 설명할 수치적분의 오차 참고)

FixedUpdate()

고정 주기로 실행되는, 모든 실행 환경에서 같은 초당 실행 횟수(기본 50회/s)를 보장하는 함수.

Pros

  1. 모든 환경에서 같은 초당 실행 횟수를 보장하기에, 시간 단계에 따른 오차가 발생하더라도 최소한 모든 환경에서 일관된 동작을 보인다.

Cons

  1. 이동을 FixedUpdate()에서 처리할 경우 설정된 초당 실행 횟수로 플레이어 위치의 FPS가 고정되어 버린다.
  2. 플레이어 Input을 제대로 처리하지 못한다. 키가 씹히는 경우가 허다하다.

수치적분의 시간단계에 따른 오차

고등학교 시절 적분을 처음 배울때를 생각해보자.

출처: https://blog.naver.com/alwaysneoi/100167857300

직사각형의 넓이가 줄어들고, 그래프를 더 많은 직사각형으로 그리면 그릴수록 해석적으로 정확한 적분 해에 가까워진다. 더 나아가 넓이를 0으로 하여 무한급수의 값을 구하면 정확한 해를 구할 수 있게 된다.

해석학에서 수치적인 근사값을 구하는 알고리즘을 연구하는 분야를 수치해석학이라고 한다. 게임이라는 실시간 시뮬레이션에서는 정확한 위치를 구하기 위한 속도 적분 식은 그다지 쓸모가 없다. 우리에게 필요한 것은 현재의 위치와 현재의 가속도로 다음 위치를 알아낼 수 있는 수치 적분이다. 바로 위의 그림에서 작은 직사각형들의 합으로 그래프의 넓이를 구해낸 것처럼 말이다.

그러나 안타깝게도 위의 경우엔 직사각형의 넚이를 한없이 0과 가깝게 만들어 정확한 넓이를 구해낼 수 있었지만, 우리의 시간 조각인 DeltaTime은 그렇지 못하다는 것이다. 직사각형의 넓이를 줄이는데 한계가 있는 상황인 것이다. 결국 수치적분을 사용하는 실시간 시뮬레이션에선 오차가 발생할 수밖에 없게 된다. 그리고 이 오차는 시간 조각이 클수록(즉, FPS가 낮을수록) 커지고 시간 조각이 작을수록(FPS가 높을수록) 작아진다.

기울어진 운동장

아래의 그림은 플레이어가 점프했을 때의 높이를 나타낸 그래프이다.

이쁜 그림은 아니지만 이쁘게 봐달라. 빨간색 그래프가 해석적으로 옳은 해를 나타내는 그래프, 초록색과 파란색은 다른 시간 단계를 가지는 수치적분을 통해 높이를 계산한 그래프이다. 작은 시간 단계를 가지는 경우가(초록색) 큰 시간 단계를 가지는 경우보다 더 정확한 값을 가지는 것을 볼 수 있다. 높이 계산 식에 따라 오차가 양의 방향으로 날지 음의 방향으로 날지는 모르지만, 자주 Update를 할수록 정확한 값에 근사한 결과를 내놓게 된다.

오차가 양의 방향으로 난다고 생각해보자. 그러면 낮은 FPS로 플레이하는 플레이어들은 높은 FPS로 플레이하는 플레이어들보다 더 높히 점프할 수 있게 된다. 싱글플레이 게임에서도 달가운 일은 아니지만, 멀티플레이 게임의 경우 게임 운영에 차질이 있을 정도의 이슈이다.

수치적분에는 오차가 있을수밖에 없다. 그렇다면 이 기울어진 운동장의 수평을 맞추기 위한 최선의 방법은 적어도 모두에게 동일한 오차가 발생하도록 하는 것이다. 이러한 이유로 인해 물리 등 모든 환경에서 동일한 동작을 보장받아야 하는 기능의 경우 FixedUpdate()에서 처리하게 된다.

FixedUpdate()의 문제점: 구린 FPS

대충 Update()/FixedUpdate()의 장단점과 지금까지 한 이야기를 종합해보면 결론은 이거다.

플레이어 Input은 Update에서, 물리 계산은 FixedUpdate에서 수행하자!

    private float horizontalInput;
    private float verticalInput;
    private bool jumpFlag;
    
    void Update()
    {
        horizontalInput = Input.GetAxis("Horizontal");
        verticalInput = Input.GetAxis("Vertical");
        if (Input.GetKeyDown(KeyCode.Space))
        {
            jumpFlag = true;
        }
    }

    private void FixedUpdate()
    {
        Vector3 planeVelocity = GetXZVelocity(horizontalInput, verticalInput);
        float yVelocity = GetYVelocity();
        velocity = new Vector3(planeVelocity.x, yVelocity, planeVelocity.z);
        
        m_characterController.Move(velocity * Time.fixedDeltaTime);

    }

자 이제 모든 환경에서 동일한 동작을 보장하고, 플레이어 Input도 정상적으로 받아들인다. 문제 해결!

...무언가 빼먹지 않았는가? FixedUpdate()의 치명적인 단점말이다.

이동을 FixedUpdate()에서 처리할 경우 설정된 초당 실행 횟수로 플레이어 위치의 FPS가 고정되어 버린다.

기본으로 설정된 FixedUpdate() 실행 횟수는 초당 50회. 240Hz 모니터도 나오는 세상에 이 무슨 처참한 FPS인가. 특히 나의 경우는 시점이 캐릭터 위치와 일치하는 일인칭 게임이기에 이러한 단점이 더욱 크게 와닫는다. 플레이어가 움직이기만 하면 세상이 50FPS로 움직인다니, 일관된 동작을 위해서라지만 너무 큰 희생이다.

Cherry-Picking: 좋은 것만 골라먹자

우리는 Update()의 부드러운 화면과, FixedUpdate()의 보장된 작동 결과 모두의 이점을 취하고싶다. 그럴 수만 있다면 응당 그래야지 않겠는가? 어떻게 하면 모든 환경에서 일관된 동작을 보장하면서 높은 FPS를 제공할 수 있을까?

2022년 GDC에서의 발표에서 헤일로 인피니트의 개발사 343 인더스트리는 헤일로의 렌더링 프로세스에 대한 발표를 진행하였다. 저기서 처음 쓰인 기술은 아니라고 생각하지만, 해당 발표에서 FixedUpdate()의 일관된 동작과 Update()의 가변 프레임레이트 모두의 이점을 취할 실마리를 찾아내었다. 해당 발표에서 "tweening"으로 소개된 기법이다. 해당 기법을 통해 헤일로 인피니트는 모든 호환되는 XBOX 시리즈 기기에서 동일한 동작을 하면서, 각 기기의 사양에 맞춰 가능한 높은 FPS를 제공할 수 있었다.

FixedUpdate()는 여전히 오브젝트의 새로운 위치를 계산한다. 다른 점이 있다면, 계산한 위치를 적용하는 것은 Update()가 맡는다는 것이다. Update() 함수는 마지막 FixedUpdate()가 실행된 뒤로 경과한 시간에 따라, 이전 위치값과 새로운 위치값을 보간하여 오브젝트를 이동시킨다. 이런 방법을 통해 일관된 물리 계산을 기기 사양에 따라 부드러운 프레임으로 보여줄 수 있던 것이다.

좋다! 해결법을 알아냈으니 우리도 한번 적용해보자!

    void Update()
    {
        horizontalInput = Input.GetAxis("Horizontal");
        verticalInput = Input.GetAxis("Vertical");
        if (Input.GetKeyDown(KeyCode.Space))
        {
            jumpFlag = true;
        }

        float interpolationAlpha = (Time.time - Time.fixedTime) / Time.fixedDeltaTime;
        m_characterController.Move(Vector3.Lerp(lastFixedPosition, nextFixedPosition, interpolationAlpha) - transform.position);
    }

    private void FixedUpdate()
    {
        lastFixedPosition = nextFixedPosition;

        Vector3 planeVelocity = GetXZVelocity(horizontalInput, verticalInput);
        float yVelocity = GetYVelocity();
        velocity = new Vector3(planeVelocity.x, yVelocity, planeVelocity.z);

        nextFixedPosition += velocity * Time.fixedDeltaTime;
    }

마지막 FixedUpdate()가 실행된 후 경과된 시간을 FixedUpdate() 실행 주기로 나누어 보간 비율을 구했다. 이를 통해 새로운 위치를 구한 후, 거기에서 현재 위치를 뺀 만큼을 이동시켰다. 이제 완벽히, 잘 작동한다!!

최종 코드

플레이어의 메쉬가 플레이어 이동방향을 바라보도록 하는 기능을 추가하였다. 또한 나의 경우 Netcode를 사용한 멀티플레이어 게임을 구현중이라 그를 위한 코드도 조금 추가되었다.

public class PlayerMovement : NetworkBehaviour
{
    [Header("Transform References")]
    [SerializeField] private Transform movementOrientation;
    [SerializeField] private Transform characterMesh;

    [Header("Movement")]
    [SerializeField] private float speed;
    [SerializeField] private float gravitationalAcceleration;
    [SerializeField] private float jumpForce;
    [Space(10.0f)]
    [SerializeField, Range(0.0f, 1.0f)] private float lookForwardThreshold;
    [SerializeField] private float lookForwardSpeed;

    private float horizontalInput;
    private float verticalInput;
    private bool jumpFlag;

    private CharacterController m_characterController;
    private GroundChecker m_groundChecker;
    private Vector3 velocity;
    private Vector3 lastFixedPosition;
    private Quaternion lastFixedRotation;
    private Vector3 nextFixedPosition;
    private Quaternion nextFixedRotation;



    // Start is called before the first frame update
    void Start()
    {
        m_characterController = GetComponent<CharacterController>();
        m_groundChecker = GetComponent<GroundChecker>();
        velocity = new Vector3(0, 0, 0);
        lastFixedPosition = transform.position;
        lastFixedRotation = transform.rotation;
        nextFixedPosition = transform.position;
        nextFixedRotation = transform.rotation;

        horizontalInput = 0.0f;
        verticalInput = 0.0f;
        jumpFlag = false;
    }

    // Update is called once per frame
    void Update()
    {
        if (!IsOwner) return;

        horizontalInput = Input.GetAxis("Horizontal");
        verticalInput = Input.GetAxis("Vertical");
        if (Input.GetKeyDown(KeyCode.Space))
        {
            jumpFlag = true;
        }

        float interpolationAlpha = (Time.time - Time.fixedTime) / Time.fixedDeltaTime;
        m_characterController.Move(Vector3.Lerp(lastFixedPosition, nextFixedPosition, interpolationAlpha) - transform.position);
        characterMesh.rotation = Quaternion.Slerp(lastFixedRotation, nextFixedRotation, interpolationAlpha);
    }

    private void FixedUpdate()
    {
        if (!IsOwner) return;

        lastFixedPosition = nextFixedPosition;
        lastFixedRotation = nextFixedRotation;

        Vector3 planeVelocity = GetXZVelocity(horizontalInput, verticalInput);
        float yVelocity = GetYVelocity();
        velocity = new Vector3(planeVelocity.x, yVelocity, planeVelocity.z);

        if (planeVelocity.magnitude / speed >= lookForwardThreshold)
        {
            nextFixedRotation = Quaternion.Slerp(characterMesh.rotation, Quaternion.LookRotation(planeVelocity), lookForwardSpeed * Time.fixedDeltaTime);
        }

        nextFixedPosition += velocity * Time.fixedDeltaTime;
    }

    private Vector3 GetXZVelocity(float horizontalInput, float verticalInput)
    {
        Vector3 moveVelocity = movementOrientation.forward * verticalInput + movementOrientation.right * horizontalInput;
        Vector3 moveDirection = moveVelocity.normalized;
        float moveSpeed = Mathf.Min(moveVelocity.magnitude, 1.0f) * speed;

        return moveDirection * moveSpeed;
    }

    /// <remarks>
    /// This function must be called only in FixedUpdate()
    /// </remarks>
    private float GetYVelocity()
    {
        if (!m_groundChecker.IsGrounded())
        {
            return velocity.y - gravitationalAcceleration * Time.fixedDeltaTime;
        }

        if (jumpFlag)
        {
            jumpFlag = false;
            return velocity.y + jumpForce;
        }
        else
        {
            return Mathf.Max(0.0f, velocity.y);
        };
    }

    private void OnControllerColliderHit(ControllerColliderHit hit)
    {
        if (hit.rigidbody)
        {
            hit.rigidbody.AddForce(velocity / hit.rigidbody.mass);
        }
    }

}
public class GroundChecker : MonoBehaviour
{
    [Header("Boxcast Property")]
    [SerializeField] private Vector3 boxSize;
    [SerializeField] private float maxDistance;
    [SerializeField] private LayerMask groundLayer;

    [Header("Debug")]
    [SerializeField] private bool drawGizmo;

    private void OnDrawGizmos()
    {
        if (!drawGizmo) return;

        Gizmos.color = Color.cyan;
        Gizmos.DrawCube(transform.position - transform.up * maxDistance, boxSize);
    }

    public bool IsGrounded()
    {
        return Physics.BoxCast(transform.position, boxSize, -transform.up, transform.rotation, maxDistance, groundLayer);
    }

}

번외: 물리 계산의 정확도 개선하기

내가 작성한 코드는 다음 프레임에서의 속도를 구하고, 이를 통해 다음 위치를 계산하는 방법을 사용한다. 이런 방법을 반 암시적 오일러 적분(semi-implicit Euler integration)이라 한다. 이 방법도 왠만한 물리엔진에서 사용해도 될 만큼 정확한 연산을 제공하지만, 더 정확한 계산을 원한다면 물리 계산에 다른 방법을 사용할 수도 있다.

그 중 하나의 방법은 베를레 적분이다. 간단히 설명하자면 베를레 적분은 이전 단계와 현재 시간 단계의 중간 시점에서의 속도를 계산하고, 이 평균 속도를 사용해 해당 시간 단계에서의 위치를 계산한다. 그 후 새로운 가속도를 구하고, 시간 단계가 끝나는 시점에 새로운 가속도를 적용한다. 이 방법은 연산 부하가 크지 않으면서도 반 암시적 오일러 적분보다 훨씬 정확하다.

레이싱 게임처럼 정확한 물리 계산이 아주 중요한 게임이라면, 4차 룽게 쿠타 방법을 사용할수도 있다. 확실히 반 암시적 오일러 적분이나 베를레 적분보다는 정확한 계산을 할 수 있지만, 연산이 확실히 무거우니 이 방법의 적용은 아주 정확한 물리 계산을 해야하는 경우에만 사용하자.

1개의 댓글

comment-user-thumbnail
2024년 1월 26일

많이 배워갑니다. 항상 무지성으로 FixedUpdate에 물리연산 처리를 해농았는데 단점이 크긴 하군요

답글 달기