こんにちは、Unityエンジニアのオオバです。

お悩みさん
お悩みさん
  • Destroyしたのに UniTask のループが止まらない
  • destroyCancellationToken がキャンセルされないことがある
  • 非アクティブな親の下に生成したオブジェクトの扱いが分からない
  • オオバ
    オオバ
    本記事ではこれらの悩みを解決します。

    UniTask のループを destroyCancellationToken で止める書き方、便利ですよね。

    僕も開発中の「勇者のどうぐ屋さん」で、あちこちに使っています。

    ところが先日、この書き方でもループが止まらないケースがあることに気付きました。

    GameObject を Destroy しているのに、歩き回るループだけが生き残る。

    しかも、破棄済みの SpriteRenderer を触り続けるという状態です。

    本記事では、どんな条件でこの罠を踏むのか、原因と対処法をまとめます。

    後半では、同じ作業中にハマった Input System の小ネタも2つ紹介します。

    一度もアクティブにならなかった MonoBehaviour は、Destroy しても OnDestroy が呼ばれません

    先に結論です。

    非アクティブな親の下に Instantiate され、一度もアクティブにならなかった GameObject の MonoBehaviour は、Awake が呼ばれていません。

    Awake が呼ばれていないオブジェクトは、Destroy しても OnDestroy が呼ばれず、destroyCancellationToken もキャンセルされない。

    一方で UniTask の Yield や Delay は PlayerLoop で回るので、GameObject の active とは無関係に進み続けます。

    対処はシンプルで、親を一度アクティブにしてから生成し、その後で非表示に戻すだけです。

    この記事のポイント
    • 非アクティブな親の下に生成し一度もアクティブにならなかったMonoBehaviourはAwakeが呼ばれない
    • Awakeが呼ばれていないとDestroyしてもOnDestroyが呼ばれない
    • destroyCancellationTokenもキャンセルされずUniTaskのループが残る
    • 親を一度アクティブにして生成してから非表示に戻せば解決する

    起きたこと

    勇者のどうぐ屋さんで、フィールドマップ(タイルマップ)を Unity 上で眺めるためのビューアを作りました。

    Claude Code と一緒に開発したツールで、カメラを動かしてマップを見て回れます。

    フィールドマップビューア(マップ005・背景の動物つき)

    フィールドマップビューア(マップ005・背景の動物つき)

    マップには、背景の動物(猫・犬・馬・牛)が置かれています。

    それぞれに FieldMapViewerPet という MonoBehaviour が付いていて、UniTask のループで歩き回る作り。

    ループは destroyCancellationToken で止めるようにしていました。

    ビューアには「P で動物を隠す」機能もあります。

    隠すときは、動物たちの親(Pets)を SetActive(false) にしているだけです。

    問題が出るのは、次の手順を踏んだときでした。

    1. P で動物を隠す
    2. 隠したままマップを切り替える(新しいマップの動物は、非アクティブな親の下に Instantiate される)
    3. もう一度マップを切り替えて、動物を Destroy する

    3 の時点で、2 で生成した動物の歩行ループが止まりません。

    破棄済みの SpriteRenderer を触り続けてしまいます。

    実はこれ、動かして気付いたわけではありません。

    コードを見直していて 「隠したまま生成した動物って、ちゃんと止まるんだっけ?」 と引っかかったのがきっかけです。

    普段の操作ではまず踏まない手順なので、見直していなければ気付かなかったと思います。

    原因:Awake が呼ばれていないと OnDestroy も呼ばれない

    原因は、MonoBehaviour のライフサイクルにありました。

    GameObject が一度もアクティブにならなかった場合、その MonoBehaviour の Awake は呼ばれていません。

    そして Unity は、Awake が呼ばれていないオブジェクトを Destroy しても OnDestroy を呼びません。

    ※Unity の OnDestroy のリファレンスにも、以前にアクティブだったゲームオブジェクトでのみ呼ばれると書かれています

    destroyCancellationToken は、OnDestroy のタイミングでキャンセルされるトークンです。

    OnDestroy が呼ばれなければ、当然キャンセルもされません。

    もう1つのポイントが、UniTask の待機の仕組みです。

    UniTask.Yield や UniTask.Delay は PlayerLoop の上で回っています。

    コルーチンと違って、GameObject の active とは関係なく進み続けます。

    整理すると、次の3つが重なって罠になっていました。

    ループの中身を簡略化すると、次のような形です(説明用に省略しています)。

    async UniTaskVoid WalkLoop()  
    {
        var token = destroyCancellationToken;  
        while (!token.IsCancellationRequested)  
        {
            // 行き先を決めて歩く。SpriteRenderer の向きや見た目を更新する  
            await UniTask.Delay(1000, cancellationToken: token);  
        }
    }
    

    普段はこの書き方で問題ありません。

    Destroy すれば OnDestroy が呼ばれて、トークン経由でループが抜けるからです。

    「一度もアクティブにならなかった」という条件がそろったときだけ、この前提が崩れます。

    解決:親を一度アクティブにしてから生成する

    対処として、マップを出すときの順番を変えました。

    親を一度アクティブにしてから動物を生成し、その後で表示設定(隠す)を戻します。

    // 一度もアクティブにならなかった動物は破棄しても歩行ループが止まらない(OnDestroyが呼ばれない)ので、出してから隠す  
    _petRoot.gameObject.SetActive(true);  
    SpawnPets();  
    SetPetsVisible(_isPetVisible);  
    

    ポイントは「一度でもアクティブになったかどうか」です。

    一度アクティブになっていれば、そのあと SetActive(false) で隠している間に Destroy しても、OnDestroy は呼ばれます。

    destroyCancellationToken もキャンセルされ、ループはきちんと止まる。

    この直し方には、うれしい副作用もありました。

    隠している間も動物は歩き続けるので、もう一度表示したときに、うろうろしている位置が自然につながります。

    補足:同じ作業でハマった Input System の小ネタ

    ビューアを作る中で、Input System 1.18 まわりでも2つハマりました。

    QueueStateEvent でドラッグを注入しても上書きされる

    マウスのドラッグでカメラを動かす処理を検証しようとして、QueueStateEvent でマウスの状態を注入しました。

    ところが、実機マウスの状態イベントが次のフレームで上書きしてしまい、ドラッグ移動の検証ができません。

    最終的には InputState.Change を使って同じフレームの中で状態を書き換え、入力処理のメソッドを直接呼んで検証しました。

    ホイール1目盛りの値が1になる

    ホイールでズームする処理では、1目盛りを120として割る前提で書いていました。

    ところが Input System 1.18 の InputSettings.scrollDeltaBehavior が UniformAcrossAllPlatforms だと、ホイール1目盛りの値は1になります。

    120で割ると、ズームがほぼ効きません。

    こちらは、設定値を見て分岐するようにしました。

    補足:ビューア全体で決めたこと

    ついでに、ビューアの設計で迷ったところも残しておきます。

    表示方法は2案を比べました。

    1つはトップダウンADVの UI 表示基盤(Canvas にマップを重ねる)を流用する案。

    もう1つは、マップをワールド空間に置いて正投影カメラで見る案です。

    マップの外まで引いて俯瞰できるのが決め手になり、ワールド空間の案を選びました。

    通れないマスの重ね表示とカーソルのマス座標

    通れないマスの重ね表示とカーソルのマス座標

    もう1つ気を付けたのが、ゲーム本編との見た目のずれです。

    マップはゲームと同じ FieldMapFactory で組み立てています。

    動物の行き先の抽選・経路探索・座る寝るといった動きも、ADV のクラスをそのまま共用。

    ビューア側で変えたのは描画だけで、SpriteRenderer で表示しています。

    ツールのためだけに別実装を作ると、本編とずれた瞬間にツールの意味が無くなってしまうんですよね。

    まとめ

    destroyCancellationToken で UniTask のループを止める書き方は便利です。

    ただし、OnDestroy が呼ばれることが前提になっています。

    今回のポイントをまとめます。

    「非表示のまま生成する」という処理は、表示切り替えのある UI やツールでよく出てきます。

    UniTask のループを持つコンポーネントを非アクティブな親の下に生成している箇所がないか、一度見直してみてください。

    UniTask のキャンセル全般については、こちらの記事でまとめています。

    オススメ記事
    検証環境
    参考サイト