クラゲ日記

UnityのTipsなど技術関連がメイン。ゆるゆる更新します。

【Unity】SerializeFieldなキャッシュ変数をビルド前にキャッシュする

こんにちは。けっとです。

パネルの中に複数のボタンが置かれるUIなど特にそうですが、Sceneに配置する個々のオブジェクトが階層化されていくことがあります。

そしてそういう場合、UIパネル内のボタン文字列をメニュー選択項目に合わせて変更する場合など階層下の子に何度もアクセスする場合が多くなります。

これを transform.Find(); や GetComponent<>(); で逐一取得するのは無駄ということで、変数をキャッシュするモチベーションが生まれるわけです。

例えば、"Button1", "Button2"という2つのボタンをもつUIパネルオブジェクトを制御するスクリプト(HogePanel)を考えると、こんな感じにAwake();でキャッシュを初期化するのが一般的かと思います。

using UnityEngine;
using System.Collections;

public class HogePanel: MonoBehaviour{
    private Transform mButton1;
    private Transform mButton2;

    void Awake(){
        mButton1 = transform.Find("Button1");
        mButton2 = transform.Find("Button2");
    }
...
}

これはこれで悪くないのですが、ビルド前から分かっている参照対象ならビルド前に初期化出来るはずです。 ということで次のような方法を試してみました。特徴としては、

  • キャッシュ初期化はビルド前(実行時に負荷がない)
  • コードから初期化するので、キャッシュ先を柔軟に変えられ、ドラッグ&ドロップミスがない
  • コードから初期化するので、同じ構造を持つオブジェクトが複数あってもOK(これはPrefabを使っても同じですが)
  • SerializeFieldなprivate変数 なのでInspectorを汚さない
  • キャッシュ変数はTransform, GameObject, Component などSerializeFieldに出来るものならなんでもOK

といったところです。まず1つ目。継承されるオレオレMonoBehaviourクラス(以前の記事のTransCacheBehaviourの改良です)です。

using UnityEngine;
using System.Collections;

public abstract class CacheBehaviour : MonoBehaviour {
    public virtual bool InitCacheField()
    {
        return false;
    }
}

次に2つ目。Scene中のキャッシュ変数を全て初期化しなおすためのEditor拡張クラスです。 ショートカットキーを作ってキャッシュをしなおすようにしておきます。

using UnityEngine;
using UnityEditor;
...

public class MyCommonEditorWindow : EditorWindow {
...
    [MenuItem ("My/Serialize Cache Reflesh &c")]
    private static void RefleshCache()
    {
        int n = 0;
        var tcbs = GameObject.FindObjectsOfType<CacheBehaviour>();
        foreach (var tcb in tcbs)
        {
            if( tcb.InitCacheField())
            {
                ++n;
            }
        }
        Debug.Log(n.ToString() + " Components' cache field were refleshed.");
    }
...
}

かなりシンプルですが、準備のクラスは以上2つです(MyCommonEditorWindowはAssets/Editor以下に入れないとダメかも)。 使う側のクラスは、上と同じHogePanelの場合

using UnityEngine;
using System.Collections;

public class HogePanel: CacheBehaviour{
    [SerializeField]
    private Transform mButton1;
    [SerializeField]
    private Transform mButton2;

    public override bool InitCacheField()
    {
        mButton1 = transform.Find("Button1");
        mButton2 = transform.Find("Button2");
        return true;
    }
...
}

こうなります。これでEditor中に"Alt+C"を押すと、 Consoleに "xxx Components' cache field were refleshed."と表示されシーン中のオブジェクトのキャッシュが初期化されます。 Awake();でのキャッシュ初期化も、正直大したコストではないとは思いますが、数が増えればバカには出来ませんし、出来るんだからビルド前にやっておこうか、という感じでした。

…注意点としては、うっかり[SerializeField]を付け忘れるとログも正しく表示されるのに実行時はnullになっていることです。一度慣れればミスしても症状からすぐ想像が付くようになるのですが……。

steamデビューしました!

こんばんは。案の定ブログを放置気味のけっとです。い、いいんです。書きたい時だけ書くんだから…!

で、正にタイトル通りなのですが、自サイトノンリニア公式にて書いた通り、「星追いの巫女」の日本語・英語両対応版がplayismsteamで販売開始しました。

去年の夏に頒布した同作は、「作ったもんがちだ!」的精神のもと、プログラミング、モデリングなどボリュ~ミィ~な作業を人間性を捨てた進捗マシーンのごとく強引に推し進めた末の完成ということもあり、単にリーダーだったということ以上に思い入れが強い作品です。

その流れ(?)で、販売はあくまでノンリニアからですが、販売に伴う作業は結局全部自分でやってしまいました。翻訳リストの作成、翻訳済み英文の組み込み、ゲームプログラムの多言語対応、ええと、あとはsteamの実績・トレカ・バッジ・壁紙のデザイン・実装・素材作り……こうして見ると一言で言えばただの翻訳移植でも結構いろいろありましたね……。大変だったなぁ……(遠い目)。

でもでも、そのかいあってこうして無事に両サイトで販売が開始されて、晴れてsteamクリエイター(?)になることが出来ました!今までほそぼそと作っていた身としては、あれだけ大きい場で作品を扱ってもらえるというのは本当にウレシイものです。

steamの市場でゲームがどんな評価を受けるかはわかりませんがとりあえず一つステップを登れたのかなと思いつつ、これからもいっしょうけんめいゲームを作っていきたいなと思います。 今後ともよろしくお願いします。

f:id:kurage-create:20150728015817p:plain

UI Panel 制御のための Unity Transform のキャッシュ方式比較

結果のメモのようなものです。 百万回くらいやられていそうなTransformのキャッシングの比較をしました。

テストのモチベーション

構造化を考えてUIを作っていると、パネルという概念があることに気付きます。 例えば、RPGのパーティのステータスを表示するということを考えると、例えばUnity+NGUIならこんな感じになるでしょう。

f:id:kurage-create:20150615013740p:plain

これをコードから制御します。 classを実装してUIを制御するならパネル1種類に1classくらいが適当でしょう。すると、例えばこんな感じになります。

class CharacterPanelControl : MonoBehaviour{
...
  public void UpdateHP(int hp){
    Transform hpTrans = transform.FindChild("HPLabel");
    hpTrans.GetComponent<UILabel>().text = hp.ToString();
  }
...
}

transform.FindChild(); がちょっと遅そうですが、大抵の場合はこれで十分です。初期化時など、パネルの情報を丸ごと書き換える場合は transform.FindChild() がたくさん呼ばれますが、それでも1秒に一回程度ならそこまで問題にはなりません。 ただ、毎フレームに近いレベルになるとちょっと不安です。たまたまそれに近いことが起こりそうだったので調査してみた次第です。

テストの問題設定

  • classから実行中に変化しない相対パスを持つ100個の子Transformに繰り返しランダムアクセスを行う
  • 各キャッシュ方式でのアクセス速度の比較。

です。方式によってはキャッシュを実装するコストがバカにならないので、必ずしも速ければいいというものではありません。

テストコードの抜粋

...
public class PerformanceTest : MonoBehaviour {

    Dictionary<string, Transform> mCachedTransform;
    List<Transform> mCachedList;
    private string[] mPathes;
    private Transform[] mTransforms;
    private Transform mSelf;
    private enum eCache{
        eHoge,
        eFuga, 
        eMoge,
    };
    const int NUM = 100;
...
    private void StartTest(){
        const int TEST_NUM = 100000;
        int[] indexes = new int[TEST_NUM];
        for( int i = 0; i < TEST_NUM; ++i ){
            indexes[i] = Random.Range(0, mPathes.Length);
        }
        Transform temp = null;
        float time, take;

        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = mSelf.Find(mPathes[indexes[i]]);
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("mSelf.Find(): " + take.ToString() );
        
        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = mSelf.FindChild(mPathes[indexes[i]]);
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("mSelf.FindChild(): " + take.ToString() );
        
        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = transform.Find(mPathes[indexes[i]]);
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("transform.Find(): " + take.ToString() );
        
        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = transform.FindChild(mPathes[indexes[i]]);
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("transform.FindChild(): " + take.ToString() );
        
        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = mCachedTransform[mPathes[indexes[i]]];
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("string Dictionary cache: " + take.ToString() );
        
        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = mCachedList[indexes[i]];
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("List cache: " + take.ToString() );
        
        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = mTransforms[1];
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("Array cache: " + take.ToString() );
        
        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = mTransforms[(int)eCache.eFuga];
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("Array cache with enum cast: " + take.ToString() );
        }
...
}

テストコードの全文は最後に。全コピペでどこかにAddComponentして実行すればAキーで実行出来ると思います。

出力結果

mSelf.Find(): 0.02063704
mSelf.FindChild(): 0.01855803
transform.Find(): 0.02024984
transform.FindChild(): 0.0211978
string Dictionary cache: 0.01583314
List cache: 0.001600742
Array cache: 0.0006182194
Array cache with enum cast: 0.0006425381

結論

  • 平均して1フレームに1回レベルでアクセスがあるなら、Array cache with enum cast を使用
  • それ以上なら今まで通り transform.FindChild() を使用

enum cast を試したのは、流石にintリテラルを前提には出来ないからです。普通のintアクセスと比べると一応遅くはなってますが問題ないでしょう。 (Array cacheはindexes[]へのintランダムアクセス分他より速くなっています。コードを変えれば分かりますが、List cacheの約2倍速いという点では同じでした) FindChildも個人的には予想通りというか、全然いいじゃんというレベルでした。

キャッシュ用基底クラスを作ってみる

では実際に、キャッシュを使う場合の基底用オレオレBehaviourクラスを作ると、こんな感じかなと思います。

public abstract class TransCacheBehaviour : MonoBehaviour {
    private Transform[] mCachedTrans;

    protected void InitCache( string[] cachePath ){
        mCachedTrans = new Transform[(int)cachePath.Length];
        for( int i =0; i < cachePath.Length; ++i ){
            mCachedTrans[i] = transform.FindChild(cachePath[i]);
        }
    }
    protected Transform CachedTrans(int index){
        return mCachedTrans[index];
    }
}

使い方としてはこうなります。

public class CharacterPanelControl : TransCacheBehaviour {
    private enum eCacheChilds{
        HPLabel,
        UnitNameLabel,
        JobNameLabel,
    }
    private static string[] CACHE_PATH = {
        "HPLabel",
        "UnitNameLabel",
        "JobNameLabel"
    };

    void Awake(){
        InitCache( CACHE_PATH );
    }
    public void UpdateHP(int hp ){
        CachedTrans( (int)eCacheChilds.HPLabel ).GetComponent<UILabel>().text = hp.ToString();
    }
...
}

eCacheChilds.HPLabel.ToString()の結果は"HPLabel"になるのでCACHE_PATHはコードの重複になりますが、enumのToStringは非常に遅いので注意が必要です。

そのほか

テストの目的は以上で達成出来たかなと思いますが、得られたその他の知見をいくつか挙げてみます。

Find? FindChild??

速度同じやん……。 ぶっちゃけどう違うのか分かってなかったんですが、今ぐぐったらあっさりと…Finding Children question - Unity Answers 要はFindChildがFindのラッパですね。理屈上はFindの方が僅かに速いんでしょう。

this.transform は遅い?

風の噂で聞いたのでついでに。FindChild()のオーバーヘッドに比べれば気にならないくらいですね。 ただ、Unity - this.transformを計測してみた - Qiitaによるとやはり速いには速いようなので、オレオレMonoBehaviourを作るならついでにキャッシュしておいてもいいかもしれません。

Dictionary<string, Transform>はいいやつだったよ…

これが使える速さならキャッシュの下準備がラクになると思ったのですが、キャッシュする価値はないレベルですね……。 FindChildがオマケのついたルックアップみたいなもんだろうとたかをくくっていたら、思ったより速いんですね。

List のランダムアクセス

Listのランダムアクセス、Arrayと同じだと思っていたら、倍も遅いんですね。何かオマケの処理でも入ってるんでしょうか。

以下、テストコードの全文です。

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class PerformanceTest : MonoBehaviour {

    Dictionary<string, Transform> mCachedTransform;
    List<Transform> mCachedList;
    private string[] mPathes;
    private Transform[] mTransforms;
    private Transform mSelf;
    private enum eCache{
        eHoge,
        eFuga, 
        eMoge,
    };

    //ランダム文字列
    private static string chars = "0123456789abcdefghijklmnopqrstuvwxyz";

    const int NUM = 100;

    private string GetRandomStr(int length ){
        string result = "";
        //new string('x', length);
        int cLength =  chars.Length;
        for( int i = 0; i < length; ++i ){
            result += chars[Random.Range(0, cLength)];
        }
        return result;
    }

    // Use this for initialization
    void Start () {
        mSelf = transform;
        mPathes = new string[NUM];
        mTransforms = new Transform[NUM];
        mCachedTransform = new Dictionary<string, Transform>();
        mCachedList = new List<Transform>();
        for( int i  =0; i < NUM; ++i ){
            string path = GetRandomStr(10);
            if( mCachedTransform.ContainsKey( path ) ){
                i--;
                continue;
            }
            var go = new GameObject(path);
            mPathes[i] = path;
            mTransforms[i] = go.transform;
            mCachedList.Add(go.transform);
            mCachedTransform.Add( path, go.transform );
        }
    }

    // Update is called once per frame
    void Update () {
        if( Input.GetKeyDown(KeyCode.A)){
            StartTest();
        }
    }

    private void StartTest(){
        const int TEST_NUM = 100000;
        int[] indexes = new int[TEST_NUM];
        for( int i = 0; i < TEST_NUM; ++i ){
            indexes[i] = Random.Range(0, mPathes.Length);
        }
        Transform temp = null;
        float time, take;

        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = mSelf.Find(mPathes[indexes[i]]);
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("mSelf.Find(): " + take.ToString() );
        
        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = mSelf.FindChild(mPathes[indexes[i]]);
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("mSelf.FindChild(): " + take.ToString() );
        
        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = transform.Find(mPathes[indexes[i]]);
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("transform.Find(): " + take.ToString() );
        
        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = transform.FindChild(mPathes[indexes[i]]);
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("transform.FindChild(): " + take.ToString() );
        
        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = mCachedTransform[mPathes[indexes[i]]];
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("string Dictionary cache: " + take.ToString() );
        
        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = mCachedList[indexes[i]];
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("List cache: " + take.ToString() );
        
        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = mTransforms[1];
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("Array cache: " + take.ToString() );
        
        time = Time.realtimeSinceStartup;
        for( int i = 0; i < TEST_NUM; ++i ){
            temp = mTransforms[(int)eCache.eFuga];
        }
        take = Time.realtimeSinceStartup - time;
        Debug.Log("Array cache with enum cast: " + take.ToString() );
        
    }

    
}