Unity Timelineでポストエフェクト制作を効率化するテクニック

はじめに
株式会社 QualiArts でテクニカルアーティストをしている我彦です。ツールの開発やTimelineを用いた新しい表現の実装を担当しています。
カットシーンやライブシーンではPostEffectを用いた演出表現が頻繁に用いられます。カット毎に演出を切り替えたりする際にVolumeとVolumeProfileを作成し、Timelineに配置するという工程が発生し、作業者の負担になることがあります。これを解決するためにTimelineとVolumeProfileを連携させる手法を実装し、作業者が基本的にTimeline内でPostEffectの制作作業をできることを目指しました。本記事ではその手法を紹介します。
概要
Timelineには自作でTimelineTrackを作成するためのクラスが用意されています。 これを用いて、Clip作成・削除にあわせてVolumeProfileの作成・削除を行う仕組みと、少ないVolumeで制御を行う仕組みを作成しました。また、ここで作成されたVolumeProfileを1つのScriptableObjectのSubAssetとして管理する仕組みも作成しています。
これらの仕組みにより、Timeline内でPostEffectの制作作業を行えるようにしました。
実装方針
今回考えた手法の方針は以下のようになっています。
- Sceneに2つのVolumeをコントロールするコンポーネントを作成する。
- VolumeProfileをSubAssetとして管理するScriptableObjectを作成する。
- TimelineでClip作成時にVolumeProfileを作成して、ScriptableObjectにSubAssetとして格納する。
- TimelineでVolumeとVolumeProfileをコントロールする。
これに加えてClipの複製 やSplit、削除にも対応してVolumeProfileの作成・削除がコントロールできるようになっています。
SceneにVolumeをコントロールするコンポーネントを作成する
まずはVolumeを用意していきます。VolumeController
というスクリプトからVolumeの作成をしています。
[ExecuteAlways]
public class VolumeController : MonoBehaviour
{
private Volume[] _volumes;
public Volume[] Volumes => _volumes;
[SerializeField]
private int _priority;
private void OnEnable()
{
_volumes = new Volume[2];
for (int n = 0; n < _volumes.Length; n++)
{
var volumeObject = new GameObject($"Volume{n}");
volumeObject.layer = gameObject.layer;
volumeObject.hideFlags = HideFlags.DontSave;
var volumeComponent = volumeObject.AddComponent<Volume>();
// 後に作るvolumeのPriorityは+1する
volumeComponent.priority = _priority + n;
volumeObject.transform.SetParent(transform);
_volumes[n] = volumeComponent;
volumeObject.SetActive(false);
}
}
private void OnDisable()
{
if (_volumes == null || _volumes.Length == 0) return;
foreach (var volume in _volumes)
{
DestroyImmediate(volume.gameObject);
}
_volumes = null;
}
}
今回はTimeline上でPostEffectのブレンドもできるようにするために2つのVolumeをOnEnableのタイミングで作成するようにしています。
// 後に作るvolumeのPriorityは+1する
volumeComponent.priority = _priority + n;
2つ目に作成するVolumeは1つ目のVolumeのPriorityより1大きい値を与えています。これはVolumeのPriorityが同じ値だとPostEffectの見た目がおかしくなることがあるためこのようにしています。
VolumeProfileをSubAssetとして管理するScriptableObjectを作成する
従来のフローではカットなどの単位でユニークなVolumeProfileを作成していました。再利用できる場面であればメリットはありますが、カットごとなどで固有に演出を設定する場合では管理コストのみが増えていってしまいます。そのため、SubAssetという仕組みを利用してVolumeProfileを1つのScriptableObjectにまとめることで、管理するファイル数を削減しました。
SubAssetとは
UnityにはMainAssetとSubAssetがあり、SubAssetはMainAssetの子として管理され、複数のアセットをまとめて1つのアセットとして扱うことが可能です。
今回取り上げているVolumeProfileの中にあるBloomなどのPostEffectも1つ1つはVolumeComponentというAssetでVolumeProfileのSubAssetとして管理されているものです。
ScriptableObjectの作成
VolumeProfileを入れるVolumeProfileData
というScriptableObjectは先ほど作成したVolumeをコントロールするコンポーネントに渡すようにしています。そのためEditor拡張でScriptableObjectを作成するようにしています。
[CustomEditor(typeof(VolumeController))]
public class VolumeControllerEditor : Editor
{
private VolumeController _volumeController;
private void OnEnable()
{
_volumeController = target as VolumeController;
}
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
if (_volumeController == null)
{
return;
}
if (GUILayout.Button("VolumeProfileData作成"))
{
CreateVolumeData();
}
}
private void CreateVolumeData()
{
if (_volumeController != null && _volumeController.VolumeProfileData == null)
{
var volumeData = CreateInstance<VolumeProfileData>();
var path = EditorUtility.SaveFilePanelInProject(
"Save VolumeProfile Data",
_volumeController.gameObject.name,
"asset",
"Save VolumeProfile Data",
"Assets/");
AssetDatabase.CreateAsset(volumeData, path);
AssetDatabase.SaveAssets();
_volumeController.SetVolumeData(volumeData);
EditorUtility.SetDirty(_volumeController);
}
}
}
VolumeProfileData作成
を押すことでVolumeProfileをまとめておくScriptableObjectを作成することができます。
Timelineの制御実装
Timeline側の実装を紹介していきます。この手法の実装については弊社エンジニアの記事があるのでそちらも参考にしてみて下さい。
Timelineでは主にVolumeの制御とClipの作成やコピーペースト・削除・Split時のVolumeProfileの作成・削除の管理を行なっています。
Volumeをコントロールする振る舞いの作成
Timelineで使うTrackとClipの作成をします。このTrackを用いてPostEffectの制御をします。今回のTimelineを作るために以下のクラスを実装しています。それぞれで実装しているものを紹介します。
-
VolumeProfileTimelineTrack
- Track上にClipが作られる際の挙動の設定
-
VolumeProfileTimelineMixerBehaviour
- Volumeのコントロールなどの振る舞いを記述
-
VolumeProfileTimelineBehaviour
- VolumeProfileTimelineMixerBehaviourにVolumeProfileなどの設定を渡す
-
VolumeProfileTimelineAsset
- VolumeProfileを設定する
- VolumeProfileTimelineに設定を渡す
MixerBehaviour
の実装を紹介します。MixerBehaviour
ではVolumeController
にあるVolumeを取得して、VolumeにVolumeProfileの適用とWeightの設定をしています。Mixをしているかどうかで処理が分岐していたり、Clipがない場合の処理もあります。
public class VolumeProfileTimelineMixerBehaviour : PlayableBehaviour
{
public override void ProcessFrame(Playable playable, FrameData info, object playerData)
{
// Volumeを持つVolumeControllerを取得
var volumeController = playerData as VolumeController;
if (volumeController == null) return;
// 時間でのClipの数を取得
int inputCount = playable.GetInputCount();
// Clipがない場合は非アクティブにする
if(inputCount == 0)
{
volumeController.SetVolumeActive(0, false);
volumeController.SetVolumeActive(1, false);
return;
}
// ヒープ領域を使わないValueTupleで保持する
(VolumeProfileTimelineBehaviour behaviour, float inputWeight) clipA = default;
(VolumeProfileTimelineBehaviour behaviour, float inputWeight) clipB = default;
for (int i = 0; i < inputCount; i++)
{
float inputWeight = playable.GetInputWeight(i);
var inputPlayable = (ScriptPlayable<VolumeProfileTimelineBehaviour>)playable.GetInput(i);
if (inputWeight > 0f)
{
var behaviour = inputPlayable.GetBehaviour();
if (clipA.Equals(default))
{
clipA = (behaviour, inputWeight);
// 1つ目のvolumeにVolumeProfileをセット
volumeController.SetVolumeProfile(0, clipA.behaviour.volumeProfile);
}
else
{
clipB = (behaviour, inputWeight);
// 2つ目のvolumeにVolumeProfileをセット
volumeController.SetVolumeProfile(1, clipB.behaviour.volumeProfile);
break;
}
}
}
// Clipがない場合
if (clipA.Equals(default))
{
volumeController.SetVolumeActive(0, false);
volumeController.SetVolumeActive(1, false);
return;
}
if (clipA.Equals(default) == false)
{
// Mixが必要ない場合
if (clipB.Equals(default))
{
// 与えられたVolumeをそのまま入れる
volumeController.SetVolumeWeight(0, clipA.inputWeight);
volumeController.SetVolumeActive(0, true);
volumeController.SetVolumeActive(1, false);
}
// Mixが必要な場合
else
{
// 一応時間でソートする
ref var leftClip =
ref clipA.behaviour.startTime < clipB.behaviour.startTime ? ref clipA : ref clipB;
ref var rightClip =
ref clipA.behaviour.startTime < clipB.behaviour.startTime ? ref clipB : ref clipA;
var rightWeight = rightClip.inputWeight;
volumeController.SetVolumeWeight(0, 1f);
volumeController.SetVolumeWeight(1, rightWeight);
volumeController.SetVolumeActive(0, true);
volumeController.SetVolumeActive(1, true);
}
}
}
}
このように実装するとPostEffectの適用ができます。
VolumeProfileの作成と削除
VolumeProfileを作成するに当たってTrackEditorの機能を用いて実装を行いました。
[CustomTimelineEditor(typeof(VolumeProfileTimelineTrack))]
public class VolumeProfileTimelineTrackEditor : TrackEditor
{
private List<Object> _subAssets = new List<Object>();
private VolumeController _volumeController;
private VolumeProfileData _volumeprofileData;
public override void OnTrackChanged(TrackAsset track)
{
// VolumeController取得
_volumeController = TimelineEditor.inspectedDirector.GetGenericBinding(track) as VolumeController;
if (_volumeController == null) return;
// volumeProfileData取得
_volumeprofileData = _volumeController.VolumeProfileData;
if (_volumeprofileData == null) return;
// SubAsset取得
GetSubAssets();
var clips = track.GetClips().ToList();
// Clip内のVolumeProfile取得
var volumeProfiles = clips
.Select(x => x.asset)
.OfType<VolumeProfileTimelineAsset>()
.Select(x => x.volumeProfile)
.Where(x => x != null)
.ToList();
// Clip削除時の処理
DeleteClips(clips, volumeProfiles);
// Clip作成時の処理
CreateClips(clips);
}
}
TrackEditor
はTrackのEditor拡張でOnTrackChanged
でTrackの中でClipが作成されるなどの変化があった際の挙動を記述することができます。これを用いてClip作成・削除時のVolumeProfileの作成・削除を管理しました。
OnTrackChanged
はtrackを引数に持つのでGetGenericBinding
を使ってBindしたものを取得することができます。これを用いてBindしたVolumeController
を取得します。
VolumeController
内のVolumeProfileData
のSubAsset取得を以下のGetSubAssets
から行います。SubAssetの判定は、IsSubAsset
を使って行うことができます。
Method OnTrackChanged | Timeline | 1.7.6
Playables.PlayableDirector-GetGenericBinding
private void GetSubAssets()
{
if (_volumeprofileData == null) return;
// SubAsset取得
_subAssets = AssetDatabase.LoadAllAssetsAtPath(AssetDatabase.GetAssetPath(_volumeprofileData))
.Where(AssetDatabase.IsSubAsset).ToList();
}
Clip作成時・削除時の処理について説明していきます。
どちらも共通しているのはSubAssetの数とClipの数を比較してVolumeProfileの作成と削除を行なっていることです。
Clip作成時の処理について紹介します。こちらはClip作成以外にもClipを分割する時やClipの複製時に呼ばれます。
CreateClips
はClipの数がSubAssetの数より大きい場合に呼ばれ、VolumeProfileを作成してClipに渡しています。
private void CreateClips(List<TimelineClip> clips)
{
var clipAssets = clips.Select(clip => clip.asset as VolumeProfileTimelineAsset).ToList();
// clip数とSubAssetの数を比較する
if (clipAssets.Count > _subAssets.Count)
{
// Clip作成時の処理
foreach (var clip in clips)
{
var clipAsset = clip.asset as VolumeProfileTimelineAsset;
// subAssetsにないVolumeProfileがある場合、SubAssetに追加
if (!_subAssets.Contains(clipAsset.volumeProfile))
{
CreateVolumeProfile(clipAsset);
clip.displayName = " ";
Undo.RecordObject(clipAsset, "Create VolumeProfile");
return;
}
}
// 複製の処理
var duplicateAssets = clipAssets
.GroupBy(x => x.volumeProfile)
.Where(x => x.Count() > 1)
.SelectMany(x => x.Skip(1))
.ToList();
if(duplicateAssets.Count == 0) return;
foreach (var asset in duplicateAssets)
{
var copyProfile = ScriptableObject.CreateInstance<VolumeProfile>();
EditorUtility.CopySerialized(asset.volumeProfile, copyProfile);
asset.volumeProfile = copyProfile;
CreateVolumeProfile(asset);
Undo.RecordObject(asset, "Create VolumeProfile");
}
}
}
複製処理では複製元のVolumeProfileをCopySerialized
で複製していますがCreateVolumeProile
で複製したVolumeProfile内のVolumeComponentの複製処理をしています。
VolumeProfileを普通にCopySerialized
を用いてコピーをするとVolumeComponent
については複製されず複製元も複製先も同じVolumeComponent
を参照してしまい共通化されてしまうのでVolumeComponent
に対してCopySerialized
をしています。
AssetDatabase.AddObjectToAsset
を使うことでSubAssetとして指定したアセットを追加することができます。これを用いてVolumeProfileData
にVolumeProfileをSubAssetとして追加を行ったり、VolumeProfileに複製 したVolumeComponent
をSubAssetとして追加したりしています。
private void CreateVolumeProfile(VolumeProfileTimelineAsset clipAsset)
{
var copyProfile = ScriptableObject.CreateInstance<VolumeProfile>();
if (clipAsset.volumeProfile != null)
{
clipAsset.volumeProfile.components.ForEach(component =>
{
// VolumeComponent複製
var copyComponent = ScriptableObject.CreateInstance(component.GetType()) as VolumeComponent;
EditorUtility.CopySerialized(component, copyComponent);
copyComponent.hideFlags = HideFlags.HideInInspector | HideFlags.HideInHierarchy;
copyProfile.components.Add(copyComponent);
});
}
clipAsset.volumeProfile = copyProfile;
_volumeprofileData.AddSubAsset(clipAsset.volumeProfile);
clipAsset.volumeProfile.components.ForEach(component =>
{
AssetDatabase.AddObjectToAsset(component, clipAsset.volumeProfile);
EditorUtility.SetDirty(component);
});
clipAsset.volumeProfile.name = Guid.NewGuid().ToString(); // 一意の名前を付与
EditorUtility.SetDirty(clipAsset.volumeProfile);
AssetDatabase.SaveAssetIfDirty(clipAsset.volumeProfile);
}
最後に削除処理について紹介します。SubAssetを削除する際にはUndo.DestroyObjectImmediate
を使って削除を行い、削除後にはVolumeProfileDataにDirtyフラグをつけてSaveAssetIfDirty
でVolumeProfileDataのみにSaveを行います。
private void DeleteClips(List<TimelineClip> clips, List<VolumeProfile> volumeProfiles)
{
// Clip内のVolumeProfileがSubAssetにない場合、Clip削除
if (clips.Count < _subAssets.Count)
{
foreach (var subAsset in _subAssets)
{
var deleteFlg = true;
foreach (var volumeProfile in volumeProfiles)
{
if (subAsset == volumeProfile)
{
deleteFlg = false;
}
}
if (deleteFlg == false) continue;
// ClipsにないAsset削除
Undo.DestroyObjectImmediate(subAsset);
EditorUtility.SetDirty(_volumeProfileData);
AssetDatabase.SaveAssetIfDirty(_volumeProfileData);
}
}
}
これらの実装などを行うことでClipの作成・削除処理とVolumeProfileの作成・削除処理を連携させることができます。それぞれの挙動の様子は以下のようになっています。



画面右側にあるのがTrack毎にVolumeProfileの管理を行なっているVolumeProfileData内でVolumeProfileがClipの作成・削除に連動していることがわかります。
複製処理に近いものとしてTimelineにはSplit
という処理もありますがこちらも複製の実装で対応できます。

まとめ
TimelineでのClip作成・削除とVolumeProfileの作成・削除を連携させることでTimeline内でPostEffectの作成をする方法について記載しました。
Timelineの拡張やAssetの管理を活用して、PostEffect制作をよりやりやすくできたと感じています。