読者です 読者をやめる 読者になる 読者になる

Unityでよく使うデザインパターン

unity UnityAdventCalendar2016

はじめに

この記事はカヤックUnityアドベントカレンダー2016の9日目の記事になります。
昨日に引き続き清水がお送りします。

今日はUnityの開発でよく使われるデザインパターンをいくつか紹介します。

Flyweight

Flyweightは複数のオブジェクトから共通のオブジェクトを参照することでリソースの節約を図るというデザインパターンです。

例えば、同じ見た目のグラフィックを表示する場合、複数のComponentがそれぞれ個別にMaterialを生成して持つのでなく、1つのMaterialを共有して参照した方がメモリの節約になります。
共有しているオブジェクトを変更すると、それを参照しているオブジェクト全てに影響を与えるので、各インスタンスで特殊化が必要なものには使えないことに注意します。
前述のMaterialの例のように、アセットに関しては自然とこのパターンを使っていることが多いです。

アセット以外の、ランタイムで生成するオブジェクトや、GameObjectが保持しているオブジェクトに関してはFlyweightを意識して設計した方がよい場合があります。
例えば、昨日の記事でも例として上げた、1つのprefabから複数のオブジェクトをインスタンス化する場合を考えてみましょう。
prefabに含まれるMonoBehaviourの中に、巨大なオブジェクトをシリアライズして保持するフィールドがあったとします。
そのprefabをInstantiateすると、保持している巨大なオブジェクトがまるまる複製されるので、CPUのコストも、複製を保持するためのメモリのコストも大きなものとなります。
各インスタンスでその巨大なオブジェクトを変更しないなら、そのオブジェクトをScriptableObjectに分離することでこの問題を解決できます。

以下のコードはパターン適用前のものです。

using System;
using UnityEngine;

public class VeryFatObject : MonoBehaviour
{
    // とても要素数が多い配列
    public string[] data;
}

public class NoFlyweightSample
{
    private VeryFatObject _prefab;

    private Start()
    {
        for (int i = 0; i < 10; i++)
        {
            // VeryFatObject.data がまるまる複製されるため、インスタンス化に時間がかかる
            // VeryFatObject.data 10個分のメモリが必要になる
            Instantiate(_prefab)
        }
    }
}

ScriptbleObjectに分離してパフォーマンスの向上を図ったものが以下のコードです。

using System;
using UnityEngine;

public class VeryFatObject : ScriptableObject
{
    // とても要素数が多い配列
    public string[] data;
}

public class LightObject : MonoBehaviour
{
    // 配列そのものでなく、ScriptableObjectへの参照を持つ
    [SerializeField]
    private VeryFatObject _object;
}

public class FlyweightSample : MonoBehaviour
{
    private LightObject _prefab;

    private void Start()
    {
        for (int i = 0; i < 10; i++)
        {
            Instantiate(_prefab);
        }
    }
}

変更後のコードでは、LightObjectはVeryFatObjectへの参照を持っているだけなのでInstantiateがすぐに終わりますし、メモリ使用量も1つ分のVeryFatObjectで済みます。

ScriptableObjectとして分離するのが難しい場合には、そのオブジェクトのフィールドを空にした状態で複製して、複製後に参照をセットする方法もあります。
複製対象のクラスのソースコードを変更できない場合ではこちらの方法を使うことになるでしょう。

using System;
using UnityEngine;

public class VeryFatObject : MonoBehaviour
{
    // とても要素数が多い配列
    public string[] data;
}

public class RuntimeFlyweightSample
{
    [SerializeField]
    private VeryFatObject _prefab;

    private void Start()
    {
        // dataを一時退避
        string[] tmp = _prefab.data;

        // 複製前にdataを消しておく
        _prefab.data = null;

        for (int i = 0; i < 10; i++)
        {
            // dataが無いのですぐ終わる
            var clone = (VeryFatObject)Instantiate(_prefab);

            // 全てのインスタンスで同じVeryFatObjectへの参照が使われる
            clone.data = tmp;
        }

        // 複製元のdataを戻す
        _prefab.data = tmp;
    }
}

Singleton

Singletonは、あるクラスのインスタンスを1つしか作らないように制限するデザインパターンです。
そのクラスを使う箇所全てでnewして使うと効率が悪いため、1つだけインスタンスを作って使いまわそうというものです。
静的クラスのように使いたいけれど、何らかの理由でインスタンスが必要な場合に使います。
UnityではMonoBehaviourの機能を利用するために使われることが多いです。

using UnityEngine;

public class SingletonSample : MonoBehaviour
{
    private static SingletonSample _instance;

    public static SingletonSample instance
    {
        get
        {
            if (!_instance)
            {
                var go = new GameObject("SingletonSample");
                DontDestroyOnLoad(go);
                _instance = go.AddComponent<SingletonSample>();
            }
            return _instance;
        }
    }

    private void Awake()
    {
        if (_instance == null)
        {
            _instance = this;
        }
        else
        {
            // 本来のSingletonの実装ではコンストラクタをprivateにして外からnewできないようにするが、
            // MonoBehaviourではコンストラクタを定義できない。
            // そのため、すでにインスタンスがあったら破棄する。
            Destroy(this);
        }
    }
}

Singletonは、静的クラスと同様に、密結合が増えたり、ユニットテストがしづらいなどの問題があるので、使いどころには注意しましょう。
絶対に1つのインスタンスしか使わないと確信できる場合にのみ使い、多用は避けます。

Object Pool

Object Poolはオブジェクトが繰り返し生成されたり破棄されたりするのを回避するためのパターンです。
使い終わったオブジェクトを破棄せずにキャッシュして、必要になったら再利用することで、オブジェクトの生成数の合計を同時に使う数だけに抑えることができます。
生成、破棄のコストが重い場合に効果あるので、GameObjectを使い回す目的でよく使われます。
シューティングゲームの弾丸など、大量に使うが1つ1つの寿命が短いオブジェクトが効果的な例として有名です。
注意点として、再利用時に前回使用時の状態が残っているとバグになるため、再利用時、または使用終了時に確実に初期化されるようにします。

サンプルコードを書こうと思いましたが、公式のチュートリアルに記事があったので、そちらを紹介することで代えさせて頂きます。
ObjectPool.csの作成

Object Poolを使用する場合、使い終わったオブジェクトもキャッシュに残ってメモリを余分に使うというコストがあるので、導入するべきかどうかの検討はしっかりしましょう。
キャッシュを使う場合の常として、バグが発生しやすいという懸念もあります。

おわりに

デザインパターンにはいろいろなものがありますが、今回は特にMonoBehaviourと関わりが深いものを紹介させて頂きました。
デザインパターンをいろいろ知っておくと、良い設計や問題解決の助けになるので、他のパターンも勉強してみると良いでしょう。
ただし、適していない対象に使った場合、無駄に処理が複雑化したり、実行時のパフォーマンスが下がったりします。
そのため、パターンが適しているかどうかをよく考えて使うのが重要となります。

明日はアファトによるテクスチャフォーマットの記事になります。