서론:유니티의 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)로 값을 얻을 수 있습니다. 유니티에선 아래와 같이 사용할 수 있습니다.
왜 이렇게 함?: 사실 전 처음에 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$초 만큼 계산한 것과 같아집니다.