したかみ ぶろぐ

Unity成分多め

【Unity】PlayableGraphはどのようにAnimatorのCulling Modeの影響を受けるのか

始めに

趣味でPlayable APIについて調査や検証をしているのですが、どうしてもAnimatorのCulling Modeについて腑に落ちない部分がありました。

Geminiに相談しながら実際に動かして調査をしてみたので、その備忘録を残そうと思います。

動作環境はUnity6.3LTS(6000.3.2f1)です。


AnimatorのCulling Mode

アニメーションを制御するAnimatorにはCulling Modeが設定でき、カメラに映っている/いないに応じて再生を制御できます。この設定により、画面外でのモーション再生を抑制し無駄なリソースを削減できます。

Culling Modeで設定できるModeは以下の3つです。

Mode 説明
Always Animate 画面外でも常にアニメーションを再生
Cull Update Transforms 常にアニメーションは再生するが、画面外ではTransformへの書き込みを停止
Cull Completely 画面外ではアニメーションを完全に停止

docs.unity3d.com

簡単な検証

それぞれのCulling Modeについて、Unityで動作確認してみます。

  1. 適当なモーションを再生するようAnimatorを設定
  2. GameビューとSceneビューの両方で画面外へ移動
  3. Aniamtorビューからモーションの再生状態を確認

Always Animate

画面外へ移動しても常にAnimatorControllerが動き続けていることが分かります。

Cull Update Transforms

画面外へ移動しても常にAnimatorControllerは動作しますが、モデルのTransformへの反映は行われませんでした。

Cull Completely

画面外へ移動したとき、Animatorが一時停止していることが分かります。 また、再度画面内に移動したとき一時停止したところから再生されます。



PlayableGraph

PlayableGraphはPlayable APIが提供する機能で、再生可能なもの(アニメーション、音など)を木構造で定義し、ブレンドやミキシングを行い出力できます。

詳細な説明は省くので、公式ドキュメントなどを参照ください。

docs.unity3d.com

PlayableGraphとAnimatorについて

PlayableGraphでモーションを再生するとき、最終的にAnimatorへ出力されます。 実装としてはPlayableGraph内に AnimationPlayableOutput を作成し、そこにAnimatorを設定して再生したモーションをシーン上へ反映します。

var graph = PlayableGraph.Create("PlayableGraph"); // PlayableGraph作成
var output = AnimationPlayableOutput.Create(graph , "AnimOutput", animator); // animatorを出力先に設定

output.SetSourcePlayable(hogeAnimationPlayable); // 出力に何かしらアニメーションの出力を設定



Culling Modeへの疑問

AnimatorにはCulling Modeが存在しますが、PlayableGraphにCullingを設定するAPIもありません。

docs.unity3d.com

いくつか情報を調べるとPlayableGraphはAnimatorのCulling Modeを参照しないと見かけたため、実際にそうなのかを検証してみました。



PlayableGraphでAnimatorのCulling Modeを検証

PlayableGraphでAnimatorを再生し、Culling Modeによって再生状態に影響するか検証します。

PlayableGraphの状態はPlayableGraph Visualizerから確認し、また PlayableGraph.IsPlaying() をチェックします。

github.com

1つのPlayableを再生する

AnimationClipを再生するAnimationClipPlayableを一つだけ設定し検証してみます。

コードは以下の通りです。

コード(折り畳み)

AIが書いたコードです。

using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Animations;

[RequireComponent(typeof(Animator))]
public class SimpleAnimationPlayableExample : MonoBehaviour
{
    [SerializeField]
    private AnimationClip _animationClip;

    private Animator _animator;
    private PlayableGraph _playableGraph;

    // 表示用
    private string _statusText = "";
    
    private void Start()
    {
        _animator = GetComponent<Animator>();
        
        // Playable Graphを生成(名前は任意)
        _playableGraph = PlayableGraph.Create("Example Playable");

        // 一つのAnimationを表すもの(= Playable)を作成
        // Playableは必ずPlayable Graphに所属するので引数にPlayable Graphを渡す
        var clipPlayable = AnimationClipPlayable.Create(_playableGraph, _animationClip);

        // Playable Graphで計算されたアニメーションデータはAnimatorで使われてキャラなどが動く
        // そのためにPlayable GraphとAnimatorを紐づけるのがPlayableOutput
        var playableOutput = AnimationPlayableOutput.Create(_playableGraph, "Animation", _animator);
        // 最終的に出力するアニメーションをPlayable Outputに登録
        playableOutput.SetSourcePlayable(clipPlayable);
        
        _playableGraph.Play();
    }

    private void Update()
    {
        // --- デバッグ表示用情報の更新 ---
        var mode = _animator.cullingMode;
        
        _statusText = $"<b>Culling Mode:</b> {mode}\n" +
                      $"<b>PlayableGraph Is Playing</b>: {_playableGraph.IsPlaying()}";
    }

    void OnGUI()
    {
        GUI.color = Color.black;
        GUI.skin.label.fontSize = 24;
        GUILayout.Label(_statusText);
    }
    
    private void OnDisable()
    {
        _playableGraph.Destroy();
    }
}


結果

適当なモーションを再生して実行します。

Culling Mode: Cull Update Transforms

こちらはAnimatorの設定と同じく、GameObjectが画面外へ移動してもPlayableGraphは再生し続けますがTransform情報の更新は止まりました。


Culling Mode: Cull Completely

こちらは少しAnimatorの設定と異なります。

GameObjectが画面外へ移動しても、PlayableGraph.IsPlaying() はずっとtrueのままです。 しかし、PlayableGraph Visualizerを見るとAnimationClipPlayableが止まっていることが分かります。

これは AnimationPlayableOutput の入力をPauseして、AnimationClipPlayableが停止したようです。

つまり、Animatorの挙動と同様に画面外へ移動するとアニメーションが停止する結果となります。

(↑画面外へ移動したとき、AnimationClipノードの白縁が消えて再生が停止する)


複雑なPlayableGraphによるCull Completely

Cull CompletelyではAnimationPlayableOutputの入力となるPlayableを停止することが分かりました。

では、もう少し複雑なPlayableGraphではCull Completelyがどのような動作になるかを検証します。

作成するPlayableGraphを次のようにします。

  1. AnimationPlayableOutputにAnimationMixerPlayableを設定
  2. AnimationMixerPlayableに以下の2つを設定
    1. AnimationClipPlayable
    2. 適当なScriptPlayable


コードは以下の通りです。

コード(折り畳み)

AIが書いたコードなので、かなり雑でコメントが多いです。

using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Animations;

[RequireComponent(typeof(Animator))]
public class PlayableMixingTest : MonoBehaviour
{
    [Header("Settings")]
    public AnimationClip animationClip; // テスト用のアニメーションクリップをセット

    [Range(0f, 1f)] public float clipWeight = 1.0f;     // クリップの強さ
    [Range(0f, 1f)] public float rotationWeight = 1.0f; // 回転スクリプトの強さ

    private PlayableGraph _graph;
    private AnimationMixerPlayable _mixer;
    private Animator _animator;
    
    // 表示用
    private string _statusText = "";

    void Start()
    {
        _animator = GetComponent<Animator>();

        // 1. Graph作成
        _graph = PlayableGraph.Create("MixingGraph");

        // 2. Mixer作成(入力ポートを2つ用意)
        _mixer = AnimationMixerPlayable.Create(_graph, 2);

        // 3. Output作成
        var output = AnimationPlayableOutput.Create(_graph, "AnimOutput", _animator);
        output.SetSourcePlayable(_mixer);

        // --- Input A: AnimationClipPlayable (Port 0) ---
        if (animationClip != null)
        {
            var clipPlayable = AnimationClipPlayable.Create(_graph, animationClip);
            // Graphにつなぐ: clipPlayable -> mixerのPort 0
            _graph.Connect(clipPlayable, 0, _mixer, 0);
        }

        // --- Input B: ScriptPlayable (Port 1) ---
        var rotationPlayable = ScriptPlayable<RotationBehaviour>.Create(_graph);
        var behaviour = rotationPlayable.GetBehaviour();
        behaviour.TargetTransform = this.transform;
        
        output.SetWeight(1.0f);
        
        // Graphにつなぐ: rotationPlayable -> mixerのPort 1
        _graph.Connect(rotationPlayable, 0, _mixer, 1);

        // 4. 再生(Animatorのカリングに任せる)
        _graph.Play();
    }

    void Update()
    {
        if (!_graph.IsValid()) return;

        // Inspectorの値をMixerのウェイトに反映
        _mixer.SetInputWeight(0, clipWeight);
        _mixer.SetInputWeight(1, rotationWeight);

        // --- デバッグ表示用情報の更新 ---
        var mode = _animator.cullingMode;
        // Mixer自体の時間(グラフ全体の進行状況)
        double time = _mixer.GetTime();
        
        _statusText = $"<b>Culling Mode:</b> {mode}\n" +
                      $"<b>Time:</b> {time:F2}\n" +
                      $"<b>Clip Weight:</b> {clipWeight:F2}\n" +
                      $"<b>Rotation Weight:</b> {rotationWeight:F2}\n" +
                      $"<b>PlayableGraph Is Playing</b>: {_graph.IsPlaying()}";
    }

    void OnGUI()
    {
        GUI.color = Color.black;
        GUI.skin.label.fontSize = 24;
        GUILayout.Label(_statusText);
    }

    void OnDisable()
    {
        if (_graph.IsValid()) _graph.Destroy();
    }

    // ---------------------------------------------------------
    // Playable Behaviour
    // ---------------------------------------------------------
    class RotationBehaviour : PlayableBehaviour
    {
        public Transform TargetTransform;

        // PrepareFrameは毎フレーム(カリングされていない間)呼ばれます
        public override void PrepareFrame(Playable playable, FrameData info)
        {
            Debug.Log("RotationBehaviour PrepareFrame called");
            
            if (TargetTransform == null) return;

            // 重要: Mixerで設定されたWeight(重み)を取得する
            // これにより、Inspectorのスライダーで回転の強さを制御できます
            float weight = info.effectiveWeight;

            // ウェイトが0なら処理しない(無駄な計算を省く)
            if (weight <= 0f) return;

            // 回転速度に weight を掛けることで、ブレンド具合を反映
            // 例: Weightが0.5なら、回転速度も半分になる
            float rotateSpeed = 180f * weight;

            TargetTransform.Rotate(0, rotateSpeed * info.deltaTime, 0);
        }
    }
}

結果

GameObjetを画面外に出すと、AnimationPlayableOutputの入力となるAnimationMixerPlayableが停止しました。しかし、AnimationMixerPlayableの入力となるAnimationClipPlayableとScriptPlayableは停止せずPlayginのままになります。


次に ScriptPlayable.PrepareFrame() にDebug.Logを仕込み、毎フレーム動作をしているかをチェックします。

実行確認してGameObjectが画面外に移動すると、Debug.Logが停止することが分かりました。 ScriptPlayableがPlaying状態にもかかわらず ScriptPlayable.PrepareFrame() が実行されていません。

(↑ Transform上から、ScriptPlayableが行う回転が止まっていることも分かる)

これはPlayableGraphが再帰的にPlayableを実行するため、親となるAnimationMixerPlayableが停止してその子となるScriptPlayableも実行されませんでした。

つまり、Culling ModeがCull Completelyの場合、画面外ではPlayableGraphに含まれる全てのPlayableが停止することになります。

PlayableGraphを手動で更新する場合のCull Completely

最後に DirectorUpdateMode.Manual を設定し、 PlayableGraph.Evaluate() を毎フレーム実行して手動で更新する場合を検証します。

コード(折り畳み)

AIが書いたコードに少しだけ手を加えています。

  • Start()_graph.SetTimeUpdateMode(DirectorUpdateMode.Manual) をして手動更新に設定
  • Update()_graph.Evaluate(Time.deltaTime) を実行して更新
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Animations;

[RequireComponent(typeof(Animator))]
public class PlayableMixingTest_Manual : MonoBehaviour
{
    [Header("Settings")]
    public AnimationClip animationClip; // テスト用のアニメーションクリップをセット

    [Range(0f, 1f)] public float clipWeight = 1.0f;     // クリップの強さ
    [Range(0f, 1f)] public float rotationWeight = 1.0f; // 回転スクリプトの強さ

    private PlayableGraph _graph;
    private AnimationMixerPlayable _mixer;
    private Animator _animator;
    
    // 表示用
    private string _statusText = "";

    void Start()
    {
        _animator = GetComponent<Animator>();

        // 1. Graph作成
        _graph = PlayableGraph.Create("MixingGraph");

        // 2. Mixer作成(入力ポートを2つ用意)
        _mixer = AnimationMixerPlayable.Create(_graph, 2);

        // 3. Output作成
        var output = AnimationPlayableOutput.Create(_graph, "AnimOutput", _animator);
        output.SetSourcePlayable(_mixer);

        // --- Input A: AnimationClipPlayable (Port 0) ---
        if (animationClip != null)
        {
            var clipPlayable = AnimationClipPlayable.Create(_graph, animationClip);
            // Graphにつなぐ: clipPlayable -> mixerのPort 0
            _graph.Connect(clipPlayable, 0, _mixer, 0);
        }

        // --- Input B: ScriptPlayable (Port 1) ---
        var rotationPlayable = ScriptPlayable<RotationBehaviour>.Create(_graph);
        var behaviour = rotationPlayable.GetBehaviour();
        behaviour.TargetTransform = this.transform;
        
        output.SetWeight(1.0f);
        
        // Graphにつなぐ: rotationPlayable -> mixerのPort 1
        _graph.Connect(rotationPlayable, 0, _mixer, 1);

        // 手動更新モードに設定
        _graph.SetTimeUpdateMode(DirectorUpdateMode.Manual);
   
        // 4. 再生(Animatorのカリングに任せる)
        _graph.Play();
    }

    void Update()
    {
        if (!_graph.IsValid()) return;

        // Inspectorの値をMixerのウェイトに反映
        _mixer.SetInputWeight(0, clipWeight);
        _mixer.SetInputWeight(1, rotationWeight);
        
        // 手動でPlayableGraphを更新
        _graph.Evaluate(Time.deltaTime);
        
        // --- デバッグ表示用情報の更新 ---
        var mode = _animator.cullingMode;
        // Mixer自体の時間(グラフ全体の進行状況)
        double time = _mixer.GetTime();
        
        _statusText = $"<b>Culling Mode:</b> {mode}\n" +
                      $"<b>Time:</b> {time:F2}\n" +
                      $"Director Update Mode: {_graph.GetTimeUpdateMode()}\n" +
                      $"<b>PlayableGraph Is Playing</b>: {_graph.IsPlaying()}";
    }

    void OnGUI()
    {
        GUI.color = Color.black;
        GUI.skin.label.fontSize = 24;
        GUILayout.Label(_statusText);
    }

    void OnDisable()
    {
        if (_graph.IsValid()) _graph.Destroy();
    }

    // ---------------------------------------------------------
    // Playable Behaviour
    // ---------------------------------------------------------
    class RotationBehaviour : PlayableBehaviour
    {
        public Transform TargetTransform;

        // PrepareFrameは毎フレーム(カリングされていない間)呼ばれます
        public override void PrepareFrame(Playable playable, FrameData info)
        {
            Debug.Log("RotationBehaviour PrepareFrame called");
            
            if (TargetTransform == null) return;

            // 重要: Mixerで設定されたWeight(重み)を取得する
            // これにより、Inspectorのスライダーで回転の強さを制御できます
            float weight = info.effectiveWeight;

            // ウェイトが0なら処理しない(無駄な計算を省く)
            if (weight <= 0f) return;

            // 回転速度に weight を掛けることで、ブレンド具合を反映
            // 例: Weightが0.5なら、回転速度も半分になる
            float rotateSpeed = 180f * weight;

            TargetTransform.Rotate(0, rotateSpeed * info.deltaTime, 0);
        }
    }
}

結果

「複雑なPlayableGraphによるCull Completely」と同じように、GameObjectが画面外へ出るとPlayableGraph全体の動作が停止しました。

動作としては先ほどと同じく、

  1. PlayableGraphは再帰的にPlayableを実行する
  2. 画面外へ出ると、AnimationOutputPlayableの子となるPlayableが停止する
  3. Playableが停止すると、AnimationOutputPlayableの子で再帰実行が停止する
  4. 結果としてPlayableGraph全体が停止する


まとめ

結果をまとめますと、

  • PlayableGraphでもAnimatorのCulling Modeを参照する
    • Always Mode: 常に再生する
    • Cull Update Transforms: 常にPlayableGraphは動作するが、画面外ではTransformへの書き込みは停止
    • Cull Completely: 画面外ではPlayableGraphに含まれるPlayableは停止する

また、Cull CompletelyでのPlayableGraphの動作は以下の通りです。

  • PlayableGraph自体は再生し続ける(常に PlayableGraph.IsPlaying() == true
  • AnimationOutputPlayableの子となるPlayableのみが停止する
    • 再帰的にPlayableを走査する仕組みから実行が打ち切られて、全体のPlayable実行が停止する
      • PlayableGraphを手動実行する場合(DirectorUpdateMode.Manual)も変わらない


AnimatorのCulling Modeを参照しないなどの情報を見かけて自作する心配をしてましたが、その必要はなさそうです。 また時間があればPlayableGraphの調査やモーション制御を作る予定なので、いい感じまとまればブログを書くかもです。



使用したアセット

再生しているモーションは以下のアセットを使用しています。

assetstore.unity.com