UnityのTimelineのカスタムクリップの出入りに合わせて気軽に処理をするはずだったが落とし穴があった件
はじめに
この記事は Colorful Palette アドベントカレンダー 12/7の記事です。
こんにちは、クライアントエンジニアのめいじんと申します。
普段はリアルタイムライブ配信のシステムや3DMVの実装に関わる業務を行っています。
タイムラインのカスタムクリップの挙動を追う
リアルタイムの演出機能を実装していく上でタイムラインのカスタムクリップを実装することはよくあります。
その中で気をつけて実装しないと思わぬところで不具合となる場合があるので、タイムラインのカスタムクリップの挙動を追いながら気をつけるポイントを紹介します。
クリップに入った時・抜けたタイミングで処理をしたい
カスタムクリップを実装する場合、クリップに入った時や抜けた時に何かしらの処理をしたい事が多々あります。
カスタムクリップの挙動を追う上で「クリップに入る時・抜ける時」を判別して処理を行う。
という所に焦点を合わせて進めていきます。
各種イベントの呼び出し順序を追ってみる
手始めにカスタムクリップのBehaviourにログ入れて動きを見てみます。 それぞれの実装としてはこうなります。
using System.Linq;
using UnityEngine;
using UnityEngine.Timeline;
using UnityEngine.Playables;
[TrackClipType(typeof(CustomTimelineClip))]
public class CustomTimelineTrack : TrackAsset
{
}
using UnityEngine;
using UnityEngine.Playables;
public class CustomTimelineClip : PlayableAsset
{
public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
{
var playable = ScriptPlayable<CustomTimelineBehaviour>.Create(graph);
return playable;
}
}
using UnityEngine;
using UnityEngine.Playables;
public class CustomTimelineBehaviour : PlayableBehaviour
{
public override void OnBehaviourPlay(Playable playable, FrameData info)
{
base.OnBehaviourPlay(playable, info);
Debug.Log($"[CustomTimeline] Behaviour OnBehaviourPlay");
}
public override void OnBehaviourPause(Playable playable, FrameData info)
{
base.OnBehaviourPause(playable, info);
Debug.Log($"[CustomTimeline] Behaviour OnBehaviourPause");
}
public override void OnGraphStart(Playable playable)
{
base.OnGraphStart(playable);
Debug.Log("[CustomTimeline] Behaviour OnGraphStart");
}
public override void OnGraphStop(Playable playable)
{
base.OnGraphStop(playable);
Debug.Log("[CustomTimeline] Behaviour OnGraphStop");
}
public override void OnPlayableCreate(Playable playable)
{
base.OnPlayableCreate(playable);
Debug.Log("[CustomTimeline] Behaviour OnPlayableCreate");
}
public override void OnPlayableDestroy(Playable playable)
{
base.OnPlayableDestroy(playable);
Debug.Log("[CustomTimeline] Behaviour OnPlayableDestroy");
}
public override void PrepareFrame(Playable playable, FrameData info)
{
base.PrepareFrame(playable, info);
}
public override void ProcessFrame(Playable playable, FrameData info, object playerData)
{
base.ProcessFrame(playable, info, playerData);
}
}
これを実行するとログにはこのように出ました。
(CustomTimelineClipを抜けるまで実行した結果です)
クリップに入る時・抜ける時を処理する
ログの動きを見るとBehaviourのOnBehaviourPlay、OnBehaviourPauseに
それぞれクリップに入る時の処理、抜ける時の処理を実装すれば実現できそうです。
PlayableDirectorポーズの落とし穴
これでカスタムクリップの処理が実現できた!と思ったらところで下記のようなコンポーネントを作成してGameObjectにしてアタッチ、カスタムクリップの途中でPlayableDirectorをポーズ・再開して動きを見てみました。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Playables;
public class Controller : MonoBehaviour
{
private PlayableDirector _playableDirector;
// Start is called before the first frame update
void Start()
{
_playableDirector = GetComponent<PlayableDirector>();
_playableDirector.Play();
}
// Update is called once per frame
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
if (_playableDirector.state == PlayState.Playing)
{
Debug.Log("[CustomTimeline] PlayableDirector Pause");
_playableDirector.Pause();
} else if (_playableDirector.state == PlayState.Paused)
{
Debug.Log("[CustomTimeline] PlayableDirector Resume");
_playableDirector.Resume();
}
}
}
}
PlayableDirectorのポーズでもOnBehaviourPlay/OnBehaviourPauseが呼び出されてしまっています。
ログのPlayableDirector Pause、PlayableDirector Resumeが出ている所がPlayableDirectorをポーズ・再開している部分ですが、この間でOnBehaviourPause/OnBehaviourPlayが呼び出されているのが分かると思います。
このままではカスタムクリップに入る時、出る時の判別が正確にはできません。
解決方法を探ってみる
OnBehaviourPlay/OnBehaviourPauseの引数にはPlayableが渡されるので
これで再生状態が判別できるのでは?と思いPlayableのPlayStateをログに出してみることにしました。
CustomTimelineBehaviourクラスのOnBehaviourPlay/OnBehaviourPauseを下記のように変更します。
public override void OnBehaviourPlay(Playable playable, FrameData info)
{
base.OnBehaviourPlay(playable, info);
Debug.Log($"[CustomTimeline] Behaviour OnBehaviourPlay playState={playable.GetPlayState()}");
}
public override void OnBehaviourPause(Playable playable, FrameData info)
{
base.OnBehaviourPause(playable, info);
Debug.Log($"[CustomTimeline] Behaviour OnBehaviourPause playState={playable.GetPlayState()}");
}
OnBehaviourPlayのPlayStateは常にPlayingで入ってきていてこれだけだとPlayableDirectorがポーズされたのかクリップに入ったのか判別できませんがクリップを抜けた時に呼び出されるOnBehaviourPauseのPlayStateはPausedになっていたのでこれを利用して状態の制御はできそうです。
そこでBehaviourの実装をこのように変えてみました。
private bool _isEnterClip;
public override void OnBehaviourPlay(Playable playable, FrameData info)
{
base.OnBehaviourPlay(playable, info);
Debug.Log($"[CustomTimeline] Behaviour OnBehaviourPlay playState={playable.GetPlayState()}");
if (!_isEnterClip)
{
_isEnterClip = true;
OnEnterClip();
}
}
public override void OnBehaviourPause(Playable playable, FrameData info)
{
base.OnBehaviourPause(playable, info);
Debug.Log($"[CustomTimeline] Behaviour OnBehaviourPause playState={playable.GetPlayState()}");
if (_isEnterClip && playable.GetPlayState() == PlayState.Paused)
{
_isEnterClip = false;
OnExitClip();
}
}
private void OnEnterClip()
{
Debug.Log("[CustomTimeline] Behaviour OnEnterClip");
}
private void OnExitClip()
{
Debug.Log("[CustomTimeline] Behaviour OnExitClip");
}
これを実行するとログにはこう出ていました。
期待している動作になるようになりました!
この方法で「クリップに入る時・抜ける時」を判別してPlayableDirectorのポーズ・再開の干渉を受けずに処理できるようになりました。
クリップ同士のフェードの場合にはご注意
タイムラインにはクリップ同士を重ねてクリップ間のパラメータを補間する機能があります。
カスタムクリップでこの機能を使用した時に動作のログを見てみるとこうなりました。
各クリップの出入りは処理できていますがクリップの数だけ呼び出されてしまっています。
クリップが重なっている区間は1つのクリップとして続いているものとして処理したい場合は都合が悪くなってしまいます。
MixerBehaviourを使う
MixerBehaviourはクリップ間の補間の処理を記述するためのものですが、クリップの有無に関わらずProcessFrameが動作するので、これが利用出来そうです。
下記のような実装を追加して動かしてみたところログがこのように出ました。
(見やすくするためにBehaviourのログは非表示にしています)
using System.Linq;
using UnityEngine;
using UnityEngine.Timeline;
using UnityEngine.Playables;
[TrackClipType(typeof(CustomTimelineClip))]
public class CustomTimelineTrack : TrackAsset
{
public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount)
{
var playable = ScriptPlayable<CustomTimelineMixerBehaviour>.Create(graph, inputCount);
var customTimelineMixerBehaviour = playable.GetBehaviour();
var director = go.GetComponent<PlayableDirector>();
if (director != null)
{
customTimelineMixerBehaviour.Director = director;
customTimelineMixerBehaviour.Clips = GetClips().ToArray();
}
return playable;
}
}
using UnityEngine;
using UnityEngine.Timeline;
using UnityEngine.Playables;
public class CustomTimelineMixerBehaviour : PlayableBehaviour
{
public TimelineClip[] Clips { get; set; }
public PlayableDirector Director { get; set; }
private TimelineClip _currentClip;
public override void OnPlayableCreate(Playable playable)
{
base.OnPlayableCreate(playable);
Debug.Log("[CustomTimeline] MixerBehaviour OnPlayableCreate");
}
public override void ProcessFrame(Playable playable, FrameData info, object playerData)
{
base.ProcessFrame(playable, info, playerData);
var currentClip = GetCurrentClip(Director.time);
if (currentClip != _currentClip)
{
if (currentClip != null && _currentClip == null)
{
Debug.Log($"[CustomTimeline] MixerBehaviour EnterClip clip={currentClip.displayName}");
OnEnterClip();
}
else if (currentClip == null)
{
Debug.Log($"[CustomTimeline] MixerBehaviour ExitClip clip={_currentClip.displayName}");
OnExitClip();
}
_currentClip = currentClip;
}
}
public override void OnGraphStart(Playable playable)
{
base.OnGraphStart(playable);
Debug.Log("[CustomTimeline] MixerBehaviour OnGraphStart");
}
public override void OnGraphStop(Playable playable)
{
base.OnGraphStop(playable);
Debug.Log("[CustomTimeline] MixerBehaviour OnGraphStop");
}
public override void OnBehaviourPlay(Playable playable, FrameData info)
{
base.OnBehaviourPlay(playable, info);
Debug.Log($"[CustomTimeline] MixerBehaviour OnBehaviourPlay playState={playable.GetPlayState()}");
}
public override void OnBehaviourPause(Playable playable, FrameData info)
{
base.OnBehaviourPause(playable, info);
Debug.Log($"[CustomTimeline] MixerBehaviour OnBehaviourPause playState={playable.GetPlayState()}");
}
private void OnEnterClip()
{
Debug.Log("[CustomTimeline] MixerBehaviour OnEnterClip");
}
private void OnExitClip()
{
Debug.Log("[CustomTimeline] MixerBehaviour OnExitClip");
}
private TimelineClip GetCurrentClip(double time)
{
if (Clips == null || Clips.Length == 0)
{
return null;
}
foreach (var clip in Clips)
{
if (clip.start <= time && time <= clip.end)
{
return clip;
}
}
return null;
}
}
これで期待している動作になりました。
MixerBehaviourのProcessFrameは毎フレーム必ず動作するので処理負荷には気をつける必要がありますが、用途によって使い分ける事でクリップに入る時、出る時の処理は実現できました。
さいごに
今回、カスタムクリップの入る時・抜ける時の処理で気をつけるところを紹介しました。処理の内容としては難しくはないと思いますが内部でフラグ管理を行う必要があり若干の泥臭さはある印象です。
将来的にUnity側でOnClipEnter/OnClipExitと言ったような明示的にクリップの出入りが検出できる仕組みが実装されることを期待しつつ次の日にバトンを渡したいと思います。