UnityのTimelineは非常に拡張性が高く、標準のAnimationトラックやActivationトラックだけでなく、Playable API(`PlayableAsset` と `PlayableBehaviour`)を利用することで、完全に独自のC#制御スクリプトをタイムライン上に配置できます。しかし、独自のパラメータ書き換え(例:ライトの明滅、シェーダーパラメータの遷移、マテリアルのカラー変更など)を実装した際、「カットシーンが再生終了したのに、キャラクターの顔が青ざめたまま戻らない」「シークバーを動かしたときにプロパティの値がメチャクチャになる」といったパラメータ固着問題が実務上ほぼ100%発生します。本記事では、このライフサイクル制御のバグを根本解決する頑健な実装パターンを提示します。

1. 問題の根源:Playableグラフの非アクティブ化とステート復元の不在

Timelineの評価システムは、毎フレームグラフの各ノード(Playable)を巡回し、`ProcessFrame` コールバックを呼んでパラメータを計算・反映します。しかし、Timelineの再生が終点(End)に達して停止した際、あるいはTimelineコンポーネントが非アクティブ化された際、Playableシステムは**何のアフターケアも行わずに動作を急停止(Pause)します。**

つまり、`ProcessFrame` はあるフレームで突然呼ばれなくなり、その結果、制御対象のオブジェクトは「最後に呼ばれたフレームの値(通常はエフェクトが最大になった瞬間や、遷移途中の値)」で置き去りにされます。Unityの標準Animationトラックであれば、内部的なアニメーションクリップのバインドが状態復元(Write Defaultなど)を処理してくれますが、C#スクリプトで自作した `PlayableBehaviour` の場合、値の復元処理を自分で明示的にプログラムしなければ、パラメータは永遠に上書きされた値で固まってしまいます。

2. メモリリークを引き起こす参照バインディングの残留

さらに深刻なのが、C#の参照管理問題です。`PlayableBehaviour` は内部的に `PlayableDirector` からバインドされたシーンオブジェクト(`Light` や `MeshRenderer` などの参照)を保持します。もし、シーン遷移やDirectorの破棄時に、これらの参照ポインタをクリーンアップしないままでいると、Playableグラフの内部データ構造がそれらの参照を握り潰したままメモリに残留し、GC(ガベージコレクター)が対象のオブジェクトをメモリから破棄できなくなります。これが、シーンのロード・アンロードを繰り返すと徐々にメモリが圧迫されていく慢性的なメモリリークの原因となります。

3. 解決策:ステートロールバックとクリーンアップの頑健なコードパターン

このトラブルを防ぐためには、`PlayableBehaviour` のライフサイクル(再生開始、停止、グラフ破棄)を正しくハンドリングし、初期値の退避・復元および参照のクリアを確実に行う実装パターンを記述する必要があります。

以下に、実務で使用できる「ライトの明るさを動的に制御するカスタムPlayable」を例にした、頑健な実装コードを示します。

① PlayableAsset(データを保持・バインディングを設定するクラス)

using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

[System.Serializable]
public class CustomLightControlAsset : PlayableAsset, ITimelineClipAsset
{
    // インスペクターで設定するパラメータデータ
    public Color lightColor = Color.white;
    public float intensityMultiplier = 1.0f;

    // Timeline上でのクリップの機能を定義
    public ClipCaps clipCaps => ClipCaps.Blending;

    public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
    {
        // Behaviourのインスタンスを作成してパラメータを流し込む
        var playable = ScriptPlayable<CustomLightControlBehaviour>.Create(graph);
        var behaviour = playable.GetBehaviour();
        
        behaviour.lightColor = lightColor;
        behaviour.intensityMultiplier = intensityMultiplier;

        return playable;
    }
}

② PlayableBehaviour(毎フレームの制御と復元・リーク対策クラス)

using UnityEngine;
using UnityEngine.Playables;

public class CustomLightControlBehaviour : PlayableBehaviour
{
    // 制御用パラメータ (Assetからコピーされる)
    public Color lightColor;
    public float intensityMultiplier;

    // 制御対象の参照キャッシュ
    private Light targetLight;

    // 再生前の状態を記憶しておく退避用変数
    private float originalIntensity;
    private Color originalColor;
    private bool hasCachedState = false; // 二重キャッシュ防止用のフラグ

    // 1. 再生開始時に呼ばれる(シーク時やクリップ進入時)
    public override void OnBehaviourPlay(Playable playable, FrameData info)
    {
        // グラフのバインディングから操作対象のオブジェクト(Light)を解決して参照を取得
        if (targetLight == null)
        {
            // info.output がバインド先オブジェクト(解決できない場合は呼び出し元等から探索)
            // ここでは実装をシンプルにするため、シーンからキャッシュする構成等をとります
        }
    }

    // 毎フレームの更新処理
    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        targetLight = playerData as Light;
        if (targetLight == null) return;

        // 初めて処理が行われる瞬間に、元の状態(初期値)を厳格に記憶(退避)する
        if (!hasCachedState)
        {
            originalIntensity = targetLight.intensity;
            originalColor = targetLight.color;
            hasCachedState = true;
        }

        // クリップの進行度(0〜1)に基づいて補間計算
        double time = playable.GetTime();
        double duration = playable.GetDuration();
        float progress = (float)(time / duration);

        // 動的に値を上書き
        targetLight.intensity = originalIntensity + (intensityMultiplier * progress);
        targetLight.color = Color.Lerp(originalColor, lightColor, progress);
    }

    // 2. 再生終了・一時停止時に呼ばれる(クリップ脱出時、タイムライン停止時)
    public override void OnBehaviourPause(Playable playable, FrameData info)
    {
        RestoreOriginalState();
    }

    // 3. プレイアブルインスタンスの破棄時に呼ばれる
    public override void OnPlayableDestroy(Playable playable)
    {
        RestoreOriginalState();
    }

    // 状態を完全に元に戻す(ロールバック)
    private void RestoreOriginalState()
    {
        if (targetLight != null && hasCachedState)
        {
            targetLight.intensity = originalIntensity;
            targetLight.color = originalColor;
            hasCachedState = false; // フラグをリセット
        }
    }

    // 4. Playableグラフ自体が完全に破棄された時に呼ばれる(メモリリーク防止の要)
    public override void OnGraphDestroy(Playable playable)
    {
        // シーンオブジェクトへの参照をすべて null にし、GCによる回収を許可する
        targetLight = null;
    }
}

この実装で極めて重要なのは、`hasCachedState` フラグによる「最初の1回だけの初期値退避」と、`OnBehaviourPause` / `OnPlayableDestroy` 両方で `RestoreOriginalState()` を呼び出している点です。これにより、タイムラインが途中でシークされても、再生が終了しても、確実にライトの強さは元の輝度に戻ります。また、`OnGraphDestroy` 内で `targetLight = null` にすることで、オブジェクト参照がメモリに残り続けるリークを完璧に防止します。