LoginSignup
2
0

Delphi で学ぶ古典ゲームの仕組み 第2回:ゲームループを作ろう

Last updated at Posted at 2024-04-26

はじめに

今回はゲームの根幹である「ゲームループ」を作成します。
第1回で「ゲームは全て同期的に動く」と解説しましたが、その同期的に動く部分です。

ゲームループの概念

ゲームループの概要

ゲームループは上図のようにゲームを開始してから終了するまでずっと動き続けるループです。
この中で状態を更新していきます。
ここで特別なメソッドが登場します。

名称 役割
Update キャラクターやアニメーションなどの更新処理
Render 画面の描画処理

この2つは多くのゲームエンジンでも同じ、または同じような名前で定義されています。

近代的 OS でのゲームループの実装方針

さて、ではゲームループをどのように製作するかですが、Windows では第1回で述べた VSYNC 割り込みを自由に使えません。
Windows 自身が VSYNC 割り込みを使って同期を取っているためです。
特に Windows Vista 以降は画面表示に DirectX を使っているのもあり、画像を同期して GPU に転送するというのは完全に OS の仕事になっています。
じゃあ OS が同期を取ってくれているから FPS をあまり考えなくて良いのかというとそうではありません。
Windows の同期タイミングでゲームが描画されていれば Windows が画面を送る時にちょうど表示されるようになります。

ということで、VSYNC 割り込みを使えないため、今回は Thread を使ってゲームループを再現します。
また、ここでは、Windows を題材に紹介しましたが他の OS も基本的な考え方は同じです。

TGameMaster の製作

ゲームの全てを司る GameMaster クラスを作成します。

TGameMaster の実装

TGameMaster クラスがゲームループ用の Thread を持つことにしましょう。

TGameMasterの定義
uses
  System.Classes
  , System.SysUtils
  ;

type
  TGameMaster = class
  private type
    TLoopThread = class(TThread)
    private var
      FGameMaster: TGameMaster;
    protected
      procedure Execute; override;
    public
      constructor Create(const AGM: TGameMaster); reintroduce;
    end;
  private var
    FLoop: TLoopThread;
    FCanvas: TCanvas;
  public
    constructor Create(const ACanvas: TCanvas); reintroduce;
    destructor Destroy; override;
  public
    procedure Start;
  end;

このコードでは TLoopThread という Thread を TGameMaster の中で宣言しました。
この TLoopThread がゲームループとなります。
Start メソッドを呼ぶとループが動き出す仕組みにします。

実装をしてみます。

TGameMasterの実装
{ TGameMaster }

constructor TGameMaster.Create(const ACanvas: TCanvas);
begin
  inherited Create;
  FCanvas := ACanvas;
  FLoop := TLoopThread.Create(Self);
end;

destructor TGameMaster.Destroy;
begin
  FLoop.Terminate;
  while not FLoop.Finished do
    Sleep(100);

  inherited;
end;

procedure TGameMaster.Start;
begin
  if not FLoop.Started then
    FLoop.Start;
end;

Create は引数に ACanvas を受取ります。
この Canvas を使って画面に描画していきます。
また、Create で TLoopThread のインスタンスを生成しています。
Destroy では、TLoopThread の動作を止めています。
Start で、ゲームループを起動させます。

LoopThread の実装

LoopThread の実装です。
コンストラクタで FreeOnTerminate を True にして終了時に自動的に廃棄されるようにします。

{ TGameLoop.TLoopThread }

constructor TGameMaster.TLoopThread.Create(const AGM: TGameMaster);
begin
  inherited Create(True);

  FGameMaster := AGM;
  FreeOnTerminate := True; // スレッドが終了したら自動廃棄する
end;

次に、ループを実装します。
今回は 60[FPS] を目指すことにします。

ゲームループ
procedure TGameMaster.TLoopThread.Execute;
begin
  // 初期化処理
  var SW: TStopwatch;

  // アプリケーションやスレッドが終了したら終わらせる
  while (not Terminated) and (not Application.Terminated) do
  begin
    // 描画矩形の計算
    FCanvasR := RectF(0, 0, FCanvas.Width, FCanvas.Height);

    SW.Reset;
    SW.Start;

    // 更新・描画処理を呼び出す

    SW.Stop;

    // 経過時間を取得する
    var ElapsedMSec := SW.Elapsed.Ticks * 1000 / TStopwatch.Frequency;

    // 余り時間
    var WaitTime := Round(16.67 - ElapsedMSec);
    if WaitTime > 0 then
      Sleep(WaitTime);
  end;
end;

基本的な構造はこれだけです。
while を使って終了フラグが立つまで、ずっとループを回します。
最初に Canvas の矩形を計算しています(Canvas の矩形はこの Canvas を所有しているコントロールの大きさになるため変更される可能性があり、毎回計算しています)。
「更新・描画処理を呼び出す」とコメントのある部分がメインの処理です。

このメイン処理がどれだけ時間がかかったか測るために TStopwatch を使って計測しています。
TStopwatch を使うと各 OS の高性能タイマーを使って経過時間を計れます。
Start で計測開始、Stop で計測終了です。
TStopewatch には ElapsedElapsedMilliseconds というプロパティがあるのですが、これらの解像度は msec まです。
そこで、Elapsed プロパティの Ticks プロパティを使って小数点以下をマイクロ秒とした値を取得します。

処理時間の算出
var ElapsedMSec := SW.Elapsed.Ticks * 1000 / TStopwatch.Frequency;

これで、メイン処理にかかる時間がわかりました。

今回は、60FPS を目指すので、メイン処理が 16.67 [msec] より速く終わってしまった場合、わざと Sleep を入れて実行を止めています。
例えば処理が 3 [msec] で終わってしまった場合、残りの 13.37 [msec] 分 Sleep させるということです。
とはいえ、Sleep に小数点以下を渡せないので Round で整数化しています。

…ですが!ここで、少し問題が生じます。

Sleep の問題

Sleep は当該 Thread の動きを止めて別のスレッドに実行権を譲る仕組みですが、引数に指定された時間かならず止まって戻ってくるという訳では無いのです。
また、コンテキストスイッチが発生するので、その分、停止している時間が長くなってしまいます。

解決作として、Sleep にかかった時間を計測して、指定された秒数よりかかった分を次のループ時に引きます。
具体的には以下のようにコードを書き換えます。

初期化処理部に変数を宣言
  var SW: TStopwatch;
  var SleepWatch: TStopwatch; // Sleep している時間を計る Stopwatch
  var AdjustTime := 0.0;      // 誤差を補正する変数
Sleep 部分
    // 経過時間
    var ElapsedMSec := SW.Elapsed.Ticks * 1000 / TStopwatch.Frequency;

    // 余り時間:AdjustTime 分引いている
    var SleepTime := 16.67 - ElapsedMSec - AdjustTime;

    // SleepTime が 0 以下の場合は何もしないで次のループへ
    if SleepTime > 0 then
    begin
      // SleepTime が 0 より大きい場合 Sleep する
      SleepWatch.Reset;
      SleepWatch.Start;

      Sleep(Round(SleepTime));

      SleepWatch.Stop;

      // Sleep に要した時間と指定した時間の差分を取り次のループではその分を減らす
      AdjustTime :=
        SleepWatch.Elapsed.Ticks * 1000 / TStopwatch.Frequency - SleepTime;
    end
    else
      AdjustTime := 0;
  end;

このように変更する事で、概ね 60 [FPS] 前後でループを回せるようになりました。

また、ここでマイクロ秒の計算が2箇所出てきたので、レコードヘルパーを使って処理をまとめておきます。

レコードヘルパーとして実装
type
  TStopwatchHelper = record helper for TStopwatch
  private
    function GetElapsedMicroSec: Double;
  public
    property ElapsedMicroSec: Double read GetElapsedMicroSec;
  end;

{ TStopwatchHelper }

function TStopwatchHelper.GetElapsedMicroSec: Double;
begin
  Result := Elapsed.Ticks * 1000 / TStopwatch.Frequency;
end;

FPS の計測と表示

今までのコードで概ね 60 [FPS] として回せるとしても、実際に計ってそれを更新・表示する必要があります。

ここで一番最初に述べた Update, Render のメソッド2つを定義します。

メソッド定義
    TLoopThread = class(TThread)
    (中略)
    private
      procedure Update;
      procedure Render;
    (中略)
    end;

Update メソッド

Update メソッドは、各種更新処理を書く場所ですがまだ更新処理がないので中身は空っぽです。

Update
procedure TGameMaster.TLoopThread.Update;
begin
  // 各更新処理を呼ぶ
end;

Render メソッド

Render メソッドは、実際の画面描画処理でした。

画面描画処理を1回通ると画面が更新されるため、1秒間に Render が何回呼ばれるかを図れば FPS が計算できることになります(VSYNC で同期を取っていた事とにていますね)。
そこで、Render メソッドで、FPS の計測と描画を実装します。

FPS を計測するために以下の3つの変数を TLoopThread に定義します。

メンバ変数の追加
    TLoopThread = class(TThread)
    private var
      FFPS: Double;          // FPS を保存
      FFrameCount: Integer;  // 1秒間に処理できた Frame の数
      FFPSWatch: TStopWatch; // 1秒間を計る Stopwatch
    (省略)

ループでは、初期化処理にこの2行を追加して FPS 計測の準備をします。

初期化処理
  FFrameCount := 0;
  FFPSWatch := TStopwatch.StartNew;

Render メソッドの実装です。
最初に Canvas の BeginScene / EndScene を呼んで描画可能状態ににておきます。
そして背景を黒で塗りつぶしたら描画準備が完了します。

その次に FillText を使って左下に FPS を表示します。

最後に、FPS を計測しています。
ここでは Render を通った回数をカウントして 1000[msec] で割り FPS を算出しています。

Render
procedure TGameMaster.TLoopThread.Render;
begin
  FGameMaster.FCanvas.BeginScene;
  try
    try
      // 背景のクリア
      FGameMaster.FCanvas.Clear(TAlphaColors.Black);

      // FPS 表示
      FGameMaster.FCanvas.Fill.Color := TAlphaColors.White;
      FGameMaster.FCanvas.Fill.Kind := TBrushKind.Solid;
      FGameMaster.FCanvas.FillText(
        RectF(
          6,
          FGameMaster.FCanvasR.Bottom - 28,
          FGameMaster.FCanvas.Width,
          FGameMaster.FCanvasR.Bottom
        ),
        Format('FPS: %.3f', [FFPS]),
        False,
        1,
        [],
        TTextAlign.Leading,
        TTextAlign.Center);
    except
    end;
  finally
    FGameMaster.FCanvas.EndScene;;
  end;

  // FPS 更新処理
  Inc(FFrameCount);
  var FPSElapsed := FFPSWatch.ElapsedMilliseconds;
  if FPSElapsed > 999 then
  begin
    FFPS := FFrameCount * 1000 /  FPSElapsed;

    FFrameCount := 0;
    FFPSWatch.Reset;
    FFPSWatch.Start;
  end;
end;

この Update と Render をループの中に書けば描画処理が動き始めます。

  while (not Terminated) and (not Application.Terminated) do
  begin
    SW.Reset;
    SW.Start;

    // 更新・描画処理を呼び出す
    Update;
    Render;

    SW.Stop;
    (以下省略)

ここまでのコード

ここまでのコードをまとめます。

コード全文
unit uGameMaster;

interface

uses
  System.Classes
  , System.Diagnostics
  , System.Types
  , System.SysUtils
  , FMX.Graphics
  ;

type
  TGameMaster = class
  private type
    TLoopThread = class(TThread)
    private var
      FFPS: Double;
      FFrameCount: Integer;
      FFPSWatch: TStopWatch;
      FGameMaster: TGameMaster;
    private
      procedure Update;
      procedure Render;
    protected
      procedure Execute; override;
    public
      constructor Create(const AGM: TGameMaster); reintroduce;
    end;
  private var
    FLoop: TLoopThread;
    FCanvas: TCanvas;
    FCanvasR: TRectF;
  public
    constructor Create(const ACanvas: TCanvas); reintroduce;
    destructor Destroy; override;
  public
    procedure Start;
  end;

implementation

uses
  System.UITypes
  , FMX.Forms
  , FMX.Types
  ;

type
  TStopwatchHelper = record helper for TStopwatch
  private
    function GetElapsedMicroSec: Double;
  public
    property ElapsedMicroSec: Double read GetElapsedMicroSec;
  end;

{ TStopwatchHelper }

function TStopwatchHelper.GetElapsedMicroSec: Double;
begin
  Result := Elapsed.Ticks * 1000 / TStopwatch.Frequency;
end;

{ TGameMaster }

constructor TGameMaster.Create(const ACanvas: TCanvas);
begin
  inherited Create;
  FCanvas := ACanvas;
  FLoop := TLoopThread.Create(Self);
end;

destructor TGameMaster.Destroy;
begin
  FLoop.Terminate;
  while not FLoop.Finished do
    Sleep(100);

  inherited;
end;

procedure TGameMaster.Start;
begin
  if not FLoop.Started then
    FLoop.Start;
end;

{ TGameLoop.TLoopThread }

constructor TGameMaster.TLoopThread.Create(const AGM: TGameMaster);
begin
  inherited Create(True);

  FGameMaster := AGM;
  FreeOnTerminate := True;
end;

procedure TGameMaster.TLoopThread.Execute;
begin
  // 初期化処理
  var SW: TStopwatch;
  var SleepWatch: TStopwatch;
  var AdjustTime := 0.0;

  FFrameCount := 0;
  FFPSWatch := TStopwatch.StartNew;

  // アプリケーションやスレッドが終了したら終わらせる
  while (not Terminated) and (not Application.Terminated) do
  begin
    SW.Reset;
    SW.Start;

    // 更新・描画処理を呼び出す
    Update;
    Render;

    SW.Stop;

    // 余り時間
    var SleepTime := 16.67 - SW.ElapsedMicroSec - AdjustTime;

    if SleepTime > 0 then
    begin
      SleepWatch.Reset;
      SleepWatch.Start;

      Sleep(Round(SleepTime));

      SleepWatch.Stop;

      AdjustTime := SleepWatch.ElapsedMicroSec - SleepTime;
    end
    else
      AdjustTime := 0;
  end;

  // 終了処理
end;

procedure TGameMaster.TLoopThread.Render;
begin
  FGameMaster.FCanvas.BeginScene;
  try
    try
      // 描画矩形の計算
      FGameMaster.FCanvasR :=
        RectF(0, 0, FGameMaster.FCanvas.Width, FGameMaster.FCanvas.Height);

      // 背景のクリア
      FGameMaster.FCanvas.Clear(TAlphaColors.Black);

      // FPS 表示
      FGameMaster.FCanvas.Fill.Color := TAlphaColors.White;
      FGameMaster.FCanvas.Fill.Kind := TBrushKind.Solid;
      FGameMaster.FCanvas.FillText(
        RectF(
          6,
          FGameMaster.FCanvasR.Bottom - 28,
          FGameMaster.FCanvas.Width,
          FGameMaster.FCanvasR.Bottom
        ),
        Format('FPS: %.3f', [FFPS]),
        False,
        1,
        [],
        TTextAlign.Leading,
        TTextAlign.Center);
    except
    end;
  finally
    FGameMaster.FCanvas.EndScene;;
  end;

  // FPS 更新処理
  Inc(FFrameCount);
  var FPSElapsed := FFPSWatch.ElapsedMilliseconds;
  if FPSElapsed > 999 then
  begin
    FFPS := FFrameCount * 1000 /  FPSElapsed;

    FFrameCount := 0;
    FFPSWatch.Reset;
    FFPSWatch.Start;
  end;
end;

procedure TGameMaster.TLoopThread.Update;
begin
  // 各更新処理を呼ぶ
end;

end.

GameMaster の生成

最後に TGameMaster を生成します。
今回は Form 全体をゲーム画面の領域とします。

OnCreate で TGameMaster のインスタンスを作り FGameMaster 変数に格納します。
OnDestroy で GameMaster のインスタンスを破棄します。
そして、OnShow でゲームループを開始します。

宣言
type
  TfrmMain = class(TForm)
    procedure FormCreate(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure FormShow(Sender: TObject);
  private var
    FGameMaster: TGameMaster;
  public
  end;
実装
procedure TfrmMain.FormCreate(Sender: TObject);
begin
  FGameMaster := TGameMaster.Create(Canvas);
end;

procedure TfrmMain.FormDestroy(Sender: TObject);
begin
  FGameMaster.Free;
end;

procedure TfrmMain.FormShow(Sender: TObject);
begin
  FGameMaster.Start;
end;

実行

では、これを実行してみます!

左下の FPS が変わっていることがわかるでしょうか?
概ね 60[FPS] 前後で更新されていることがわかります。

まとめ

GameMaster を作って、さらにその中に GameLoop を作成しました。
これでゲームの骨子が完成しました。
次回は、キャラクターの移動を実装します。

第3回に続く

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0