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` にすることで、オブジェクト参照がメモリに残り続けるリークを完璧に防止します。