- 第一話) 画像が表示できました ←初回
- 第四話 WebGLを使い始めたらどう見てもマインクラフトです ←前回
- (以下続刊)
ゲームを作る日記の続きです。ゲームは作っているのですが、今回ちょっと脱線してます。
同じようなことをしているひとを発見する
Qiita内でもすでに先駆者が! 私と似たようなことやってますね。なんかゲームの方向性も似ているし、プラットフォームもウェブで同じです。がんばってください。わたしもがんばります。
やっぱりライブラリに頼り始める
当初はCanvas APIを叩いていたはずが突如WebGLに切り替えてグリグリ3Dを始めたりと、とにかく行き当たりばったりに進めていたのですが、自力でWebGLを叩くのに限界を感じて、結局別のライブラリに頼ることにしました。いろいろ作っているうちに欲が出てきてしまい、3D表現するなら影やボーンアニメーションなんかもやりたくなってしまったわけです。そこまで自分で作るのはさすがに無謀だと判断しました。
それで今回いろいろライブラリを探したところ、**babylonjs**がいいかなと思ってこれを使い始めました。活発に開発が続いていることと、グラフィックだけではなくゲーム開発が念頭に置かれていてゲーム関連の機能を豊富にサポートしていること、何よりコードがTypeScriptで書かれていることが嬉しいポイントです。この手のライブラリは相当に複雑なのでうまく動かないことも多々ありますし、ときどきライブラリのコードのほうを読み込む必要が出てくるので可読性は重要です。APIも(オブジェクト指向言語としては)素直でわかりやすいです。
WebGLのライブラリとしてはthree.jsも有名です。これも以前少し触ってみたことがあって感触は良かったのですが、コードがJavaScriptで直接書かれているのが嬉しくなかったので、今回はパスしました。どちらかといえばthree.jsのほうが長く開発が続いているし情報も多いので何かと楽だとは思うのですが、よほど突っ込んだことをしなければthree.jsもbabylonjsも機能としてはそれほど極端な差はないと思います。
それで、このあいだまで作っていたのは一旦脇に置いて、BabylonJSの練習をするべくデモを作り始めました。こんな感じです。
「デバッグレイヤ」がサポートされていて、シーン上でいまどのくらいの頂点や面が読み込まれているか、FPSがどれくらい出ているかを簡単に確認できます。バウンディングボックスを表示できたりするのも便利です。シャドウマップもサポートされているので、影の表現もわりと簡単でした。ピッキングも簡単にできるようになったので、ブロックを置いたり取り除いたりすることもできるようにしてみました。ブロックを置けるようになったので、ゲートを作りました。ゲート・オブ・バビロンです。babylonjsの検証が終わったら、もとのプロジェクトと統合しようと思います。
Babylonjsのバグを踏みまくる
それでですね! babylonjsのバグを次から次と踏みました! あああああああああああああ! シャドウマップを使うコード自体は単純なのですが、実際に使ってみると何故か一部の場所でしか影が表示されません! 仕方ないのでbabylonjsの中身をデバッガで追いましたとも! なかなか大変でしたが、原因はわかりました。頂点がゼロのメッシュがシャドウマップのレンダリングリストに含まれると、影が表示されないのです。確かに普通の使い方では頂点ゼロのメッシュなんか使わないと思いますが、筆者のマインクラフト風のデモはまあ少々特殊で、要するにブロックが一個もない中空部分には頂点ゼロのメッシュが生成されるのです。それで、頂点ゼロのメッシュのバウンディングボックスは大きさがNumber.MAX_VALUE
であると計算されたせいで計算の途中のどこかで値がundefined
になってしまい、結果的に影が表示されないというバグでした。つらい。
それから、テクスチャの座標系がデフォルトで上下が反転していて使いにくいのですが、それを反転するオプションflipY
が効かないという(たぶん)バグにもハマりました。つらい。
さらに、外部ファイルのメッシュを読み込んだ時に材質が反映されないというバグも踏みました。これは自分の使い方が悪いのかどうかさっぱりわからず、かなり深くコードを追ってようやく原因がわかりました。キラキラと綺麗な水面の表現をしてくれるWaterMaterial
を使おうとするとバグが起きるのです。まさか、ファイルからメッシュを読み込むのと、動的に生成される水面のマテリアルという無関係の機能が関係するとは思わなくてつらかったです。しかもバグが起きているところがTypeScriptのデコレータが生成している部分のコードで、最近TypeScriptを触っていないのでぎょっとしました。Material.prototype.__serializableMembers
がシリアライズされるプロパティ一覧になっていて、この一覧をデコレータで設定しているのですが、StandardMaterial
のデコレータでは引数を渡していて正しく設定しているのに、WaterMaterial
のほうではデコレータに引数を渡していなくてundefined
で上書きされてしまっているのが原因でした。つらい。
@serializeAsColor3("diffuse")
public diffuseColor = new Color3(1, 1, 1);
@serializeAsColor3()
public diffuseColor = new Color3(1, 1, 1);
さらにさらに、Blenderのエクスポータでもバグを踏みました。アーマチュアを非表示にするとエクスポート対象から除外されるのですが、メッシュをエクスポートするときには結局そのアーマチュアを参照するので、そこで変なエラーになっていました。単にアーマチュアを表示してエクスポートしたら正常に出力できました。でもこの原因を探るためにエクスポータのPythonスクリプトまで追いかける破目になってつらかったです。
それで、バグ報告をしようとしたら、なんかgithubではissueを受け付けていないみたいで、html5gamedevsっていうフォーラムで報告しなければいけないらしくて心が折れました。こんなフォーラムじゃ報告済みのバグなのか探せないんですけど……。ライブラリ自体はわりと気に入ったのですが、バグ報告がしにくいのはなんとかなりませんでしょうか……。3Dグラフィックス関連は複雑でどんなライブラリでもホントにバグが多くてつらいです……。
最適化がつらい
それで、babylonjsを使い始めたらわりとグラフィック部分が楽になったので、地形の自動生成にも取り組み始めました。マインクラフトと同様に16*16ブロックを1チャンクという単位にして地形を生成するのですが、最初に実装したときはとにかく重く、ゲームを開始した直後は画面がカックカク、周囲がひと通り生成されるとFPSは安定するのですが、動きまわって新しい場所に行くとまた地形の生成が始まってカックカクになって、開発にも支障が出るレベルのパフォーマンスしか出ませんでした。筆者自身のメモとして、パフォーマンスチューニングの経過をメモしておきます。
最初、Web Workersを使って地形の生成だけを別スレッドで計算する方法を試したのですが、Web Workersでデータをやりとりするとデータのコピーが発生するせいでパフォーマンスが上がりませんでした。数千から数万要素もある頂点データの配列を出し入れすると結構もたつくのです。ゲームなので地形の生成がいつ発生するかはユーザの操作次第なので、事前に生成するというのもあまり良い手とも思えませんでした。事前の生成に頼ったところで、生成が完了するまでの時間とカクツキが発生する頻度のトレードオフにしかならず、根本的な解決にはなりません。
プロファイルを取ったところ、そのときボトルネックになっていたのはマップの参照でした。purescriptのMap
は2-3木で実装されていて任意のオブジェクトをキーとして使えるのですが、まずこのMap
の参照が遅すぎてダメだったので、文字列をキーにするStrMap
に置き換えました。これだけでかなり早くはなったのですが、それでもやっぱり遅かったです。座標をキーにしてブロックの情報を格納していたのですが、まず座標をキーとして使うために文字列に変換していたところで大量の文字列オブジェクトが作成されてガーベージコレクタでカックカクになってました。それで文字列を作り過ぎないようにひとつの数値に3次元の座標を詰め込んでキーにしてみたところ、ガーベージコレクタが走らなくなってカクツキが減りました。それでもどうしても遅くて、結果的に長さ161616のUint8Array
にブロックの情報を格納することで、ようやくある程度の効率が確保出来ました。こういう低レベルなデータ構造は拡張性に乏しくて嫌なんですが、効率面では結局TypedArray
が最強のデータ構造ってことです……。
それである程度は早くなったものの、プロファイルを取ってみるとまだ大量のオブジェクトが作成されているのがわかりました。これはpurescriptのような純粋な言語では配列が不変なため、地形の生成時に大量の配列を作っては使い捨てていたことが原因でした。これを避けるためにSTArray
というpurescriptで可変な配列を扱うための方法に書き換えたところ、大量の配列はなくなりました。それでもプロファイラの上ではまだ大量のオブジェクトが作成されていて、調べてみるとこれはクロージャでした。purescriptのST
はEff
で実装されていて、一行ごとにbindE
という関数を呼ぶようなコードが吐かれてるのですが、これが処理一行ごとにひとつのクロージャを生成していて結果的に大量のオブジェクトが生成されていました。
このEff
で大量のクロージャが生成される問題は、もしかしたら将来的にはコンパイラの最適化で解決される可能性もあると思いますが、現状のpurescriptではどうにもならず、この地形の生成部分だけはJavaScriptで書き直しました。つらい。まあ何にせよ、こうやって幾つも最適化を施して、ようやく何とか不満がないパフォーマンスが出るようになりました。
これでCPU側のボトルネックは解消されたものの、今度はGPU側がネックになってしまっていて、現状の実装ではちょっと動きまわるだけで簡単に100万頂点や200万頂点が読み込まれてしまうのです。ざっくり計算すると、1チャンクあたり、2ポリゴン×16ブロック×16ブロック×6面で3000ポリゴン強。複雑なチャンクではもっと多くなると思います。マインクラフトではデフォルト設定でプレイヤーを中心に10チャンクまでが描画されるそうですが、チェビシェフ距離で10チャンク単位(160ブロック単位)以内の範囲をロードするとすると、一片が21チャンクの立方体の範囲を描画するので3000ポリゴン×21×21×21=2700万ポリゴン以上。ただし、プレイヤーキャラクターは地表面にいることが多くチャンクの半分は空っぽだろうから、たぶん1400万ポリゴンくらい。もっともこれ全部が一度に描画されるわけではなく、視野に含まれるものだけが描画されます。視野角60度とすると適当に1/6にすると230万ポリゴンくらい。筆者の非力なマシンでは無理があるポリゴン数です……。目に見えない地中にも大量の頂点が浪費されているのが問題で、解決の目処は立っているので(そのうち)頑張ろうと思います。最適化が好きな人もいるとは思いますが、コードがどんどん硬直化してくるし複雑になるので私は最適化苦手です。
今回ぜんぜんコード出しませんでした。ごめんなさい。
参考文献
- Terrain generation, Part 1 - The Word of Notch Notchさんによる地形生成のちょっとした解説。でもこれだけじゃぜんぜんわからないよ!もっと詳しく教えてよ!