Spineでインポート時にAnimatorControllerとReferenceAssetsを自動で作る

Spine

こんにちは、エンジニアの木原です。

今回は検証だったり最新の技術の紹介…ではなく、ちょっとした効率化についてのメモがてら共有しようかと思います。

基本的にコードがメインなので、エンジニアの方向けの記事となります。もしこの記事を読んでくれている方がクリエイターの方だったら、是非周りのエンジニアの方に紹介してみて下さい。

また、今回紹介する記事ではUnityとSpineについての効率化です。cocos-2dxや他のツールではないのでお気をつけ下さい。

使用しているUnityは 2018.4.12f1、spineは3.6系です。

Spineのインポートと必要なAssetを作るのが面倒臭い

UnityでSpineを使う際、Spineからバイナリとしてエクスポートした、「atlas.txt」「png」「skel.bytes」のファイルをUnityへドラッグ&ドロップすると自動でインポート処理が走り、Unityで使える形式へと自動変換されます。

その後、SkeletonAnimationやSkeletonAnimator、Timelineなどを使うことでアニメーションを再生することができるようになります。

このとき、SkeletonAnimatorで使うAnimatorControllerや、UnityのTimelineで使えるReferenceAssetsの作成は自動では行われず、SkeletonDataのインスペクタからボタンを押さないといけません。また、AnimatorControllerは作った後、AnimatorにAnimationClipを手動で登録しないといけないです。(Spineにそういう機能があれば教えて下さい)

インポート時、AnimatorControllerがないので、

インスペクターから作るけど、(緑枠のボタンを押す)

Animatorの中にStateやClipはない

内包されている状態ではあるので、これらのclipを選択してドラッグ & ドロップすればMecanimが使えるようになる。

それを毎回やるのも面倒なので、インポートした時点で一緒に作ってくれるようにしました。

手順その1

↓のソースコードをコピペしてプロジェクトの中に入れて下さい。

その際、Editorという名のついたディレクトリに入れるようにしましょう。そうしないとAndroidやiOSといったプラットフォームではエラーが起きてビルドできなくなります。

using System.Reflection;

using UnityEngine;
using UnityEditor;
using UnityEditor.Animations;

using Spine.Unity;
using Spine.Unity.Editor;


/// <summary>
/// Spineをimportするときに追加でアセットを作る.
/// Editorでしか動作しないので、Editorという名のディレクトリに入れること.
/// </summary>
public class SpineEditorAutoCreateAssets
{

    /// <summary>
    /// Spineのランタイムの方の処理で<see cref="SpineEditorUtilities.OnPostprocessAllAssets"/>というメソッドがSkeletonDataを作っている.
    /// SkeletonDataを作り終わった以降に呼び出すこと.
    /// </summary>
    /// <param name="skeletonDataAsset"></param>
    public static void OnSkeletonDataAssetImported(SkeletonDataAsset skeletonDataAsset)
    {
        //自動でAnimatorControllerを作成する。
        CreateAnimatorController(skeletonDataAsset);
        //自動でReferenceAssetsを作成する。
        CreateAnimationReferenceAssets(skeletonDataAsset);

        AssetDatabase.SaveAssets();
    }


    /// <summary>
    /// SkeletonDataのインスペクタで「Create Animation Reference Assets」を押したときと同じ挙動をさせる。
    /// 元のソースは<see cref="SkeletonDataAssetInspector.CreateAnimationReferenceAssets"/>というメソッドを流用した。
    /// </summary>
    /// <param name="skeletonDataAsset">対象のskeletonData</param>
    private static void CreateAnimationReferenceAssets(SkeletonDataAsset skeletonDataAsset)
    {
        const string AssetFolderName = "ReferenceAssets";
        string parentFolder = System.IO.Path.GetDirectoryName(AssetDatabase.GetAssetPath(skeletonDataAsset));
        string dataPath = parentFolder + "/" + AssetFolderName;
        if (!AssetDatabase.IsValidFolder(dataPath))
        {
            AssetDatabase.CreateFolder(parentFolder, AssetFolderName);
        }

        FieldInfo nameField = typeof(AnimationReferenceAsset).GetField("animationName", BindingFlags.NonPublic | BindingFlags.Instance);
        FieldInfo skeletonDataAssetField = typeof(AnimationReferenceAsset).GetField("skeletonDataAsset", BindingFlags.NonPublic | BindingFlags.Instance);
        foreach (var animation in skeletonDataAsset.GetSkeletonData(false)?.Animations)
        {
            string assetPath = string.Format("{0}/{1}.asset", dataPath, SpineEditorUtilities.GetPathSafeName(animation.Name));
            AnimationReferenceAsset existingAsset = AssetDatabase.LoadAssetAtPath<AnimationReferenceAsset>(assetPath);
            if (existingAsset == null)
            {
                AnimationReferenceAsset newAsset = ScriptableObject.CreateInstance<AnimationReferenceAsset>();
                skeletonDataAssetField.SetValue(newAsset, skeletonDataAsset);
                nameField.SetValue(newAsset, animation.Name);
                AssetDatabase.CreateAsset(newAsset, assetPath);
            }
        }
    }


    /// <summary>
    /// AnimatorControllerを作り、AnimationClipを突っ込む。
    /// インスペクタで作るときは<see cref="SkeletonBaker.GenerateMecanimAnimationClips(SkeletonDataAsset)"/>を呼び出している。
    /// assetの作成はSkeletonBakerに任せて、AnimationClipの調整だけ行っている。
    /// </summary>
    /// <param name="skeletonDataAsset"></param>
    private static void CreateAnimatorController(SkeletonDataAsset skeletonDataAsset)
    {
        var controllerPath = GetAnimatorControllerPath(skeletonDataAsset);
        var controller = AssetDatabase.LoadAssetAtPath(controllerPath, typeof(RuntimeAnimatorController)) as AnimatorController;
        if (controller == null)
        {
            Debug.LogError("Animator Controllerの取得に失敗しました");
            return;
        }

        foreach (var asset in AssetDatabase.LoadAllAssetsAtPath(controllerPath))
        {
            if (asset is AnimationClip)
            {
                controller.AddMotion(asset as AnimationClip);
            }
        }
        SetClipPosition(controller.layers[0].stateMachine);

    }


    private static string GetAnimatorControllerPath(SkeletonDataAsset skeletonDataAsset)
    {
        SkeletonBaker.GenerateMecanimAnimationClips(skeletonDataAsset);
        string dataPath = AssetDatabase.GetAssetPath(skeletonDataAsset);
        string controllerPath = dataPath.Replace(SpineEditorUtilities.SkeletonDataSuffix, "_Controller").Replace(".asset", ".controller");

        return controllerPath;
    }


    private static void SetClipPosition(AnimatorStateMachine stateMachine)
    {
        ChildAnimatorState[] childStates = stateMachine.states;

        Vector3 pos = Vector2.zero;
        int row = 1;
        int h = 0;

        for (int j = 0; j < childStates.Length; j++)
        {
            h = (j + 1) % 10;
            pos.x = 210f * row + 50f;
            pos.y = 80f * h + 10f;
            childStates[j].position = pos;

            if ((j + 1) % 10 == 0)
            {
                row++;
            }
        }

        stateMachine.states = childStates;
    }
}

手順その2

Spine側のソースコードに↑のクラスのOnSkeletonDataAssetImportedを呼び出すように細工しましょう。

細工するのは「SpineEditorUtilities.cs」です。その中にIngestSpineProjectというメソッドがあります。それを↓のように2行追加しましょう。

追加したとき、コンパイルエラーが起きたら、先程のソースコードの配置場所が悪い可能性があります。Unityのコンパイルされる順番によっては、エラーになるのでもし起きたときはどのディレクトリに入っているか確認すると良いでしょう。

static SkeletonDataAsset IngestSpineProject (TextAsset spineJson, params AtlasAsset[] atlasAssets) {
			string primaryName = Path.GetFileNameWithoutExtension(spineJson.name);
			string assetPath = Path.GetDirectoryName(AssetDatabase.GetAssetPath(spineJson));
			string filePath = assetPath + "/" + primaryName + SkeletonDataSuffix + ".asset";

			#if SPINE_TK2D
			if (spineJson != null) {
				SkeletonDataAsset skeletonDataAsset = (SkeletonDataAsset)AssetDatabase.LoadAssetAtPath(filePath, typeof(SkeletonDataAsset));
				if (skeletonDataAsset == null) {
					skeletonDataAsset = SkeletonDataAsset.CreateInstance<SkeletonDataAsset>();
					skeletonDataAsset.skeletonJSON = spineJson;
					skeletonDataAsset.fromAnimation = new string[0];
					skeletonDataAsset.toAnimation = new string[0];
					skeletonDataAsset.duration = new float[0];
					skeletonDataAsset.defaultMix = defaultMix;
					skeletonDataAsset.scale = defaultScale;

					AssetDatabase.CreateAsset(skeletonDataAsset, filePath);
					AssetDatabase.SaveAssets();
				} else {
					skeletonDataAsset.Clear();
					skeletonDataAsset.GetSkeletonData(true);
				}
				//↓ココに追加!
                SpineEditorAutoCreateAssets.OnSkeletonDataAssetImported(skeletonDataAsset);//ここに←を追加!
				//↑ココに追加!
				return skeletonDataAsset;
			} else {
				EditorUtility.DisplayDialog("Error!", "Tried to ingest null Spine data.", "OK");
				return null;
			}

			#else
			if (spineJson != null && atlasAssets != null) {
				SkeletonDataAsset skeletonDataAsset = (SkeletonDataAsset)AssetDatabase.LoadAssetAtPath(filePath, typeof(SkeletonDataAsset));
				if (skeletonDataAsset == null) {
					skeletonDataAsset = ScriptableObject.CreateInstance<SkeletonDataAsset>(); {
						skeletonDataAsset.atlasAssets = atlasAssets;
						skeletonDataAsset.skeletonJSON = spineJson;
						skeletonDataAsset.defaultMix = defaultMix;
						skeletonDataAsset.scale = defaultScale;
					}

					AssetDatabase.CreateAsset(skeletonDataAsset, filePath);
					AssetDatabase.SaveAssets();
				} else {
					skeletonDataAsset.atlasAssets = atlasAssets;
					skeletonDataAsset.Clear();
					skeletonDataAsset.GetSkeletonData(true);
				}
				//↓ココに追加!
                SpineEditorAutoCreateAssets.OnSkeletonDataAssetImported(skeletonDataAsset);//ここに←を追加!
				//↑ココに追加!
				return skeletonDataAsset;
			} else {
				EditorUtility.DisplayDialog("Error!", "Must specify both Spine JSON and AtlasAsset array", "OK");
				return null;
			}
			#endif
		}

わざわざSpine側のソースをいじるのは、自分たちでもう一つOnPostprocessAllAssetsを実装したとしても、事前にSkeletonDataのインポートが終わってないといけないということと、ランタイムの更新なんてそんな高い頻度で行わないのでコメントさえ残しておけば後から調整は可能という理由からです。

Spineのインポート自体、全て実装するにしても、結局ソースを持ってきたり、元の方の処理とバッティングする可能性があるなどのリスクもあり、ちょっといじっておくくらいが丁度よいんじゃないか、という判断をしました。

結果

Before

Import直後はmaterialやskeletonDataの最低限のモノだけが作られます

After

AnimatorControllerとReferenceAssetsもimportと同時に作られました

さらにAnimatorの中身もデフォルトでは整地された状態に!

以上、UnityとSpineのちょっとした効率化というか、小技でした。