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

Counter: 12118, today: 2, yesterday: 1

はじめに

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

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

実際にゲーム開発でC++11を本格的に導入して2年ほど経過しましたので、 C++をゲームプログラミングで効率的に使う方法をまとめてみました。

もくじ


CHAPTER-1 moveの活用

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

なお、moveに関して十分な知識のある方ば、Chap.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;

これで、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 moveコンストラクタとmove operator = を実装

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

string c = a + b;

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

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

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

この、3行目の"c = a + b"の動作は、Hogeクラスにmoveコンストラクタが実装されているか否かできまります。 http://melpon.org/wandbox/permlink/7v7e0TFsorASaOzQ Hogeにmove代入演算子がなくても、ビルドはとおります。プログラムは何事もなかったかのように、COPYを行ってゆっくりと動作するでしょう。 目に見えにくいので注意が必要です。

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

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

これで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


1-3 moveの落とし穴(地雷)

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

1-3-1 地雷1 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は、コピーコンストラクタの有無、moveコンストラクタの有無、代入演算子の有無などで、以下のような動作になります。

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で書かれたソースをビルドすると、コピーコンストラクタが無いクラスは、moveされ、copyコンストラクタが定義されているクラスはコピーされます。
(VisualStudioではいずれもコピーになります)

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

1-3-2 地雷2 move後のクラスオブジェクトへのアクセス

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

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

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

cpp

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

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

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

1-3-3 地雷3 継承クラスのmoveコンストラクタ

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

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のmoveコンストラクタが正しく動作しないことがあります。
問題はココ

  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コンストラクタに渡すmoveでSubClassのsubStr_が消失してしまう現象を確認しています。

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

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

1-3-4 VisualStudioはmoveコンストラクタを自動生成してくれない

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

struct Hoge {
       string name_;
};

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

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

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

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

Hoge hoge;
hoge = foo();

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

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

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

http://melpon.org/wandbox/permlink/jBdMQWZLwuwOAzqP

Chap.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());
   }
}

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

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

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

2-3 ラムダ式の注意点

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

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

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

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

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

Chap.4 応用編 描画ループの処理

4-1 1/60秒の間隔で呼ばれるメインループ

たとえば、a(), b(), c() という処理に比較的時間のかかる3つの関数を順番に実行する手順を考えてみましょう。

void func() {
     a();
     b();
     c();
}

このようにプログラムが書ければベストです。 しかし、ゲーププログラミングは、通常ですと60分の1秒の間隔で入力やレンダリングの処理が必要です。

cpp

これだと、a(),b(),c()すべての処理が終わるまで描画が止まってしまい、ゲームになりません。

つまり、入力や描画の処理と、a(),b(),c()の処理を非同期に行う必要があります。 非同期処理には、スレッドを使う方法と、処理をフレーム単位に分割して行う方法があります。

4-2 Threadもco-routineも使わないマルチタスク的処理

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


トップ   新規 一覧 単語検索 最終更新   ヘルプ   最終更新のRSS