기록공간

[DirectX 12] Direct3D 초기화 - 3 : 시간 측정 (타이머) 본문

DirectX/기초

[DirectX 12] Direct3D 초기화 - 3 : 시간 측정 (타이머)

입코딩 2020. 3. 23. 12:27
반응형

애니메이션을 정확하게 수행하려면 시간을 측정해야 한다. 특히, 프레임 간 경과 시간(Elapsed time), 다시 말해 애니메이션의 인접한 두 프레임 사이에 흐른 시간의 양을 측정할 수 있어야 한다. 프레임률이 높은 경우 프레임 간 경과 시간이 상당히 짧으므로, 정밀도가 높은 타이머를 사용할 필요가 있다.

 

성능 타이머

정밀한 시간 측정을 위해, Windows가 제공하는 성능 타이머(Perfomance timer)를 사용한다. 이를 성능 카운터(Performance counter) 라고도 부른다. 성능 타이머를 조회하는 메서드를 사용하려면 반드시 Windows.h를 포함시켜야 한다.

 

성능 타이머의 시간 측정 단위는 '지나간 클럭 틱들의 개수(count)' 이다. 성능 타이머로부터 틱 수 단위의 현재 시간을 얻을 때에는 다음과 같이 QueryPerformanceCounter 함수를 사용한다.

__int64 currTime;
QueryPerformanceCounter((LARGE_INTEGER*)&currTime);

이 함수가 함수의 반환값이 아니라 매개변수를 통해서 현재 시간 값을 돌려줌을 주목하자. 함수가 돌려주는 현재 시간 값은 64비트 정수이다.

 

초 단위 시간을 얻으려면, 우선 QueryPerformanceFrequency 함수를 이용해서 성능 타이머의 주파수(초당 틱 수)를 알아내야 한다.

__int64 countsPerSec;
QueryPerformanceFrequency((LARGE_INTEGER*)&countsPerSec);

다음으로, 주파수의 역수를 취해서 틱당 초 수를 얻는다.

mSeconsPerCount = 1.0 / (double)countsPerSec;

이제 틱당 초 수 mSecondsPerCount에 틱 수 valueInCounts를 곱하면 초 단위 시간이 나온다. 

valueInSecs = valueInCounts * mSecondsPerCount;

그런데 QueryPerformanceCounter 함수가 돌려준 개별 측정치 자체는 별 의미가 없다. 애니메이션에 필요한 것은 두 측정치의 차이, 즉 한 번의 QueryPerformanceCounter 호출로 얻은 값을 그 다음 번 QueryPErformanceCounter 호출로 얻은 값에서 뺀 결과이다. 그것이 바로 지난 번 호출로부터 흐른 경과 시간이다. 우리에게 중요한 것은 성능 타이머가 돌려준 실제 값이 아닌 측정한 두 시간 값 사이의 상대적인 차이이다. 코드를 보면 더 쉽게 이해할 수 있을 것이다.

__int64 A = 0;
QueryPerformanceCounter((LARGE_INTEGER*)&A);

/* 어떤 작업을 수행한다. */

__int64 B = 0;
QueryPerformanceCounter((LARGE_INTEGER*)&B);

이 경우 어떤 작업에 걸린 시간은 (B - A) / mSecondsPerCount 초이다.

 

GameTimer 클래스

GameTimer 클래스의 구현을 논의해보도록 하겠다.

class GameTimer
{
public:
	GameTimer();

	float TotalTime()const; // 초 단위
	float DeltaTime()const; // 초 단위

	void Reset(); // 메시지 루프 이전에 호출해야 함
	void Start(); // 타이머를 시작 또는 재개할 때 호출해야 함
	void Stop();  // 타이머를 정지할 때 호출해야 함 
	void Tick();  // 매 프레임 호출해야 함

private:
	double mSecondsPerCount;
	double mDeltaTime;

	__int64 mBaseTime;
	__int64 mPausedTime;
	__int64 mStopTime;
	__int64 mPrevTime;
	__int64 mCurrTime;

	bool mStopped;
};

생성자의 주된 임무는 성능 타이머의 주파수를 조회해서 틱당 초 수를 설정하는 것이다. 

GameTimer::GameTimer()
: mSecondsPerCount(0.0), mDeltaTime(-1.0), mBaseTime(0), 
  mPausedTime(0), mPrevTime(0), mCurrTime(0), mStopped(false)
{
	__int64 countsPerSec;
	QueryPerformanceFrequency((LARGE_INTEGER*)&countsPerSec);
	mSecondsPerCount = 1.0 / (double)countsPerSec;
}

 

 

프레임 간 경과 시간

애니메이션의 프레임들을 렌더링할 때에는 프레임들 사이에서 시간이 얼마나 흘렀는지 알아야 한다. 그래야 게임의 물체들을 경과 시간에 기초해서 적절히 갱신할 수 있다. 프레임 간 경과 시간을 계산하는 과정은 이렇다. 

 

T(i)가 i번째 프레임을 렌더링할 때 측정한 성능 타이머 값이고 T(i-1)이 그 이전 프레임에서 성능 타이머 값이라고 하자. 그러면 측정치 T(i-1)과 T(i)의 차이인 TimeDelta = T(i) - T(i-1)이 바로 그 두 프레임 사이의 경과 시간이다. 실시간 렌더링을 위해서는 프레임률(초당 프레임 수)이 적어도 30은 넘어야 한다. 따라서 TimeDelta 값은 상당히 작은 수치일 때가 많다.

 

다음 코드는 TimeDelta를 계산하는 방법을 보여준다.

void GameTimer::Tick()
{
	if( mStopped )
	{
		mDeltaTime = 0.0;
		return;
	}

	// 이번 프레임의 시간을 얻는다.
	__int64 currTime;
	QueryPerformanceCounter((LARGE_INTEGER*)&currTime);
	mCurrTime = currTime;

	// 이번 프레임의 시간과 이전 프레임의 시간의 차이를 구한다.
	mDeltaTime = (mCurrTime - mPrevTime) * mSecondsPerCount;

	// 다음 프레임을 준비한다.
	mPrevTime = mCurrTime;

	// 음수가 되지 않게 한다.
	// 프로세서가 절전 모드로 들어가거나 실힝이 다른 프로세서와 
	// 엉키는 경우 mDeltaTime이 음수가 될 수 있다.
	if(mDeltaTime < 0.0)
	{
		mDeltaTime = 0.0;
	}
}

float GameTimer::DeltaTime()const
{
	return (float)mDeltaTime;
}

응용 프로그램의 메시지 루프에서는 이 Tick 메서드를 다음과 같은 방식으로 호출한다.

int D3DApp::Run()
{
	MSG msg = {0};
 
	mTimer.Reset();

	while(msg.message != WM_QUIT)
	{
		// 윈도우 메시지가 있으면 처리한다.
		if(PeekMessage( &msg, 0, 0, 0, PM_REMOVE ))
		{
			TranslateMessage( &msg );
			DispatchMessage( &msg );
		}
		// 없으면 게임 작업을 수행한다.
		else
		{	
			mTimer.Tick();

			if( !mAppPaused )
			{
				CalculateFrameStats();
				Update(mTimer);	
				Draw(mTimer);
			}
			else
			{
				Sleep(100);
			}
		}
	}

	return (int)msg.wParam;
}

이 예에서 보듯이, 응용 프로그램은 프레임마다 TimeDelta를 계산해서 UpdateScene에 넘겨준다. 이에 의해 응용 프로그램은 애니메이션의 이전 프레임으로부터 흐른 시간에 기초해서 장면을 적절히 갱신할 수 있다. 이 예에 나오는 Reset 메서드 구현은 다음과 같다.

void GameTimer::Reset()
{
	__int64 currTime;
	QueryPerformanceCounter((LARGE_INTEGER*)&currTime);

	mBaseTime = currTime;
	mPrevTime = currTime;
	mStopTime = 0;
	mStopped  = false;
}

주목할 것은 Reset 메서드가 mPrevTime을 현재 시간으로 설정한다는 점이다. 이것이 중요한 이유는, 애니메이션의 첫 프레임에서는 이전 프레임이라는 것이 없으므로 이전 시간 값 T(i-1)도 없다는 점이다. 따라서 메시지 루프를 시작하기 전에 이처럼 Reset 메서드 안에서 이전 시간 값을 초기화해 주어야 한다.

 

전체 시간

유용하게 사용할 수 있는 또 다른 시간 측정치로, 응용 프로그램이 시작된 이후에 흐른 시간이 있다. 여기서는 그런 시간을 전체 시간(Total time)이라고 부른다.

 

전체 시간이 유용한 상황의 예를 하나 들어 보겠다. 플레이어가 어떤 레벨을 300초 안에 깨야 한다고 하자. 레벨을 시작할 때, 응용 프로그램이 시작된 후 그때까지 흐른 전체 시간 T(start)를 저장해 둔다. 레벨이 시작된 후에는 주기적으로 전체 시간 T를 점검한다. 만일 T - T(start) > 300sec 이면 플레이어는 300초 내에 레벨을 깨지 못한 것이므로, 응용 프로그램은 플에이어의 패배를 선언한다. 이 예의 경우 플레이어가 게임을 일시정지시킨 동안 흐른 시간은 전체 시간에 포함시키지 말아야 한다.

 

전체 시간은 어떤 수량을 시간의 함수로서 애니메이션할 때에도 유용하다. 예를 들어 장면의 주번을 도는 광원의 운동을 시간의 함수로 표현한다고 하자. 그런 경우 광원의 위치를 다음과 같은 매개변수 방정식으로 서술할 수 있다.

 

x = 10cosT

y = 20

z = 10sinT

 

여기서 T는 시간을 나타낸다. 이 방정식을 이용해서 T의 증가에 따른 광원의 좌표를 계산하면, 광원은 y = 20 평면세어 반지름이 10인 원을 따라 움직이게 된다. 이런 종류의 애니메이션에서도 일시정지 누적 시간을 전체 시간에 포함하지 말아야 한다.

 

전체 시간을 구현하기 위해 GameTimer 클래스는 다음과 같은 멤버 변수들을 사용한다.

__int64 mBaseTime;
__int64 mPausedTime;
__int64 mStopTime;

mBaseTime은 Reset이 호출될 때 현재 시간으로 초기화 된다. 그 시간을 응용 프로그램이 시작한 시간으로 간주할 수 있다. 대부분의 경우 Reset은 메시지 루프로 진입하기 전에 한 번만 호출한다. 그러므로 mBaseTime은 변하지 않는다. 

 

mPauseTime은 타이머가 일시 정지된 동안 계속해서 누적된다. 유효한 전체 시간을 구하려면 실제로 흐른 전체 시간에서 누적된 일시 정지 시간을 빼야 하므로, 이 변수에 일시 정지 시간을 누적해 둘 필요가 있다. mStopTime 변수는 타이머가 정지된 시점의 시간으로, 일시 정지 누적 시간을 계산하는 데 쓰인다. 

 

GameTimer 클래스는 중요한 두 메서드로 Stop과 Start가 있다. 응용 프로그램은 타이머를 일시 정지하거나 재개할 때 이 메서드들을 호출해야 한다. 그래야 GameTimer가 누적 시간을 적절히 갱신할 수 있다. 다음은 이 두 메서드의 구현 코드이다.

void GameTimer::Stop()
{
	// 이미 정지 상태이면 아무 일도 하지 않는다.
	if( !mStopped )
	{
		__int64 currTime;
		QueryPerformanceCounter((LARGE_INTEGER*)&currTime);

		// 그렇지 않다면 현재 시간을 타이머 정지 시점 시간으로 저장하고,
		// 타이머가 정지되었음을 뜻하는 불리언 플래그를 설정한다.
		mStopTime = currTime;
		mStopped  = true;
	}
}

void GameTimer::Start()
{
	__int64 startTime;
	QueryPerformanceCounter((LARGE_INTEGER*)&startTime);


	// 정지(일시 정지)와 시작(재개) 사이에 흐른 시간을 누적한다.
	//
	//                     |<-------d------->|
	// ----*---------------*-----------------*------------> time
	//  mBaseTime       mStopTime        startTime     

	// 정지 상태에서 타이머를 재개하는 경우
	if( mStopped )
	{
		// 일시 정지된 시간을 누적한다.
		mPausedTime += (startTime - mStopTime);	

		// 타이머를 다시 시작하는 것이므로, 현재의 mPrevTime은 
		// 유효하지 않다. 따라서 현재 시간으로 다시 설정한다.
		mPrevTime = startTime;

		// 이제는 정지 상태가 아니므로 관련 멤버들을 갱신한다.
		mStopTime = 0;
		mStopped  = false;
	}
}

마지막으로, TotalTime 멤버 함수는 REset이 호출된 이후 흐른 시간에서 일시 정지된 시간을 제외한 시간을 돌려준다. 구현은 다음과 같다.

float GameTimer::TotalTime()const
{
	// 타이머가 정지 상태이면, 정지된 시점부터 흐른 시간은 계산하지 말아야 한다.
	// 또한, 이전에 이미 일시 정지된 적이 있다면 시간차 mStopTime - mBaseTime에는 
	// 일시 정지 누적 시간이 포함되어 있는데, 그 누적 시간을 전체 시간에 포함하지 
	// 말아야 한다. 이를 바로잡기 위해, mStopTime에서 일시 정지 누적 시간을 뺀다.
	//
	//                     |<--paused time-->|
	// ----*---------------*-----------------*------------*------------*------> time
	//  mBaseTime       mStopTime        startTime     mStopTime    mCurrTime

	if( mStopped )
	{
		return (float)(((mStopTime - mPausedTime)-mBaseTime)*mSecondsPerCount);
	}
	
	// 시간차 mCurrTime - mBaseTime에는 일시 정지 누적 시간이 포함되어 있다. 이를
	//  전체 시간에 포함하면 안 되므로, 그 시간을 mCurrTime에서 뺀다.
	// 
	//
	//  (mCurrTime - mPausedTime) - mBaseTime 
	//
	//                     |<--paused time-->|
	// ----*---------------*-----------------*------------*------> time
	//  mBaseTime       mStopTime        startTime     mCurrTime
	
	else
	{
		return (float)(((mCurrTime-mPausedTime)-mBaseTime)*mSecondsPerCount);
	}
}

  응용 프로그램에서 추가적인 GameTimer 인스턴스들을 생성해서 범용 '스톱워치'로 사용하는 것도 얼마든지 가능하다. 예를 들어 어떤 폭탄의 도화선에 불이 붙었을 때 새 GameTimer 인스턴스를 생성하고, 이후 TotalTime이 5초가 넘으면 폭탄이 폭발하는 사건을 구현하는데에 활용할 수 있을 것이다.

 

여기서 초기화 파트를 마친다. 나머지 부분에 대한 설명은 예제 프레임워크를 참고하길 바란다.

(자세한 내용은 https://github.com/d3dcoder/d3d12book Chapter 4를 참고)

반응형
Comments