コンテンツへスキップ

QualiArtsengineer blog

Unityを活用したUIアニメーションの開発フローのツール改修事例

Unityを活用したUIアニメーションの開発フローのツール改修事例

10 min read

はじめに

私は株式会社QualiArtsでエンジニアをしています横田直輝です。現在、スマートフォン向けのゲームアプリの開発に携わっています。

少し前ですが、2023年にCEDECで「UIアニメーションの開発フロー」をテーマに登壇しました。その後も開発フローを調整する過程で、いくつかのUnityエディタの改修を行ってきました。 今回はその中で、特にデザイナーの方に好評だったUnityエディタの機能をご紹介したいと思います。アニメーション制作の効率化やクオリティ向上に興味のある方にとって、少しでもお役に立てれば幸いです。

Titanとは

Titanのロゴ
Titanのロゴ

まずはTitanについて簡単に説明をします。

Titanは、UnityのTimelineを拡張した、UIアニメーション制作に特化した内製ツールです。 UIアニメーション開発フローの基幹として、デザイナーがUnity上で演出を作りやすくすることを目的に開発されています。 現在、複数のプロジェクトで使用されており、継続的に様々な機能改善が行われています。

Titanは、次の画像のように、特定の機能を持つTrackに対象のコンポーネントを紐づけ、Clipを使ってUIを操作しながら演出を作成します。

Titanでの演出実装例
Titanでの演出実装例

Titanについて詳しく知りたい場合は、過去に発表した次の資料をご確認ください。


複雑な演出作成におけるTimelineとHierarchyウィンドウの課題

大きな演出をTitanで作成すると、Timelineが複雑になると同時に、Hierarchyウィンドウでどのオブジェクトが操作されているのかが分かりにくくなります。 これにより、演出作成中に細かな確認が必要となり、アニメーション作成の効率が低下する問題が発生しました。 この問題を解決するために、Timelineを含むエディタ拡張を用意しました。今回は、次の2点を紹介します。

  • Hierarchyウィンドウのエディタ拡張
  • Markerの拡張

Hierarchyウィンドウのエディタ拡張

Hierarchyウィンドウのエディタ拡張については、次の2点のエディタ拡張を紹介したいと思います。

  • Hierarchyウィンドウのハイライト機能
  • TimelineにBindingされたオブジェクトのハイライト機能

Hierarchyウィンドウのハイライト機能

このコードでは、PlayableDirectorが設定されているかどうかを判定し、その結果に応じてオブジェクトをハイライトするかどうかを設定しています。 今回は、Timelineを持つオブジェクトをハイライトすることが目的だったため、PlayableDirectorが設定されているかどうかで判定しました。 その解決方法としてこの機能実装をしてみました。

Timelineが設定されているオブジェクトがハイライトされます
Timelineが設定されているオブジェクトがハイライトされます
実装例
[InitializeOnLoad]
public class HierarchyColor
{
    static HierarchyColor()
    {
        //このイベントにメソッドを登録することで、Hierarchyウィンドウ内のオブジェクトの描画方法をカスタマイズできます
        EditorApplication.hierarchyWindowItemOnGUI += HierarchyWindowItemOnGUI;
    }

    private static void HierarchyWindowItemOnGUI(int instanceID, Rect selectionRect)
    {
        var gameObject = EditorUtility.InstanceIDToObject(instanceID) as GameObject;
        // オブジェクトにPlayableDirectorが設定されているかどうか判定
        if (gameObject != null && gameObject.GetComponent<PlayableDirector>() != null)
        {
            var guiContent = EditorGUIUtility.ObjectContent(gameObject, typeof(GameObject));
            var textOnlyContent = new GUIContent(guiContent.text);

            var textSize = EditorStyles.label.CalcSize(textOnlyContent);
            float iconSize = EditorGUIUtility.singleLineHeight;
            float margin = 3;

            // オブジェクトの背景を描画
            var rect = new Rect(selectionRect.x + iconSize, selectionRect.y + margin, textSize.x + margin, selectionRect.height - margin);
            EditorGUI.DrawRect(rect, new Color32(240, 170, 109, 255));

            // オブジェクトのテキスト描画
            var textRect = new Rect(selectionRect.x + iconSize, selectionRect.y, selectionRect.width - iconSize, selectionRect.height);
            GUI.color = Color.black;
            GUI.Label(textRect, textOnlyContent);
            GUI.color = Color.white;
        }
    }
}

HierarchyColor クラスは、Unity エディタのHierarchyウィンドウで PlayableDirector コンポーネントを持つゲームオブジェクトをハイライト表示するためのクラスです。EditorApplication.hierarchyWindowItemOnGUI イベントにフックし、該当するゲームオブジェクトの背景色を変更し、テキストを黒色で表示します。

TimelineにBindingされているオブジェクトのハイライト機能

Hierarchyウィンドウ上で、表示中のTimelineにバインドされているオブジェクトがあればハイライトする機能です。 Timelineで演出を実装しているときに、どのオブジェクトをTimelineで操作をしているかが可視化されるため、作業がやりやすくなりました。

次の画像では Timeline2にバインディングされている Rootimageのオブジェクトがハイライトされています。

Trackにバインディングされたいるオブジェクトをハイライト
Trackにバインディングされたいるオブジェクトをハイライト
実装例
[InitializeOnLoad]
public class HighlightBindingTrackComponent
{
    private static HashSet<GameObject> _bindingGameObjects = new HashSet<GameObject>();
    private static GameObject _lastSelectedObject;
    private static GUIStyle _highlightStyle = new GUIStyle();
    private static PlayableDirector _currentPlayableDirector;

    static HighlightBindingTrackComponent()
    {
        // このイベントはUnityエディタ内のオブジェクト選択が変更されたとき呼び出されます
        Selection.selectionChanged += OnSelectionChanged;
        EditorApplication.hierarchyWindowItemOnGUI += HierarchyWindowItemOnGUI;
    }

    private static void HierarchyWindowItemOnGUI(int instanceID, Rect selectionRect)
    {
        var targetObject = EditorUtility.InstanceIDToObject(instanceID) as GameObject;
        // Timelineにバインディングされているオブジェクトかどうかを判定
        if (_bindingGameObjects.IsNullOrEmpty() == false && _bindingGameObjects.Contains(targetObject))
        {
            HighlightGameObjectInHierarchy(targetObject, selectionRect);
        }
    }

    private static void HighlightGameObjectInHierarchy(GameObject targetObject, Rect selectionRect)
    {
        var guiContent = EditorGUIUtility.ObjectContent(targetObject, typeof(GameObject));
        var textOnlyContent = new GUIContent(guiContent.text);
        float iconSize = EditorGUIUtility.singleLineHeight;

        var textRect = new Rect(selectionRect.x + iconSize, selectionRect.y, selectionRect.width - iconSize, selectionRect.height);
        _highlightStyle.normal.textColor = targetObject.activeInHierarchy ? new Color32(240, 170, 109, 255) : new Color32(240, 170, 109, 128);
        EditorGUI.LabelField(textRect, textOnlyContent, _highlightStyle);
    }

    private static void OnSelectionChanged()
    {
        _bindingGameObjects.Clear();
        EditorApplication.update -= AddBindingGameObject;

        var selectionObject = Selection.activeGameObject;
        if (selectionObject == _lastSelectedObject) return;

        var playableDirector = selectionObject.GetComponent<PlayableDirector>();
        if (playableDirector == null) return;

        _currentPlayableDirector = playableDirector;
        EditorApplication.update += AddBindingGameObject;
    }

    private static void AddBindingGameObject()
    {
        _bindingGameObjects.Clear();
        if(_currentPlayableDirector == null || _currentPlayableDirector.playableAsset == null) return;
        var timelineAsset = _currentPlayableDirector.playableAsset as TimelineAsset;
        if (timelineAsset == null) return;
        // Timelineの全てのTrackを取得し、各Trackにバインディングされているオブジェクトを収集する
        foreach (var track in timelineAsset.GetOutputTracks())
        {
            var bindingGameObject = _currentPlayableDirector.GetGenericBinding(track) as Component;
            if (bindingGameObject == null) continue;
            _bindingGameObjects.Add(bindingGameObject.gameObject);
        }
    }
}

Selection.selectionChangedイベントを使用して、Timelineの選択が変更されたときに処理を行います。 選択されているTimelineを元に、EditorApplication.hierarchyWindowItemOnGUIイベントを使用して、Hierarchyウィンドウ内の項目の外観をカスタマイズします。 今回は、選択されたTimelineのTrackにバインディングされたオブジェクトがあれば、それらをハイライト表示しています。

Markerの拡張

UnityのTimelineで使用されるMarkerは、特定のイベントやポイントを示すための機能です。 マーカーはシーケンス内の指定された位置に配置され、特定のアクションやイベントをトリガーするために使用されます。

Timeline上のMarker
Timeline上のMarker

ただし、Timeline上にMarkerが増えると、どのMarkerがどれなのかをInspectorウィンドウを見ないと判別しづらくなります。 Titanでは独自Markerを用意しているため、特定の演出ではMarkerが増えデザイナーからわかりにくいという意見をいただきました。 そのため、エディタ拡張による改善を行いました。

Markerのエディタ拡張

まずはMarkerの拡張方法について説明します。 UnityにはMarkerEditorクラスが用意されており、これを継承することでMarkerの拡張が可能です。

public class CustomMarkerEditor : MarkerEditor
{
    public override MarkerDrawOptions GetMarkerOptions(IMarker marker)
    {
        return base.GetMarkerOptions(marker);
    }

    public override void DrawOverlay(IMarker marker, MarkerUIStates uiState, MarkerOverlayRegion region)
    {
        base.DrawOverlay(marker, uiState, region);
    }
}

MarkerEditorでできることは大きく2つあります。

MarkerDrawOptions

MarkerDrawOptionsを使うことで、特定のMarkerの上にマウスが乗った際に、ツールチップのように特定の文字を表示することができます。

MarkerDrawOptionの実装例

MarkerDrawOptionsの実装例は次のようになります。

public override MarkerDrawOptions GetMarkerOptions(IMarker marker)
{
    return new MarkerDrawOptions { tooltip = "Tipsを表示"}
}

このコードでは、tooltipプロパティに任意の文字列を設定することで、ツールチップに表示する内容を指定できます。 次の画像のように表示されます。

ツールチップを表示する
ツールチップを表示する
DrawOverlay

DrawOverlayメソッドは、Markerの描画中に特定のビジュアル要素を追加するために使用されます。

DrawOverlayの実装例

Markerの直下に線を引きたい場合は、次のような実装になります。

public override void DrawOverlay(IMarker marker, MarkerUIStates uiState, MarkerOverlayRegion region)
{
    DrawMarkerUnderline(region, new Color32(0xff, 0xff, 0xff, 0xff));
    base.DrawOverlay(marker, uiState, region);
}

protected void DrawMarkerUnderline(MarkerOverlayRegion region, Color color)
{
    float markerRegionCenter = region.markerRegion.xMin + (region.markerRegion.width - 1.0f) / 2.0f;
    var lineRect = new Rect(markerRegionCenter,
        region.timelineRegion.y + region.markerRegion.y,
        1.0f,
        region.timelineRegion.height);

    EditorGUI.DrawRect(lineRect, color);
}

DrawOverlayメソッドは、マーカーのオーバーレイを描画する際に DrawMarkerUnderline メソッドを呼び出して、指定された色でマーカーの位置に下線を描画します。

Timeline上では次の画像のように表示されます。

白い線がMarker直下に描画されます
白い線がMarker直下に描画されます

特定のMarkerに視覚的なヒントを追加することで、Timeline上での認識性と操作性が向上します。

エディタ拡張例

MarkerEditorを継承した具体的な拡張例を紹介します。

Marker上に特定の画像を表示させる

まずは次の画像を御覧ください。

増えすぎたMarkerの例
増えすぎたMarkerの例

Markerの数が増えると、それぞれのMarkerを識別するのが難しくなります。

そこで、解決策として、すべてのMarkerに共通のエディタ拡張を行い、Marker上に特定の画像を表示するようにしました。 これにより、Timelineウィンドウを見るだけで、どのMarkerが設定されているかをひと目で把握できるようになりました。

画像を表示することで、Timeline上で機能が確認できる
画像を表示することで、Timeline上で機能が確認できる
実装例

DrawOverlayを使って、特定のテクスチャを読み込んで表示をしています。

//表示するテクスチャ画像
private Texture2D _markerTexture;

public void DrawOverlay(IMarker marker, MarkerUIStates uiState, MarkerOverlayRegion region)
{
    if (uiState != MarkerUIStates.Selected)
    {
        // 画像を表示する領域を計算
        float markerRegionCenter2 = region.markerRegion.xMin + (region.markerRegion.width - 20) / 2.0f;
        Rect bgRect = new Rect(markerRegionCenter2,
            region.markerRegion.y - 20f,
            20f,
            20f);
        GUI.DrawTexture(bgRect, _markerTexture);
    }

    base.DrawOverlay(marker, uiState, region);
}

事前にMarkerUIStatesMarkerUIStates.Selectedかどうかの判定をしています。 つまり、Markerが選択されていない状態のときに限り、画像がMarkerの上に表示されるようになっています。 こうすることで、Markerを移動させたり編集したりする際に画像が邪魔になるのを防ぎます。

特定のエリアを描画する

特定のマーカー間を視覚的に描画する実装を行いました。 例えば、演出中にタップを禁止する時間を設けたい場合、その特定の時間をタイムライン上に表示する目的で利用することができます。

Marker間を描画する
Marker間を描画する
実装例

今回用意したMarkerには、対象のMarkerを識別ができるようIdを設定できるようにしました。 今回用意したマーカーには、対象のマーカーを識別できるようにIDを設定できるようにしました。 複数のマーカーが設定されていても、同じIDを持つマーカー間を描画できるようになります。

Idを指定する
Idを指定する

次のコードは、特定の「TargetStartMarker」と「TargetEndMarker」の間を描画しています。 TargetEndMarkerにIDが設定されている場合、対応する「TargetStartMarker」の位置を計算し、対象のエリアを描画します。

public override void DrawOverlay(IMarker marker, MarkerUIStates uiState, MarkerOverlayRegion region)
{
    if (marker is TargetEndMarker targetEndMarker && uiState != MarkerUIStates.Selected)
    {
        float positionX = 0;
        foreach (var maker in marker.parent.GetMarkers())
        {
            // MarkerがTargetStartMarker かつIDが一致するかどうか判定
            if (maker is TargetStartMarker targetStartMarker && targetStartMarker.Id == targetEndMarker.Id)
            {
                var targetStartMarkerTime = targetStartMarker.time;
                if (region.startTime > targetStartMarkerTime)
                {
                    positionX = 0;
                }
                else
                {
                    var ratio = (float)((targetStartMarkerTime - region.startTime) / (region.endTime - region.startTime));
                    positionX = ratio * region.timelineRegion.width;
                }

                DrawTapWaitArea(GetHighlightRect(region, positionX));
            }
        }
    }

    base.DrawOverlay(marker, uiState, region);
}

private Rect GetHighlightRect(MarkerOverlayRegion region, float startPosX)
{
    return new Rect(
        region.trackRegion.x + startPosX,
        region.markerRegion.y,
        region.markerRegion.x - region.trackRegion.x - startPosX + region.markerRegion.width / 2f,
        region.markerRegion.height
    );
}

protected void DrawHighlightRect(Rect targetRect)
{
    var highlightColor = new Color32(0x75, 0xbd, 0x00, 0x60);
    EditorGUI.DrawRect(targetRect, highlightColor);
}

まとめ

ツールは作って終わりではなく、使っていく中で改修を続けることが大事です。 実際に使ってもらうと、さまざまな不備が出てくるものですので、ツールの利用者とコミュニケーションを重ねて調整していくことが重要になります。 今回紹介したエディタ拡張については特にデザイナーの方に喜んでいただけたので、もし何かの参考になれば幸いです。

2015年に株式会社サイバーエージェント入社し、エンジニアとして複数のスマートフォン向けのゲームアプリ開発に参加。現在は株式会社QualiArtsにて新規タイトルの開発に従事し、アニメーションや各種演出周りの実装を担当