FPSの話〜完結編〜

そもそもどういう問題であったかおさらいする。
60FPSを実現するためには1フレームを1000/60=16.666…msに区切るように時間待ちをしなければならない。しかし、timeGetTime()などの一般的に使うタイマ関数では1ms単位の時間しか取得することができない。ではこの.666…という小数部分をどうすればよいのか、という話だった。

まず既存の単純な解決法として、1フレーム時間をもっとも近い整数である16msか17msのどっちかに固定して、誤差はまるっきり無視してしまうというものがある。当然ながらこれだと厳密な60FPSの維持を放棄していることになり、16msだと62.50FPS、17msだと58.82FPSが実際のFPSとなる。


整数値で誤差もきちんと扱うには、ブレゼンハムのアルゴリズムを応用する。
ブレゼンハムのアルゴリズムとは、離散的な整数値ピクセルのディスプレイで斜めの直線を綺麗に引くために考案されたアルゴリズムである。


理論からきちんと書こうと思ったが、なんだかわけわかんなくなってきたので要点だけ。
16.666…=1000/60=50/3であるから、時間の単位を3倍して1フレーム時間を50だと思って調整すれば小数誤差が出ないというのがブレゼンハムの発想である。(誤差が小数にならないだけで、整数誤差はちゃんと出るということに注意)
具体的な流れを示すとこのようになる。

  1. 取得する時刻はすべて3倍して扱う。つまり48,51,54,...というように3の倍数になる。
  2. 最初のフレームは誤差0として、時刻が50になるまで時間待ちをする。
  3. しかし時刻は3の倍数でしか取得できないため、ちょっと少なめの48まで待つことにする。この1フレームは実際の時間に直すと48/3=16msとなる。
  4. 50-48=2を誤差として持ち越す。
  5. 次のフレームでは誤差が2であるため、50+2=52になるまで時間待ちをする。
  6. しかしやはり3の倍数でしか取得できないので、少なめの51まで待つことにする。この1フレームは実際の時間に直すと51/3=17msとなる。
  7. 52-51=1を誤差として持ち越す。
  8. 次のフレームでは誤差が1であるため、50+1=51になるまで時間待ちをする。
  9. 今度は3の倍数であるので、きっちり51まで待つ。この1フレームは実際の時間に直すと51/3=17msとなる。
  10. 誤差は51-51=0に戻るので、以下同様に繰り返す。

こうして、[16,17,17]という最適な同期周期が導き出される。「少なめ」のところは「多め」でもよいのだが、前者のがコードが簡単そうだったので。

もともと参考にしたのはこちらのページの下の方なのだが、そこに書いてある方法は実装が間違っていて常に17ms同期になってしまっている。これは誤差を毎フレームごとにしか取っていなくて、累積させていないのが原因である。
それを修正して任意のFPSに一般化したコードを下記に示す。

void WaitFrame(const DWORD FPS){
	static const DWORD FRAME_TIME = 1000;
	static DWORD prevTime = 0;
	static DWORD bresenhamError = 0;
	
	DWORD nowTime = timeGetTime() * FPS;	//時間単位をFPS倍する
	
	//DWORDの最大値を超えた
	if(prevTime > nowTime){
		nowTime += ULONG_MAX - prevTime;
		prevTime = 0;
	}
	
	DWORD processTime = nowTime - prevTime;	//処理にかかった時間
	
	prevTime = nowTime;

	//時間内に処理が終わっていればwait
	if(FRAME_TIME + bresenhamError > processTime){
	
		//前フレームに待てなかった誤差分も余計に待つ
		DWORD waitTime = FRAME_TIME + bresenhamError - processTime;
		
		//FPSの倍数にalignし、調整した分を誤差として次に持ち越す
		bresenhamError = waitTime % FPS;
		waitTime -= bresenhamError;
		
		Sleep(waitTime / FPS);
		
		prevTime += waitTime;	//Sleepの寝過ごしは考慮しない
	}
}

先ほどの60FPSの場合は50/3と約分できたので3倍して50区切りにしていたが、約分せずにFPS倍して1000区切りにすれば任意のFPSについて適用できるというわけだ。

30FPSと45FPSで検証してみたところ、1フレーム時間はそれぞれ[33,33,34]、[22,22,22,22,23,22,22,22,23]という周期的な値を示した。

なお上記のコードはフレームスキップに関して考慮していないが、今回は割愛させていただく。