C++11がゲームプログラミングにもたらすもの

Counter: 7007, today: 2, yesterday: 4

はじめに

C++AdventCalender2014 22日目の記事です。

21日目の記事は、yohhoyさんによるメモリモデル?なにそれ?おいしいの?です。
23日目の記事は、ボレロ村上さんによるコンパイル時Brainfuckコンパイラ ――C++14 constexpr の進歩と限界――です。

AdventCalenderに参加するのも今年で3回目になりました。
ゲームプログラミングにC++11を導入して約1年半経過しましたので、その間に気がついたことをまとめました。
長くなってしまい、書きたいことの半分ぐらいしか書けませんでいたが、続編を次の機会ということでお許しください。

ゲームプログラミングの開発環境が変化してきました。
かつてアセンブラやCで書かれていたゲームプログラムは、C++での開発が主流になったといえるでしょう。
C#やJavaでのゲームプログラミングも現実的になってきましたが、本格的なゲーム開発言語はC++が多いと思います。
なぜならば、ゲームは処理速度、メモリ効率、レスポンス、いずれも高い次元で動作することが要求されるからです。

C++11の登場で、C++でのゲームプログラミングも大幅に進化しました。
それは、アセンブラからCへ、CからC++へと変遷していったときと同じぐらい、大きなインパクトがあります。 C++11により、C++のウイークポイントが解消され、実行速度、メモリ効率、開発効率、ともに大幅な改善がされたためです。

さらに、C++は14,17とこれからも進歩を遂げ、ますます効率的で使い易い言語になっていきます。
ビャーネさんの言葉を借りると、C++は理解している者にとってはとても優しい言語です。 ゲームプログラミング特有のコーディングを踏まえて、まとめてみました。

なお、Chapter-1とChapter-2はムーブ(右辺値参照)とラムダ式のおさらいなので、C++11を熟知している方は読み飛ばしてください。


もくじ


CHAPTER-1 moveの活用

moveの活用による恩恵は計り知れません。 とくに、速度とメモリ効率、そしてデバッグ効率を重視するゲームプログラミングにおいて、 moveセマンティクスの導入は大きな効果がありました。

なお、moveに関して十分な知識のある方ば、Chapter-1は読み飛ばしてください。


1-1 moveのおさらい

moveを使う前に、右辺値参照について知っておく必要があります。

このプログラムを見てください。

string a = "1";
string b = "2";
string c = a + b;
//         ~~~~~
//         右辺値

この"a + b"の部分が右辺値です。
右辺値とは、名前のない一時的に生成されるオブジェクトのことです。
この場合、string型で値が"12"の一時オブジェクトが生成されます。 わかりやすくC++03の書式で置き換えると、

string a = "1";
string b = "2";
string tmp(a + b);
string c = tmp;

このような動作になります。
tmpは式の外では不要になる、一時オブジェクトです。

最後の"c = tmp"で行われるコピーが無駄な動作ということは明白ですね。
C++03では、右辺値を変数に代入する時点で、コピーが発生してメモリと処理速度の無駄が発生していました。

コピーの無駄を省くにはtmpをcにエイリアスしてしまえはば解決しますが、

string& c = a + b; // コンパイルエラー!

これはエラーになります。"a + b"は右辺値なので、参照型として使うことができません。 C++03では、メモリ上の何処かに生成された一時オブジェクトを、式の外へ持ち出す手段がありませんでした。

C++11では、右辺値参照という新しい機能が追加されました。

string&& c = a + b; // 右辺値への参照をGET!

これで、C++03で記述する以下の動作とほぼ等しくなります。

string tmp = a + b;
string& c = tmp;

C++03では、右辺値として生成されたオブジェクトを使う場合、いったんコピーする必要がありました。

では、moveはどこで使うかというと、

string&& c = a + b;

とするかわりに、

string c = move(a+b);

とすることで、"a + b"の一時オブジェクトをcに移動することが可能になります。 最初の例と大きな違いがないように見えますが、前者は(右辺値)参照、後者は移動(move)という違いがあります。

一般的には、moveのコストはcopyよりもずっと小さく、stringならばバッファのポインタとサイズをコピーするだけで終わります。 メモリ上にアロケートされた実体はコピーされずにそのまま使われます。

なお、上記の例はわかりやすくするために move(a+b)と書きましたが、a+bは明らかに右辺値なのでmoveは省略できます。 moveを明示的に使うのは、左辺値を右辺値に変換するときに使用します。

string a = "1";
string c = move(a); // cにaのインスタンスが移動する。
// これ以降はaにアクセスしてはならない。
// aは、ヌケガラ、デガラシ、捨てられたバナナの皮のようなもの。
// アクセスすると、未定義動作の洗礼を受けることになる。

この例だと、aをcに移動させているだけで、なんのメリットもないコードです。 しかし、moveは後述するコンストラクタや代入演算子で必要になります。


1-2 ムーブコンストラクタとムーブ代入演算子を実装

下記のプログラムは、C++03とC++11では動作が大きく異なります。

string c = a + b;

先ほど解説したとおり、C++11では、"a + b"の一時オブジェクトはcにmoveされます。

なぜmoveされるのか? それは、stringにムーブ代入演算子とムーブコンストラクタがあるからです。 もし、自前のクラスで、ムーブコンストラクタやムーブ代入演算子が定義されていなかったら、moveされません。

Hoge a = 1;
Hoge b = 2;
Hoge c = a + b;

この、3行目の"c = a + b"の動作は、Hogeクラスにムーブコンストラクタが実装されているか否かできまります。

Hogeにムーブ代入演算子がなくても、ビルドはとおります。プログラムは何事もなかったかのように、*COPY*を行ってゆっくりと動作するでしょう。 目に見えにくいので注意が必要です。

moveを使うには、ムーブコンストラクタとムーブ代入演算子を定義する必要があります。

struct Hoge {
  string str_;
  Hoge(const Hoge& hoge) : str_(hoge) {} // コピーコンストラクタ
  Hoge(Hoge&& hoge) : str_(move(hoge)) {} // ムーブコンストラクタ
  Hoge& operator = (const Hoge& hoge) { str_ = hoge.str_; return *this; } // コピー代入演算子
  Hoge& operator = (Hoge&& hoge) { str_ = move(hoge.str_); return *this; } // ムーブ代入演算子
};

これでOKです。
インスタンスに対する操作が標準的な実装ならば、以下のように省略することができます。

struct Hoge {
  string str_;
  Hoge(const Hoge& hoge) = default;
  Hoge(Hoge&& hoge) = default;
  Hoge& operator = (const Hoge& hoge) = default;
  Hoge& operator = (Hoge&& hoge) = default;
};

コードの例
http://melpon.org/wandbox/permlink/OHFJkkmV7XZllqL7
http://melpon.org/wandbox/permlink/7v7e0TFsorASaOzQ


1-3 ムーブの落とし穴(地雷)

moveを活用することで速度とメモリ効率を同時に向上できますが、いくつか落とし穴があります。 実際に私が踏んでしまた地雷について解説します。


1-3-1 右辺値参照引数のムーブ

右辺値参照で受け取った引数を、別の関数の右辺値参照の引数として呼び直す場合、

void hoge(string&&);

void foo(string&& str) {
     hoge(move(str)); // このmoveを忘れるとコピーされてしまう
}

このように、hogeにstrをムーブしたければ、move指定が必要です。
なぜならば、右辺値参照引数のstrは左辺値だからです。ややこしいですが、&&で宣言した変数も、&で宣言した変数と同じように振る舞います。

では、可変長引数の場合はどうでしょう?

template<typename... ARGS>
void hoge(ARGS&&... args) {
}

template<typename... ARGS>
void foo(ARGS&& ...args) {
     hoge(move(args...)); // コンパイルエラー
}

残念ながら、このプログラムはコンパイルエラーです。このような場合は、std::forwardを使用します。

template<typename... ARGS>
void hoge(ARGS&&... args) {
}

template<typename... ARGS>
void foo(ARGS&& ...args) {
     hoge(forward<ARGS>(args)...); // OK
}

右辺値参照の変数を使って他の関数を呼ぶ場合、 うっかりmoveやforwardを忘れるとせっかくの右辺値参照の値がコピーされてしまうので注意が必要です。


1-3-2 std::swapの違い

C++03でのstd::swapは、以下のような実装でした。

template<typename T>
void swap(T& a, T& b) {
     T tmp = a;
     a = b;
     b = tmp;
}

このように、代入を3回行うことでaとbを入れ替えています。
これが、C++11になると以下のような実装に変わりました。

template<typename T>
void swap(T& a, T& b) {
     T tmp = move(a);
     a = move(b);
     b = move(tmp);
}

コピー3回から、move3回に変わっています。
通常のクラスなら気にすることはないのですが、バッファやリソースの管理を行っているクラスで、コピーやswapに特殊な操作が必要な場合は要注意です。

std::swapは、コピーコンストラクタの有無、ムーブコンストラクタの有無、代入演算子の有無などで、以下のような動作になります。

copy constructormove constructoroperator= const&operator= &&copyされる回数moveされる回数
C++03なしなしなしなし3回0回
C++11なしなしなしなし0回3回
C++11(VS2013)なしなしなしなし3回0回
C++11bothなしbothなし3回0回
C++11bothありbothあり0回3回
C++11ありありbothなし2回1回
C++11ありなしbothあり1回2回

C++03で書かれたソースをビルドすると、コピーコンストラクタが無いクラスはムーブされ、コピーコンストラクタが定義されているクラスはコピーされます。
(VisualStudioではいずれもコピーになります)

C++11では、ムーブコンストラクタとムーブ代入演算子をセットで定義しておかないと、std::swapが期待通りの動作をしてくれないようです。


1-3-3 ムーブ後のクラスオブジェクトへのアクセス

以下のプログラムは、何の問題もなく動くはずでした。すくなくともC++03までは。

void foo(string&);
void func() {
     string a = "hoge";
     foo(a);
     cout << a << endl;
}

しかし、ムーブセマンティクスが導入されたC++11では、foo()の実装次第では安全でなくなります。

void foo(string& a) {
     string b = move(a);
     // ここでaはヌケガラとなる
}
void func() {
     string a = "hoge";
     foo(a); // fooの中でaが破壊される
     cout << a << endl; // 未定義動作
}

これを回避するには、関数のAPIをしっかりと設計するしかありません。 参照で受け取った引数を破壊するような関数は設計しないほうが無難です。

この問題のやっかいなところは、ヌケガラとなった変数にアクセスしても、「それなりに」動作してしまうことです。 上記のプログラムも、何事もなかったかのように動作してしまいます。

標準ライブラリのムーブされたオブジェクトへのアクセスは、ランタイムエラーにしてほしいところです。


1-3-4 継承クラスのムーブコンストラクタ

以下のプログラムは、潜在的な問題を含んでいます。

struct BaseClass {
  string baseStr_;
  BaseClass(BaseClass&& bc) : baseStr_(move(bc.baseStr_)) {}
};
struct SubClass : BaseClass {
  string subStr_;
  SubClass(SubClass&& sc) : BaseClass(move(sc))
                          , subStr_(move(sc.subStr_)) {}
};

一見して問題のなさそうなプログラムですが、SubClassのムーブコンストラクタが正しく動作しないことがあります。
問題はココ

  SubClass(SubClass&& sc) : BaseClass(move(sc))

BaseClassに、SubClassの引数であるscをmoveして渡しているところです。
このmoveにより、scのインスタンスはBaseClassの引数に「移動」します。 baseStr_は良いのですが、subStr_も一緒に移動してしまい、受け取り手がいないので闇に葬られてしまいます。

SubClassの次の行で、

	, subStr_(move(sc.subStr_)) {}	

としていますが、このときすでにscはヌケガラなので、subStr_も空っぽです。 したがって、このプログラムは意図した動作をしない可能性があります。

「可能性があります」と書いたのは、実はこのケースはほとんどの場合、意図した動作をします。 BaseClassのmoveコンストラクタの引数の評価が遅延されると、subStr_のmove動作は行われません。 引数の評価のタイミングに依存したコードとなっています。

実際に試したところ、単純なプログラムでは正常に動作しましたが、比較的大きなクラスをライブラリ化するなどのいくつかのステップを踏むことで、 BaseClassのムーブコンストラクタに渡すmoveでSubClassのsubStr_が消失してしまう現象を確認しています。

同じプログラムが環境によって動作したりしなかったりは、デバッグを困難にする困った問題です。 予防的な意味でも、下記のようにベースクラスのムーブコンストラクタを呼ぶ場合はキャストしたほうが良いでしょう。

struct SubClass : BaseClass {
  string subStr_;
  SubClass(SubClass&& sc) : BaseClass(move(static_cast<BaseCLass&&>(sc)))
                          , subStr_(move(sc.subStr_)) {}
};

1-3-5 VisualStudioはムーブコンストラクタを自動生成してくれない

以下のようなクラスがあります。

struct Hoge {
    string name_;
};

このHogeというクラスには、コンストラクタ、コピーコストラクタ、代入オペレーターなどが自動生成されます。

C++11になり、ムーブ代入演算子も自動生成されるようになったのですが、VisualStudio 2013は現時点でムーブコンストラクタおよびムーブ代入演算子を生成してくれません。

以下のプログラムを実行した場合、

Hoge foo() {
     Hoge hoge;
     return hoge;
}

Hoge hoge;
hoge = foo();

GCCやClangでは、ムーブコンストラクタと代入演算子が自動生成され、Hogeのインスタンスであるstring name_はムーブされます。
ところが、VisualStudio 2013で同じプログラムを実行すると、string name_は関数foo()のreturn時にコピーコンストラクタによりコピーされ、 hoge = foo(); でコピー代入演算子によりコピーされます。つまり、stringが2回もコピーされる結果となります。

VisualStudioでは、下記のように明示的にムーブコンストラクタとムーブ代入演算子を定義する必要があります。

struct Hoge {
   string name_;
   Hoge();
   Hoge(Hoge&& h) : name_(move(h.name_)) {}
   Hoge& operator = (Hoge&& h) { name_ = move(h.name_); return *this; }
};

Chapter-2 ラムダ式の活用

ラムダ式の導入は、C++11での最大のトピックといっても過言ではありません。
特に、処理が複雑になりがちなゲームプログラミングにおいて、ラムダ式の活用はコード量を削減し、 パフォーマンスを犠牲にすることなくバグの出にくいプログラムが可能になりました。

本章ではゲームプログラミングにおけるラムダ式の活用例を紹介いたします。


2-1 コールバックの活用

C++03でも、boost::lambdaを活用することで、効率的なプログラミングが可能でした。
しかし、C++11でのラムダ式の導入は、従来のアプローチをはるかに超える便利さがあります。

例として、ボタンを押された時に指定した関数を呼び出す処理を考えてみましょう。

ボタンが押された判定は、bool buttonClicked(); という関数を使用します。

template<typename Func>
void buttonCheck(Func f) {
  if (buttonClicked()) f();
}

これでOKです。たとえば、ボタンが押された時に、MyClass::playSound(123); という関数を呼び出す処理を行いたい場合、

// 呼び出し時
buttonCheck(bind(&MyClass::playSound, this, 123));

と書けばOKです。ちょっとわかり辛いですが、なんとか1行で書けました。
(C++03のときは、boost::bindのお世話になりました)

では、playSound(123)の後に続けてplaySound(124)を実行して、音を2回鳴らすように改造するにはどうすれば良いでしょう? buttonCheckに渡すのはあくまでも「関数のポインタ」なので、手続きを関数のポインタに変換することはできません。 仕方がないので、以下のように書きます。

class MyClass {
...
void playSound2(int a, int b) {
     playSound(a);
     playSound(b);
}
...
buttonCheck(bind(&MyClass::playSound2, this, 123, 124));

MyClassにメソッドを追加することになります。 単独の関数でも構いませんが、いずれにしてもbuttonCheckを呼び出す場所と離れたところに処理を記述する必要がありました。

ラムダ式を使うと、以下のようにシンプルにかけます。

buttonCheck([this]{ playSound(123); playSound(124); });

とても簡単に書けるようになりました。この、「簡単でわかりやすい」というのがラムダ式を使う大きなメリットなのです。

従来は、コールバックを要求するAPIに対して関数を追加して対応していましたが、ラムダ式を使うとコードサイズをぐっと減らすことができます。


2-2 遅延評価

ラムダ式をうまく使うことで、値の評価を実際に使うときまで引き延ばすことが簡単にできるようになりました。

例として、引数を元になんらかの計算を行う tryCalculationという関数があるとします。 ただし、毎回計算を行うわけではなく、needCalculation()がtrueのときだけ計算する関数です。

この関数を、乱数を計算するrand()というメンバ関数を使って呼び出してみます。

// needCalculation()がtrueのときに、引数をもとに処理を行う関数
void tryCalculation(int value) {
    if (needCalcucation()) {
        doCalculatoin(value); // 引数を用いてなんらかの計算をする処理
    }
}

struct MyClass {
    int rand();
    ...
    void func() {
        tryCalculation(rand()); // ここでrand()が呼ばれて乱数が生成される
    }
};

特に問題のないプログラムですが、計算を実行する必要がないときにもrand()による乱数生成の処理を行ってしまうのが欠点です。 必要なときだけ乱数の計算を行うように修正すると、

// needCalculation()がtrueのときに、引数をもとに処理を行う関数
void tryCalculation(function<int()> func) {
   if (needCalcucation()) {
      doCalculatoin(func()); // ここでfunc()が実行されて値を得る
   }
}

struct MyClass {
 int rand();
 ...
 void func() {
      tryCalculation([this]{return rand()}); // rand()の実行は、doCalculationまで遅延される
 }
};

このように、ほんの少しの修正で遅延評価の処理を書くことができます。

パフォーマンスを重視するゲームにおいて、遅延評価は大きな効果を発揮します。


2-3 遅延実行

ゲームプログラムでは、一定時間後に特定の処理を行いたいという事が良くあります。 たとえば、「テキストを表示して1秒後に消す」と言った処理です。

遅延実行はラムダ式を使うことで、簡単に記述することが可能です。

// テキストを表示して1秒後に閉じる処理
void textOpenAndClose() {
     showText();
     delayedExec(1.0, []{ closeText(); });
}

もう少し複雑な例として、「テキスト1を表示して1.5秒後にテキスト2を表示し、テキストはそれぞれ2秒表示後に閉じる」 という手順は以下のように記述できます。

void textOpenAndClose() {
  // 指定されたテキストをsec秒間表示して閉じる関数
  auto show2sec = [](int n, float sec) { 
    showText(n);  // テキストnを表示
    delayedExec(sec, [n]{ closeText(n); }) // sec秒後にテキストnを閉じる
  };
  // テキスト1を表示して、2秒後に閉じる
  show2sec(1, 2.0f);
  // 1.5秒後にテキスト2を表示して2秒後に閉じる
  delayedExec(1.5, [&show2sec]{ show2sec(2, 2.0f); });
}

ラムダ式を使うことで、処理を局所的に記述でき、コードが簡潔にかけますね。 複雑な手順もわかりやすくコーディングできます。

指定された時間後に関数を実行する、deleyedExecの中身も紹介しておきます。
この例ではスレッドを使用していますが、スレッドを使用しない例はChapter-4で紹介します。

// 関数を指定された時間だけ遅延実行する
void delayedExec(float sec, function<void()> func) {
    thread th([sec, func] {
        this_thread::sleep(sec*1000.0); // src秒間スリープ(ミリ秒に変換)
        func(); // 関数を実行
    });
    th.detach(); // thを破棄可能にするため、スレッドを切り離す
}

2-4 ラムダ式の注意点

便利で一度使い出すと手放せないラムダ式ですが、私が遭遇したハマりポイントをいくつか紹介します。


2-4-1 キャプチャのパフォーマンス

ラムダ式には、任意の変数を渡すことができますが、渡し方によりオーバーヘッドが発生します。

&による参照渡しの場合はポインタとして4バイトないし8バイト、コピー渡しの場合は変数やクラスのサイズ分のメモリがヒープ上に確保されてコピーされます。

大きなクラスオブジェクトやコピーにコストがかかるものは、コピー渡しにしないほうがよいでしょう。 "[=]"の記述のみによるキャプチャは、思わぬオーバーヘッドを見落としてしまうので、できるかぎり自動にしないで変数を列挙するようにしています。

C++11では、コピー不可のオブジェクトを実体として渡すことはできません。参照として渡すことになるので、生存期間の管理が必要です。

C++14からはmoveによるキャプチャがサポートされる予定です。
C++14からサポートされるmoveキャプチャの例

  string hoge = "hoge";
  auto func = [h = move(hoge)]{ //hogeがhにmoveされる
    cout << h << endl;
  };

2-4-2 shared_ptrのキャプチャ問題

参照によるキャプチャはオブジェクトの寿命の問題があるため、解決方法としてshared_ptrを使うことが考えられます。

ただし、shared_ptrをそのままキャプチャすると、参照カウントの問題が発生することがあるので注意が必要です。

下の例では、MyClassの参照カウントはゼロにならず、ゾンビオブジェクト化してメモリリークを引き起こします。

struct MyClass {
  function<void()> callback_;
  ...
  void hoge();
};
...
// shared_ptrのインスタンスを作成
auto myptr = make_shared<MyClass>();
// callback_関数に、自分のshared_ptrをキャプチャしてラムダ式を登録
myptr->callback_ = [myptr] { myptr->hoge(); }
//                  ~~~~~
// このmyptrは、callback_にコピーして保存されるため、MyClassは自身の参照を保持することになりゾンビとなる。

単独な循環参照なら比較的気がつきやすいですが、多くのクラスを介した三つ巴の循環参照を引き起こす場合は発見が困難です。 しかも、プログラムはメモリを食いつぶしながらも正常に動作してしまうので、発見が遅れる困ったバグとなります。

この問題は、weak_ptrを利用することで解決できます。

...
// shared_ptrのインスタンスを作成
auto myptr = make_shared<MyClass>();
// callback_関数に、自分のweak_ptrをキャプチャしてラムダ式を登録
weak_ptr<MyClass> wptr = myptr;
myptr->callback_ = [wptr] { if (auto p = wptr.lock()) p->hoge(); }
//                  ~~~~~
// werk_ptrは自身で保持しても循環参照とならない

一行増えてしまいますが、shared_ptrをキャプチャする場合は必ずweak_ptrを利用することをお勧めします。

C++14の場合は、以下のように書けるので便利です。

...
// shared_ptrのインスタンスを作成
auto myptr = make_shared<MyClass>();
// callback_関数に、自分のweak_ptrをキャプチャしてラムダ式を登録
myptr->callback_ = [wptr = weak_ptr<MyClass>(myptr)] { if (auto p = wptr.lock()) p->hoge(); }
//                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//                  c++14ではこのように書ける

Chapter-3 コピーコンストラクタの廃止によるパフォーマンスアップ

ゲームプログラムでは処理速度の向上が必要不可欠です。「富豪的プログラミング」は許されません。
もし、処理速度を全く問題にしないゲームを作るのならば、スクリプトやJava,C#などの他の言語の方が良い選択肢かもしれません。

ムーブセマンティクスの導入で、オブジェクトの無駄なコピーを省くことができるようになりました。 しかし、既存のコードをリファクタリングして無駄なコピーを見つけるのは手間がかかります。

そこで、既存のクラスを簡単にリファクタリングする方法として、コビーコンストラクタの廃止というアプローチを紹介します。


3-1 コピーコンストラクタをムーブコンストラクタに置き換える

このような従来のコードがあるとします。

struct Hoge {
  string ins_; // クラスインスタンス
  Hoge();
  Hoge(const Hoge& h) : ins_(h.ins_) {} // コピーコストラクタ
  Hoge& operator = (const Hoge& h) { ins_ = h.ins_; } // 代入演算子
};

ごく一般的なクラスの定義だと思いますが、これを以下のように変えてしまいます。

struct Hoge {
  string ins_;
  Hoge();
  Hoge(const Hoge&) = delete;                          // コビーコストラクタ廃止
  Hoge(Hoge&& h) : ins_(move(h.ins_)) {}               // ムーブコンストラクタ
  Hoge& operator = (const Hoge&) = delete;             // コピー代入演算子廃止
  Hoge& operator = (Hoge&& h) { ins_ = move(h.ins_); } // ムーブ代入演算子
  // 自分の複製を作るメンバ関数(複製の生成が必須の場合)
  Hoge clone() const {
    Hoge h;
    h.ins_ = ins_; // インスタンスをコピー
    return h;
  }
};

コピーコンストラクタと代入演算子を使用禁止にして、代わりにムーブコンストラクタとムーブ代入演算子を定義します。
クラスオブジェクトの複製が必要な場合、clone()メンバ関数を定義します。

この変更を行った後、ビルドしてエラー箇所を全部直せば、このクラスのコピーはclone()以外では実行されなくなります。
もし、コピーを嫌ってポインタや参照でやりとりしていた箇所があったとすると、コードをスッキリ直すことができるでしょう。

もちろん、このクラスをvector<>,map<>などSTLのコンテナに格納することも可能です。 ただし、vectorの場合はpush_backは使用できません。すべてemplace_backに置き換えます。

無駄なコピーを行っている箇所をコンパイラが見つけてくれます。 この簡単な変更で、C++03で書かれた従来のゲームプログラムが2割以上高速化したという事例もあります。


3-2 shared_ptrをunique_ptrに置き換える

shared_ptrはとても便利ですが、それなりのオーバーヘッドがあります。 リファレンスカウントを増減する際、スレッド同期を行う為の処理が入るため、ループの内部やタイミングがクリティカルな箇所での使用は要注意です。

ポインタを本当にシェアしたい場合は仕方がないですが、単にオブジェクトの解放を自動化したいだけならば、unique_ptrのほうが断然高速に動作します。

unique_ptrはコピーできないため、従来は使用範囲が制限されていましたが、ムーブセマンティクスの導入で移動が可能になり使い勝手がぐっと向上しました。 vector<>などの標準ライブラリのコンテナにも入れることができます。

シェアしないポインタをすべてunique_ptrに置き換えることでパフォーマンスの向上が図れます。

シェアしないで済みそうなポインタはすべてunique_ptrに置換して、ビルドエラーを修正するといったリファクタリングでも効果があるでしょう。


Chapter-4 応用編 描画ループの処理

ほとんどのゲームプログラムは、描画ループというものが存在します。

ハードウエアに依存しますが、一般的に60分の1秒(16.67ms)を周期としてレンダリング処理が行われます。

ゲームプログラムのメインルーチンは、以下のような構造になります。

int main() {
  // finished()がtureになるまでループ
  while(!finished()) {
    update(); // ゲームの処理
    render(); // 描画処理
  }    		     
}

ゲームのメインルーチンは、毎秒60回コールされるupdate()から呼ばれるので、時間のかかる処理は分割するなどの工夫が必要になります。

スレッドを利用することで、メインループとは独立した処理を書くことができますが、マルチスレッドのプログラミングにはコストかかります。 ファイルのロードや通信などの時間のかかる処理はスレッド化するとしても、ゲームのメインの部分はメインループで処理を行うのが一般的でしょう。

メインループから呼ばれるメインスレッドでの処理を簡潔に行うため、以下のようなアプローチがあります。

  1. イベントドリブン型モデル
    • 長所: プログラムが書きやすい
    • 短所: 処理時間のかかるプログラムは書けない(スレッドを起こすなどの工夫が必要)
  2. コルーチンによる擬似マルチタスク
    • 長所: 処理時間のかかるプログラムもかける
    • 短所: メモリ管理、スタック領域の管理が難しい。

いずれも一長一短あり併用する場合もありますが、ここではイベントドリブン型の処理を取り上げます。


4-1 スレッドを使用しない遅延実行関数

Chapter 2-3で紹介した、遅延実行を行う関数delayedExec()を、スレッドを使わないモデルで実装してみます。

// 遅延実行を登録/実行する関数
// 引数なしで呼ぶと実行処理(通常はメインループのupdate()から呼ぶ)
void delayedExec(float sec = 0.0f, function<void()> func = nullptr) {
  // 遅延実行する関数を保持するコンテナ 要素の追加でイテレータを破壊てほしくないのでlistを使用
  static list<tuple<int, function<void()>>> queue;
  if (sec) {
    // 遅延実行関数の登録
    int frame = sec * 60.0f; // 秒からフレーム数に変換
    queue.emplace_back(make_tuple(frame, move(func)));
    return;
  }
  // 引数を省略した時は実行処理
  for (auto it = begin(queue); it != end(queue); ) {
    int& count = it->get<0>();
    if (--count == 0) { // 時間が来た!
      it->get<1>()();// 遅延実行 この関数の中からdeleydExecを呼んでもOK
      it = queue.erase(it);
    }
    else { // まだまだ
      ++it;
    }
  }
}

この関数は、キューに関数を登録する部分と、キューに溜まっている関数を実行する2つの部分で構成されています。
もちろん、クラス化してもっとスッキリ記述できますが、あくまでも例ですのでその点は突っ込まないでください。

メインループから呼ばれるupdate()の中で、関数を実行するための処理を一行入れます

...
void update() {
     ...
     delayedExec(); // タイミングの計算と関数の実行
     ...

これだけで、「テキスト1を表示して1.5秒後にテキスト2を表示し、テキストはそれぞれ2秒表示後に閉じる」 という手順をスレッドを使わずに記述できます。

void textOpenAndClose() {
  // 指定されたテキストを2秒間表示して閉じる関数
  auto show2sec = [](int n, float sec) { 
    showText(n);  // テキストnを表示
    delayedExec(sec, [n]{ closeText(n); }) // sec秒後にテキストnを閉じる
  };
  // テキスト1を表示して、2秒後に閉じる
  show2sec(1, 2.0f);
  // 1.5秒後にテキスト2を表示して2秒後に閉じる
  delayedExec(1.5, [&show2sec]{ show2sec(2, 2.0f); });
}

4-2 タスクマネージャの紹介

C++11の新機能を活用したゲーム用タスクマネージャーを作成しましたので、サンプルプログラムとし公開します。

主な特徴は、

  1. タスククラスはコピー不可でムーブ可能なクラスです。
  2. タスククラスはヒープを一切使用しません。(new/mallocしている箇所はありません)
  3. ゲームの流れを視覚的に記述できます。
  4. 名前付きオブジェクトを使用しています。
  5. シングルスレッドで動作します。

となっています。

各処理は「タスク」という名前付きオブジェクトで管理され、タスク間の連携をラムダ式とイニシャライザリストを使って局所的に記述できるようになっています。

以下のプログラムが、ゲームの流れ定義する処理部分です。 「タイトルロゴ」「メインメニュー」「ゲーム」「セッテイング」「エンディング」というそれぞれのタスクの流れを定義しています。

  TaskQueue().run({
              "titleLogo", titleLogo, { 
                  "mainmenu", mainMenu, {
                    { gameMain,    { ending, "mainmenu" } },
                    { settingMenu }
                }
              } });

'titleLogo', 'mainMenu', 'gameMain', 'ending', 'settingMenu' はクラスオブジェクトではなく関数です。 つまり、この部分にラムダ式を書くことができます。

これらの関数をラムダ式で表現すると以下のようになります。

  TaskQueue().run({
    "titleLogo", [](TaskArgs& args) {
      // titleLogoの処理
    },
    { // titleLogoの引数
      "mainmenu", [](TaskArgs& args) {
        // mainMenuの処理
      },
      { // titleLogoの引数
        { // gameMainタスク
	  [](TaskArgs& args) {
          // gameMainの処理
          }, // gameMainの引数
          { [](TaskArgs&) {
            // endingの処理
            }, 
            {"mainmenu"}
          },
          { // settingMenuタスク
	    [](TaskArgs&){
              // settingMenuの処理
            }
          }
        }
      }
    }
  });

このように直接処理を記述することができます。

このサンプルプログラムは、以下のクラスから構成されています。

  1. NamedObject 名前付きオブジェクト基底クラス
  2. Task タスク処理
  3. TaskArgs タスクの引数
  4. TaskQueue タスクの実行管理

詳細はソースコードをみてください。

これらのクラスを利用したサンプルプログラムです。

ソースコードは こちら です

時間の関係で、Clang3.4でのテストしかしていません。以下のコマンドでビルドできます。

c++ -o t1 -g -Wall -Wunused-variable -std=c++11 TaskTest.cpp
// C++AdventCalender 2014 22th
// C++11によるゲームプログラミング
// Created by TECHNICAL ARTS h.godai 2014
//
#include <functional>
#include <deque>
#include <vector>
#include <memory>
#include <string>
#include <map>
#include <boost/optional.hpp>

#include "NamedObject.hpp"
#include "Task.hpp"
#include "TaskQueue.hpp"

using namespace std;
using namespace ts::namedobj;

// stub関数
bool keyWait();          // キー入力待ち
void initializeScreen(); // 画面を初期化する
int selectedMenu();      // 選択されたメニュー番号を返す
bool gameMainLoop();     // ゲームのメインルーチン

int main() {
  // タイトルロゴを表示するタスク
  auto titleLogo = [](TaskQueue& tq, TaskArgs& ar){
	assert(ar.size() > 0);
	initializeScreen();
	ar.at(0).valid("titlelogo");
	tq.waitPred(ar.at(0), [] { return keyWait(); });
	return TaskStatus::RemoveTask;
  };

  // メインメニューのタスク 
  auto mainMenu = [](TaskQueue& tq, TaskArgs& ar) {
	cerr << "mainMenu" << endl;
	switch (selectedMenu()) {
	default:
	return TaskStatus::ContinueTask;
	case 1:
	  // 引数の最初のタスクを実行する
	  ar.at(0).valid("mainMenu");
	  tq.addTask(ar.at(0).clone());
	  return TaskStatus::RemoveTask;
	case 2:
	  // 引数の二番目のタスクを実行する
	  ar.at(1).valid("mainMenu");
	  tq.addTask(ar.at(1).clone());
	  return TaskStatus::RemoveTask;
	case 3:
	  // 終了する
	  cerr << "finish! ===============" << endl;
	  tq.finish();
	  return TaskStatus::RemoveTask;
	}
  };

  // エンディングのタスク
  auto ending = [](TaskQueue& tq, TaskArgs& ar) {
	// do ending
	cerr << "ending" << endl;
	ar.at(0).valid("ending");
	// keyWait()がtrueを返したら、最初の引数のタスクを実行する
	tq.waitPred(ar.at(0), [] { return keyWait(); });
	return TaskStatus::RemoveTask;
  };

  // ゲームメインルーチンのタスク
  auto gameMain = [](TaskQueue& tq, TaskArgs& ar) {
	// do main
	cerr << "gameMain" << endl;
	// gameMainLoop()がtrueを返したら、最初の引数のタスクを実行する
	tq.waitPred(ar.at(0), [] { return gameMainLoop(); });
	return TaskStatus::RemoveTask;
  };

  // セッティングメニューのタスク
  auto settingMenu = [](TaskQueue& tq, TaskArgs& ar) {
	cerr << "settingMenu" << endl;
	Task ptask(ar.parent_);
	// 何かキーが押されたら、呼び出し元のタスクを実行する
	tq.waitPred(ptask, [] { return keyWait(); });
	return TaskStatus::RemoveTask;
  };

  // タスクマネージャーにタスクを登録
  TaskQueue taskqueue;
  taskqueue.run({
	"titleLogo",
	  titleLogo, {
	  "main", mainMenu,{
		{ // game main
		  gameMain, 
			{ending, {"main"} }
		  
		}
	  , { // setting menu
		  settingMenu
		}
	  }
	}
	});

  // ゲームのメインループ
  uint32_t frame = 0;
  while(!taskqueue.finished()) {
	cerr << "Frame:" << ++frame << endl;
	taskqueue.update();
	//draw(); // ゲームの場合レンダリングの処理が入る
  }
}

おわりに

長い文章を最後まで読んでいただいてありがとうございました。

とりとめもない話になってしまい、まとまりがありませんが、少しでも何かのお役に立てれば幸いです。

今後も、ゲームプログラミングのTIPSとして、ドキュメント化していく作業を続けたいと思います。


添付ファイル: filec++advent_sample.zip 257件 [詳細]

トップ   編集 凍結 差分 バックアップ 添付 複製 名前変更 リロード   新規 一覧 単語検索 最終更新   ヘルプ   最終更新のRSS
Last-modified: 2014-12-24 (水) 13:22:19 (1303d)