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

お悩みさん
お悩みさん
  • AnimationClipの再生でNullReferenceExceptionが毎フレーム出る
  • 参照が未設定のアセットを探しても見つからない
  • どのアセットの参照が切れているのか一気に洗い出したい
  • オオバ
    オオバ
    本記事ではこれらの悩みを解決します。

    ある日、バトル中にアクションを再生するたび、毎フレーム NullReferenceException が出るようになりました。

    Console が赤いログで埋まっていくあの感じ、焦りますよね。

    しかも厄介なことに、スタックトレースが指しているのはアニメーションの長さを読んでいる行。

    参照を渡したところとは、だいぶ離れた場所です。

    「参照が外れたアセットがあるんだろうな」と思って探したものの、未設定の参照は1件も見つかりませんでした。

    本記事では、この「見つからない参照切れ」の正体と、全件をまとめて洗い出したやり方を紹介します。

    参照切れはfileID 0にならず、消えたGUIDを指したまま残っていました

    先に結論です。

    アセットの中の AnimationClip の参照には、fileID も guid もちゃんと入っていました。

    ところが、その guid を持つファイルがプロジェクトのどこにも無かったんです。

    YAML の上では値が入っているので、未設定({fileID: 0})を探す方法では引っかかりません。

    しかも AnimationClipPlayable.Create は、クリップが null でも例外を出しません。

    そのため、エラーはクリップの長さを読む行まで先送りされ、原因から遠い場所で NRE になっていました。

    この記事のポイント
    • 参照切れの参照はfileIDとguidが入ったまま残るのでfileID 0の検索では見つからない
    • AnimationClipPlayable.Createはnullを渡しても例外を出さず後の.lengthで落ちる
    • .assetの参照guidを.metaのguid集合と突き合わせれば数秒で全件見つかる
    • 直すのはデータ側でC#でnullを握りつぶさない
    • リネームは.metaごとgit mvする

    起きたこと

    舞台は、Playable で AnimationClip を再生する自前のアクションシステムです。

    アクションの定義は ScriptableObject のアセット(.asset)に持たせていて、その中に再生する AnimationClip の参照が入っています。

    症状は次のとおり。

    まず疑ったのは、クリップの設定し忘れです。

    アクション定義のアセットを開いて、AnimationClip の参照のうち {fileID: 0}(何も入っていない状態)のものを検索しました。

    結果は0件。

    全部のアクションにクリップが入っている、という結論になってしまいます。

    「じゃあ何が null なんだ?」と、ここでしばらく手が止まりました。

    原因:参照先のファイルがもう無かった

    改めて参照の中身を見ると、fileID も guid も入っています。

    ただ、その guid を持つ .meta ファイルが、プロジェクトのどこにもありませんでした。

    Unity のアセット参照は、ざっくり言うと「どのファイルか(guid)」と「そのファイルの中のどれか(fileID)」の組です。

    住所録に住所は書いてあるのに、その家がもう取り壊されていた、という状態に近いですね。

    guid の持ち主がいなくなった経緯は2パターンありました。

    ケース1 のほうが気付きにくいと思います。

    ファイル自体は名前を変えて残っているので、プロジェクトを見ても「消えたもの」が無いからです。

    原因:NREが原因から遠い場所で起きる理由

    もう1つのポイントが、エラーの出る場所です。

    参照先が無いクリップは、実行時には null として扱われます。

    ところが AnimationClipPlayable.Create(graph, null) は、null を渡しても例外を出しません。

    Playable は作られ、そのまま再生の流れに乗っていきます。

    落ちるのは、その後でクリップを取り出して長さを読んだときです。

    流れを簡略化すると、次のような形になります(説明用に省略しています)。

    // クリップの参照が切れていても、ここでは例外が出ない  
    AnimationClipPlayable playable = AnimationClipPlayable.Create(graph, clip);  
    
    // ...再生の処理が続く...  
    
    // GetAnimationClip() が null を返し、ここで初めて NullReferenceException  
    float length = playable.GetAnimationClip().length;  
    

    スタックトレースが指すのは .length の行です。

    クリップを渡したところは、とっくに通り過ぎています。

    つまり、エラーの出た行をいくら眺めても、原因には辿り着けないということです。

    見つけ方:.metaのGUID集合と突き合わせる

    原因が分かれば、探し方もはっきりします。

    「アセットが参照している guid のうち、どの .meta にも無いもの」を列挙すればいいわけです。

    僕は Python で次の手順を書きました。

    1. アクション定義の .asset から、AnimationClip の参照({fileID: N, guid: X})を全部抜き出す
    2. Assets/ と Packages/ の全 .meta から guid を集めて集合にする
    3. 1 の guid のうち、2 の集合に無いものを出力する

    実行すると、数秒で全件の参照切れが出てきました。

    Inspector で1つずつ開いて見ていたら、どれだけ時間がかかったか分かりません。

    同じ考え方のスクリプトを、説明用に書き起こしておきます。

    参照のフィールド名(例では _animationClip)は、自分のプロジェクトに合わせて変えてください。

    import re
    import sys
    from pathlib import Path  
    
    CLIP_REF = re.compile(r"_animationClip: \{fileID: (-?\d+), guid: ([0-9a-f]{32})")  
    META_GUID = re.compile(r"^guid: ([0-9a-f]{32})", re.MULTILINE)  
    
    
    def collect_guids(roots):  
        guids = set()  
        for root in roots:  
            for meta in Path(root).rglob("*.meta"):  
                match = META_GUID.search(meta.read_text(encoding="utf-8", errors="ignore"))  
                if match:  
                    guids.add(match.group(1))  
        return guids  
    
    
    def main(project):  
        project = Path(project)  
        guids = collect_guids([project / "Assets", project / "Packages"])  
        for asset in (project / "Assets").rglob("*.asset"):  
            text = asset.read_text(encoding="utf-8", errors="ignore")  
            for file_id, guid in CLIP_REF.findall(text):  
                if guid not in guids:  
                    print(f"{asset}: fileID={file_id} guid={guid}")  
    
    
    if __name__ == "__main__":  
        main(sys.argv[1])  
    

    {fileID: 0} の未設定の参照には guid が無いので、正規表現に引っかからず、出力にも出ません。

    出てくるのは「値は入っているのに持ち主がいない参照」だけ、という仕組みです。

    対策:直すのはデータ側

    見つかった参照は、正しいクリップへ再リンクしました。

    直したのはデータだけで、C# のコードには手を入れていません。

    「C# 側で null チェックを入れておけば落ちないのでは?」と思うかも知れません。

    ただ、null を握りつぶすと、エラーは出ないのにモーションだけが出ないという状態になります。

    バトル中に1つのアクションだけモーションが出ないバグ、正直かなり気付きにくいですよね。

    赤いログが毎フレーム出ていたからこそ、今回はすぐに異常に気付けました。

    データの壊れはデータで直す、という判断です。

    対策:リネームは.metaごとgit mvする

    再発を防ぐために、アセットのリネームのやり方も決めました。

    外部ツールでファイル名だけを変え、Unity に .meta を作り直させると、GUID が変わります。

    GUID が変われば、そのファイルを指していた参照は全部、持ち主のいない guid を指すことになる。

    ケース1 は、まさにこの流れで起きていました。

    そこで、Unity の外でリネームするときは、.meta ごと git mv することにしています。

    git mv Assets/Motion/old_name.fbx Assets/Motion/new_name.fbx  
    git mv Assets/Motion/old_name.fbx.meta Assets/Motion/new_name.fbx.meta  
    

    .meta が同じ中身のまま付いていけば、GUID は変わりません。

    ファイルの引っ越しに、表札も一緒に持っていくイメージですね。

    削除のほう(ケース2)は、消す前に「どこから参照されているか」を確かめるしかありません。

    先ほどのスクリプトをリネームや削除の後に流しておけば、切れた参照にその場で気付けます。

    まとめ

    今回の参照切れは、未設定の検索には引っかからず、エラーも原因から離れた場所で出るという、二重に見つけにくいものでした。

    ポイントをまとめます。

    「設定されているはずなのに null」という NRE に出会ったら、参照先の guid がまだ生きているかを疑ってみてください。

    同じところでハマっているのは、あなただけではありません。

    Playable API の基本は、こちらの記事でまとめています。

    👉 そろそろ始めないか?Playable API入門【Unity】

    オススメ記事
    検証環境