비타Cpp

시간 측정 본문

DirectX12/튜토리얼

시간 측정

멍C 2021. 10. 10. 12:25

애니메이션, 물리 구현 등을 할 때 꼭 필요한 것이 있다. 바로 시간(Time)이다. 애니메이션은 각 프레임간에 경과한 시간(Elapsed Time),  다시 말해 애니메이션의 인접한 두 프레임 사이에 흐른 시간의 양을 측정할 수 있어야 한다. 물리 구현도 마찬가지로 가속도 같은 경우 경과한 시간에 따라 속도가 점점 증감하기 때문에 시간이 필수이다. 이런 시간 값은 프레임과 프레임 사이의 경과 시간을 계산해야 하므로 프레임률이 높은 경우 그에 따른 정밀도가 높은 타이머가 필요하다.

 

성능 타이머

정밀한 시간측정을 위해, Windows는 성능 타이머(Performance Timer)를 제공한다. 이를 성능 카운터(Performance Counter)라고도 한다. 이를 이용하기 위해서는 Windows.h를 포함시켜야 한다(#include <Windows.h>)

 

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

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

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

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

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

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

mSecondsPerCount = 1.0 / (double)countsPerSec;

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

valueInSecs = valueInCounts * mSecondsPerCount;

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

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

/*어떤 작업을 수행*/

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

이 경우 '어떤 작업'에 걸린 시간은 (B-A) 개, 즉 (B-A) * mSecondsPerCounter이다.


MSDN에는 QueryPerformanceCounter에 관한 다음과 같은 주의 사항이 있다.: "다중 프로세서 컴퓨터의 경우 이 함수가 어떤 프로세서에서 실행되는지에 따라 결과가 달라져서는 안된다. 그러나 기본 입출력 시스템(BIOS) 또는 하드웨어 추상층(HAL)의 버그 때문에 프로세서에 따라 다른 결과가 나올 수 있다." SetThreadAffinityMask 함수를 적절히 이용하면 응용 프로그램의 주 스레드가 다른 프로세서로 전환되는 일을 방지할 수 있다.


GameTimer클래스

GameTimer.h

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;	//초당 틱 수를 역수로 하여 틱당 초 수를 구한다.

 

프레임 간 경과 시간(Elapsed Time)

애니메이션의 프레임을 렌더링할 때에는 프레임들 사이에서 시간이 얼마나 흘렀는지 알아야 한다. 그래야 게임의 물체들을 경과 시간에 따라 적절히 갱신할 수 있다. 프레임 간 경과 시간을 계산하는 과정은 이렇다. ti가 i번째 프레임을 렌더링할 때 측정한 성능 타이머 값이고 ti-1이 그 이전 프레임에서의 성능 타이머 값이라고 하자. 그러면 측정치 ti-1과 ti의 차이인 Δt = ti - ti-1이 바로 그 두 프레임 사이의 경과 시간이다. 실시간 렌더링을 위해서는 프레임률이 적어도 30은 넘어야 한다.

다음 메서드는 Δt를 계산하는 방법을 보여준다. 

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

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

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

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

	// 음수가 되지 않게 한다. SDK 문서의 CDXUTTimer 항목에 따르면,
	// 프로세서가 절전 모드로 들어가거나 실행이 다른 프로세서와
	// 엉키는 경우 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)
	{
		//Windows 메시지가 있으면 처리한다.
		if (PeekMessage(&msg, 0, 0, 0, PM_REMOVE))
		{
			TranslateMessage(&msg);
			DispatchMessage(&msg);
		}
		//없다면 Direct3D 작업(게임) 작업을 수행한다.
		else
		{
			mTimer.Tick();

			if (!mAppPaused)
			{
				CalculateFrameStats();
				Update(mTimer);
				Draw(mTimer);
			}
			else
			{
				Sleep(100);
			}
		}
	}
	return (int)msg.wParam;
}

이 예제 코드를 보면, 프레임마다 Δt를 계산해서 UpdateScene에 넘겨준다. 이에 의해 응용 프로그램은 애니메이션의 이전 프레임으로부터 흐른 시간에 기초해서 장면을 적절하게 갱신할 수 있게 된다.

메시지 루프에 진입전 타이머를 리셋시켜주는 GameTimer::Reset 메서드의 구현은 다음과 같다.

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

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

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

 

전체 시간

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

다음은 GameTimer 클래스에서 전체 시간을 구현하기 위해 필요로 하는 멤버 변수이다.

__int64 mBaseTime;		//타이머가 시작된 시간
__int64 mPausedTime;		//일시 정지된 시간(누적된 시간)
__int64 mStopTime;		//정지(일시 정지)된 시점의 시간

mBaseTime은 Reset이 호출될 때 현재 시간으로 초기화 된다. 그 시간을 응용 프로그램이 시작된 시간으로 간주한다. mPauseTime은 타이머가 일시 정지된 시간 동안 계속해서 누적된다. 유효한 전체 시간을 구하기 위해서는 일시 정지된 시간은 제외해야 되기 때문에 이 누적되는 시간을 측정해 둘 필요가 있다. mStopTime은 정지(일시 정지)된 시점의 시간으로, 일시 정지 누적 시간을 계산하는 데 쓰인다.

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

다음은 이 두 메서드의 구현 코드이다.

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

		//그렇지 않다면 현재 시간을 타이머 정지 시점으로 저장하고,
		// 타이머가 정지되었음을 뜻하는 bool 변수를 설정한다.
		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 - mBestTime에는 
	// 일시 정지 누적 시간이 포함되어 있는데, 그 누적 시간은 전체 시간에 포함되지 말아야 한다.
	// 이를 바로잡기 위해, 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);
	}
}

 

Comments