9
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ゲームアプリにおける更新と描出を異なる周期かつ同時並行的に制御する

Last updated at Posted at 2017-07-09

ゲームアプリケーションは一般的なツール系アプリケーションとは異なり、ユーザーの操作を受け付けるだけでなく、時間の経過によって状態が変化する場合が多い。アプリケーション内部に現実とは異なる独立した時間を持つ、映画やアニメーションに似た性質を持っていると言える。ここで、アプリケーション内部の独立した時間を一定間隔で分割した、その一つの時間の単位をフレームという。
ハードウェアの性能を比較する時に使われる、FPS(Frames per second)は、現実時間の1秒間で何度フレームを計算できるかという性能指標であり、例えば、1秒間に60個のフレームを計算する事を60FPSという。なお、元々フレームはコンピュータ上の1画面分の表示状態を記録したメモリ上の単位であり、厳密にはFPSは1秒間の画面の更新回数であるが、ゲームのプログラムでは描出に伴って周期的な処理を書く必要があるため、描出に関係する全ての処理のサイクルをフレーム単位で考える。

さて、ご存知の通り、モニター(画面)の表示を更新する時には、Vブランク割り込みが利用される。これは、画面の描出中に、画面の状態を示すデータを書き換えると、画面の途中で描出内容が変わってしまい、画面内に境界線が出現してしまうからである。この現象をティアリングという。ティアリングを回避するには画面が表示されていない時に画面の表示を書き換えればよい。このタイミングを知らせるのがVブランク割り込みである。

前述の通り、アプリケーション内部の時間はフレームを単位として進行する。そして、フレームの時間はモニターのVブランク割り込みの間隔によって決定する。ここで問題となるのが、モニターの更新間隔(リフレッシュレート)が、モニターによって異なる場合があり、それに伴ってアプリケーションの時間も影響を受けるのである。
例えば、以下のプログラムを実行した時に、モニターのリフレッシュレートが異なると実行結果も異なる。具体的には、1秒後の変数iの値が、リフレッシュレートが120Hzのモニターでは120に、60Hzのモニターでは60になる。

  for (int i = 0; ++i; ) {
    std::cout << i << std::endl;
    wait_for_vblank(); // Vブランク割り込み待ち
  }

この様な現象がゲームの難易度に影響する場合がある。画面のリフレッシュレートが2倍になればコントローラーの操作も2倍速にしなければ同じ結果を得られないからだ。この問題を回避する為には、画面のリフレッシュレートとは別の間隔でアプリケーションの時間を進める必要がある。前置きが長くなったが、本稿ではこれを実現する方法を紹介する。

一般的なゲームでは、ゲームの状態から、その情報を画面に描出する為に、ゲーム内部の情報を変更する必要は無い。また、ゲーム内部の情報を更新する為に、画面の描出に使用する情報を使う事はない。よって、ゲーム内部の情報の更新は、その情報の描出処理とは切り離す事ができる。
例えば、更新処理と描出処理をそれぞれupdate, renderというスレッドで管理するとする。それぞれのスレッドは、フレームとなるループによって構成される。ここで、updateスレッドは10ミリ秒間隔(100FPS)で、renderスレッドは50ミリ秒間隔(約20FPS)で処理されるとする。
以下のプログラムでは、updateスレッドで算出した変数iをrenderスレッドで表示している。それぞれのスレッドは同時並行的に実行される可能性がある為、情報の受け渡しにはmutexによる排他制御が必要である。また、汎用的な処理を受け渡せるように、std::functionを利用している。

#include <thread>
#include <functional>
#include <list>
#include <iostream>
#include <mutex>

struct Period {
    Period(std::chrono::high_resolution_clock::duration const& period) : period_(period) {}
    void operator ()() {
        auto end_time = std::chrono::high_resolution_clock::now();
        auto elapsed_time = end_time - begin_time_;
        if (elapsed_time < period_) {
            std::this_thread::sleep_for(period_ - elapsed_time);
        }
        begin_time_ += period_;
    }
    std::chrono::high_resolution_clock::duration const period_;
    std::chrono::high_resolution_clock::time_point begin_time_ = std::chrono::high_resolution_clock::now();
};

int main() {
      using namespace std::literals::chrono_literals;
      bool life = true;
      std::list<std::function<void()>> render_request;
      std::mutex mutex;
  
      std::thread update([&]{ // 更新処理
        int i = 0;
        Period period{10ms};
        while (life) {
            ++i;
            if (std::unique_lock<decltype(mutex)> lock{mutex}) {
                render_request.emplace_back([i]{ std::cout << i; }); // 変数iを描出する処理を要求する
                render_request.emplace_back([i]{ std::cout << std::endl; });
            }
            period();
        }
      });
  
  std::thread render([&]{ // 描出処理
    Period period{50ms};
    while (life) {
        decltype(render_request) requests;
        if (std::unique_lock<decltype(mutex)> lock{mutex}) {
            requests.swap(render_request); // 全ての要求を受け取る
        }
        std::cout << "begin frame" << std::endl;
        for (auto&& it : requests) {
            std::forward<decltype(it)>(it)();
        }
        std::cout << "end frame" << std::endl;
        period();
    }
  });
  
  std::this_thread::sleep_for(1s);
  life = false;
  update.join();
  render.join();
}

このプログラムを実行すると、約1秒後に以下のような出力を得る。(スレッドのスケジューリングによっては全く異なる出力になる可能性があるので注意)ここで、描出スレッドのフレームが約20回、即ち描出フレームが20FPSである事と、それぞれの描出フレームにおいて、更新スレッドの全ての要求が処理されている事が確認できる。
しかし、一般的なゲームでは前回の描出結果から最終的な描出に至る中間の状態を描出する必要はない。これは、描出スレッドから見て、最後の更新フレームの要求だけを描出する事で実現できる。この様に中間のフレームを描出せずにスキップする手法をフレームスキップという。フレームスキップでは、描出される映像の時間的連続性が失われる代わりに、実行中の処理負荷等によってフレームの計算時間が一定時間を超えた時に、描出される映像が現実時間に追い付かなくなるという問題を回避する事ができる。殆どのゲームに搭載されている機能である。

begin frame
1
end frame
begin frame
2
3
4
5
6
end frame
(中略)
begin frame
92
93
94
95
96
end frame

フレームスキップを実現する方法は幾つもあるが、今回は3つのバッファを利用する。3つのバッファとは、2つのスレッド間のデータが取る状態が、更新状態、描出可能状態、描出状態の高々3つである事から、それぞれ独立した空間で管理する手法である。

  • 更新状態
    • 更新スレッドで変更中の状態
  • 描出可能状態
    • 更新スレッドの変更が完了して描出可能な状態
  • 描出状態
    • 描出スレッドで処理中の状態

今回は、要求を蓄積するリストしか連絡情報が存在しない為、リストが3つあれば実現できる。具体的には以下のようなコードになる。ここで、描出処理の要求が排他制御の外側に移動している事に注意。

(前略)
int main() {
      using namespace std::literals::chrono_literals;
      bool life = true;
      std::list<std::function<void()>> updating_request; // 更新バッファ
      std::list<std::function<void()>> updated_request; // 描出可能バッファ
      std::list<std::function<void()>> rendering_request; // 描出バッファ
      std::mutex mutex;
  
      std::thread update([&]{ // 更新処理
        int i = 0;
        Period period{10ms};
        while (life) {
            ++i;
            updating_request.emplace_back([i]{ std::cout << i; }); // 変数iを描出する処理を要求する
            updating_request.emplace_back([i]{ std::cout << std::endl; });
            if (std::unique_lock<decltype(mutex)> lock{mutex}) {
                std::swap(updating_request, updated_request); // 更新完了バッファと交換する
            }
            updating_request.clear(); // 交換したバッファをクリアする
            period();
        }
      });
  
  std::thread render([&]{ // 描出処理
    Period period{50ms};
    while (life) {
        if (std::unique_lock<decltype(mutex)> lock{mutex}) {
            rendering_request.swap(updated_request); // 描出中バッファと更新完了バッファを交換する
        }
        std::cout << "begin frame" << std::endl;
        for (auto&& it : rendering_request) {
            std::forward<decltype(it)>(it)();
        }
        std::cout << "end frame" << std::endl;
        period();
    }
  });
  
  std::this_thread::sleep_for(1s);
  life = false;
  update.join();
  render.join();
}

さて、このプログラムを実行すると以下の出力を得る。

begin frame
end frame
begin frame
6
end frame
begin frame
10
end frame
(中略)
begin frame
96
end frame
begin frame
100
end frame

この出力結果は概ね期待通りであるが、最初のフレームで要求を全く処理していない事が確認できる。これは、更新スレッドよりも描出スレッドの方が早く処理された時に発生する現象である。例えば、更新スレッドの間隔を100ms等に設定すると容易に発生する。また、同じ出力が複数回出現する事も確認できる。
この問題を回避する為には、描出した後に少なくとも1回バッファが交換されてから描出フレームを開始すれば良い。要するに同期させるのである。更新スレッドと描出スレッドを同期する為に、特定の条件を満たすまでスレッドを待機させる事ができる条件変数(condition_variable)を利用する。

#include <thread>
#include <functional>
#include <list>
#include <iostream>
#include <mutex>
#include <condition_variable>

struct Period {
    Period(std::chrono::high_resolution_clock::duration const& period) : period_(period) {}
    void operator ()() {
        auto end_time = std::chrono::high_resolution_clock::now();
        auto elapsed_time = end_time - begin_time_;
        if (elapsed_time < period_) {
            std::this_thread::sleep_for(period_ - elapsed_time);
        }
        begin_time_ += period_;
    }
    decltype(auto) value() const { return period_; }
    std::chrono::high_resolution_clock::duration const period_;
    std::chrono::high_resolution_clock::time_point begin_time_ = std::chrono::high_resolution_clock::now();
};

int main() {
      using namespace std::literals::chrono_literals;
      bool life = true;
      std::list<std::function<void()>> updating_request; // 更新バッファ
      std::list<std::function<void()>> updated_request; // 描出可能バッファ
      std::list<std::function<void()>> rendering_request; // 描出バッファ
      std::mutex mutex;
      std::condition_variable cv;
      bool drawable = false;
  
      std::thread update([&]{ // 更新処理
        int i = 0;
        Period period{10ms};
        while (life) {
            ++i;
            updating_request.emplace_back([i]{ std::cout << i; }); // 変数iを描出する処理を要求する
            updating_request.emplace_back([i]{ std::cout << std::endl; });
            if (std::unique_lock<decltype(mutex)> lock{mutex}) {
                std::swap(updating_request, updated_request); // 更新完了バッファと交換する
                drawable = true; // 描出可能である
            }
            cv.notify_one();
            updating_request.clear(); // 交換したバッファをクリアする
            period();
        }
      });
  
  std::thread render([&]{ // 描出処理
    Period period{50ms};
    while (life) {
        if (std::unique_lock<decltype(mutex)> lock{mutex}) {
            if (!cv.wait_for(lock, period.value(), [&drawable]{ return drawable; })) {
                continue;
            }
            rendering_request.swap(updated_request); // 描出中バッファと更新完了バッファを交換する
            drawable = false; // 次回バッファが交換されるまで描出不可能である
        }
        std::cout << "begin frame" << std::endl;
        for (auto&& it : rendering_request) {
            std::forward<decltype(it)>(it)();
        }
        std::cout << "end frame" << std::endl;
        period();
    }
  });
  
  std::this_thread::sleep_for(1s);
  life = false;
  update.join();
  render.join();
}

なお、昨今のゲームアプリケーションはアンリアルエンジンやUnityといった高機能なゲームエンジン上で動作する物が多く、この様な原始的な制御プログラムを書く機会は減っていると思われる。

以上

9
10
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
9
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?