C++の欠点をカバーする技術

多胡順司氏(以下、多胡):続きまして、ゲームプログラム開発者向けの話をさせていただきます。

まず、ユーザーコード開発効率の最適化のお話です。先ほどお見せしたこのエンジン・モジュールの図で、エンジン側はテスト駆動開発でやっていますとお話をしましたが、じゃあその上のアプリケーション側はどうするのかというお話です。

もちろんアプリケーション側もテスト駆動開発をすることは可能です。とくにアプリケーション側のコードでもシステムに近いようなものはテスト駆動開発をしたほうがいいでしょう。とはいえ、個々のキャラクターの制御など、テスト駆動開発してもあまりメリットがないものもあると思います。それはどうするかという話です。

その前に、ユーザーコードは全部C++で書いています。利点としては、もちろん当たり前ですが、「速い」というのと、メモリを直接いじれる言語なので詳細な最適化が可能になるという点ですね。

欠点としては、イテレーションが遅いというのがあります。コンパイルして、リンクして、読み込みをしなければ結果が確認できない問題があります。

その問題をカバーするために「Runtime Compiled C++」という技術を取り入れています。これはなにかというと、プログラム実行中にC++のコードを更新可能にするというものです。

これもデモムービーを用意してきたのでご覧ください。左下のものがプレイヤーのソースコードです。移動速度を変えてみましょうというので、とりあえず5倍ぐらいにしてみましょうか。5倍に変えてコンパイルすると即座にゲームに反映されます。

もう1つ別の例として、これは草のシステムのソースコードですが、ちょっと動きが寂しいので直してみます。風の影響を受けるようにバッと直してコンパイルすると、すぐにゲームに反映されるという点ですね。

Runtime Compileの原理

ご覧いただいたとおり、ゲームのリスタートすらなく、ゲームを実行している間にコードの変更が適用できるものですが、この原理を簡単にご紹介させていただきます。RccObjectというクラスを用意して、これがRuntime Compileの最小単位となります。

仮想クラスになっていて、初期化と、terminateの関数と、インターフェースの取得の関数と、シリアライズの関数があるみたいなもので、これを継承して使う感じです。

通常のビルド時は、普通のRuntime Compileのcppファイルが通常どおりコンパイルされてリンクされます。ゲームが動いたらRccObjectが生成されるのですが、これを全部システムで管理して、システムは全部のインスタンスの存在を知っている状況にしておきます。

また、もとのcppファイルの部分は常にファイルの変更を監視しておいて、ファイルの変更が検出されたら検出されたcppファイルをコンパイルして、一時的なdllにリンクして、そのdllをロードします。

このもともとの「MyActorA.cpp」から作られた「MyActorA」が2つあったとしたら、その2つともシリアライズして、不要になったインスタンスは削除してしまいます。続いて、新しいコードから新しいインスタンスを生成して、もともとの状態をそこにDeserializeします。

すると以降は、この「MyActorA」は新しいコードで動作して、古いコード自体はメモリには残りつつも利用されない状況になって、晴れてコードがリアルタイムに反映できるようになります。

コードとしては、RCCのシステムからRccObjectを作って、そのハンドルからインターフェースを取得して処理を呼び出すものになります。

その「MyActorA」の実装でいうと、RccObjectを継承して、初期化関数、終了処理と、インターフェースを取得する関数を実装して、あとはシリアライズ実装して、MyActor自体のインターフェースを実装するかたちになります。

Runtime Compileの欠点

この便利なRuntime Compileのシステムですが、制限事項があります。前の例で言うと、この「MyActorA」というクラスから呼び出す、外部関数の呼び出しに制限があります。

具体的にいうとinline関数かvirtual関数しか呼び出せません。cppファイル単体でリンクが通るようにする必要があるので、そのコンパイル結果に含まれるようにinlineにするか、そもそもアドレスを実行時に取得するvirtual関数にする必要があります。

ここで問題があります。システムの関数に関して、グローバル関数はインスタンスがないのでvirtual化できないのでどうするかというと、そのグローバル関数をシステムクラスのメンバ関数にして、すべてのシステムクラスのインスタンスをSystem Tableに登録することが必要になります。

System Table自体のアドレスは、tmp.dll、テンポラリのdllを読み込んだ時に、dll側に渡すというコードになります。なので、システムのコードはすべて、System Tableからシステムを取得して関数呼び出し、というかたちに書く必要があります。

Cyllista Game Engineでは、エンジンのAPIをすべてRccObjectから呼び出し可能にしているので、すべてのAPIがinlineかvirtualになっています。

inlineにするのは比較的軽量な関数とか頻繁に呼び出される関数類ですね。virtualにするのは複雑な関数。あとは、実装を隠蔽する必要があるものは全部virtualにするということをやっています。

見てきたとおり、このRuntime Compile、利点と欠点があります。もちろん利点は高速なイテレーションが実現できるという点があります。

これはどちらかというと副次的なメリットですが、System Tableに全システムを登録する必要があるので、結果的にAPIは全部そこから辿っていけばいいので、一覧性があります。

まあまあ大きいメリットとしては、コンパイル時間が短縮されます。そもそも関数の呼び出しが実行時に解決されるので、コンパイル時間が短くなります。リンカがほとんどなにもしなくてよくなるので、コンパイル時間が短くなります。

あとは、C++で書いているのでVisual Studioでデバッグが可能である。これがスクリプト言語に比べての大きなメリットになるかと思います。

欠点としては、関数の呼び出しをvirtualで書かなきゃいけなくなるので、virtual呼び出しのオーバーヘッドがあります。RccObjectに関しては、RCC専用のシリアライズ処理を実装する必要性があります。

ですが、欠点と利点を天秤にかけて、この高速のイテレーションが実現できるというメリットが大きいのでこれを採用している、ということです。

ビルド時間を短縮するための工夫

何度か話に出てくるこのビルド時間の短縮について話をさせていただきますと、ビルド時間というのは、エンジニアのイテレーション速度に直結します。なので、Cyllista Game Engineではとにかくビルド時間を短くすることをやっています。

具体的にどのぐらいの数字かと言いますと、先ほど一番冒頭に見ていただいたエンジンのデモで、今、コメントを除く有効行数が40万行ぐらいです。エンジンのコードとアプリケーションのコードが13万行ぐらいで、あとはライブラリです。このビルドにどのぐらい時間がかかると思いますか? これがなんと30秒で終わります。

アプリケーション側のコードを修正してコンパイルするので、普通に考えるとリンカとかが走ってまあまあ時間がかかるんですが、だいたい5秒以内に終わります。

さらに実行時コンパイル。デモを見ていただいたので速さはわかると思いますが、2秒以内には終わるという感じです。

このビルド時間の短縮についてどんなことをやっているのかというと、すごく地味なことしかやっていないのですが、とにかく不要なヘッダーをインクルードしないことを徹底しています。

あと、C++で書いているのですが、テンプレートは極力利用しない。利用は最小限に留める。必要な場面では使いますけど、あまり使わないようにしています。

あと、ツール類はそもそもPythonで書いているのでビルド不要というのも、ビルド時間の短縮に貢献していると思います。ビルド時間は常にCIで監視しているので、変なコードがあがったらわかるようになっています。

ビルド時間短縮についてはもう1つ大事な要素があります。とにかく速いPCを用意してください。速いCPUと速いディスクがあれば、ビルド時間はけっこう速くなります。とくにディスクはSSDでRAIDを組むと速いのでいいと思います。

任意のバージョンのアセット取得を高速化

続いて、最後にゲームコンテンツ開発者向けの話を簡単にさせていただきます。1個目はアセットシステムですが、これはほぼすべてのアプリケーション開発者が利用する根幹のシステムです。

アセット自体はDCCツールで作られていて、Mayaのファイルなどの元ファイルから中間ファイルがエクスポートされて、実機用のファイルにコンバートして、バラバラのファイルだと読み込みが遅いのでアーカイブに固める。暗号化とか圧縮化処理をここでかけて、実際に実機で読み込むのはアーカイブファイルになります。こういった流れになるのがわりと一般的かなとは思います。

実際は1個のMayaファイルからいろんな中間ファイルが出力されます。もちろんアーカイブも複数のファイルをまとめられるので、簡単に書いていますが、本当はもっと複雑な図になります。

アセットシステムの要件ですが、とにかく任意のバージョンのアセットを高速に取得したいという要件があります。

任意のバージョンのアセットを取得できるメリットは大きくて、テストが通っている安定版を取得したいときもあれば、最新版をとにかく取得したい場面もあります。もしくは過去の特定のバージョンを見たいときもあるので、アセットのバージョンを切り替えるときに待たされるのは、開発者にとってはイテレーション速度を下げることになります。ですので、とにかく任意のバージョンのアセットを高速に取得するところに主眼を置いています。

バージョン管理について

アセットのバージョン管理の問題点に、そもそもファイルサイズが大きいというものがあります。バージョン管理をしたいだけならファイルをPerforceに登録すればいいだけなのですが、サイズが大きくて、さらにファイル数が多いので、更新処理に非常に時間がかかります。

あと、アーカイブにまとめるところまでやるので、依存が複雑になっていきます。多数のファイルが依存しているファイルを更新したときに、実際に更新しなければいけないアーカイブのファイルの数が膨れ上がることもあるので、アセットのバージョン管理はなかなか難しいという問題があります。

ゲーム開発にはアセットの運用上の特徴があります。ほとんどの場合は一部のアセットのみが必要になります。ステージAを開発している間はステージBのアセットは不要なので、実際ファイルの読み込みリクエストがあったときにはじめてファイルを用意しましょう、というスタイルをとっています。

じゃあそのためにどうするのかというと、メタファイルというものを用意して、アセットの関係性、依存とかの情報をこのメタファイルのほうに持たせています。

さらに、これはPerforceの運用の都合なのですが、メタファイル自体は単一ディレクトリ以下にまとめているというのがあります。

バージョン管理するのはこのメタファイルと中間ファイルのみにして、実機用のファイルとアーカイブのファイルは中間ファイルから生成することをやっています。

コンバートの流れ

実際のコンバートのフローを図で説明すると、この図の一つひとつのファイルにメタファイルがついています。今はメタファイルしかない、実際のファイルがない状態のことを考えます。

このアーカイブファイルの読み込みリクエストが来たら、メタファイルから必要な情報がわかるので、そのファイル類をリクエストします。

さらに、そこの実機用のファイルのメタファイルから必要な中間ファイルがわかるので、必要な中間ファイルをPerforceから取得します。

コンバートをして、さらにコンバートして、アーカイブが手に入る流れになります。

実際に、アセットエクスプローラーはメタファイル自体を実ファイルかのように扱うようになっています。実体がローカルに存在しないファイルも表示できるようになっています。

もちろんアセットのコンバートを毎回するのはさすがに重いので、コンバート結果はキャッシュされています。

コンバート情報に、コンバートに必要な情報をハッシュ化した単純なkey-valueのローカルのキャッシュとサーバキャッシュを持っていて、キャッシュにヒットすればそれをそのまま使って、ヒットしなければコンバートします。コンバートした結果はローカルキャッシュとサーバに登録されるという流れですね。

なので、実際はアーカイブファイルをリクエストしたら、キャッシュを取得して終わり、みたいなかたちになるのがほとんどです。

アセットシステムに関しては、メタファイルさえあれば任意のバージョンを取得できるので、ファイルの更新もメタファイルだけを取得するということになります。

なので、特定のバージョンを取るというのも、特定のバージョンのメタファイルを取る。あとはアセットシステムに任せておけば、そのバージョンのファイルが自動的に読み込まれるというようなかたちになっています。

メタファイルだけを取ればいいので、任意のバージョンを高速に取得することができるようになります。Perforceは特定ディレクトリ下の取得が速いので、その機能を使うためにメタファイルを1つのディレクトリにまとめたりします。

あとはローカルとサーバのキャッシュを用意することで目的のファイルを高速に取得するということをやっています。

アセットのプレビュー速度は?

続いて、アセットのプレビューについて話します。アーティストにとってDCCツールから実機へのプレビューの速度はイテレーション速度に直結するので、アセットのプレビューをいかに速くできるのかが肝になってきます。

実際にプレビューしている様子のデモムービーを用意してきたので、これもまたご覧ください。先ほど紹介したこのエディタから、もとのファイルをMayaで開きます。これは1万7,000トライアングルぐらいのきのこです。これを大きくします。一瞬なので見ておいてくださいね。

はい、1秒で実機に反映することが可能になっています。1万7,000トライアングルぐらいのアセットで1秒ぐらいですね。

これはどうやっているのかというと簡単で、中間ファイルを専用バイナリフォーマットにしています。FBXとかCOLLADAとかを中間ファイルとして利用している方も多いかとは思いますが、出力も読み込みも遅いので専用のバイナリフォーマットを用意しています。

これにより出力処理も読み込みも高速化できます。とくに読み込みはメモリに展開するだけなので速いですね。あと、プレビュー専用のコンバートのモードを用意しています。表示に影響しない最適化などを無効化するようになっています。

実際に専用フォーマットとFBXを、100,000トライアングルぐらいのモデルのエクスポートとコンバート時間を比較してみたのですが、3.6倍ぐらい速かったですね。

さらにプレビュー専用のモードも、同じく100,000トライアングルでコンバート時間を比較するのですが、最適化を除くことで3倍ぐらい高速化することができました。

まとめになりますが、Cyllista Game Engineの紹介をして、開発効率を最大化するためにやっている取組みなどを説明しました。

Cyllista Game Engineは、ゲーム開発者が最高のパフォーマンスを発揮できるということを徹底的に追求しています。ですので、一緒にエンジンを作りたい方は、スタッフを募集していますのでぜひよろしくお願いします。ありがとうございました。

(会場拍手)

ゲームに使う言語は?

質問者1:たいへん興味深い話ありがとうございました。エンジンコードはC++とPythonで構成されているというお話なのですけれども、アプリケーションコードのほうはC++よりも軽量なスクリプト言語を使いたい要求はないのでしょうか?

多胡:もちろんゲームの制御自体はスクリプトを使うのですが、個々のインスタンスだとかっていうのはC++で書くスタイルでやっていく感じです。

質問者1:じゃあゲームに使うスクリプトは、ここの今日お話しになった中には入ってないという……。

多胡:そうですね。これとは別にスクリプト言語を用意しています。

質問者1:ありがとうございました。

多胡:ほかにどなたか?

質問者2:実際に他職種の方がアセットなどを作ったりするときに、どのくらい中間ファイルのほうのフォーマットを意識したりしている感じですかね。

中間フォーマットで対応している形式や作り方が制約されたりすると、そこに従ってアーティストの方が作ったりとかすることがあると思うのですが、実務上、けっこう制約があったりするのでしょうか?

多胡:ゲームに必要な情報を可能な限りMayaから収集しているだけなので、そこにとくに大きな制約は生まれません。

質問者2:実際に作るときに、エンジニアの方に「こういう作り方しないとダメだよ」というかたちではなくて、ある程度自由に作っていただいて取り込んでという感じでしょうか?

多胡:そうですね。あとで必要になって、中間ファイルに出力していなかったデータがあったら、Mayaから出し直す作業が必要になるときはありますが、可能なかぎり中間ファイルには必要になりそうなものは含めておくというようにしています。しかも、それもバイナリでダーッと吐いておくというのをやっているだけなので速いです。

質問者2:ありがとうございます。

Mayaファイルの管理について

多胡:ほかに?

質問者3:ご講演ありがとうございます。デザイナーさんが作成したMayaのデータを中間ファイルに吐き出して、その中間ファイルとメタを管理されているということですが、MayaのMaya ASCIIだったりMaya Binary自体はとくに管理はしていないのでしょうか?

多胡:もとのMayaファイルも管理はしています。同じようにPerforceに登録して、「中間ファイルのもとのMayaはこれだよ」というのはわかるようにしてあります。なので、出し直しが必要になったときは、スクリプトでもとのMayaからバッと出し直すことができるようにしています。

質問者3:わかりました。ありがとうございます。

多胡:ほかに?

質問者4:ご講演ありがとうございます。RCCの原理でちょっとよくわからない点がありまして質問です。おそらくcppを更新するたびに一時的なdllが作成されると思うのですけれども、こういったcppを編集すればするほど、たぶんdllがどんどん溜まっていくかたちでいいんですよね。

多胡:はい。そうですね。溜まっていきます。

質問者4:溜まった状態でそのままexeファイルの実行時にそのまま反映されるということですが、そこからエンジンを1回閉じて起動したときは、ちゃんとそのdllが更新されていますよ、というのをあらかじめ伝えた上で、dllの中にリンクするということをしているのでしょうか?

多胡:起動したときはもう1回ビルドしてね、というスタイルをとっています。なので、1回ソースコードを編集してそのままexeだけを読んじゃうと古いままになってしまうので、そのときはもう1回ビルドしてもらうかたちです。

質問者4:そのときはやっぱりフルビルドというか、もう全部ビルドしないとできないということですか?

多胡:そうですね。はい。

質問者4:わかりました。ありがとうございます。

多胡:はい。ほかに質問はよろしいでしょうか? では、本日はありがとうございました。

(会場拍手)