コンテンツへスキップ

QualiArtsengineer blog

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

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

10 min read

はじめに

株式会社 QualiArts でテクニカルアーティストをしている我彦です。ツールの開発やTimelineを用いた新しい表現の実装を担当しています。

カットシーンやライブシーンではPostEffectを用いた演出表現が頻繁に用いられます。カット毎に演出を切り替えたりする際にVolumeVolumeProfileを作成し、Timelineに配置するという工程が発生し、作業者の負担になることがあります。これを解決するためにTimelineとVolumeProfileを連携させる手法を実装し、作業者が基本的にTimeline内でPostEffectの制作作業をできることを目指しました。本記事ではその手法を紹介します。

GATSBY_EMPTY_ALT

概要

Timelineには自作でTimelineTrackを作成するためのクラスが用意されています。 これを用いて、Clip作成・削除にあわせてVolumeProfileの作成・削除を行う仕組みと、少ないVolumeで制御を行う仕組みを作成しました。また、ここで作成されたVolumeProfileを1つのScriptableObjectのSubAssetとして管理する仕組みも作成しています。

これらの仕組みにより、Timeline内でPostEffectの制作作業を行えるようにしました。

実装方針

今回考えた手法の方針は以下のようになっています。

  1. Sceneに2つのVolumeをコントロールするコンポーネントを作成する。
  2. VolumeProfileをSubAssetとして管理するScriptableObjectを作成する。
  3. TimelineでClip作成時にVolumeProfileを作成して、ScriptableObjectにSubAssetとして格納する。
  4. TimelineでVolumeとVolumeProfileをコントロールする。

これに加えてClipの複製やSplit、削除にも対応してVolumeProfileの作成・削除がコントロールできるようになっています。

GATSBY_EMPTY_ALT

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のタイミングで作成するようにしています。

VolumeController01

// 後に作るvolumeのPriorityは+1する
volumeComponent.priority = _priority + n;

2つ目に作成するVolumeは1つ目のVolumeのPriorityより1大きい値を与えています。これはVolumeのPriorityが同じ値だとPostEffectの見た目がおかしくなることがあるためこのようにしています。

VolumeProfileをSubAssetとして管理するScriptableObjectを作成する

VolumeData01

従来のフローではカットなどの単位でユニークな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を作成することができます。

VolumeData02

Timelineの制御実装

Timeline側の実装を紹介していきます。この手法の実装については弊社エンジニアの記事があるのでそちらも参考にしてみて下さい。

Unityタイムライン拡張入門 自作トラックの作り方

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の適用ができます。

GATSBY_EMPTY_ALT

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

AssetDatabase.IsSubAsset

        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の作成・削除処理を連携させることができます。それぞれの挙動の様子は以下のようになっています。

Clip作成
Clip作成
Clip複製
Clip複製
Clip削除
Clip削除

画面右側にあるのがTrack毎にVolumeProfileの管理を行なっているVolumeProfileData内でVolumeProfileがClipの作成・削除に連動していることがわかります。

複製処理に近いものとしてTimelineにはSplitという処理もありますがこちらも複製の実装で対応できます。

Clip削除
ClipのSplit

まとめ

TimelineでのClip作成・削除とVolumeProfileの作成・削除を連携させることでTimeline内でPostEffectの作成をする方法について記載しました。

Timelineの拡張やAssetの管理を活用して、PostEffect制作をよりやりやすくできたと感じています。

2023年にサイバーエージェントに新卒入社。テクニカルアーティストとして、Unityのツール開発を担当