콘텐츠로 이동

<게임 개발> FPS에 따라 속도 조정하기

특정 FPS에서 동작하던 코드를 더 높거나 낮은 FPS로 변경했을 때 원본의 속도 그대로 유지하려면 어떻게 해야할까요?

Info

  • 본 게시글의 내용은 30FPS에서 동작하던 게임의 속도와 변화율을 그 이상 또는 이하에서 그대로 옮기고 싶을 때를 가정하고 있습니다.
  • 잘못된 내용 발견하시면 댓글로 알려주세요. 정정하겠습니다! 😄

개요

설정한 FPS에 따른 게임 속도와 변화율을 보정하는 방법에 대해 알아보기 전에 재미없고 지루한 이야기 좀 읽고 가봅시다. (필수 아님)

FPS라는 말 많이 보고 들어보셨죠? 오늘 우리가 다룰 이 FPS가 무엇인지 아시나요?

보통 FPS라하면 대중적으로 언급되는 두 가지가 있습니다. 초당 프레임 수를 의미하는 FPS와 1인칭 슈팅 게임을 의미하는 FPS죠. 오늘 우리가 알아볼 것은 초당 프레임 수FramePerSecond를 의미하는 FPS입니다. 프레임Frame은 정지된 이미지 1장을 의미합니다.

우리는 프레임이 발생하는 속도를 표기하기 위해 숫자 뒤에 FPS라는 단위를 뒤에 수식합니다. 30FPS라고 하면 초당 30 프레임을 표시함을 의미하죠. 뭐... 조금 더 엄밀하게 따지자면 헤르츠Hz라는 단위가 있지만, 이 단위는 보통 모니터의 주사율을 표시할 때 쓰기 때문에 따로 구분되어 사용하는 것 같습니다.

고전 게임

아주 오래된 게임의 경우(대략 1998 ~ 2004 사이정도?) 지금처럼 하드웨어의 성능이 좋지 않았기 때문에 특정 FPS에서 동작하도록 만들어진 게임이 꽤 있습니다. 그리고 이 게임을 현대에 와서 즐기려하면 여러가지 문제가 발생합니다. 호환성과 해상도는 나중 문제로하고... 프레임 레이트가 낮기 때문에 화면이 툭툭 끊겨 보이고 3D 게임을 하면 멀미가 엄청나게 발생합니다.

과거와는 다르게 현재는 하드웨어의 성능이 계속 좋아지고 있어서 60FPS를 기반으로 하거나 어느정도 더 높여서 개발에 착수하는 편입니다. FPS가 높으면 높을 수록 부드러운 화면 전환을 보여주기 때문에 멀미 유발도 줄어들고 눈이 편안해집니다.(1)

  1. FPS는 높으면 높을 수록 좋습니다. 왜냐하면 부드러운 화면 전환과 입력 지연(인풋랙)을 줄여주기 때문입니다. 다만, 엄청나게 높으면 GPU의 사용률이 증가하여 고주파 소리가 들릴 수도 있습니다.

고전 게임 포팅할 때 문제점

사실 굳이 먼 시절에 만들어진 게임이 아니라도 특정 FPS에서 동작하도록 만들어진 게임이 은근 있습니다. 왜냐하며 특정 FPS에서 동작하는 속도와 변화율이 적절하다고 느끼는 개발자도 있으니까요.

게임을 즐기는데 큰 문제는 없지만 이를 현대에 와서 다시 리메이크(또는 리마스터)할 때 문제가 발생합니다.

과거에 비해 더 부드러운 화면 전환과 입력 속도를 보이기 위해 기대를 품지 않고 FPS를 높였더니 높인만큼 게임의 속도와 변화율이 매우 빨라지는 겁니다. 즉, 30FPS에서 동작하던 수류탄 던지기가 60FPS에선 2배 이상 빨라져 갑자기 야구 투수로 변해버리는 겁니다. 144FPS에선 RPG-7으로 변해버리죠.

원인은 간단합니다. 특정 FPS에서 동작하도록 설계된 코드라 그래요. FPS에 따른 속도를 고려하지 않고 작성했기 때문에 그렇고 원인이 간단한만큼 수정하는 방법도 간단합니다.

가산과 감산 연산 (A += B)

1
2
3
4
5
6
7
int32_t x = 1;
int32_t speed = 2;

// 대충 X축 이동 연산
if (Input::CheckKeyNow("right")) {
    x += speed;
}

30FPS에서 동작하는 게임에서 캐릭터의 X축을 이동하기 위해 x += speed;라는 연산을 수행한다고 가정해봅시다.

30FPS는 초당 30 프레임을 의미하고 이는 1초가 30 프레임이라는 것을 나타냅니다. 자, 위 코드를 정확히 1초(30 프레임)만 수행했을 때 x의 값은 얼마가 될까요?

갑자기 기분 나쁘게 연산 질문을 해서 머리가 띵~ 하겠지만... 값을 구하는 방법은 간단합니다. xspeed를 30번 곱한 값이 할당되죠. x + (speed * 30)이 공식이 되겠죠? 최종적으로 1초 후 값은 61이고 1초 동안 61만큼 이동합니다.

사실 여기까지 보면 뭐가 문제인데? 할 수 있습니다. 진짜 문제는 FPS가 변경되었을 때죠. 60FPS와 144FPS로 높였을 때 값의 변화를 표로 확인해봅시다:

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

30FPS에서 적당히 움직이던 캐릭터가 60FPS에선 우사인 볼트로 144FPS에선 축지법을 써버리는 캐릭터로 변해버립니다. 실시간 경쟁 게임이었다면 치명적인 상황이겠죠?

델타 타임

사실 여기까지는 게임 개발을 조금이라도 배워보신 분은 델타 타임을 떠올렸을 겁니다. 사실 저는 떠올리지도 못했습니다... 🤔

델타 타임을 처음 듣는 분을 위해 간단하게 설명하고 넘어가겠습니다. 델타 타임은 두 연속된 프레임 간의 시간 간격입니다.

laraticon-1
게임 개발하는데 무슨 이론까지...

델타 타임이 등장하게 된 배경을 아시나요? 여러가지 이유가 있겠지만 저는 하드웨어의 발전이 큰 몫을 했다고 봅니다.

하드웨어가 날이 갈 수록 발전한다고 해도 모든 사용자가 발전된 하드웨어를 사용하는 것은 아닙니다. A 사용자는 저성능의 하드웨어를 사용하고 있어 프레임 하락이 발생해 30FPS로 게임을 즐기고, B 사용자는 고성능의 하드웨어를 사용하고 있어 60FPS에서 게임을 즐기고 있다고 해봅시다. 게다가 이 게임은 델타 타임이 개념이 적용되지 않았다고 또 가정해보고요.

한 프레임 당 캐릭터가 1씩 이동한다고 가정했을 때 A 사용자는 1초 후 30씩 이동하게 되고 B 사용자는 60씩 이동하게 됩니다. 누구는 걸어갈 때 누구는 뛰는거죠. 굉장히 불공평한 상황이 발생하죠.

하드웨어 간 성능에 따라 발생하는 차이 그리고 일관된 동작을 보장하기 위해 등장한 것이 델타 타임DeltaTime입니다.

두 프레임 간의 시간 간격(델타 타임)을 산출한 후 이동 연산을 수행하는 코드에 곱하면 서로 다른 FPS로 동작하더라도 동일한 이동 거리를 이동하도록 보간할 수 있습니다. 대단하죠?

그래서 어떻게 구하는데요?

델타 타임은 게임 엔진에서 기본으로 제공하는데요, 직접 그래픽스 API를 통해 개발하신다면 직접 산출해야 합니다. 복잡한 식을 요구하지는 않습니다.

DeltaTime.cpp
1
2
3
float currentTime = GetCurrentTime();           // 정밀한 타이머 함수 등을 통해 현재 시간을 취득한다.
float deltaTime = currentTime - lastTime;       // 두 프레임 간의 시간 간격
lastTime = currentTime;                         // 마지막 갱신 시간

현재 프레임의 시간과 이전 프레임의 시간을 뺀 값이 델타 타임이 됩니다. 델타 타임은 매 프레임마다 연산해서 얻기 때문에 보통 Update와 같은 메서드 안에 작성됩니다.

실수형 자료를 사용하기 때문에 정밀도 문제를 가질 수 있지만 그리 큰 문제로 이어지진 않습니다.

델타 타임의 최근 평균을 구해 사용하는 방법도 있는데, 이정도까진 안해도됩니다.

고정 델타 타임

1초를 FPS로 나눈 값... 1.0 / MAX_FPS와 같은 식으로 델타 타임을 바로 얻을 수 있습니다. 다만, 하드웨어가 무조건 설정한 FPS를 뽑아낸다는 보장이 없기 때문에 이러한 식은 잘 사용하지 않습니다.

30FPS 60FPS 144FPS
0.033333 0.0166667 0.006944

프레임 하락이 발생하지 않는다고 가정했을 때 평균적으로 얻을 수 있는 델타 타임은 위 표와 같습니다.

산출된 델타 타임의 값을 이동 연산을 수행하는 코드에 곱하면 서로 다른 FPS에서 동작하더라도 같은 이동 거리를 움직이도록 보간할 수 있습니다.

프레임의 비율

델타 타임을 통해 동일한 이동 거리를 이동할 수 있도록 보정(보간)하는 방법을 배웠는데요... 게임을 포팅하는 경우라면 동작하던 속도와 변화율을 그대로 가져와야겠죠?

뭐... 변화를 주실 생각이라면 아래의 내용은 필요 없을 수도 있습니다. 다만, 우리는 게임의 근본을 유지하기 위해(?) 원래의 속도와 변화율을 가져온다고 가정해봅시다.

x += speed * deltatTime 식으로 수정해 동일한 이동 거리를 움직이도록 개선했지만 아래 표와 같은 문제가 발생했습니다:

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

deltaTime의 값을 곱했더니 오히려 값이 더 작아져 속도가 엄청나게 느려지는 겁니다. 이럴 때는 델타 타임이 아닌 프레임의 비율의 값을 곱해야합니다. 따로 거창하게 있는 그런 용어나 개념은 아니고, 기반 FPS를 현재의 FPS로 나누어 산출된 값(비율)입니다.

x += speed * (BASE_FPS / FPS) 식으로 작성해야 30FPS에서 동작하던 속도를 그대로 옮길 수 있습니다. 30FPS의 게임을 포팅하는 경우라면 BASE_FPS는 30이고 FPS는 현재의 FPS 값이 들어가면 되겠죠?

음... 그냥 speed의 값을 늘리면 되지 않을까요?

그냥 맘 편하게 speed의 값을 증가시켜도 됩니다. 뭐 결과만 잘 나오면 됐지 과정 정도야...

곱셈 연산 (A *= B)

1
2
3
4
float x = 1.0f;
float speed = 1.01f;

x *= speed;

이번에는 곱셈 연산에 대해 알아봅시다. 곱셈 연산은 가산과 감산 연산에 비해 해결하는 방법이 더 복잡합니다. 위 코드를 30, 60, 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

A *= B 식은 보통 중력이나 가속도 등 과학적인 연산(?)이 필요할 때 사용되는 편입니다. 변화율을 요구할때죠.

FPS가 변해도 일정한 변화율을 보이도록 하려면 이 역시 보간이 필요해집니다. 위 코드가 수류탄 투척과 관련된 코드라면 높은 FPS에선 엄청난 변화율을 보여 땅에 바로 꽂혀버리는 등 이상한 상황이 발생할 수 있습니다.

pow() 함수

pow() 함수를 아시나요? X의 Y 거듭제곱된 값을 구해주는 수학 함수입니다. pow(2, 5)처럼 사용하면 2를 5번 곱한 값을 구해주죠.

사실 저는 프로그래밍 언어를 처음 공부할 때 이새기 어디다 쓰는거지 했는데... 생각보다 게임 개발에서 자주 사용되는 함수입니다.(시바ㅓㄹ 수학)

이 녀석... A *= B의 식을 보간할 때 사용합니다. 조금 더 유식한 척하면 미분(적분)을 이용한다고 들었는데요, 이 부분은 저도 잘 몰라서 생략 ㅎㅎ ㅋㅋ ㅈㅅ

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

laraticon-2
알아듣게 설명좀...

A += A * (POW(B, BASE_FPS / FPS) - 1.0) 식으로 변경하면 매 프레임마다 A의 값을 조금씩 갱신해 BASE_FPS에서 동작하던 변화율을 보이도록 할 수 있습니다. 식을 잘 보시면 델타 타임이 아닌 프레임의 비율이 사용되었습니다.

A *= B는 단순히 매 프레임마다 AA * B를 수행한 값을 할당합니다. 이러한 코드는 프레임 속도에 따라 값이 무진장 커져버립니다. 프레임 속도와 상관없이 일정한 변화율을 보이기 위해 pow() 함수의 특징을 이용하는 겁니다. 완벽히 같은 값을 산출하기 힘들지만 최대한 근삿값을 산출해내기 때문에 동일한 변화율을 보이도록 할 수 있습니다.

log()exp() 함수로도 가능하다고 본 것 같지만 pow()보단 비효율적일 것 같군요.

결론

FPS가 변화함에 따라 일정한 속도와 변화율을 보이는 방법은 간단합니다. 근삿값을 구하는 겁니다.