콘텐츠로 이동

FPS에 따라 속도 보정하기

특정 FPS에서 동작하던 코드를 더 높거나 낮은 FPS로 변경하면 동작하던 그 속도가 늘어나거나 줄어듭니다. 어떻게 해결할까요? 🤔

Danger

잘못된 내용이나 오타가 보이면 댓글 등으로 알려주세요.

개요

FPS에 따른 속도를 보정하는 코드를 바로 설명하기 전에 몇 가지 재미없는 이야기 좀 보고 갑시다! 😁

FPS라는 말은 많이 보고 들어보셨을테지만... 오늘 알아볼 FPS가 무엇인지 아시나요? 보통 FPS라고 하면 대중적으로 사용하는 두 약어가 있습니다. 초당 프레임 수를 의미하는 것과 1인칭 슈팅 게임을 의미하는 것 이 두 가지입니다. 오늘 우리가 알아볼 주제는 초당 프레임 수를 의미하는 FPS입니다.

FPS는 초당 프레임 수를 의미하는데 영어로 Frame Per Second라고 합니다. 여기서, 프레임은 정지된 이미지 1장을 의미합니다.

보통 프레임이 발생하는 속도를 표기하기 위해 FPS를 사용합니다. 30FPS라고 하면 초당 30 프레임을 표시할 수 있음을 의미하죠. 뭐... 좀 더 자세히 따지면 헤르츠Hz라는 단위가 별도로 있는데, 이는 모니터의 주사율과 프레임 레이트(프레임 표시 속도)를 구분하기 위해 따로 사용하는 편입니다.

고전 게임

D3D8로 게임을 개발하던 시절이나(?) 그 이전의 경우 지금처럼 하드웨어의 성능이 좋은 편이 아니었기 때문에 특정 FPS로 동작하도록 만든 게임들이 꽤 있습니다. 그리고 이 게임을 현대에 와서 플레이하려고 하면 멀미가 발생합니다. 왜냐하면 프레임 레이트가 대부분 낮기 때문에 화면이 툭툭 끊겨 부드러움을 느끼기 힘들고 이로 인해 불쾌감이 발생하여 멀미를 유발시킵니다. 3차원 공간을 이용하는 3D 게임이라면 더 심해지죠.

과거와는 달리 현재는 하드웨어의 성능이 엄청 좋아지고 있어서 보통 60FPS를 기반으로 개발에 착수하기 때문에 멀미 유발은 덜한 편입니다. FPS는 높으면 높을 수록 부드러운 화면 전환을 보여주기 때문에 어지간하면 높은게 좋습니다.

고전 게임을 포팅할 때 문제점

굳이 먼 시절에 만들어진 고전 게임이 아니라도 60FPS 미만으로 제작된 게임들이 은근 있습니다. 큰 문제는 없어보이지만 이를 현대에 와서 다시 리메이크 등을 할 때 문제가 발생합니다.

과거에 비해 더 부드러운 화면 전환과 입력 반응 속도를 보이기 위해 기대를 품지 않고 FPS를 높였는데, 높인만큼 속도가 매우 빠르게 증가해버리는 겁니다. 즉, 30FPS에서 동작하던 일반적인 수류탄 던지기가 매우 적당한 속도였는데, 60FPS로 변경했을 땐 약 2배 이상 빨라져 갑자기 야구 투수로 변해버리고 144FPS에선 대륙 간 탄도 미사일로 변해버리는 겁니다.

원인은 간단합니다. 특정 FPS에서만 동작하도록 작성한 코드라서 그렇습니다. 원인이 간단한만큼 수정하는 방법도 매우 간단합니다.

A += B, 가산과 감산 연산

A += B
1
2
3
int X = 1, SPEED = 2;

X += SPEED;

30FPS에서 캐릭터의 X축을 이동하기 위해 X += SPEED 연산을 수행하는 코드가 있다고 가정해봅시다. 30FPS는 초당 30 프레임을 의미하고, 이는 1초가 30 프레임이라는 것을 의미합니다. 그렇다면 위 코드를 정확히 30 프레임(1초)만 수행했을 때 X 변수의 최종 값은 얼마일까요?

갑자기 기분 나쁘게 질문해서 머릿 속이 복잡했겠지만... 최종 값을 구하는 방법은 간단합니다. 최종 값은 XSPEED를 30번 곱한 것을 더한 값이 됩니다. 즉, \(X + (SPEED \times 30)\)가 공식이라는 것이죠. 최종적으로 1초 후 값은 61이고, 이는 1초 동안 61만큼 이동한다는 뜻입니다. 못믿겠다면 직접 손으로 계산해보십시오.

뭐... 여기까지 보면 큰 문제는 없어보이지만, 진짜 문제는 FPS가 변경되었을 때 입니다. 60FPS와 144FPS로 변경했을 때 값의 변화를 표로 확인해봅시다.

시간 (초) 30FPS 60FPS 144FPS
1 61 121 289
2 121 241 577
3 181 361 865

30FPS 기준으로 적당히 움직이던 캐릭터가 60FPS나 그 이상으로 변경되면 우사인 볼트도 울 정도로 빠른 속도로 움직이거나 썬콜마냥 순간 이동을 해버립니다. 실시간 경쟁 게임이었다면 치명적인 밸런스 붕괴 현상이 일어나는 거죠. 어떻게 해결할까요?

델타 타임

게임 개발을 배우는 중이라면 델타 타임까지 들어보셨을 겁니다. 처음 듣는 분들을 위해 간단히 그 개념을 설명하겠습니다.

델타 타임Delta Time은 두 연속된 프레임 간의 시간 간격을 말합니다.

Laraticon
예? 않이 무슨 게임 개발하는데 이론까지?!

델타 타임이 등장하게 된 배경은 무엇일까요?

여러 이유가 있겠지만, 그중 하나는 하드웨어의 발전 때문? 덕분?입니다. 과거 게임 개발 시 하드웨어의 성능이 좋은 편이 아니라서 보통 고정된 프레임 속도를 기반으로 개발을 진행하였습니다. 그러다 시간이 흐르면서 하드웨어의 성능이 발전하고 좋아지면서 사용자에게 더 좋은 게임 경험을 선사하기 위해 프레임 속도를 더 높이거나 제한을 풀어버리게 됩니다.

하드웨어의 성능이 발전했다고 모든 사용자가 좋은 하드웨어를 사용한다는 보장은 없겠죠?

A 사용자는 저성능의 하드웨어를 사용하고 있어서 프레임 하락이 발생해 30FPS로 즐기고, B 사용자는 고성능의 하드웨어를 사용하고 있어서 60FPS로 즐기고 있다고 해봅시다. 한 프레임 당 캐릭터가 1씩 이동한다고 했을 때, A 사용자는 1초 후 30씩 이동하게 되고 B 사용자는 60씩 이동하게 되므로 남들은 걸어갈 때 누구는 텔레포트를 해버리는 불공평한 상황이 생깁니다.

하드웨어 간 성능에 따라 발생하는 값의 차이를 보간하기 위해 등장한 녀석이 바로 델타 타임입니다.

두 프레임 간의 시간 간격(델타 타임)을 산출한 후 이동 연산을 수행하는 코드에 곱하면 서로 다른 FPS를 갖고 있어도 동일한 이동 거리를 이동할 수 있도록 보간해줍니다. 굉장히 대단한 녀석이지요?

그래서 델타 타임은 어떻게 구하죠?

델타 타임은 금 나와라 뚝딱해서 나타나는 값이 아닙니다. 귀찮지만 직접 연산을 통해 얻어야하는 값이라서 별도의 연산 코드를 작성해야 합니다. 그렇다고 복잡한 수식이 들어가진 않고 대략 아래와 같은 코드로 쉽게 얻을 수 있습니다.

float current_time = GetCurrentTime();                  // 정밀 타이머 함수를 이용해 현재 시간 취득.
float delta_time = current_time - last_time;            // 델타 타임 산출
last_time = current_time;                               // 마지막 갱신 시간(이전 프레임의 시간)을 현재 취득한 시간으로.

델타 타임은 두 프레임 간의 시간 간격이기 때문에, 현재 프레임의 시간과 이전 프레임의 시간을 뺀 값이 바로 델타 타임이 됩니다. 델타 타임은 매 프레임마다 연산해서 얻어야 하기 때문에 Update와 같은 메서드(함수)에 작성하는 편입니다.

Info

개발 중인 게임의 FPS가 수시로 변하는 가변 프레임 레이트가 아닌 고정 프레임 레이트(수직 동기화)를 이용했다면 1초를 FPS로 나눈 값... 즉, 1.0 / MAX_FPS와 같은 코드로 고정된 델타 타임 값을 얻을 수 있습니다. 단, 하드웨어가 무조건 고정 프레임 레이트를 뽑아낸다는 보장이 없기 때문에 고정된 델타 타임 값은 잘 사용하지 않는 편입니다.

30FPS 60FPS 144FPS
0.033333 0.016667 0.006944

프레임 하락이 발생하지 않는다고 가정했을 때 얻을 수 있는 평균적인 델타 타임의 값은 위 표와 같습니다. 1.0 / FPS와 같습니다. 산출된 델타 타임의 값을 이동 연산을 수행하는 코드에 곱하면 서로 다른 FPS를 가지고 있어도 동일한 거리를 이동하도록 보간할 수 있습니다. 말 그대로 보간이기 때문에 근삿값을 얻어내 이동하는 겁니다.

프레임의 비율

자, 델타 타임을 통해 동일한 거리를 이동하도록 보간하는 방법까지 배웠는데요... 포팅을 하는 경우라면 30FPS에서 동작하던 그 속도 그대로 가져와야겠죠? X += SPEED * DELTA_TIME이라는 식으로 델타 타임을 곱해 개선을 했지만 아래 표와 같은 문제가 발생했습니다.

시간 (초) 30FPS 60FPS 개선 144FPS 개선
1 61 2.016707 2.006816
2 121 4.016747 4.006688
3 181 6.016787 6.00656

델타 타임의 값을 곱했더니 값이 너무 작아져 속도가 더 느려지는 겁니다. 30FPS에서 동작하던 그 속도를 그대로 옮기려면 델타 타임이 아닌 프레임의 비율 값을 곱해주어야 합니다. 프레임의 비율은 기반 FPS를 현재의 FPS로 나눈 값을 말합니다.

즉, X += SPEED * (BASE_FPS / FPS)의 식으로 작성해야 30FPS에서 동작하던 그 속도를 유지할 수 있습니다. 30FPS 포팅하는 경우라면 BASE_FPS는 30이 되고, FPS는 현재의 FPS의 값이 할당되는 겁니다.

포팅하는 경우가 아니라면 보통은 델타 타임을 곱하는 식을 작성하는 경우가 많을 거예요.

그냥 델타 타임 곱하고 속도 늘리면 안 됨?

시간 (초) 30FPS 60FPS 144FPS
1 61 121 289
2 121 241 577
3 181 361 865

보정이 안 된 위 표의 값들에 델타 타임을 곱하면 아래와 같이 되는데요,

시간 (초) 30FPS 보정 60FPS 보정 144FPS 보정
1 2.033313 2.016707 2.006816
2 4.033293 4.016747 4.006688
3 6.033273 6.016787 6.00656

X += SPEED * DELTA_TIME이라는 식을 사용하고 맘 편하게 SPEED 변수의 값을 늘리자라는 말인데요... 단순한 경우라면 그래도 됩니다. SPEED가 2였을 때 위와 같은 결과가 나오는데요 이를 60 등으로 변경하면 30FPS에서 동작하던 그 속도가 나오겠죠? 소수점으로 인해 발생하는 오차는 버리거나 올리는 연산 등을 통해 어느정도 보완할 수 있습니다.

A *= B, 곱셈 연산

A *= B
1
2
3
float X = 1, SPEED = 1.01;

X *= SPEED;

A += B라는 가산 또는 감산 연산에서 델타 타임을 곱하거나 프레임의 비율을 곱해 동일한 속도로 움직이도록 보간하는 방법에 대해 알아보았습니다. 하지만, 지금 알아볼 A *= B와 같은 곱셈 연산의 경우 조금 복잡해집니다. 위 코드를 각각 30FPS, 60FPS, 144FPS에서 수행했을 때의 값을 표로 확인해봅시다.

시간 (초) 30FPS 60FPS 144FPS
1 1.347848 1.816696 4.190615
2 1.816696 3.300386 17.561259
3 2.448632 5.995801 73.592486

3D 게임에서 A *= B라는 식은 보통 중력이나 가속도와 관련된 식에서 많이 사용되는데요... 이 코드를 FPS가 변해도 일정한 변화율을 보이도록 하려면 이 역시 보간이 필요해집니다. 만약, 수류탄의 중력과 관련된 코드인데 보간을 수행하지 않으면 수류탄이 떨어지는 속도가 매우 빨라져 대륙 간 탄도 미사일이 내 좌표로 찍힌 것 같은 상황이 발생합니다. 끔찍하죠?

델타 타임으로 극복이 안 되나요?

X *= SPEED * DELTA_TIME이라는 식으로 수행할 경우 오히려 값이 더 작아져버리고, 프레임의 비율을 이용해도 극복이 되지 않습니다.

POW

pow 함수를 아시나요? X의 Y 거듭제곱된 값을 산출해주는 함수입니다. pow(2, 5)처럼 사용하면 \(2 \times 2 \times 2 \times 2 \times 2\)를 구해주는 친절한 친구입니다. 프로그래밍 언어를 처음 공부했을 때 이 녀석 도대체 어디에 쓰는거지? 했는데... 이 녀석 알고보니 쓸모가 있는 녀석이었습니다. A *= B라는 식을 보간할 때 사용합니다. 조금 더 유식한 척해서 말하면 적분(미분)을 이용한다는 건데요.. 이 부분은 저도 잘 모르기 때문에 패스합니다.

아무튼 A *= B라는 식을 A += A * (POW(B, BASE_FPS / FPS) - 1.0)이라는 식으로 변경해 보간을 수행할 수 있습니다.

Laraticon
예? 않이 뭐 이해도 안 가는 식을...

A += A * (POW(B, BASE_FPS / FPS) - 1.0)라는 식으로 변경하면 매 프레임마다 A의 값을 조금씩 갱신해서 BASE_FPS에서 수행하던 근삿값을 얻어내 동일한 속도로 움직이도록 할 수 있다는 겁니다. 델타 타임처럼 근삿값을 얻어내 동일한 속도로 이동하도록 보간하는 원리이고, 델타 타임은 사용되지 않았지만 대신 프레임의 비율이 사용되었습니다.

A *= B는 단순히 매 프레임마다 AA * B를 수행한 값을 할당합니다. 이러한 코드는 프레임 속도에 따라 결과가 달라집니다. 프레임 속도와 관계없이 일정한 속도를 유지하기 위해 pow 함수를 이용한 보간을 수행하는 것입니다. 완벽히 같은 값을 얻어내긴 힘들지만 근삿값을 취득해 동일한 속도처럼 보이게 할 수 있습니다.

pow가 아닌 logexp를 이용해 얻을 수 있지만 복잡해지고 정확도를 보장하기 어렵기 때문에 보통 효율적으로 사용할 수 있는 pow를 사용합니다.

시간 (초) 30FPS 60FPS 144FPS
1 1.347848 1.816696 4.190615
2 1.816696 3.300386 17.561259
3 2.448632 5.995801 73.592486
시간 (초) 30FPS 60FPS 개선 144FPS 개선
1 1.347848 1.347848 1.347848
2 1.816696 1.816696 1.816696
3 2.448632 2.448632 2.448632