[수학] 지수 함수를 이용해 프레임 독립 Damping 구현하기

만들 것: 플레이어가 멀리 있으면 빨리, 가까이 있으면 느리게 플레이어를 따라가는 카메라

  • 서론: 유니티의 cinemachineCamera에는 Damping이라는 기능이 있습니다. 카메라가 딱딱하게 플레이어를 바로 따라가는 것이 아닌, 플레이어가 빨리 달리면 카메라가 플레이어를 쫓아가는 것처럼 조금 뒤처지게 되는 기능입니다. 이 기능엔 특징이 있는데, 플레이어가 멀리 있으면 빠르게 쫓아가고 가까이 있으면 천천히 따라간다는 것입니다. 카메라의 속도가 정해져 있으면 사실 플레이어를 바로 따라가는 것과 별 다를 것 없이 딱딱한 느낌이 들기 때문에 고무줄처럼 탄성이 느껴지는 카메라 움직임을 만들기 위해선 이 댐핑이란 기능이 필요합니다. 플레이어를 따라가는 것이라면 몰라도 시네머신 카메라엔 맵의 중앙을 중심으로 플레이어를 바라보게 회전하는 기능은 없기 때문에 직접 구현하게 되었습니다.
더보기

참고 사진

  • 아이디어: 맵의 중심으로부터 플레이어까지의 각도를 구하는건 간단하고, 그 각도에 맞게 카메라를 이동시키면 카메라는 플레이어를 바라볼 것입니다. 하지만 댐핑 기능을 넣기 위해선 현재 각도와 플레이어를 정확하게 바라볼 수 있는 목표 각도를 구한 후, 목표 각도와 현재 각도의 오차량에 비례하여 카메라를 돌려야 합니다. 그렇다면 \[\frac{dN(t)}{dt} = -kN(t)\] 라는 식으로 프레임과 프레임 사이의 오차량(N(t)) 변화를 구하는게 가능합니다.
    • $\frac{dN(t)}{dt}$: 이건 N(t)이라는 함수의 시간에 대한 미분입니다.라는 게 무슨 말이냐면, N(t)이라는 함수에 t라는 특정한 시간을 대입했을 때, 딱 그 순간적인 시간에 해당하는 기울기입니다. 함수의 그래프의 기울기는 x축의 변화량으로 x값이 변화할 때 변화하는 y값의 변화량을 나눈 $\frac{dy}{dx}$인데, 그래프의 모양이 직선이면 쉽게 기울기를 구할 수 있습니다. 하지만 그래프가 구불구불하면 딱 특정한 구간의 순간적인 변화율의 모음으로 기울기가 구성될 텐데, 그 아주 아주 짧은 순간의 기울기를 구하는 것이 미분이고, $\frac{dN(t)}{dt}$의 $dN(t)$가 $t$라는 순간의 아주 짧은 값의 변화량(y축), $dt$가 그 아주 짧은 시간(x축)으로 기울기인 셈입니다. 근데 갑자기 $N(t)$의 순간적인 기울기는 왜 구하냐 하면 $N(t)$의 값을 오차량으로 사용할 것이기 때문입니다. 자세한 설명은 곧 하겠습니다.
    • $-k$: 아까 플레이어가 멀면 빨리 따라가고, 가까이 있으면 느리게 따라간다고 했는데, 그 애매한 느리게와 빠르게를 $k$로 정합니다. $k$가 크면 플레이어에게 딱 달라붙어 따라가고, 낮으면 플레이어를 제대로 따라가지 못하는 감쇠(저항/마찰)의 역할을 한다고 생각하시면 됩니다.
    • $N(t)$: 이게 중요한데, 방금 $\frac{dN(t)}{dt}$이게 $N(t)$ 그래프에서 딱 t일 때의 기울기라고 설명했습니다. 쉽게 $t$를 1초라고 하면, 정확히 1초라는 순간의 $N(t)$의 기울기는 그 순간의 값($N(1)$)에 $-k$를 곱한 것과 같다는 것입니다. $N(T)$이 10이고 $k$가 0.5라고 가정을 해보겠습니다. 기울기($\frac{dN(T)}{dt}$)가 $10 * -0.5$인 -5가 됩니다. 그리고 $N(T)$가 5라면 기울기는 $5 * -0.5$인 -2.5가 되겠죠. 기울기가 -5일 때는 빠르게 오차가 0이 될 테고 값이 작아져 기울기가 -2.5일 때는 -5일 때보단 느리게 오차량이 0이 됩니다. 여기서 특징은 시간에 따른 $N(t)$의 변화율이 우리가 원하던 오차량의 변화율(오차량이 크면 빨리 오차량을 줄이고, 오차량이 적으면 천천히 오차량을 줄임)과 같습니다. 고로 우리는 $N(T)$를 오차량으로 둘 수 있습니다. 그럼 $N(T) =$ ~ 꼴로 만들어서 쉽게 값을 구할 수 있게 해 보겠습니다.
  • $N(T) =$ ~ 꼴로 만들기(오차 있지만 간단 버전): $\frac{dN(t)}{dt}$은 N(t)을 미분한 값입니다. 그리고 미분은 \[\displaystyle f'(x) = \lim_{h \to 0} \frac{f(x+h)-f(x)}{h}\]라는 이 미분 방정식에 함수와 x를 대입하여 x에서부터 x에 0에 아주 가까운 수를 더한 값까지의 기울기를 구하여 할 수 있습니다. 그리고  $\frac{dN(t)}{dt} = -kN(t)$에서 $\frac{dN(t)}{dt}$를 미분 방정식으로 치환할 수 있고, 치환하면 \[\lim_{h \to 0} \frac{N(t+h)-N(t)}{h} = -kN(t)\] 라는 식이 됩니다. 이 식에서 $h$를 순간이 아닌 아주 작은 수로 두면 정확하진 않아도 $h$가 순간일 때의 값과 비슷한 근삿값이 나옵니다. 근삿값으로 바꾼 식에서 양변에 $h$를 곱하고 $-N(t)$를 이항 하면 $N(t)$가 2개 생기기 때문에 결합 법칙을 사용하여 이런 식을 만들 수 있습니다. \[N(t+h) \approx N(t)(1-kh)\] 이 식은 $h$가 순간이 아닌 아주 짧은 시간이기 때문에(아주 짧은 시간은 0.01 정도) 오차가 생길 수 있기는 하지만 엄청 크진 않아 간단한 로직이거나 최적화가 필요할 때 사용할 수 있습니다. 유니티에서 아래와 같이 사용할 수 있습니다.
더보기
N = N * (1 - k * deltaTime);
// N(t)은 t만큼의 시간이 흘렀을 때의 N(t), 즉 기존의 N(t)입니다.
// 0에 가까운 deltaTime을 h에 대입하여 deltaTime이 흘렀을 때
// N을 얻을 수 있기는 하지만, 원래 0에 수렴하는 h와는 차이가 있기 때문에
// 우리가 원했던 아주 찰나의 기울기가 아닌 deltaTime을 분모로 하는 평균 기울기가 되어
// 약간의 오차가 생깁니다.

 

  • $N(T) =$ ~ 꼴로 만들기(정확하지만 어려운 버전): 어려운 버전도 방금 전에 설명한 $N(t+h) \approx N(t)(1-kh)$ 에서 이어집니다. 그런데 이번엔 시간을 흐른 시간 + 짧은 시간이 아닌 짧은 시간 + 짧은 시간 + 짧은 시간... 이렇게 짧은 시간 * n으로 나타내 봅시다. 이 식은 아주 간단한데, 아까 식에서도 $N(t)$라는 기존의 $N(t)$값에 $(1 - k * h)$를 곱하면 $t + h$의 시간이 흐른 값이 나왔기 때문에 $(1 - k * h)$를 한 번 더 곱해서 $t + h + h$의 시간이 흘렀을 때의 값을 구할 수 있습니다. 그러니까 $t$가 0이라면 그 처음의 $N(0)$에 $(1 - k * h)$를 n번 곱하면 $nh$가 총 흐른 시간이 되고 즉 $N(nh) \approx N(0)(1-kh)^n$라는 식을 만들 수 있습니다. $n * h$는 흐른 전체 시간이기 때문에 $T$이기도 하기에 이런 식으로 바꿀 수 있습니다. $N(T) \approx N(0)(1-kh)^{T/h}$ 아까 h를 그냥 작은 수로 두어 deltaTime을 대입했는데, 다시 0에 수렴하게 바꿔주겠습니다. \[N(T)=N(0)\lim_{h\to0}(1-kh)^{T/h}\] 이렇게 $\lim_{h \to 0}$을 붙여 근사가 아닌 정확한 값이 나오는 수식을 만들어줍니다. 수학을 잘 아시는 분은 위의 수식을 보고 무언가가 떠오르셨을 텐데, 바로 지수함수 $e^{x}$의 극한 정의\[e^{x}=\lim_{h\to 0}(1+xh)^{1/h}\] 입니다. $(1-kh)^{T/h}$은 $((1-kh)^{1/h})^{T}$으로 바꿀 수 있고, $N(0)\lim_{h\to0}((1-kh)^{1/h})^{T}$은 방금 말한 $e^{x}$가 $e^{x}=\lim_{h\to 0}(1+xh)^{1/h}$인 것에 따라 $x$만 $-k$로 바꿔서 $N(0)(e^{-k})^{T}$가 되고 마지막으로 \[N(0)e^{-kT}\]가 됩니다. 그리고 $e^{-kT}$는 Mathf.Exp(-k * T)로 값을 얻을 수 있습니다. 유니티에선 아래와 같이 사용할 수 있습니다.
더보기
float k = 0.25f;
float target;
float current;

void Update()
{
    float error = target - current;

    float decayedError = error * Mathf.Exp(-k * Time.deltaTime);

    current = target - decayedError;
}
  • 왜 이렇게 함?: 사실 전 처음에 current = Mathf.Lerp(current, target, Time.deltaTime * speed); 이런 식으로 코드를 작성했는데 GPT가 이렇게 쓰면 안 된다고 해서 생각을 해보니 이 방식에는 큰 문제가 있었습니다. 만약 메서드의 인수가 순서대로 0, 10, 0.1(10 fps), 1이라고 가정하고, Update() 메서드에서 계속 실행시킨다면 코드가 실행될 때마다 current와 target 사이의 거리는 current와 target의 거리 * 0.1만큼 가까워질 것입니다. 그런데 만약 Time.deltaTime이 바뀐다면, 그러니까 빠른 컴퓨터에서 fps가 10이 아닌 20가 나온다고 하면 코드가 실행될 때마다 current와 target 사이의 거리는 current와 target의 거리 * 0.05만큼 가까워질 것입니다. 단순히 생각하면 느린 컴퓨터는 0.1초에 오차량 * 0.1만큼 가까워지고 0.05초마다 가까워지는 빠른 컴퓨터는 오차량 * 0.05만큼 밖에 가까워지지 않기 때문에 공정하다고 생각될 수 있습니다. 하지만 실제로 계산을 해보면 오차량이 처음에 10이라고 했을 때 10 * 0.1만큼 가까워져 거리가 9가 되어 0.1초가 흐른 후 느린 컴퓨터는 오차량이 8.1이 됩니다. 하지만 빠른 컴퓨터는 0.05초에  10 * 0.05만큼 가까워져 거리가 9.5가 되고, 0.1초에 9.5 * 0.05만큼 가까워져 오차량이 9.025가 됩니다. 지금만 봐선 0.025 차이가 대수인가 싶을 수 있지만 계산을 10번만 더 해봐도 꽤나 유의미한 차이가 생긴다는 것을 알 수 있습니다. 즉 이런 식입니다. 예를 들어 0.3초가 흘러서 계산하고 또 0.2초가 흘러서 계산한 결과와(좌측의 식) 0.2 + 0.3인 0.5초가 흘렀을 때 계산한 결과가(우측의 식) 다릅니다. 이렇게 되면 컴퓨터의 사양에 따라 속도가 유의미하게 바뀌기 때문에 프레임에 영향을 받지 않는 식이 필요했습니다. 방금 구한 식을 봐봅시다. &N(0)e^{-kdt1}& 이 결과가 오차량이 되기 때문에 시간이 dt2일 때 다시 곱한다면 $N(0)e^{-kdt1}e^{-kdt2}$가 되고, 정리하면 $N(0)e^{-kdt1 + -kdt2}$가 됩니다. 그럼 n번 반복한다고 했을 때 $N(0) e^{-k(dt_1 + dt_2 + \cdots + dt_n)}$ 가 됩니다. 그럼 $dt_1$과 $dt_2$를 따로 계산했음에도 \[N(0)e^{-k dt_1} e^{-k dt_2} = N(0)e^{-k(dt_1 + dt_2)}\] 라는 식을 보면 모든 dt가 더해져 있기 때문에 $dt_1$을 계산하고 그 계산한 값을 다시 $dt_2$를 사용하여 다시 계산해도 $dt_1 + dt_2$초 만큼 계산한 것과 같아집니다.

결과

더보기

사용한 코드

using _Project.Core.Utilities.Debugging;
using Assets._Project.Core.Utilities.Helpers;
using Assets._Project.Features.CameraMove.Scripts;
using Assets._Project.Features.CameraMove.Scripts.SO;
using System;
using Unity.Android.Types;
using UnityEngine;

namespace Assets._Project.Features.CameraMove.Scripts.MoveCam
{
    public class CompassCamera : ICameraValueSetter
    {
        public Vector3 CamTrnEulerAngle { get; private set; }
        public float CamLength { get; private set; }
        public Quaternion RotatedQuaternion
        {
            get
            {
                return Quaternion.Euler(CamTrnEulerAngle - new Vector3(_xEulerOffset, 0, 0));
            }
        }
        public bool IsFollowCam => false;
        public Vector3 CamTrnPosition { get; private set; }

        private readonly float _damping;
        private readonly float _rotationThreshold;
        private readonly float _yPosition;
        private readonly float _xzMagnitude;
        private readonly float _lengthIncreaseValue;
        private readonly float _maxLength;
        private readonly float _xEulerOffset;
        private readonly float _lengthDamping;

        private bool _didInitCamValue;
        private readonly float _firstEulerY = 0;

        public CompassCamera(CompassParam compassParam)
        {
            _damping = compassParam.Damping;
            _rotationThreshold = compassParam.RotationThreshold;
            _xzMagnitude = compassParam.XZMagnitude;
            _yPosition = compassParam.YPosition;
            _lengthIncreaseValue = compassParam.LengthIncreaseValue;
            _maxLength = compassParam.MaxLength;
            _lengthDamping = compassParam.LengthDamping;
            _xEulerOffset = compassParam.XEulerAngle;

            _didInitCamValue = false;
        }

        public void CalculateCameraValue(CameraCalcParam calcParam)
        {
            //초기 세팅
            if (_didInitCamValue == false)
            {
                InitCamValue(calcParam);
                _didInitCamValue = true;
            }

            //폰이 돌아간 만큼 Damping으로 카메라 줌 아웃하기
            SetCameraLength(calcParam);

            //카메라 초기화 후, 나침반 안에 있으면 움직이지 말기 
            if (GetPlayerIn(calcParam) == true)
            {
                return;
            }

            //플레이어와 중앙 사이의 벡터와 거리 구하기
            Vector3 playerBetCenter = calcParam.PlayerPosition - calcParam.CenterOfGround;
            Vector2 playerBetCenterXZ = new(playerBetCenter.x, playerBetCenter.z);

            //거리와 방향 벡터 구하기
            float distance = playerBetCenterXZ.magnitude;
            Vector2 angleVector = -playerBetCenterXZ.normalized;

            //거리와 방향 벡터로 카메라의 각도를 구하고 방향 벡터로 위치 구하기
            float newAngle = GetAngle(distance, angleVector);
            Vector2 posVector = new Vector2(Mathf.Sin(Mathf.Deg2Rad * newAngle), Mathf.Cos(Mathf.Deg2Rad * newAngle)) * -_xzMagnitude;

            //대입하기
            CamTrnEulerAngle = new Vector3(CamTrnEulerAngle.x, newAngle, CamTrnEulerAngle.z);
            CamTrnPosition = calcParam.CenterOfGround + new Vector3(posVector.x, _yPosition, posVector.y);
        }

        private void InitCamValue(CameraCalcParam calcParam)
        {
            CamTrnEulerAngle = new Vector3(_xEulerOffset, _firstEulerY, CamTrnEulerAngle.z);
            Vector2 posVector = new Vector2(Mathf.Sin(Mathf.Deg2Rad * _firstEulerY), Mathf.Cos(Mathf.Deg2Rad * _firstEulerY)) * -_xzMagnitude;
            CamTrnPosition = calcParam.CenterOfGround + new Vector3(posVector.x, _yPosition, posVector.y);
        }

        private float GetAngle(float distance, Vector2 angleVector)
        {
            float targetAngle = Mathf.Atan2(angleVector.x, angleVector.y) * Mathf.Rad2Deg;

            float delta = Mathf.DeltaAngle(CamTrnEulerAngle.y, targetAngle);
            //각도가 90도를 넘으면 각도를 반대편으로 계산하기
            if (Mathf.Abs(delta) > 90f)
            {
                targetAngle = Mathf.Repeat(targetAngle + 180f, 360f);
            }

            //Exp, Lerp로 Damping 구현해서 조금씩 각도 바뀌게하기
            float angleT = 1f - Mathf.Exp(-_damping * distance * Time.deltaTime);
            float newAngle = Mathf.LerpAngle(CamTrnEulerAngle.y, targetAngle, angleT);
            return newAngle;
        }

        private void SetCameraLength(CameraCalcParam param)
        {
            float targetLength = param.MoveDirMagnitude * _lengthIncreaseValue;
            float lengthT = 1f - Mathf.Exp(-_lengthDamping * Time.deltaTime);
            CamLength = Mathf.Min(Mathf.Lerp(CamLength, targetLength, lengthT), _maxLength);
        }

        private bool GetPlayerIn(CameraCalcParam param)
        {
            //필요한 값 할당
            Vector2 center = new(param.CenterOfGround.x, param.CenterOfGround.z);
            Vector2 playerPos = new(param.PlayerPosition.x, param.PlayerPosition.z);
            Vector3 camDir3 = Camera.main.transform.position - param.CenterOfGround;
            Vector2 camDir = new Vector2(camDir3.x, camDir3.z).normalized;
            Vector2 camPosInMap = center + camDir * param.RadiusOfGround;

            //중앙과 가까운 삼각형의 두 꼭짓점 구하기
            Vector2 triLeft = center + new Vector2(-camDir.y, camDir.x) * _rotationThreshold;
            Vector2 triRight = center + new Vector2(camDir.y, -camDir.x) * _rotationThreshold;

            //삼각형 1,2와 가운데에 원 안에 플레이어가 있는지 확인
            bool firstTri = Goe2D.PointInTriangle(playerPos, camPosInMap, triLeft, triRight);
            bool secTri = Goe2D.PointInTriangle(playerPos, center - (camPosInMap - center), triLeft, triRight);
            bool circle = (playerPos - center).sqrMagnitude <= _rotationThreshold * _rotationThreshold;

            //셋 중 하나라도 플레이어가 겹치면 true 반환
            return firstTri || secTri || circle;
        }
    }
}

'Etc > 수학' 카테고리의 다른 글

[수학] 라디안, 도(°)를 방향 벡터로 바꾸는 방법  (0) 2025.06.03