ゲーム開発チーム「丸ダイス」の開発ブログです! 公式サイトはこちら

【Unity】シンプルかつ軽量なオブジェクトプールの実装

UnityのInstantiateが遅い。皆さん言ってますね。実際、モバイルとかでエフェクトや弾をガンガン出していると目に見えて遅くなっていきます。 というわけで、「使い終わったオブジェクトは貯めておいて(あるいは最初にまとめて生成して)、必要になったら取り出す」という「オブジェクトプール」の方法が良く取られます。

実装の方法はいろいろあるようですが、軽く調べたところ今回実装した方法はスマートかつ無駄なオーバーヘッドも少ない感じがしたので、上げておこうかなという次第です。

Githubに公開しておきました(被使用・使用側の実装、サンプルシーン) github.com

実装

using UnityEngine;
using System.Collections.Generic;

public abstract class PoolObj<T> : MonoBehaviour {
    private static GameObject mOriginal;
    private static Stack<T> mObjPool = new Stack<T>();

    public static void SetOriginal(GameObject origin) {
        mOriginal = origin;
    }

    public static T Create()
    {
        T obj;
        if (mObjPool.Count > 0)
        {
            obj = Pop();
        }
        else
        {
            var go = Instantiate<GameObject>(mOriginal);
            obj = go.GetComponent<T>();
        }
        (obj as PoolObj<T>).Init();
        return obj;
    }

    private static T Pop()
    {
        var ret = mObjPool.Pop();
        return ret;
    }

    public static void Pool(T obj)
    {
        (obj as PoolObj<T>).Sleep();
        mObjPool.Push(obj);
    }
    
    public static void Clear()
    {
        mObjPool.Clear();
    }

    public abstract void Init();
    public abstract void Sleep();
}

これだけです。こいつをPoolObj.csなどとしてプロジェクトに放り込めばスクリプト単体で動作します。

使用時の最小コード - Hoge オブジェクトをプールする

  • プールされるオブジェクトは Init(); Sleep(); で初期化時、休眠時の処理を実装する(しなければコンパイルエラーになる)
//Hoge.cs プールされるオブジェクト
using UnityEngine;

public class Hoge : PoolObj<Hoge> {
    public override void Init()
    {
           //gameObject.SetActive(true);
           //初期化時の処理を実装
    }
    public override void Sleep()
    {
           //gameObject.SetActive(false);
           //休眠時の処理を実装
    }
}
//Hogeを使用する側 (呼び出し位置は全て任意. SetOriginal() が 最初のCreate(); より先なら良い)
    public GameObject originalPrefab; // Hoge.cs が付いている
...
    Hoge.SetOriginal(originalPrefab); //元となるプレハブを指定
...
    Hoge hoge = Hoge.Create(); //インスタンスを取得
...
    Hoge.Pool(hoge); //インスタンスを休眠

所感

どうも、プールする親玉もMonoBehaviourにしてListで管理するみたいな実装が多い印象を受けたのですが、今回の実装のようにすれば任意のコードから操作出来ますし、Pool(); は自分で呼んでも他の誰かから呼ばれてもOKです。 (そもそも、オブジェクトの管理者にUpdate(); などMonoBehaviourの機能は必要ありません)

初期化時・休眠時の処理についても、よくある実装では gameObject.SetActive(); を使うことが多い印象です。確かに一番典型的な例ではこれで構わないでしょうね。

が、巨大なオブジェクトでは SetActive(); はInstantiate(); ほどでないにせよ遅いことが分かっていますし、後のBulletでの使用例のように、変数初期が必要な場合もあります。

つまり、オブジェクトごとに最適な休眠状態は違うが、オブジェクト指向に則った実装では、そのオブジェクトの使用者はそれを意識する必要がない。 ということです。

こんな理由で上のような実装になりました。注意点としては

  • シーンをまたいだ際にClearしないと、nullがプールされた状態のままになる

といったところでしょうか。最後にサンプルソースを。(Githubと同じです)

使用例 - クリックされた位置に Bullet を発射するシンプルなシーン

サンプルコード

//Bullet.cs プールされるオブジェクト

using UnityEngine;

public class Bullet : PoolObj<Bullet> {

    int mCount = 0;
    private Vector3 mVelocity;
    // Update is called once per frame
    void Update () {
        if( mCount > 120)
        {
            Bullet.Pool(this);
        }
        transform.Translate(mVelocity);
        mCount++;
    }
    public void Shoot(Vector3 pos, Vector3 velocity) {
        mVelocity = velocity;
        transform.position = pos;
    }


    public override void Init()
    {
        mCount = 0;
        gameObject.SetActive(true);
    }

    public override void Sleep()
    {
        gameObject.SetActive(false);
    }
}
//Game.cs 使用者

using UnityEngine;

public class Game : MonoBehaviour {

    public GameObject mBulletPrefab;

    void Awake()
    {
        Bullet.SetOriginal(mBulletPrefab);
    }

    void Update()
    {
        // ボタンが押されたらその方向に弾を撃つ
        if (Input.GetMouseButtonDown(0))
        {
            Bullet bullet = Bullet.Create();

            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            float speed = 1;
            bullet.Shoot(ray.origin, ray.direction * speed);
        }
    }
}

【Windows 10】右クリックでエクスプローラーがクラッシュする時の対処方法

概要

シェル拡張 (Shell Extension)とは

7Zipなんかのソフトをインストールすると、ファイルやフォルダを右クリックしたときに「7Zipで解凍/圧縮」といったメニューが表示されることがあります。

また、Dropboxを使っていると、Dropbox内のファイル・フォルダが同期済み/同期中かをアイコンで表示してくれたり。

こういう機能を総称してシェル拡張(Shell Extension)と言います。アプリごとによく使う機能をWindowsの Explorerと統合して使いやすく出来るんですね。

解決方法

エクスプローラー上の右クリックで出るメニュー(Context Menu)が表示されずにエクスプローラーが落ちるということは、明らかにここが怪しいです。

ということで、行き当たったこちらのページに従って、

  • ShellEx Viewなるソフトをダウンロード・実行。 

  • Options->Hide All Microsoft Extensions にチェック。

  • Context Menu に該当するシェル拡張(Type が Context Menu となっているもの。右クリックした時の追加項目です。)のうち、必要なもの(私の場合, Tortoise SVN) 以外を全てDisableに。

以上で無事解決しました。Windowsエクスプローラーが不安定/クラッシュする際は、同ソフトでシェル拡張を無効化するのを試してみては。

余談

git のクライアントはTortoise Git はやめて、Source Tree を使うことにしました。

以前、Windows10 でtortoise svnのアイコンが正しく表示されない問題

を解決する際にレジストリをいじっていたので、レジストリのアイコンオーバーレイ(Tortoise SVN とかでファイルフォルダに✓/!マーク が付くアレです) に関する項目を見に行ってみました。
するとTortoise Git では同じ内容のレジストリキーが違う命名規則で名付けられていて(上記問題に対応するためと思われます)、↑のクラッシュ問題も含めて混在させるのがなんだか怪しそうな気がしたからです。

ちなみに、Win+R -> regedit で見られるアイコンオーバーレイのレジストリキーはここ↓です。レジストリの編集は危険なので くれぐれも安易に編集しないように

\HKEY_LOCAL_MACHINESOFTWAREMicrosoftWindowsCurrentVersionExplorerShellIconOverlayIdentifiers

再生するAnimationを全部プログラムで指定する時のAnimatorControllerを一発で編集するやつ

前提知識: Unity Animator の基本的な使い方 docs.unity3d.com

タイトルの通りです。 「過程なんざどうだっていい!結果だけ教えろ!スクリプトをよこせ!」という方は解決方法の項まで飛ばして下さい

問題設定

例えば次のようなAnimationClipを作ったPlayerがいるとします。すべてLoopアニメーションです。

  • Idle (棒立ち)
  • Run (走る)
  • GrabPull (モノを押す)
  • GrabPush (モノを引く)
  • IdleGrab (モノを持って立つ)
  • Sleep (寝る)

これに対して、プログラムの側で「プログラムのここを通ったらIdleに変更!」「ここを通ったらRun!」と指定出来るとします。

↑のUnityのドキュメントでは、なんだか賢そうなステートマシンを作って、走る速度だとか攻撃してるかだとかをParameterにして、自動的にAnimationが切り替わるようにしなさいみたいな書き方をしていますね。

ただ我々プログラマーとしては、そんな自動でアニメーションが切り替わっては困ります。 なんせプロなのです。 プログラムがIdleと言ったら即座にIdleに、Runと言ったら即座にRunになって欲しいのです。

つまり、我々が欲しいステートマシンはこうです。

f:id:marudice:20160827030225p:plain

美しいですね。全部AnyStateからTransitionを繋げているので、状態に依らずにアニメーションを指定できそうです。これで

     mAnimator.SetTrigger("Run");

としたら、いかなる時でもRunになってくれそうです。が、なりません。実演は面倒なので省きますが、コードの都合で

     mAnimator.SetTrigger("Idle");
...
     mAnimator.SetTrigger("Idle");
...
     mAnimator.SetTrigger("Run");

という順に呼ばれ、かつ AnyState->Idle のTransitionが Can Transition To Self == false の場合、なんとアニメーションはIdleになります。

実際にやってみると分かりますが、AnimatorのTriggerパラメーターは、Transitionが解決されるまでは値がOnのままになっているので、

  • Idle -> Idle(IdleのTriggerは使われずOnのまま) -> Run -> 即座にIdleに戻る

という動作になります。うーん、困った。

冷静に考えなおすと、「排他的であって欲しいアニメーションの指定を排他的でない変数でコントロールしている」ことが原因と言えそうです。

ということで、integer の Prameterを使います。

f:id:marudice:20160827032932p:plain

こんな感じで、それぞれの Transition に Integer, Equalsで Conditions を設定してやれば、

     mAnimator.SetInteger("AnimIdx", 2);

などとすることで、

  • 最後に指定したアニメーションに必ず遷移する

というシンプルな(しかし、使いやすく確実な)AnimatorController が用意出来ます。

もう少しまともなプログラムを書くならenumを使って

// PlayerDefine.cs
public class PlayerDefine {
    public enum Idx {
        GrabPull = 0
        ,GrabPush = 1
        ,Idle = 2
        ,IdleGrab = 3
        ,Run = 4
        ,Sleep = 5
        ,SIZE = 6
    }
}
     mAnimator.SetInteger("AnimIdx", (int)PlayerDefine.Idx.Idle);

とするとよりGoodですね。

解決方法

これで、AnimatorControllerに設定するべきPrameterとTransitionは分かりました。 これくらいなら自動化出来そうですね。しました。

次のコードを適当な名前で.csスクリプトにしてAssets/Editor 以下に放り込むと、メニューバーにKuuというのが現れます。

// KuuAnimationEditorWindow.cs
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEditor;

public class KuuAnimationEditorWindow : EditorWindow {
    [MenuItem("Kuu/Animation/Init Animator Transition")]
    public static void InitAnimatorTransition()
    {
        int num = 0;
        foreach (Object obj in Selection.objects)
        {
            if (obj is UnityEditor.Animations.AnimatorController)
            {
                Undo.RecordObject(obj, "Init Animator Transition");
                AssetDatabase.StartAssetEditing();
                var controller = (obj as UnityEditor.Animations.AnimatorController);
                while(controller.parameters.Length > 0)
                {
                    controller.RemoveParameter(controller.parameters[0]);
                }
                List<string> triggers = new List<string>();

                for (int i = 0; i < controller.layers.Length; ++i)
                {
                    var sm = controller.layers[i].stateMachine;
                    while( sm.anyStateTransitions.Length > 0 ) {
                        sm.RemoveAnyStateTransition(sm.anyStateTransitions[0]);
                    }


                    const string cParamName = "AnimIdx";
                    if (sm.states.Length > 0) {
                        controller.AddParameter(cParamName, AnimatorControllerParameterType.Int);
                    }

                    foreach (var sta in sm.states)
                    {
                        while (sta.state.transitions.Length > 0)
                        {
                            sta.state.RemoveTransition(sta.state.transitions[0]);
                        }


                        var triggerName = sta.state.name;
                        if (triggerName.Contains("."))
                        {
                            triggerName = triggerName.Substring(0, triggerName.LastIndexOf('.'));
                        }
                        controller.AddParameter(triggerName, AnimatorControllerParameterType.Trigger);
                        var tran = sm.AddAnyStateTransition(sta.state);
                        tran.AddCondition(UnityEditor.Animations.AnimatorConditionMode.Equals, triggers.Count, cParamName);
                        if( sm.defaultState == sta.state)
                        {
                            controller.parameters[0].defaultInt = triggers.Count;
                        }
                        triggers.Add(triggerName);

                        tran.canTransitionToSelf = !sta.state.motion.isLooping;
                    }
                }

                num++;
                AssetDatabase.StopAssetEditing();

                if (triggers.Count > 0)
                {
                    var path = AssetDatabase.GetAssetPath(obj);
                    var className = RemoveControllerSuffix(System.IO.Path.GetFileNameWithoutExtension(path)) + "Define";
                    var directory = System.IO.Path.GetDirectoryName(AssetDatabase.GetAssetPath(obj));
                    var fileName = className + ".cs";

                    string outStr = "";
                    outStr += "public class " + className + " {\n";
                    outStr += "\tpublic enum Idx {\n";
                    for (int i = 0; i < triggers.Count; ++i)
                    {
                        outStr += "\t\t";
                        if (i > 0) { outStr += ","; }
                        outStr += triggers[i] + " = "+i.ToString()+"\n";
                    }
                    outStr += "\t\t,SIZE = " + triggers.Count.ToString() + "\n";
                    outStr += "\t}\n";
                    outStr += "}\n";
                    System.IO.File.WriteAllText(directory + "/" + fileName, outStr, System.Text.Encoding.UTF8);
                }

                break;
            }
        }
        Debug.Log(num.ToString() + " Animator Initialized");
    }

    public static string RemoveControllerSuffix(string objName)
    {
        return System.Text.RegularExpressions.Regex.Replace(objName, "Controller", "", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
    }

}

あとは、使う予定のステートが追加されたAnimatorController( ここでは PlayerController.controller )を選択した状態で、「Kuu/Animation/Init Animator Transition」を押します。↓

f:id:marudice:20160827040324p:plain

すると…↓

f:id:marudice:20160827040622p:plain

はい!出来ました!3秒クッキングですね! スクリプトからAssetsを編集したり追加したりしているので、AnimatorWindowへの反映とXXXDefine.cs がプロジェクトにロードされるには、一回UnityIDEをアンフォーカスが必要など若干ラグがあります。

あとは前述の通り

   mAnimator.SetTrigger("AnimIdx", (int)PlayerDefine.Idx.Run);

などとしてやれば、晴れて「用意したアニメーションを直接コードから指定して再生する」ができちゃいます! コードはコピペ改変ご自由に~。

動作確認

制作中のゲームでPlayerさんを実際に動作させたのがコチラ。ステートマシンの遷移は↑で想定した通りで、シンプルで分かりやすいですね。

youtu.be

余談

プログラムの作り方次第かもしれませんが、普通に書いていれば、コードの文脈から「今どのアニメーションを再生するべきか?」は分かることは多いので、 複雑なモデルでなければこれくらいシンプルに操作出来た方が捗るかと思います。

今回は2Dスプライトにせよ3Dモデルにせよ、アセットはある前提の話でした。

一からモデリングをして、Unityでキャラクターをアニメーションさせるまでのワークフローも、書いてみたら面白いかもしれませんね。

ではでは~

ブログ作りました!

作りましたが、あんまり書くことは思いつきません!

個人でゲームを作ってるので、公開されたら是非遊んで見てくださいね!

開発の途中経過はこんな感じ。

ギミックとギミックがつながる楽しさで遊ぶ、ちょっと冒険ありの箱庭ギミックパズルです!

ではでは、また思いついたら書きます。おしまーい。