FPS에 따라 게임 속도 보정하는 법
특정 FPS(Frame Per Second)에서 동작하던 코드를 더 높거나 낮은 FPS에서 작동하도록 변경하면 게임의 속도도 그대로 빠르거나 느려지는 경우가 있습니다. 이를 어떻게 해결할까요?
본 게시글의 내용은 30FPS에서 동작하던 그 속도와 변화율을 60FPS 또는 그 이상에서 그대로 구현하고 싶을 때를 가정하고 있습니다.
개요
잘못된 내용 또는 정보, 오탈자를 발견하시면 댓글로 알려주세요. 확인 후 정정하겠습니다! 😊
FPS에 따른 게임 속도를 보정하는 방법에 대해 바로 배우기 전에 몇 가지 재미없는 이야기 좀 보고 가봅시다.
FPS라는 말 많이 들어보셨죠? 오늘 우리가 다룰 이 FPS가 어떤 것인지 아시나요? 보통 FPS라고 하면 대중적으로 사용하는(?) 두 가지의 약어가 있습니다. 초당 프레임 수를 의마하는 FPS와 1인칭 슈팅 게임을 의미하는 FPS이죠. 오늘 우리가 알아볼 것은 초당 프레임 수를 의미하는 FPS(Frame Per Second)입니다. 여기서, 프레임(Frame)은 정지된 이미지 1장을 의미합니다.
우리는 보통 프레임이 발생하는 속도를 표기하기 위해 FPS를 사용합니다. 30FPS라고 하면 초당 30 프레임을 표시할 수 있음을 의미하죠. 뭐... 조금 더 자세히 따지면 헤르츠(Hz)라는 단위가 있습니다만... 이는 모니터의 주사율과 프레임 레이트(프레임 표시 속도)를 구분하기 위해 따로 사용하는 것 같습니다.
고전 게임
아주 오래된 게임의 경우(대략 2000년 초반에서 그 이전) 지금처럼 하드웨어의 성능이 좋은 편이 아니었기 때문에 특정 FPS에서 작동하도록 만들어진 게임이 꽤 많습니다. 그리고 이 게임을 현대에 와서 플레이하려고 하면 멀미가 발생하죠. 왜냐하면 프레임 레이트가 낮기 때문에 화면이 툭툭 끊겨 보여 부드러움을 느끼기 힘들고 이로 인해 불쾌감이 발생하기 때문입니다. 3차원 공간을 이용하는 3D 게임이라면 더욱 심해지죠.
과거와는 다르게 현재의 하드웨어는 성능이 엄청 좋아지고 있어서 보통 60FPS를 기반으로 하거나 그 제한을 풀어 개발에 착수하는 편입니다. 그래서 멀미 유발이 덜 하죠. FPS가 높으면 높을 수록 부드러운 화면 전환과 입력 지연을 줄이기 때문에 높을 수록 좋다고 하는게 여기에 있습니다.
고전 게임을 포팅할 때 문제점
굳이 먼 시절에 만들어진 게임이 아니라도 60FPS 미만으로 제작된 게임이 은근 있습니다.(특정 FPS에서 작동하는 게임)
게임을 즐기는 데 큰 문제는 없어보이지만 이를 현대에 와서 다시 리메이크(또는 리마스터)할 때 문제가 발생합니다.
과거에 비해 더 부드러운 화면 전환과 입력 반응 속도를 보이기 위해 기대를 품지 않고(?) FPS를 단순히 높였는데... 높인만큼 게임의 속도가 매우 빨라지는 겁니다. 즉, 30FPS에서 동작하던 수류탄 던지기 기능이 60FPS로 변경되었을 땐 약 2배 이상 빨라져 갑자기 야구 투수로 변해버리는 겁니다. 144FPS에선 대륙간 탄도 미사일로 변해버리구요.
원인은 간다합니다. 특정 FPS에서만 동작하도록 구현된 코드라서 그렇습니다. 즉, FPS에 따른 속도를 고려하지 않고 작성했기 때문에 그렇습니다. 원인이 간단한만큼 수정하는 방법도 간단합니다.
A += B, 가산과 감산 연산
int X = 1, SPEED = 2;
X += SPEED;
30FPS로 동작하는 게임 캐릭터의 X축을 이동하기 위해 X += SPEED
연산을 수행한다고 해봅시다. 30FPS는 초당 30프레임을 의미하고 이는 1초가 30프레임이라는 것을 의미합니다. 그렇다면 위 코드를 정확히 30프레임(1초)만 수행했을 때의 X
변수의 값은 얼마일까요?
갑자기 기분 나쁘게 연산 질문을 해서 머릿 속이 복잡하겠지만... 최종 값을 구하는 방법은 간단합니다. 최종 값은 X
에 SPEED
를 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나 그 이상이 되면 우사인 볼트도 울 정도로 빠른 속도로 움직이거나 너무 빨라서 사라져 버립니다. 실시간 경쟁 게임이었다면 치명적인 밸런스 붕괴로 이어지겠죠?
델타 타임
사실 여기까지는 게임 개발을 배우신 분들이라면 델타 타임을 떠올렸을 겁니다. 사실 저는 떠올리지도 못했는데요... 처음 듣는 분들을 위해 간단하게 델타 타임의 개념을 설명하고 넘어갑니다.
델타 타임(DeltaTime)은 두 연속된 프레임 간의 시간 간격을 말합니다.
델타 타임이 등장하게 된 배경을 아시나요? 여러가지 사항이 있겠지만 저는 하드웨어의 발전 때문이라 생각합니다.
하지만... 하드웨어가 발전했다고 모든 사용자가 좋은 하드웨어를 사용한다는 보장은 없습니다. 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
와 같은 메서드(함수)에 작성하는 편입니다.
실수형 자료를 사용하기 때문에 정밀도 문제를 가질 수 있으나 그리 큰 문제로 이어지진 않습니다. 걱정된다면 델타 타임의 최근 평균을 구해 사용하는 방법도 있긴 합니다.
개발 중인 게임의 FPS가 수시로 변하는 가변이 아닌 고정이라면 1초를 FPS로 나눈 값... 즉, 1.0 / MAX_FPS
와 같은 코드로 델타 타임을 얻을 수 있습니다. 단, 하드웨어가 무조건 고정된 FPS를 뽑아낸다는 보장이 없기 때문에 잘 사용하지 않는 편입니다.
30FPS | 60FPS | 144FPS |
0.033333 | 0.016667 | 0.006944 |
프레임 하락이 발생하지 않는다고 가정했을 때 평균적으로 얻을 수 있는 델타 타임의 값은 위 표와 같습니다. 산출된 델타 타임의 값을 이동 연산을 수행하는 코드에 곱하면 서로 다른 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 값이 들어가면 되겠죠?
X += SPEED * DELTA_TIME
이라는 식을 사용하고 마음 편하게SPEED
변수의 값을 늘리자는 말인데요... 그래도 됩니다. 결과만 잘 나오면 됐지 과정 정도야 뭐...
A *= B, 곱셈 연산
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가 변해도 일정한 변화율을 보이도록 하려면 이 역시 보간이 필요해집니다. 이 코드가 수류탄의 중력과 관련된 코드라면 높은 FPS에선 그만큼 속도가 빨라져 대륙간 탄도 미사일이 내 좌표로 찍힌 것 같이 변해버릴 수도 있습니다.
POW 함수
POW 함수를 아시나요? X의 Y 거듭제곱된 값을 구해주는 그런 수학 함수입니다. pow(2, 5)
처럼 사용하면 2를 5번 곱한 값을 구해주는 그런 녀석이란 말입니다.
프로그래밍 언어를 처음 공부할 때 이 녀석 도대체 어디에 쓰라고 가르쳐주는거지? 했는데... 이 녀석 게임 개발에서 정말 쓸모있는 녀석이었습니다. 이 녀석... A *= B
라는 식을 보간할 때 사용합니다. 조금 더 유식한 척해서 말하면 적분(미분)을 이용한다고 배웠는데요... 이 부분까진 저도 잘 몰라서 생략합니다.
아무튼 A *= B
라는 식을 A += A * (POW(B, BASE_FPS / FPS) - 1.0)
이라는 식으로 변경해 보간을 수행할 수 있습니다.
A += A * (POW(B, BASE_FPS / FPS) - 1.0)
라는 식으로 변경하면 매 프레임마다 A
의 값을 조금씩 갱신해서 BASE_FPS
에서 수행하던 그 근삿값을 산출해 동일한 속도(변화율)를 보이도록 할 수 있다는 겁니다. 델타 타임이 사용되지 않고 프레임의 비율이 사용되었습니다.
A *= B
는 단순히 매 프레임마다 A
에 A * B
를 수행한 값을 할당합니다. 이러한 코드는 프레임 속도에 따라 결과가 달라져 버립니다. 프레임 속도와 관계없이 일정한 속도(변화율)을 유지하기 위해 pow
함수를 이용하는 겁니다. 완벽히 같은 값을 산출해내긴 힘들지만 최대한 근삿값을 산출해 동일한 속도(변화율)를 나타낼 수 있습니다.
pow
가 아닌 log
와 exp
함수로도 가능하지만 복잡하고 pow
보다 비효율적이라 잘 사용하지 않습니다.
결론
FPS가 변화함에 따라 그 게임의 속도를 보간하는 방법은 간단합니다. 그저... 근삿값을 구하는 겁니다.
무분별한 매크로 및 스팸성 댓글로 인하여 티스토리 내 댓글 기능을 비활성화하고 giscus 댓글 시스템으로 운영 중입니다.
댓글 작성이 필요할 경우 GitHub 계정이 필요합니다.