ここでは、ゲームに使用する実際のスクリプトとメソッドを最適化する方法について説明します。また、ある一定の状況で最適化が有効である理由と、それらを適用する利点を詳しく説明します。
プロジェクトが円滑に実行されるように確認するチェックリストのようなものはありません。動きの遅いプロジェクトを最適化するには、プロファイラーで分析して無駄に処理時間がかかる部分を特定する必要があります。プロファイラーによる分析を行わない、または、その結果を十分理解せずに最適化することは、目隠ししながら最適化するようなものです。
ビルトインのプロファイラー を使って、どの処理がゲームを遅くしているかを把握できます。 物理演算かもしれませんし、スクリプトやレンダリングなどかもしれませんが、問題を特定するために、特定のスクリプトやメソッドをドリルダウンすることはできません。ただし、ゲームに特定の機能を有効/無効にするスイッチを設定することにより、問題をかなり絞り込むことができます。例えば、敵キャラクターの AI スクリプトを削除することによってフレームレートが 2 倍になったら、そのスクリプト、または、スクリプト関連の何らかを、最適化しなければならないことがわかります。唯一の問題は、問題を見つけるまでに、多くのさまざまなことを試さなければならない場合があることです。
モバイルデバイスのプロファイリングについては、モバイルのための最適化実用ガイド を参照してください。
最初から高速なゲームを開発しようとすることはリスクが高いといえます。なぜなら、最適化を行う前に高速化を行ったり、遅すぎるので後から作り直すのは、どちらも時間の無駄になる可能性があるためです。この点について正しい決断をするには、ハードウェアに対する直感と知識が大切です。特に、ゲームはそれぞれ異なるプラットフォームがあるため、あるゲームでは有効な最適化が別のゲームでは役に立たない事もあります。
スクリプトとゲームプレイ方法 で良いゲームとコード設計の融合例としてオブジェクトプールを紹介しています。一時的なオブジェクトにオブジェクトプールを使うと、オブジェクトを作成し破棄するよりも時間がかかりません。なぜなら、メモリ割り当てが簡易になり動的メモリの割り当てのオーバーヘッドとガベージコレクション作業を削減できるからです。
Unity で書いたスクリプトは自動メモリ管理を使用しています。ほぼすべてのスクリプト言語にこれを行います。対照的に C や C++ のような低レベル言語には手動メモリ割り当てが行われ、プログラマーが直接メモリアドレスに対して読み書きを行います。そのため、作成するすべてのオブジェクトはプログラマーが削除しなければなりません。C++ でオブジェクトを作成すると、そのオブジェクトを使い終わったときに手動でメモリの割当てを解除する必要があります。一方、スクリプト言語では、ObjectReference= NULL;
と記述するだけで終わります。
質問: GameObject myGameObject;
または var myGameObject : GameObject;
のようなゲームオブジェクト変数を使用するときに、myGameObject = null;
としても、それが破棄されないのはなぜでしょうか。
答え: ゲームオブジェクトはまだ Unity によって参照されています。なぜなら それの描画や更新などを行うために参照を維持する必要があるからです。Destroy(myGameObject);
を呼び出すことで、参照とオブジェクトを削除します。
Unity が理解することができないオブジェクト、例えば、何も継承していないクラスのインスタンス (対照的に、ほとんどのクラスやスクリプトコンポーネントは MonoBehaviour から継承されています) を作成し、その後、それに対する参照変数を null に設定すると、スクリプトと Unity はそのオブジェクトを見失ってしまいます。つまり、それにアクセスすることも、見つけることもできなくなってしまいますが、そのオブジェクト自体はメモリに残ります。その後、少し時間がたつと、ガベージコレクションが実行され、どこからも参照されていないメモリは削除されます。これは、裏でメモリの各ブロックへの参照数が追跡されているため可能です。このことは、スクリプト言語が C++ よりも遅い理由の 1 つでもあります。
オブジェクトを作成するたびにメモリが割り当てられます。スクリプト内ではとても頻繁に、気づくことさえなくオブジェクトを作成していることがあります。
Debug.Log("boo" + "hoo");
はオブジェクトを作成します。
""
の代わりに System.String.Empty
を使うようにします。Immediate Mode の GUI (UnityGUI) は遅いため、パフォーマンスに問題がある場合はどのようなときでも使用すべきではありません。
クラスと構造体には以下のような違いがあります。
クラスはオブジェクトであり、参照と同じ挙動を持ちます。Foo がクラスで以下のように定義するとします。
Foo foo = new Foo();
MyFunction(foo);
MyFunction は、ヒープに割り当てられた元の Foo オブジェクトへの参照を取得します。MyFunction 内の foo への変更は、foo が参照されるどこにでも反映されます。
クラスはデータであり、データと同じ挙動を持ちます。Foo は構造体で以下のように定義するとします。
Foo foo = new Foo();
MyFunction(foo);
MyFunction は foo のコピーを取得します。foo はヒープに割り当てられることはなく、決してガベージコレクションされることはありません。MyFunction が foo のコピーを変更しても、他の foo は影響を受けません。
「なぜオブジェクトのプールは高速か」の結論は、Instantiate (インスタンス化) と Destroy (破棄) を多用すると、ガベージコレクションの作業が多くなり、ゲームに遅延を引き起こす原因になるからです。自動メモリ管理を理解する の説明のように、インスタンス化と破棄に関する一般的なパフォーマンスの問題を回避する方法は他にもあります。例えば、負荷が軽いシーン中に手動でガベージコレクションを起動したり、ガベージコレクションをとても頻繁に行って、使用していないメモリの大きなバックログが蓄積されないようにします。
もう 1 つの理由は、特定のプレハブを最初にインスタンス化すると、その他のものを RAM に追加でロードすることや、テクスチャやメッシュを GPU にアップロードすることが必要になる場合があるためです。これも遅延の原因になる可能性があります。オブジェクトプールを使用すると、ゲーム中ではなく、レベルをロードするときにこの問題が発生します。
無数の操り人形が入った箱を持つ人形使いを想像してみてください。スクリプトが人形使いを呼び出すたびに、操り人形のコピーを箱から取り出し、人形使いがステージから降りるたびに、使用していたコピーを捨てます。オブジェクトプールは、ショーが始まる前にすべての人形を箱から出して、観客から見えるべきでないときには舞台裏のテーブルにすべての人形を置いておくのと同じようなものです。
問題の 1 つは、プールを作成すると、他の目的に使用できるヒープメモリの量を減らしてしまうことです。作成したばかりのプールにメモリを割り当て続けると、より頻繁にガベージコレクションが起動される場合があります。それだけでなく、残存オブジェクトの数が増えるほど、すべてのガベージコレクションの時間が増加するため、ガベージコレクションがより遅くなります。これらの問題を念頭に置き、大きすぎるプールを割り当てたり、プールに含まれているオブジェクトがしばらく不要であるにもかかわらずプールをアクティブにしている場合に、明らかにパフォーマンスが悪くなることがあります。さらに、オブジェクトプールに向かない種類のオブジェクトも多くあります。例えば、長時間持続する魔法のエフェクトや、大量に出現し、ゲームの進行とともに徐々に倒されていく敵がゲームに含まれている場合がなどです。このようなケースでは、オブジェクトプールによるパフォーマンスのオーバーヘッドのほうが利点を大幅に上回るので、オブジェクトプールは使用すべきではありません。
ここでは、単純な砲弾の簡単なスクリプトを横並びにして比較します。1 つはインスタンス化を使用し、他方はオブジェクトプールを使用します。
// GunWithInstantiate.js // GunWithObjectPooling.js
#pragma strict #pragma strict
var prefab : ProjectileWithInstantiate; var prefab : ProjectileWithObjectPooling;
var maximumInstanceCount = 10;
var power = 10.0; var power = 10.0;
private var instances : ProjectileWithObjectPooling[];
static var stackPosition = Vector3(-9999, -9999, -9999);
function Start () {
instances = new ProjectileWithObjectPooling[maximumInstanceCount];
for(var i = 0; i < maximumInstanceCount; i++) {
// 不使用のオブジェクトの山を任意の場所に置きます
instances[i] = Instantiate(prefab, stackPosition, Quaternion.identity);
// デフォルトで無効、これらのオブジェクトはまだ、有効になっていません
instances[i].enabled = false;
}
}
function Update () { function Update () {
if(Input.GetButtonDown("Fire1")) { if(Input.GetButtonDown("Fire1")) {
var instance : ProjectileWithInstantiate = var instance : ProjectileWithObjectPooling = GetNextAvailiableInstance();
Instantiate(prefab, transform.position, transform.rotation); if(instance != null) {
instance.velocity = transform.forward * power; instance.Initialize(transform, power);
} }
} }
}
function GetNextAvailiableInstance () : ProjectileWithObjectPooling {
for(var i = 0; i < maximumInstanceCount; i++) {
if(!instances[i].enabled) return instances[i];
}
return null;
}
// ProjectileWithInstantiate.js // ProjectileWithObjectPooling.js
#pragma strict #pragma strict
var gravity = 10.0; var gravity = 10.0;
var drag = 0.01; var drag = 0.01;
var lifetime = 10.0; var lifetime = 10.0;
var velocity : Vector3; var velocity : Vector3;
private var timer = 0.0; private var timer = 0.0;
function Initialize(parent : Transform, speed : float) {
transform.position = parent.position;
transform.rotation = parent.rotation;
velocity = parent.forward * speed;
timer = 0;
enabled = true;
}
function Update () { function Update () {
velocity -= velocity * drag * Time.deltaTime; velocity -= velocity * drag * Time.deltaTime;
velocity -= Vector3.up * gravity * Time.deltaTime; velocity -= Vector3.up * gravity * Time.deltaTime;
transform.position += velocity * Time.deltaTime; transform.position += velocity * Time.deltaTime;
timer += Time.deltaTime; timer += Time.deltaTime;
if(timer > lifetime) { if(timer > lifetime) {
transform.position = GunWithObjectPooling.stackPosition;
Destroy(gameObject); enabled = false;
} }
} }
もちろん、大型で複雑なゲームのために、すべてのプレハブに有効な一般的な解決策が必要でしょう。
スクリプトとゲームプレイ方法で述べられている「回転し、動的に照らされる、収集可能な何百ものコインをいっぺんに画面上に表示」する例は、スクリプトコード、パーティクルシステムのような Unity コンポーネント、カスタムシェーダーを効果的に使って、低性能なモバイルハードウェアに負荷をかけずに素晴らしい効果を作成できることを示すために使用されます。
この効果が大量のコインが落ち、跳ね返って、回転する 2D の横スクロールのゲームのコンテクストで使用されることを想像してみてください。コインは動的にポイントライトに照らされています。ゲームをより素晴らしくするために、コインにライトのきらめきをを与える必要があります。
もし高性能なハードウェアを持っているのなら、この問題への標準的な解決策を使用することができます。すべてのコインをオブジェクトにして、それぞれに対して、頂点ライト、フォワードライト、ディファードライトのいずれかを使ったシェーディングを行い、それからその上にイメージエフェクトのきらめきを加え、明るく反射するコインが周囲に光を放つようにします。
しかし、モバイルのハードウェアは多くのオブジェクトのせいで動作が重くなることでしょう。グローエフェクトを適用することはまったく問題外です。では、どうすればよいでしょうか。
すべてが似た動きをし、プレイヤーに詳細に観察されない多くのオブジェクトを表示する場合は、パーティクルシステムを使用して短時間で大量のオブジェクトを描画できる場合もあります。以下は、このテクニックのいくつかの典型的な活用例です。
SpritePacker と呼ばれる無料のエディター拡張機能があり、アニメーション化したスプライトパーティクルシステムを作成できます。テクスチャに複数フレーム分のオブジェクトを描画し、それをパーティクルシステムでアニメーション化したスプライトシートとして使用することができます。ここでは、回転するコインでそれを使用することにします。
SpritePacker プロジェクトに含まれているプロジェクトは、まさにこの問題に対する解決策を示す例です。
演算を低負荷に抑え見事な効果を実現するために、あらゆる種類のアセットファミリーを使用します。
readme ファイルには、システムが動く理由と仕組みを説明する例が含まれ、必要な機能とその実装の仕方を決めるために使用されたプロセスの概要を説明しています。これはそのファイルです。
問題を「回転し、動的に照らされる、収集可能な何百ものコイン」と定義します。
安易なアプローチでは、大量のコインプレハブをインスタンス化 (Instantiate) します。しかし、代わりに、ここでは、コインを描画するのにパーティクルを使用します。ただし、この方法には解決しなければならない多くの課題があります。
この例の最終的な学び、つまり「話の教訓」は、あなたのゲームに本当に必要な何かを、従来の手段で達成しようとするとそれが遅延の原因となりますが、それを達成するのが不可能という意味ではありません。それは単に、独自のシステムをずっと高速に実行できるようにするために、いくらかの工夫をする必要があることを意味します。
ここでは、数百または数千の動的オブジェクトを扱う状況に適用するための特殊なスクリプトの最適化を説明します。ゲームのすべてのスクリプトにこの技術を適用するというのはまったくひどい考えです。この方法は、実行時に大量のオブジェクトや大量のデータを扱う大規模なスクリプト用のツールや設計ガイドラインとして保存しておく必要があります。
コンピューターサイエンスでは、O(n) で示される操作の順序は、適用されるオブジェクトの数 (n) が増えると、操作を評価する必要回数が増えることを意味します。
例えば、基本的なソートアルゴリズムを検討してみましょう。n 個の数があります。これらを最小から最大にソートします。
void sort(int[] arr) {
int i, j, newValue;
for (i = 1; i < arr.Length; i++) {
// 記録します
newValue = arr[i];
//大きいものをすべて右にシフト
j = i;
while (j > 0 && arr[j - 1] > newValue) {
arr[j] = arr[j - 1];
j--;
}
// 記録していた値を大きな値の左に置きます
arr[j] = newValue;
}
}
重要なのは、ここで 2 つのループがあり、1 つが他方の内側にあるということです。
for (i = 1; i < arr.Length; i++) {
...
j = i;
while (j > 0 && arr[j - 1] > newValue) {
...
j--;
}
}
それでは、アルゴリズムにとって可能な最悪のケースを仮定してみましょう。入力された数字はソートされていますが、逆の順番にソートされています。その場合、もっとも内側のループは j 回実行されます。平均して、i は 1 から arr.Length–1 まで、j は arr.Length/2 になります。O(n) に関して、arr.Length は、n であり、したがって、合計で、もっとも内側のループは、n*n / 2 回、つまり n2/2 回実行されます。しかし、O(n) では、1/2 のようなすべての定数は必要はありません。なぜなら、実際の操作数ではなく、操作数が増加する様子について説明したいからです。そのため、アルゴリズムは O(n2) になります。データセットが大きい場合、操作の順序は非常に重要です。操作の数が指数関数的に増加する可能性があるためです。
O(n2) 操作のゲーム内で見られる例が 100 体の敵です。各敵の AI は、他のすべての敵の動きを考慮に入れます。マップをセルに分割して各敵の動きを最も近いセルに記録し、各敵に最も近いいくつかのセルをサンプリングさせるほうが高速かもしれません。つまり、O(n) の操作になります。
ゲームに 100 体の敵があり、それらすべてがプレイヤーに向かってくるとします。
// EnemyAI.js
var speed = 5.0;
function Update () {
transform.LookAt(GameObject.FindWithTag("Player").transform);
// 下のほうが、もっと悪い
//transform.LookAt(FindObjectOfType(Player).transform);
transform.position += transform.forward * speed * Time.deltaTime;
}
敵の多くが同時に走ると、遅くなる可能性があります。あまり知られていない事実は、MonoBehaviour のすべてのコンポーネントにアクセスするもの、つまり、transform、renderer、audio などは、それらに対応する GetComponent(Transform) と同様です。実際には、少し遅いです。GameObject.FindWithTag は最適化されていますが、場合によっては、例えば、内部ループや、多数のインスタンスで実行されるスクリプトでは、このスクリプトは少し遅くなることがあります。
これは改良したスクリプトです。
// EnemyAI.js
var speed = 5.0;
private var myTransform : Transform;
private var playerTransform : Transform;
function Start () {
myTransform = transform;
playerTransform = GameObject.FindWithTag("Player").transform;
}
function Update () {
myTransform.LookAt(playerTransform);
myTransform.position += myTransform.forward * speed * Time.deltaTime;
}
超越関数 (Mathf.Sin、Mathf.Pow など)、除算、平方根は、すべて乗算の時間の 100 倍ほどかかります。大きなスケールで考えると大した時間ではないですが、それらを各フレームで何千回も呼び出すと、それは積もって大きくなります。
もっとも一般的なケースはベクトルの正規化です。何度も繰り返し同じベクトルを正規化している場合は、それを 1 回正規化し、後で使用するためにそれをキャッシュすることを検討してください。
ベクトルの長さを使用することと、それを正規化すること両方をおこなう場合、normalized プロパティを使うより、長さの逆数でベクトルを乗算して正規化したベクトルを得る方が速いでしょう。
距離を比較する場合は、実際の距離を比較する必要はありません。代わりに、sqrMagnitude プロパティを使用して距離の2 乗を比較し、平方根の計算を回避できます。
もう 1 つは、定数 c で何度も除算する場合には、代わりに逆数を掛けることができます。1.0/c を掛けて、最初に逆数を計算します。
高価な操作をしなければならない場合は、それほど頻繁に行わずに結果をキャッシュすることで最適化できることがあります。例えば、Raycast を使用した砲弾のスクリプトを考えてみます。
// Bullet.js
var speed = 5.0;
function FixedUpdate () {
var distanceThisFrame = speed * Time.fixedDeltaTime;
var hit : RaycastHit;
// 毎フレームで、現在の場所から前方に、次のフレームでいるであろう場所に、レイを放ちます
if(Physics.Raycast(transform.position, transform.forward, hit, distanceThisFrame)) {
// 発射します
} else {
transform.position += transform.forward * distanceThisFrame;
}
}
FixedUpdate を Update に、fixedDeltaTime を deltaTime に置き換えることで、すぐにスクリプトを改善できました。FixedUpdate は、物理演算の更新を参照し、フレーム更新よりも頻繁に発生します。 しかし、n 秒毎のレイキャストだけにすると、もっと改善できます。n が小さいほど時間分解能が大きくなり、n が大きいほどパフォーマンスが向上します。ターゲットが大きくて遅いほど、Temporal Aliasing が発生する前の n が大きくなります。(出現の遅延は、プレイヤーがターゲットを砲撃すると、n 秒前にターゲットがあった場所に爆発が見えたり、プレイヤーがターゲットを砲撃したら、弾丸が通り過ぎてしまったりすること)。
// BulletOptimized.js
var speed = 5.0;
var interval = 0.4; // これは 'n' 秒
private var begin : Vector3;
private var timer = 0.0;
private var hasHit = false;
private var timeTillImpact = 0.0;
private var hit : RaycastHit;
// 最初のインターバルを設定
function Start () {
begin = transform.position;
timer = interval+1;
}
function Update () {
// フレームより短いインターバルは不可
var usedInterval = interval;
if(Time.deltaTime > usedInterval) usedInterval = Time.deltaTime;
// インターバルごとに、インターバルの最初にいた場所から前方に、
// 次のインターバルの最初にいるであろう場所に、レイを発します。
if(!hasHit && timer >= usedInterval) {
timer = 0;
var distanceThisInterval = speed * usedInterval;
if(Physics.Raycast(begin, transform.forward, hit, distanceThisInterval)) {
hasHit = true;
if(speed != 0) timeTillImpact = hit.distance / speed;
}
begin += transform.forward * distanceThisInterval;
}
timer += Time.deltaTime;
// レイキャストが何かにヒットした後に、実際にヒットするために
// 弾丸がレイと同じ距離を進むまで待ちます。
if(hasHit && timer > timeTillImpact) {
// ヒットする
} else {
transform.position += transform.forward * speed * Time.deltaTime;
}
}
ただ関数を呼び出すこと自体でも、オーバーヘッドが少し発生します。フレームあたり数千回 X= Mathf.Abs(x) のようなものを呼び出す場合は、代わりに、単に x = (x > 0 ? x : -x); を呼び出す方がよい場合があります。
Unity で使われる NVIDIA の PhysX 物理エンジンはモバイルで利用できますが、モバイルプラットフォーム上では、デスクトップよりも容易にハードウェアの性能限界に達します。
ここではモバイル上で、よりよいパフォーマンスを得るために物理演算を調整するためのヒントを紹介します。