WisdomSoft - for your serial experiences.

7.5 FPSの計測

ゲームやエンターテイメント系のアプリケーションは画面の更新が頻繁に行われ、常に何かが動いている状態です。このような表現には受動的なイベント駆動は適さず、常に画面を更新し続けるフレーム駆動な設計が用いられます。

7.5.1 フレームアニメーション

ゲーム画面は、画面を構成する様々な要素にロジックを分散する必要がありますが、画面を構築するときには分散した描画ロジックを同期させながら安定した結果を取得しなければなりません。例えば、シューティングゲームのようなものを考えた場合、画面上には自分の機体、敵機、飛び交うミサイル、画面演出などが複雑にアニメーション氏、常に動き続けます。これらの要素が周囲を気にせずに無秩序に動き回ってしまうと、どのタイミングで判定を行ってよいのか分からなくなります。ゲームとしては、機体がミサイルや敵機に接触したときにダメージを受けたり、ゲームオーバーになるなどの条件を付けなければなりません。

では、スレッドを使わずにタイマーを使ってはどうかと考えるかもしれません。確かに、タイマーであればシングルスレッドなので、メソッドが呼び出されるたびにすべての画面要素を次のステップに移行させて、処理の最後に判定を行うということができます。しかし、タイマーの精度には限界があるため、低速で単純なアニメーションしか実現できません。不自然に感じない高速なアニメーションを実現するには、少なくとも1秒間に 30 回ほど画面を更新しなければなりません。

これまでのアニメーション処理は、図形などを平行移動させるというものが中心でしたが、映画や漫画アニメのように映像が次々と変化するアニメーションを実現するにはイベントに応じて画面を更新する方法は不向きです。

映画のように映像が次々と変化する、いわゆる動画をウィンドウに表示するには、連続したイメージを一定のリズムで描画し続ければよいのです。ただし、これを実現するには、どのようなコンピュータでも一定の速度で描画されるようにプログラムしなければなりません。

連続した画像を次々と表示するようなプログラムでは、ある瞬間に表示される個々の画像をフレームと呼びます。1秒間に何回画面を書き換えることができるかをフレームレートと呼び、単位は FPS (Frame Per Second) と書きます。例えば、フレームレート 30fps のプログラムは 1 秒間に 30 回画面を更新することを表しています。

図1 フレームの生成と描画
図1 フレームの生成と描画

FPS を安定させるにはコンピュータの処理速度を計測して制御しなければなりません。遅いコンピュータの場合はプログラムの処理が遅くなるだけで、それだけでは大きな問題にはなりませんが、超高速コンピュータで実行されたとき、速度の制御をしないでフルスピードでフレームを切り替えると、画面になにが表示されてるか判らなくなってしまう可能性があるのです。シューティングゲームのミサイルが目で確認できないほど早く処理されてしまうと、もはやゲームになりません。

そこで、フレーム単位でゲームを制御する場合は 1 秒間に画面を更新する回数を制御する必要があります。簡単なミニゲームなどであればイベントで制御したほうが簡単に実装できますが、高度な画面演出が必要なゲームや、非常に多くの要素が複雑に絡み合うゲームはフレーム主導のプログラムとなるでしょう。フレーム主導の処理方法は、ある瞬間の画面が表示されるタイミングを厳密に指定できるためです。1 秒間に画面を更新する回数が定められていれば、100 枚目の画像が表示される瞬間が開始から何ミリ秒後であるかを判断することができます。

1 秒間に表示するフレーム数が多いほど人間の目には画面が滑らかに動いて見えますが、どんなにコンピュータが高速でもディスプレイのハード的な限界を超えることはできません。ディスプレイの垂直走査周波数(リフレッシュレートとも呼ばれる、ディスプレイを 1 秒間に更新する回数)を超える FPS を記録しても意味はないのです。人間の目と脳では、少なくとも 30 FPS 以上であれば滑らかな動画として認識することができます。一般的にはおよそ 60 FPS を維持すれば快適な動作であると考えることができます。

ただし、フレーム主導のゲームに問題点がないわけではありません。60 FPS の設定で画像を次々と切り替えているとき、コンピュータの速度が更新速度についていけず 60 FPS を下回ることがあります。このとき、一般的な処理方法はいくつかのフレームの表示を省略することで問題を解決します。動画のように、開始から n 秒後に表示しなければならない画像が決定されている場合、表示する画像をいくつか省略してでも時間に合わせる必要があります。そうしなければ、音楽と映像が同期しなくなります。

しかし、ゲームの場合はフレームを無視してしまうと、プレイヤーには突然映像が飛んでしまうように見えてしまいます。シューティングや動的なパズルゲームなどではこうしたフレーム落ちが致命的で、ミサイルや落ちてくるブロックが突然ワープしてしまいます。これではゲームになりません。そのため、フレーム落ちが問題となるゲームでは、処理が遅れている場合でもフレームは飛ばさずに FPS を落とすしかありません。音楽など、他の要素と厳密に同期させる必要がある場合は FPS を厳守し、そうでなければ遅くなるのは許容するという方法が安全です。

さて、FPS をコントロールするプログラムは、どのように画面を更新するタイミングを調整するべきでしょうか。一般的には、ミリ秒単位の精度を持つタイマーを使って、直前にフレームを表示した時間と、現在の処理の時間間隔をミリ秒単位で取得し、Thread.Sleep() メソッドを使って実行速度を制御します。

例えば 60 FPS の設定で、現在の時間が開始から 500 ミリ秒経過していると考えてください。60 FPS では 1 秒間に 60 枚のフレームを表示するため、500 ミリ秒が経過した時点で 30 フレームを表示していることが理想的です。そこで、現在のフレームが表示されるべき時間と現在の時間を比較し、現在のフレームが表示されるべき時間が現在の時間よりも大きければ Sleep() で待機します。現在のフレームが表示されるべき時間は、次のように求められます。

理想時間(ms) = 現在のフレーム * 1000 / FPS

画面の更新が FPS に設定されている速度よりも遅れているのであれば、画面の更新に関係する情報だけを更新し、実際の画面の描画を省略するという方法を用います。つまり、いくつかのフレームを飛ばしてしまうのです。処理が遅れている最大の原因は、多くの場合グラフィックス処理であると考えられます。ゲームプログラムの場合、コンピュータにかける負担の多くが画面の処理です。フレームを飛ばせば、本来表示されるはずの画面がいくつか無視されてしまいますが、前述したとおり、音楽と同期しなければならない動画再生ソフトなどではこの方法が使われてます。

7.5.2 時間差を計測する

フレームアニメーションを実現するには、直前に描画したフレームから次の描画するフレームまでの処理に費やした時間を計測しなければなりません。FPS の計測は 1 秒経過するごとに初期化しなければならないためです。フレームアニメーションの速度制御は、現在のフレームから求めた理想時間が現在の実際に費やした時間よりも進んでいる場合、処理が進みすぎていると判定して Sleep() メソッドを呼び出して適切な時間だけスレッドを停止させます。

そのためには、前の一秒から現在までの時間の差を求める必要があります。この時間は、ミリ秒単位で正確に取得しなければなりません。

現在の時刻を取得するには DateTime クラスの静的な Now プロパティがありますが、このプロパティはシステムの現在の日付と時刻を得ることができるものの、分解能が低く Windows NT 系でも 10 ミリ秒、9x にいたっては 55 ミリ秒が限界です。

そのため、フレームアニメーションの時間の測定には System.Environment クラスTickCount プロパティを使います。

System.Environment クラス
System.Object
    System.Environment
public sealed class Environment
Environment クラス TickCount プロパティ
public static int TickCount { get; }

Environment クラスは、アプリケーションを実行しているシステムの情報を静的なメンバで提供するクラスです。このクラスをインスタンス化することはありません。

TickCount プロパティは、システムが起動してからの経過時間をミリ秒単位で返します。よって、直前に呼び出した値と、現在の値の差を求めることで、処理に費やした時間を計測することができます。

コード
using System;
using System.Threading;
using System.Drawing;
using System.Drawing.Imaging;
using System.Windows.Forms;

public class MovePoint
{
	private int x, y, maxX, maxY;
	private bool addX, addY;

	public int X { get { return x; } }
	public int Y { get { return y; } }

	public MovePoint(int x, int y, int mx, int my)
	{
		this.x = x;
		this.y = y;
		this.maxX = mx;
		this.maxY = my;
		this.addX = true;
		this.addY = true;
	}

	public void Next(int count) 
	{
		if (addX) x += count;
		else x -= count;

		if (addY) y += count;
		else y -= count;

		if (X > maxX) addX = false;
		else if(X < 0) addX = true;
		if (Y > maxY) addY = false;
		else if(Y < 0) addY = true;
	}

	public static implicit operator Point(MovePoint pt) 
	{
		return new Point(pt.X, pt.Y);
	}
}

public class Test : Form
{
	private const int FPS = 60;
	private Image image, offScreen;

	private void Test_Start()
	{
		int frame = 0;
		int before = Environment.TickCount;
		int width = offScreen.Width, height = offScreen.Height;
		MovePoint[] pts = {
			new MovePoint(0, 0, width, height),
			new MovePoint(image.Width, image.Height / 4, width, height),
			new MovePoint(image.Width / 2, image.Height, width, height)
		};
		Image buffer = new Bitmap(width, height);
		Graphics g = Graphics.FromImage(buffer);
		Graphics offg = Graphics.FromImage(offScreen);
		Brush backBrush = new SolidBrush(Color.White);
		Brush foreBrush = new SolidBrush(Color.Black);

		while(!IsDisposed) 
		{
			int now = Environment.TickCount;
			int progress = now - before;
			int ideal = (int)(frame * (1000.0F / FPS));

			g.FillRectangle(backBrush, 0, 0, width, height);
			g.DrawImage(image, new Point[] { pts[0], pts[1], pts[2] });
			offg.DrawImage(buffer, 0, 0);
			pts[0].Next(2);
			pts[1].Next(3);
			pts[2].Next(4);

			if (ideal > progress) Thread.Sleep(ideal - progress);
			Invalidate();
			
			frame++;
			if (progress >= 1000) 
			{
				Text = "FPS=" + frame;
				before = now;
				frame = 0;
			}
		}
	}

	protected override void OnPaint(PaintEventArgs e)
	{
		base.OnPaint (e);
		e.Graphics.DrawImage(offScreen, 0, 0);
	}

	public Test() 
	{
		SetStyle(
			ControlStyles.DoubleBuffer |
			ControlStyles.UserPaint |
			ControlStyles.AllPaintingInWmPaint, true
		);
		image = Image.FromFile("test.bmp");
		offScreen = new Bitmap(640, 480);
		Thread thread = new Thread(new ThreadStart(Test_Start));
		thread.Start();
	}

	static void Main() 
	{
		Application.Run(new Test());
	}
}
実行結果
コード1 実行結果

コード1は、メッセージループを処理するスレッドとは別の新しいスレッドを生成して Test_Start() メソッドを実行させています。Test_Start() メソッドは while 文の繰り返しで、Test クラスの image フィールドに格納されているイメージを使ってアニメーションを行い、その結果を offScreen に描画しています。

このプログラムを実行すると、イメージの左上隅、右上隅、左下隅の各頂点が offScreen イメージの幅と高さの範囲で移動し続け、image フィールドに保存されているイメージが画面上を移動し続けます。この処理は無秩序に行われるのではなく、最大で1秒間に 60 回画面を更新するようにフレーム単位で処理しています。処理を開始してから 1 秒ごとに、前の 1 秒間で描画したフレーム数をウィンドウのタイトルバーに表示しています。

このプログラムでは 60fps で描画するように設定していますが、タイトルバー表示される数値は 62 になっています。これは、1000 ミリ秒に 60 回のフレームを表示しようとすると、1 フレームの理想的な表示時間が 16.66666667 ミリ秒となり、きれいに割り切れません。これを整数で表現しようとするため、誤差が生まれてしまいます。プログラムではフレームを表示する理想時間を計算するとき、1000.0F を指定して float 型で計算することで誤差を少なくしています。