"HTML5ゲーム重い問題"をいかに解決するか? CPU負荷を改善する方法

HTML5で「重い」問題をクリアしてリッチなゲームを作る #1/2 >> 2はこちら

HTML5で「重い」問題

岡山知弘氏:それでは、30分ほどお時間をみなさんにいただいて、HTML5で重たい問題は、みなさんがHTML5を触られている方であれば、まさに直面しているかなとは思うんですけど、そこで弊社でどういうふうにアプローチしたのかをお伝えさせていただけたらなと思います。よろしくお願いします。

名前は岡山知弘と申します。僕の経歴について簡単に紹介します。もともとプランナーをやっていました。DeNAに転職した時にHTML5のクライアントエンジニアになって、だいたい転職後でもHTML5のクライアントエンジニアをやっていました。リードをやっていたのは『乃木恋』とかですね。タイトルにいろいろ携わらせていただいて、今に至るという感じです。

今回の話をさせていただく前にお伝えしたいことがあります。HTML5のリッチなゲーム制作はほかの環境と比較して異常に制作難易度が高いと思われている節がありますが……。

よく言われている難易度の高い例として、UnityやUnrealなどの統合開発環境がまったくないので、本当にゼロベースで作る必要がありますよね。JavaScriptという言語性だったり、ブラウザという実行環境の特殊性も相まって。リッチなゲームが作りやすい環境ではないのは確かでしょう。

ですけど、負荷改善や速度改善は結局アプローチする方法があまり変わらないので、変わらず難しい方法をとる必要があるかなと。そのなかでどういうふうにやっていけば速度改善できるのかみたいな話が今日はできればなと思っています。

今日、このJavaScriptという言語の特殊性だったり、ブラウザの実行環境の特殊性があるから難しいと評価されているところをある程度切り崩せればうれしいなと思っています。どの環境でも変わらず難しいのであって、HTML5だから難しいのではないと。そして手法や考え方をお伝えできたらなと思ってます。

アジェンダについてです。負荷改善をやる前に考えておきたいことをまとめています。実際にゴリゴリ最適化されている方はすでにご存知とは思うんですが、コンシューマのゲームで最適化した場合は、だいたい描画と処理が2:8や3:7ぐらいになるんですね。

じゃあHTML5のリッチなゲームを作る際にどれぐらいを目指すのかというと、JavaScriptがやはり重たいので、4:6とか5:5ぐらいがバランスがいいかなと思っています。

FPSについて、FPSという言葉なんですけど、「1秒間に何回描画するの?」という単位ですね。コンシューマだと60FPSが多くて、First Person ShootingのFPSだったら、30FPSもけっこうあります。

HTML5のゲームの場合だったら、表現したい表示物をフルでのせたモックを最初に作ると思うんですけど、16.6ms(60FPS)のうち、描画負荷がもう9ms超えているのであれば、30FPSにしたほうがのちのち地獄を見なくて済む感じですね。

メモリ使用量について

メモリの使用量については、アプリを開発されている方もここらへんの肌感覚があると思います。最新の機種だったら200MBよりもっと使えたりするんですけど、下位の端末まで広くカバーするようなタイトルの場合は、だいたいこれぐらいを常時占有するのかなと思っています。

HTML5のゲームの場合は、「ブラウザから実行されているのでちょっと抑えめで150MBぐらいかな」みたいなのを、とりあえずラインだけはある程度決めておくことが大事でした。

というのは、実際ある程度の基準がないと、あとで「普通に1GBとかいってるんだけど、これいいんだっけ?」みたいな話はけっこうあるんですよ。そうなったときにリソースを全部作り直すことは現実的じゃなくなっちゃうので、ある程度ここらへんは考えておく必要があります。

あとは、CPU・メモリ・GPUのバランスをどう取るかという選択がそもそもできるようにしておくのがすごく重要だということを、エンジニアだけじゃなくてプランナーやディレクターなどにも共有しておく必要があります。

というのは、例えばGPUの負荷が高い場合。UIアニメーションをすごく含んだ超リッチなものを作っていましたと。描画コストが非常に高いです。改善策としてTexture Bufferを使って、5回描画しているものをテクスチャに1回焼きつけ、流用し更新があったタイミングでまたそこを再更新するやり方をとりました。これは結局、描画コストをメモリへコストを移動しています。メモリがすごく圧迫されている状態でこれをやると逆効果にもなりえます。

ここの認識をチーム全体で認識していれば「じゃあ、UIの演出をちょっと抑えましょう」という話もしやすくなるので、まず認識レベルを統一しましょう。

コストのバランスが取りやすい技術を本当に選定できてるのか、安易なプラグインの導入だったり、汎用性・速度面に課題がある技術を選定してしまっていないかをちゃんと考えておく必要があります。

「Texture Bufferを標準的に作ってしまうライブラリを使っていた場合」と書いてますが、この例を具体的に言うと、PixiJSはこういうことをやります。リッチなUIを作るときにメモリ負荷が不必要に高くなる場合もあります。

ほかにも、こういったピーキーなことができないイージーな設計を目指しているゲームライブラリはけっこう多いんですね。なので、そこらへんをちゃんと判断しておく必要があります。

ただこれは一発で判断できないんですよね。例えばPixiJSのサンプルでよく出てくるんですが、「描画負荷がすごく低くてすごく速いサンプル!」みたいな。実際は中でバッファ作っていて、それを焼き回しているだけなので速いのが当然なんですね。逆にそういった小細工せずに普通に早いライブラリもあります。そういうところをある程度理解できた状態で技術を選定しないと、後で非常に苦労します。というよりしました。

そして、ゲームを作っていたら上記のような「まさか」が起こるんですね。そこで現在起こっている状況をガッツリコードを見る前にある程度分析しましょうという話です。

ハナから重たいとか動作する状況ではない場合は、単純にリソースが多すぎたり、重ねて表示しているものが多すぎるとか、Draw/Updateの比率が明らかに変とかを疑います。なにも気にすることなくやっていると、Drawingが1でUpdateが9とかだったりするんですよ。

なので、そこらへんのところをまず最初に簡単に見て、今どういう状態なのかを把握しておきます。

だんだん重たくなったり、ブラウザが落ちたりする原因はメモリリークだったりします。特定の場面だけ瞬間的に停止したように見えること(スパイク)が起こっていたら、その裏でローディングが走っていないか?や、同期処理で重たいことをやっているか? などの原因が考えられます。処理を止めちゃっているわけなので。また、特定のタイミングで表示物が大量に発生しているなど。エフェクトなどですね。

このような例がないか、ある程度アタリをつけましょう。まあやると思うんですけど、一応定義だけしておきましょう。

重たい処理をGoogle Chromeのプロファイラで特定する

アタリをつけてCPUっぽいなとなったら、実際にCPUの負荷改善です。まずはやり方から紹介します。重たい処理をGoogleのChromeのプロファイラで特定しましょう。

昔作ったサンプルの例です。

Google Chromeでこういうプロファイラが見れるんですね。ラベルの表示やレンダーオブジェクトが実行時の負荷として幅をとっていることが分かります。もし処理が「そもそもそこまで重たくないはずだ」なんて部分があれば、不必要に重たくなっていることがひと目でわかります。

先ほどお伝えしたとおりです。プロファイラを見たら、1フレーム内でどこに負荷がかかっているのかをひと目で見ることができます。

関数がある程度アタリをつけられるので、こうなったら、あとはタイマーしかけて具体的に見ていきましょう。こういうことを繰り返しやって、「ここだ」というところを完璧に特定していきます。なんとなくではなく、完璧に特定します。

よくある原因は無駄な処理をしている場合です。無駄な処理を省いていく作業が必要になります。具体的に言うと、無駄なforループをやっている、本来は処理しなくていいにもかかわらず処理しちゃっているなどですね。ifでちゃんとステートメントを見て省けば高速化したりします。

高速化と言っていますが、ms(ミリ秒)レベルの話なので、もし1ms改善した時点で相当効果高い改善です。

改善作業をしていると根本的に設計を見直す必要が出てしまうこともあります。例えば逐次的に処理をしていた場合に、ゲームの場合はステートメントの管理を大量にやっていたりすると思います。ifが大量にある場合、条件を判定するという処理自体がCPUの先読みを阻害したりするので改善の余地があったりします。

例えばコンポーネント指向で、処理が必要なときにその分だけアタッチして、そもそも条件判断自体を、都度させないという方法もあります。例えばタッチしたときに処理を走らせる方法(イベント・ドリブン)と、タッチしたときにフラグを変更し、Updateでフラグを都度チェックし、trueになっていたら処理を走らせるという方法の違いです。

あとは僕のポリシーなんですけど、僕はイージーな設計よりもシンプルな設計が好きです。具体的に言うと、createCharaという関数の中でエラーハンドリングするのはあまり好きじゃない。

なぜかというとそれは呼び出す側の責任だからです。ここは本当にみなさんの好みだったり「いや、そうじゃないでしょ」という意見をいつも言われるんですけど、僕はこれをチームの決定でいつも強行突破しています。

使いづらさの代わりピーキーなことができます。例えば、4体しか本来表示しないというエラーハンドリングが入っているcreateCharaよりも、ただキャラを生成するcreateCharaによって、とりあえずキャラクターだけ作っておいて、それをどう料理するかはこちらで管理できるようになりますよね。

そういった幅の広さが速度改善においては改善のし易さにつながってきたりします。ガチガチにエラーハンドリングしていて、その例外処理に依存してしまっている場合、ちょっとした例外的な改善を行えなくなることがあるんです。

なんでも安全に設計するより、関数名から期待できる動作のみを行うという設計は低レイヤー部分でよく行います。もちろんハイレイヤーのフレームワーク的なクラスなどはイージーな設計にしますけど。

細かいJavaScript特有のルール

次ですね。ここはJavaScript特有のルールの話ですね。「速度改善」とかでググったりするとだいたい出てくる内容になっちゃうんですけど、JavaScriptにおける配列のlengthは都度計算しているので、ちゃんと一時変数に入れて使用しましょう。

JSにおける条件式は先頭から順番に評価されるんですね。なので、その評価が確定した時点で、そのほかの残っている式はチェックされない。

例えば、軽いチェックをやったあとに重いチェックをやるなどの簡単なところからやります。

ほかに、「連続したプロパティアクセスはtemp変数で一時避難も当たり前」のように言われているのですが、こういうふうに一時変数を作ってあげたりすると、速度に差がけっこう出ます。

ただ、この話は本当にいまさらな話なんですけど、なぜ速くなるかの理由は昔と今はけっこう違っています。現在のChromeなどのJavaScriptの実行環境ではJITコンパイルがされていて、部分的にコンパイルされてます。Chromeだと全部コンパイルされてます。

そもそも、なぜ未だにに一時変数を作る方法が有効なのか? コンパイルされてるなら問題なさそうですよね。しかしコンパイルされていようと、プロパティ情報をまとめたシンボルテーブルがあって、それを呼び出すときは名前で文字列検索をやっているんですね。厳密には違うんですけど。

文字列検索は重たいじゃないですか。1文字ずつ検索していって、マッチしているかどうかを検索するみたいな。なので、そこでショートカットを作ってあげると速くなるのは、そういう理由になります。

さらに、JavaScriptではだいたいプロパティ名はそのままシンボルテーブル化されるので、変数が長ければ長いほど重たいです。

なぜそんなことになるのかというと、安易にコンパイラが変数名を変えちゃったり、シンボルのポインタ周りをいじったりすると、JavaScriptの特性上、外部のJSファイルからも呼ばれることがあるし、DOMから呼ばれることもあるので、簡単に変更できないんです。だからこういうことが起きます。

なので、影響範囲を我々が把握した状態で、一括してMinifyするとJavaScriptの解析時間や実行速度すら速くなったりするのは、こういう理由があります。

ちなみに昔はJSがインタープリタと言われていましたけど、もはや違います。コンパイルされてますし。ぜんぜん違う言語と思ったほうがいいかもしれません。

CPUにやさしいループ

最後に、CPUの負荷改善でちょっとしたおもしろい話があります。CPUにやさしいループ。

ここに三次元のループがあるんですけど、重要なところは枠で囲っている部分です。要素のアクセスがijmの順かmjiの順番かというだけの違いなんですけど、どっちが速いと思います?

正解は実験1のほうですね。このijmのほうが速いです。ちなみに倍ぐらい違います。環境によってはもう本当に5倍とか変わります。

なんでかというと、CPUのキャッシュメモリが活かせているからになります。配列のアクセスがijmかmjiか。このときに配列をどういうふうに作っているか、どのように配列へアクセスするかに影響を受けます。

配列ijのキャッシュが効きやすくなる。配列mjだと、mが一番深いループによってキャッシュがききづらく、何度もキャッシュを作り直す処理が発生します。

今回の例はちょっと適当に書いちゃったところはあるんですけど、適当に書いたといってもソースコードをコピペしてGoogle Chromeのコンソールなどに貼り付けて実行したら、差ができます。

このような要領でデータの配置やCPUはどういうふうに動いているかなどを考えながらforループとか書くと速くなったりします。よく3Dの計算やシェーダで使う考え方ですね。メモリがどういうふうになっているかを想像しながら書くという。ただ、やりすぎると可読性がかなり犠牲になるので、ほどほどにやりましょう。

CPUの話は以上です。お伝えした話なんですけど、負荷改善をするための動き方となってます。まず着手方法を知って、言語のルールを把握して、実行環境のルールを把握して、最後、実行環境をそもそも載っているマシンのルールを把握すると良い。道のりは長いですけどね。ただ、そういうことをやっていくと小手先ではなく本当の負荷改善ができていきます。

<続きは近日公開>