#53 「最適化の取り組み 後編」
皆さんお久しぶりです。プログラマーの堀内です。
中編からだいぶ間が空いてしまいました。
さて、今回ですが前回の中編に続き『リトルリコレクター』の軽量化(最適化)についての続きになります。
前回の記事をご覧になっていない方は前編・中編を読んだ上で、ご覧になっていただくとわかりやすくなっております。
背景の軽量化
エフェクト
まず、前回の記事で取り上げたエフェクトの軽量化です。
エフェクトの軽量化として比較的簡単に行える対応は、「描画されるエフェクトの数を減らす」「エフェクトに使用しているテクスチャの解像度を落とす」というものです。
しかし、それを行えば見栄えが大きく劣化してしまうという問題があります。このゲームの最大の売りの一つは絵なので、なるべく見栄えの劣化を伴わない対応を模索していました。
気にならない範囲でテクスチャの解像度を落としてはみたものの、画面の大半を霧が覆っており、それだけでは対処しきれない負荷がかかっていました。
十分な速度を出すためには、見た目を犠牲にしてでもテクスチャ解像度を更に下げるか、霧の密度が落ちるのを覚悟でエフェクトの数を減らすか、という局面に陥っていました。
今の仕組みを維持している限り、これ以上の最適化は難しいと判断し、霧の表現の実現方法を全く別のものに差し替えることで、見た目の劣化を抑えながら負荷の軽減が実現できる仕組みを作ることにしました。
具体的には、これまでパーティクルを使用して実現していたものを、軽量なシェーダーに置き換えるというものです。
パーティクルからシェーダーへ
パーティクルはオブジェクトを放出することでエフェクトを表現する仕組みのため、霧の密度を上げようとするとそれだけオブジェクトの数が増えることになります。
中編でお話した「半透明描画の重なり」の問題がここで直撃してきます。霧を濃くしようとすればするほど、半透明オブジェクトが積み重なり、描画コストが跳ね上がっていました。
一方シェーダーであれば、一つのオブジェクトに対して自由な密度の霧を描画することができます。
オブジェクトの数が劇的に少なくなった結果(近・中・遠景で3つまで抑えられました)、前回記事で問題にしていた「半透明描画の重なり」を大幅に抑えることができ、処理の重さの最大の原因を取り除くことができました。
具体的なオブジェクト数の変化のイメージとしては、以下のようなものです。
| 方式 | 霧オブジェクト数(目安) | 半透明の重なり |
|---|---|---|
| パーティクル | 数十〜数百個 | 非常に多い |
| シェーダー | 3個(近・中・遠景) | ほぼなし |
この変更だけで、描画負荷が大きく改善されました。
見た目はどうなったか
最適化前(パーティクル)
最適化後(シェーダー)
そして気になる見た目の劣化度合いについてですが、嬉しいことに、パーティクルを使用していたときよりも自然な霧表現にすることができ、結果的に一挙両得の結果となりました。
パーティクルを使用していたときは霧というよりも湯気に近い印象だったのですが、シェーダーにする過程で表現を調整した結果、霧の動きや形状をより自然なものにすることができたためです。
パーティクルはあくまで「点の集まり」で霧を表現するため、どうしても粒感が出てしまいます。
シェーダーではピクセル単位で霧の濃度や動きを計算できるため、より滑らかで自然な見た目を実現しやすいという利点もありました。
負荷を下げようとした結果、見た目まで良くなるというのは開発していて素直に嬉しい瞬間でした。
スクリプトの最適化
原因が一箇所に絞れないケース
次にスクリプト周りの最適化の話です。
スクリプト周りでは計測をした際に、一概にここの処理が原因で負荷がかかっているという箇所が、実はあまり見つかりませんでした。
エフェクトのように「これが重い」とはっきり特定できるケースと違い、スクリプトの負荷は細かい処理が積み重なって全体的に重くなっているケースが多いです。
こういった場合は、一つひとつの処理を見直して少しずつ削っていくアプローチが有効です。
そこでいわゆる重たい計算や不要な処理などを、必要なとき以外は実行しないようにすることで全体的な負荷を削って行きました。
具体的に見直した処理
具体的には以下のような処理を対象にしました。
- 数値計算(除算や平方根、三角関数など)
- 物理演算
- GC(ガベージコレクション)
- UI
数値計算
除算は乗算に比べてコストが高いため、頻繁に呼ばれる箇所では逆数を使った乗算に置き換えました。
また距離の比較をする際に Vector3.Distance(内部で平方根を計算)を使っていた箇所を、sqrMagnitude(二乗距離)での比較に変更しました。
平方根の計算はコストが高いため、大小比較だけが目的であれば二乗のまま比較するほうが効率的です。
// 変更前
if (Vector3.Distance(a, b) < threshold) { ... }
// 変更後
if ((a - b).sqrMagnitude < threshold * threshold) { ... }
物理演算
画面外や非アクティブなオブジェクトに対して物理演算が走り続けていた箇所を見直し、不要なタイミングでは演算が走らないようにしました。
Unityの物理演算はオブジェクトが静止していても一定のコストがかかるため、使わないときは Rigidbody.Sleep() を呼ぶか、コンポーネント自体を無効化するのが有効です。
GC(ガベージコレクション)
毎フレームの処理の中でメモリの確保(new)が走っていた箇所を見直しました。
GCはメモリの解放タイミングが予測しにくく、処理が一瞬止まるスパイクの原因になります。
対策としては、オブジェクトを使い回す「オブジェクトプール」パターンを採用したり、ループ内での不要な配列生成を避けるようにしました。
また、最初にまとめてメモリを確保しておくことで、プレイ中のGC発生を抑えることができました。
UI
UnityのUIはCanvasが更新されるたびに再計算が走る仕組みになっています。
静的なUI(変化しない背景や枠など)と動的なUI(HPバーやスコアなど)が同じCanvasに混在していると、動的な部分が更新されるたびに静的な部分まで再計算されてしまいます。
これを別々のCanvasに分けることで、不要な再計算を防ぎました。
積み重ねの大切さ
これらの変更は一つひとつは小さなものですが、毎フレーム呼ばれる処理であれば積み重なって大きな差になります。
1フレームあたり16ms(60FPS)という限られた時間の中では、0.1msの節約でも積み重なれば体感できる差になります。
スクリプト周りでは、細かな処理の積み重ねで負荷が高まっていき原因が突き止めづらくなっていくので、スクリプトを書く際は、なるべく負荷のかからない方法を取るのがとても大切でした。
さいごに
まだまだ最適化の取り組みとしてはあるのですが、実際に効果が一番現れたものを今回は紹介させていただきました。
今回の最適化を通じて改めて感じたのは、「問題の本質を見極めてから手を動かす」ことの重要さです。
エフェクトの件では、削るのではなく「仕組みごと変える」という発想の転換が突破口になりました。
スクリプトの件では、目立たない小さな改善の積み重ねが全体の底上げにつながりました。
最適化に正解はなく、ゲームの内容や環境によって有効な手段は変わってきます。
ただ「計測して、特定して、改善する」というサイクルを丁寧に回すことが、どんな場合でも基本になると思っています。
前編・中編・後編と長くなりましたが、最後までお読みいただきありがとうございました。