0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

オブジェクトを破棄したフレーム内限定で起きる問題

Last updated at Posted at 2023-10-04

更新履歴: GetComponentsInChildren への回避策と Leaked Managed Shell を発生させるサンプルコードを追加
 

Leaked Managed Shell に関連して気付いた不具合? に近い Unity の挙動。

--

Unity のマニュアルに記載の通り、Object.Destroy(obj, 0) はたとえ遅延設定が0でも同一フレーム内の処理が全て終わった後にオブジェクトの破棄を実行する。

Unity - Scripting API: Object.Destroy
Actual object destruction is always delayed until after the current Update loop, but is always done before rendering.

ScriptExecutionOrder 💬 なんてのもあるが Unity のコンポーネントの実行順は基本的に安定しない。

オブジェクトを即時破棄(Object.DestroyImmediate)してしまうと、コンポーネントがどの順番で実行されるかによってエラーが出たりでなかったりすることになるので、ループの最後にオブジェクトを破棄するって設計は妥当だと言える。

問題を起こす / LMS を発生させるコード

ただし問題があって、Unity の一部の機能はループ内で削除済みとマークされたオブジェクトを同一フレーム内であれば取得できてしまう。

GameObject.Find() GameObject.FindWithTag() GetComponentsInChildren<T>() その他アニメーション系のメソッド

Note
Resources.FindObjectsOfTypeAll() は破棄する側の対策だけでは不十分で、使う側が注意しないと Leaked Managed Shell を引き起こす可能性がある

※ シグネチャが度々 Obsolete されているので古いコードから探す場合は .FindObjectsOfTypeAll で検索する必要がある

internal class ManagedShellDestroyTests : MonoBehaviour
{
    [SerializeField] public GameObject TestObj, TestObj2;
    Transform _parent;
    string _objName, _objTag;
    string _objName2, _objTag2;

    void Start()
    {
        _parent = TestObj.transform.parent;
        _objName = TestObj.name;
        _objName2 = TestObj2.name;
        _objTag = TestObj.tag;
        _objTag2 = TestObj2.tag;

        StartCoroutine(nameof(DestroyTest));
    }

    IEnumerator DestroyTest()
    {
        // GetComponentsInChildren には includeInactive オプションがある
        UnityEngine.Debug.Log("GetComponentsInChildren(true): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: true)?.Length);
        UnityEngine.Debug.Log("GetComponentsInChildren(false): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: false)?.Length);

        // 削除前に非アクティブにしたとしても includeInactive で見つかってしまう
        //TestObj.SetActive(false);

        // Destroy(obj, t) t = 0 の時に限れば削除前に parent を null すれば防げる
        //TestObj.transform.SetParent(null, false);

        // タグと名前は破棄する前に変更しておけば問題を回避できる
        //   ※ Find 系は SetActive(false) でも同様の結果が得られる
        // ただ、アクティブかどうかに関係なく名前やタグにのみ依存する Unity の機能は意外と存在する
        // 自作スクリプトが削除済みオブジェクトを取得した時の予防にもなるので変更しておくのが無難
        // 【注】空の文字列は境界チェックしていない決め打ちのコードで問題が起きるので避けた方がイイ
        // 【注】name をそのまま辞書のキーとして突っ込む可能性もあるので同じ名前を使うのも避けるべき
        //TestObj.name = "DESTROYED" + TestObj.GetHashCode();
        //TestObj.tag = "Untagged";
        //TestObj.layer = 0;

        UnityEngine.Debug.LogWarning("=== OBJECT PROPERTIES CLEARED ===");

        // GetComponentsInChildren からは破棄前に SetParent(null, false) すれば逃れられる
        UnityEngine.Debug.Log("GetComponentsInChildren(true): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: true)?.Length);
        UnityEngine.Debug.Log("GetComponentsInChildren(false): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: false)?.Length);

        // 型ベースの検索メソッドから逃れる方法は無さそう
        // ※ Object.FindObjectsOfType は非アクティブにすれば回避できる
        UnityEngine.Debug.Log("Resources.FindObjectsOfTypeAll: "
            + Resources.FindObjectsOfTypeAll(typeof(Transform))?.Length);

        UnityEngine.Debug.LogWarning("=== OBJECT DESTROYED ===");
        Destroy(TestObj, t: 0);
        TestObj = null;

        // なにもしないと削除したフレーム内では取得できてしまう ※別コンポーネントからも取得可
        UnityEngine.Debug.Log("= Find Tests ===");
        UnityEngine.Debug.Log(Object.FindObjectsOfType<Transform>()?.Length);
        UnityEngine.Debug.Log(GameObject.FindGameObjectsWithTag(_objTag).Length);
        UnityEngine.Debug.Log(GameObject.FindGameObjectWithTag(_objTag));
        UnityEngine.Debug.Log(GameObject.FindWithTag(_objTag));
        UnityEngine.Debug.Log(GameObject.Find(_objName));

        // 取得は出来るが Destroy 後は名前もタグもエラーが出て設定は出来ない
        //TestObj.tag = "Untagged";
        //TestObj.name = string.Empty;

        // 削除済みオブジェクトは NullReferenceException だけど、所謂ヌルじゃないヌル状態(のハズ)
        //UnityEngine.Debug.Log("NullReferenceException thrown: " + TestObj.name);
        //UnityEngine.Debug.Log("NullReferenceException thrown: " + TestObj.transform);

        // includeInactive 次第で GetComponentsInChildren の結果が変わる
        // 挙動を見るに Managed Shell 内部で削除フラグが立っているだけで、そもそもネイティブ側は
        // 削除フラグ自体が無い DB なのかテーブルなのかを参照してオブジェクトを取得してるんじゃないだろうか
        UnityEngine.Debug.Log("GetComponentsInChildren(true): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: true)?.Length);
        UnityEngine.Debug.Log("GetComponentsInChildren(false): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: false)?.Length);

        // GetComponentsInChildren を通して削除済みオブジェクトに関連付けられているコンポーネントの取得が可能
        // 同一フレーム内ならプロパティーへのアクセスも可能な状態
        // そして次のフレームでこれらは MissingReferenceException になって Leaked Managed Shell に化ける
        var trans = _parent.GetComponentsInChildren<Transform>(includeInactive: true);
        UnityEngine.Debug.Log("Retrieved Object Names: " + string.Join("\n- ", trans.Select(x => x.name)));
        UnityEngine.Debug.Log("Retrieved Object GameObjects: " + string.Join("\n- ", trans.Select(x => x.gameObject)));

        yield return null;  // 1フレーム待つ

        // 前のフレームで取得した一部のオブジェクトは Leaked Managed Shell に化けているので
        // ?.name や ?.gameObject は意図した動作をしない
        //UnityEngine.Debug.Log("Retrieved Object Names (NEXT FRAME): " + string.Join("\n- ", trans.Select(x => x?.name)));
        //UnityEngine.Debug.Log("Retrieved Object GameObjects (NEXT FRAME): " + string.Join("\n- ", trans.Select(x => x?.gameObject)));

        // 検索メソッドは削除した次のフレームでは想定通りの動作をする
        UnityEngine.Debug.Log("= Find Tests (NEXT FRAME) ===");
        UnityEngine.Debug.Log(Object.FindObjectsOfType<Transform>()?.Length);
        UnityEngine.Debug.Log(GameObject.FindGameObjectsWithTag(_objTag).Length);
        UnityEngine.Debug.Log(GameObject.FindGameObjectWithTag(_objTag));
        UnityEngine.Debug.Log(GameObject.FindWithTag(_objTag));
        UnityEngine.Debug.Log(GameObject.Find(_objName));

        UnityEngine.Debug.Log("GetComponentsInChildren(true): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: true)?.Length);
        UnityEngine.Debug.Log("GetComponentsInChildren(false): "
            + _parent.GetComponentsInChildren<Transform>(includeInactive: false)?.Length);

        UnityEngine.Debug.Log("Resources.FindObjectsOfTypeAll (NEXT FRAME): "
            + Resources.FindObjectsOfTypeAll(typeof(Transform))?.Length);

        EditorApplication.isPaused = true;
    }

    void LateUpdate()
    {
        // Update() 内で破棄したオブジェクトは見つからない
        // ※マニュアルの説明通りなら仕様として保証している訳では無さそう
        UnityEngine.Debug.Log("[LateUpdate] Obj: " + GameObject.FindGameObjectsWithTag(_objTag).Length);
        UnityEngine.Debug.Log("[LateUpdate] Obj: " + GameObject.FindGameObjectWithTag(_objTag));
        UnityEngine.Debug.Log("[LateUpdate] Obj: " + GameObject.FindWithTag(_objTag));
        UnityEngine.Debug.Log("[LateUpdate] Obj: " + GameObject.Find(_objName));

        // LateUpdate() 内でオブジェクトを破棄
        Destroy(TestObj2, t: -1);  // t = マイナスでも結果は当然同じ
        TestObj2 = null;

        // Update() 同様、LateUpdate() 内で破棄したオブジェクトは同一フレームなら見つかる
        UnityEngine.Debug.Log("[LateUpdate] Obj2: " + GameObject.FindGameObjectsWithTag(_objTag2).Length);
        UnityEngine.Debug.Log("[LateUpdate] Obj2: " + GameObject.FindGameObjectWithTag(_objTag2));
        UnityEngine.Debug.Log("[LateUpdate] Obj2: " + GameObject.FindWithTag(_objTag2));
        UnityEngine.Debug.Log("[LateUpdate] Obj2: " + GameObject.Find(_objName2));
    }
}

雑感

GameObject MonoBehaviour は取っつきやすいし便利だし Unity 最大級の発明だと思うけど、C# マネージド側とネイティブ側で辻褄を合わせるのが大変なんだろうな。

ECS / DOTS ではサブシーンを作って **Authoring ってクラスを貼り付けて~~、ってワークフローになるみたいだけど、今までの取っつきやすい部分は入り口としてそのまま残しつつ、その中で別ワールドを作って根本からガラリと変えたシステムにしたいんだろう。

--

Update から LateUpdate に変えたらなんか上手く行った、(必要ないはずだけど)ヌルチェックを入れたら上手く行った、ココではおまじないとして1フレーム待ってください!!絶対に消さないで!!とか、そんな場合は 👆 の現象を疑ってみるといいかもです。

オブジェクト名の初期化の注意点
1)空の文字列は境界チェックせずにスライスするコードとかで問題が起きるので避ける
2)名前をそのまま辞書のキーとして突っ込む可能性もあるので全く同じ名前も避ける

非アクティブ化だけでは足らずに名前を変えなきゃいけないってシチュエーションはあるにはある、けど凄いレアなケース。

とは言えオブジェクトの生成と破棄を高速で繰り返したい!って状態はそもそものロジックに問題があると思うので、オブジェクトプール ObjectPool<T> 💬 を使うなりなんなりに変えるべき。

そして破棄するときにほんの些細なコストを追加して GameObject の値の初期化をしておく、ってのがやっぱり無難な落としどころ。

--

以上です。お疲れ様でした。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?