Unity ECSでスキンメッシュをアニメーションさせる

Unity ECSでスキンメッシュをアニメーションさせる

Unity ECSでスキンメッシュをアニメーションさせる

はじめに

株式会社QualiArtsでUnityエンジニアをしている住田です。Unityのプロジェクトにてクライアントリードエンジニアをしており、並行して「CA.unity」や「技術書典」といった会社を跨いだ横軸活動の牽引などもしております。

Unity ECS、みなさま触っていますでしょうか? 筆者は最近Unity ECSを触ってみたのですが、ちょっとした処理でも、設計思想や各機能の役割を理解していないと難しい部分が多いと感じています。GameObjectベースではコンポーネントに任せていた処理を、ECSでは何をデータとして持ち、どのSystemで更新するのか、自分で考える必要があります。

その中で今回は、スキンメッシュをアニメーションさせる処理を作ってみたのでご紹介します。従来のAnimator Controllerが担っていた役割を、Unity ECSではどのようにデータとSystemへ分けて考えるのか。キャラクターアニメーションを組み立てるうえで必要になる考え方を、今回作ったサンプルをもとに説明します。

Unity ECSにおけるスキンメッシュアニメーション

GameObjectベースのUnityでは、スキンメッシュのアニメーションはAnimatorやAnimator Controllerが基本的な再生手段です。現在の再生時間を進め、状態遷移を扱い、AnimationClipからボーンの姿勢を評価し、SkinnedMeshRendererへ反映するところまでを制御してくれます。普段の実装では、これらの処理がどの順番で行われているかを意識する必要はなく、コンポーネントに設定するだけで特定のモーションを利用可能です。

Unity ECSで同じような処理を作る場合、この暗黙にまとまっていた責務を、ECS上のデータとSystemに分けて明示する必要があります。今回のサンプルでは、完全なAnimator Controller互換を作るのではなく、スキンメッシュをアニメーションさせるために必要な最小限の仕組みを構築しました。

Unity ECSで歩行モーションを再生している様子

今回作成したサンプルでは、AnimationClipをEditor上であらかじめサンプリングしておき、Runtimeではその結果を参照しながらボーンのLocalTransformとSkinMatrixを更新しています。

GameObject / Animatorでの役割今回のECS実装での置き場所
AnimationClipBakerでサンプリングした読み取り専用データ(BlobAsset)
Animator Controllerの再生状態Entityごとの再生状態(IComponentData)
SkinnedMeshRenderer.bonesボーンEntityのDynamicBuffer
Mesh.bindposesbindposeのDynamicBuffer
ボーン姿勢の更新SystemでLocalTransformへ反映
描画用のスキニング行列Entities GraphicsのSkinMatrix

この記事では、この対応関係を入口にして、AnimationClipとSkinnedMeshRendererをどのようにAuthoringし、ECSのSystemでどのようにポーズ更新とSkinMatrixの反映を行うかを見ていきます。

AnimationClipとSkinnedMeshRendererのAuthoring

GameObjectベースのUnityでは、AnimationClipやSkinnedMeshRendererはInspector上で設定し、そのまま実行時のComponentとして参照できます。一方でUnity ECSでは、Runtimeで動く中心はEntityとComponentDataであり、GameObjectやMonoBehaviourは取り扱いません。

普段のComponentの感覚でシーン上で設定したい情報と、実行時にECSで処理したい情報の間には変換が必要になります。これを担うのがAuthoring ComponentとBakerです。

Unityの公式ドキュメントでは、この変換処理はBakingと呼ばれています。BakerはAuthoring Componentを読み取り、EntityにECS用のデータを書き込む役割を持ちます。

Baker自体はUnity.Entities側で用意されている変換用の基底クラスです。サンプルコードではBaker<CharacterSkinningAuthoring>を継承し、「このBakerはCharacterSkinningAuthoringを読み取ってEntityを作る」という対応関係を型で表しています。

なお、今回の実装は Unity 6.5 / Entities 6.5.0 / Entities Graphics 6.5.0 で確認しています。BakingやBakerの考え方を追ううえでは、以下の公式ドキュメントも参考になります。

今回のサンプルでは、RuntimeのSystemがAnimationClipやSkinnedMeshRendererを直接参照するのではなく、Bakerで必要な情報だけをECSのデータへ変換します。大きく分けると、AnimationClip、SkinnedMeshRenderer、そしてサンプリング設定です。

Authoring ComponentからECSデータへ変換する流れ

Authoring ComponentはGameObject側の設定を持ち、Bakerがそれを読み取って、Runtimeで扱うBlobAsset、DynamicBuffer、IComponentDataへ変換します。

以下は、記事用に名前を整理したAuthoring Componentの例です。

public sealed class CharacterSkinningAuthoring : MonoBehaviour
{
    [SerializeField]
    private SkinnedMeshRenderer skinnedMeshRenderer;

    [SerializeField]
    private AnimationClip motionClip;

    [SerializeField]
    private GameObject sampleRoot;

    [SerializeField]
    private float sampleRate = 24.0f;

    public SkinnedMeshRenderer SkinnedMeshRenderer => skinnedMeshRenderer;

    public AnimationClip MotionClip => motionClip;

    public GameObject SampleRoot => sampleRoot;

    public float SampleRate => sampleRate;
}

Authoring Component自体は、Inspectorで設定したい情報を持つだけの存在です。実行時の状態や更新処理は持たせず、ECSで使うデータへの変換はBaker側に実装します。

ここでのsampleRateは、実行時の更新頻度ではなく、AnimationClipをECSで扱うためのキーフレーム列へ焼き込む粒度です。Bakerで一定間隔ごとにAnimationClipをサンプリングし、その時点の各ボーンのローカル空間での情報を保存します。RuntimeではAnimationClipを直接評価せず、保存済みのデータを読みながら姿勢を補間します。

このような実行中に変更しないアニメーションデータは、BlobAssetとして持たせています。ここでBlobAssetに入れているのは、AnimationClipからBaking時に取り出したキーフレーム列です。clipの長さ、サンプリング間隔、各ボーンの時刻ごとのローカル姿勢のように、実行中にEntityごとに変わらない情報だけを対象にします。

一方で、clip内の再生時刻、再生速度のような実行時の状態はBlobAssetには入れていません。Entityごとに変わる情報なので、IComponentDataとして持たせ、Systemで更新します。

Bakerでは、まずSkinnedMeshRendererから「どのボーンが、どの順番でメッシュに対応しているか」を取り出します。あわせて、各ボーンがメッシュの初期姿勢に対してどう対応しているかを表すbindposeも取得します。

これらはSystemから参照したい情報なので、Entityに紐づく配列のようなデータとして保持します。このようなEntityに持たせる可変長のデータをDynamicBufferとして扱います。

また、AnimationClipBakeUtilityは今回のサンプル用に実装したUtilityです。AnimationClipを一定間隔で評価し、Runtimeで参照するためのBlobAssetを生成します。記事ではBakerの流れを見やすくするためにUtilityとして切り出していますが、ここで行っている処理もBakingの一部です。

以下も、Bakerの主要な流れだけを抜き出したサンプルです。

// Baker<TAuthoring>はUnity.Entitiesが用意している変換用の基底クラスです。
// TAuthoringには、このBakerが読み取るAuthoring Componentの型を指定します。
public sealed class CharacterSkinningBaker : Baker<CharacterSkinningAuthoring>
{
    // BakeはSubSceneの変換など、UnityのBaking時にUnityから呼び出されます。
    public override void Bake(CharacterSkinningAuthoring authoring)
    {
        var renderer = authoring.SkinnedMeshRenderer;
        var mesh = renderer.sharedMesh;
        var bones = renderer.bones;
        var bindposes = mesh.GetBindposes();

        // Meshやbone Transformが変わった時に再Bakingされるようにします。
        DependsOn(mesh);
        foreach (var bone in bones)
        {
            DependsOn(bone);
        }

        var entity = GetEntity(TransformUsageFlags.Dynamic);

        var boneEntities = AddBuffer<CharacterBoneEntity>(entity);
        boneEntities.ResizeUninitialized(bones.Length);
        for (var i = 0; i < bones.Length; i++)
        {
            boneEntities[i] = new CharacterBoneEntity
            {
                Value = GetEntity(bones[i], TransformUsageFlags.Dynamic)
            };
        }

        var bindPoseBuffer = AddBuffer<CharacterBindPose>(entity);
        bindPoseBuffer.ResizeUninitialized(bindposes.Length);
        for (var i = 0; i < bindposes.Length; i++)
        {
            bindPoseBuffer[i] = new CharacterBindPose
            {
                Value = bindposes[i]
            };
        }

        // AnimationClipを一定間隔で評価し、BlobAssetを生成します。
        var motion = AnimationClipBakeUtility.BuildMotionBlob(
            authoring.SampleRoot,
            authoring.MotionClip,
            bones,
            authoring.SampleRate);

        AddBlobAsset(ref motion, out _);
        AddComponent(entity, new CharacterMotionSettings
        {
            Motion = motion
        });
    }
}

Utilityの中では、sampleRateから必要なフレーム数を決め、AnimationClip.SampleAnimationで各時刻の姿勢をGameObjectへ反映しています。その時点の各ボーンのローカル座標、回転、スケールを読み取り、BlobAssetのキーフレーム列として保存します。

ここで重要なのは、SkinnedMeshRenderer.bonesとMesh.bindposesのindexを揃えたまま保持することです。SkinMatrixを作る時には、同じindexにあるボーンとbindposeを組み合わせます。順番がずれると、メッシュの頂点に対して違うボーンの行列を適用してしまうためです。

ECS設計に沿ったポーズ更新とSkinMatrixの反映

実行時は、Authoringで作ったデータを読みながら、Systemで状態を更新していきます。今回実装するのは、再生状態の更新、ボーン姿勢の更新、SkinMatrixの反映です。その間で、UnityのTransform SystemがLocalToWorldを計算します。

ここで意識しているのは、ECSの更新順序です。Entityごとの再生状態を更新したあと、BlobAssetからサンプリングした姿勢をボーンEntityのLocalTransformへ書き込みます。その後、UnityのTransform SystemがLocalToWorldを計算し、描画タイミングでLocalToWorldとbindposeからSkinMatrixを作ります。

Runtimeでボーン姿勢とSkinMatrixを更新する順序

再生状態の更新とボーン姿勢の反映はSimulation側で行い、LocalToWorldが計算された後にPresentation側でSkinMatrixを更新します。

まず、Entityごとの再生時間を進めます。ここでも簡単なサンプルを紹介します。実際のゲーム実装では接地状態や速度からモーション種別や再生速度を決めることになりますが、ここでは説明のために、Modeなどの状態は省略しています。

public struct CharacterAnimationState : IComponentData
{
    public float LocalTime;
    public float PlaybackRate;
}

[BurstCompile]
public partial struct CharacterAnimationStateSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        state.Dependency = new UpdateAnimationStateJob
        {
            DeltaTime = SystemAPI.Time.DeltaTime
        }.ScheduleParallel(state.Dependency);
    }

    private partial struct UpdateAnimationStateJob : IJobEntity
    {
        public float DeltaTime;

        private void Execute(ref CharacterAnimationState animation)
        {
            animation.LocalTime += DeltaTime * animation.PlaybackRate;
        }
    }
}

次に、ボーン姿勢を更新する簡単なサンプルを紹介します。BlobAssetに入れたモーションデータからボーンごとの姿勢をサンプリングし、各ボーンEntityのLocalTransformへ反映します。各ボーンEntityには「どのモーションデータの何番目のボーンか」を表すComponentを持たせています。

public struct CharacterBonePoseTarget : IComponentData
{
    public Entity MotionEntity;
    public int BoneIndex;
}

[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(TransformSystemGroup))]
public partial struct CharacterBonePoseSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<CharacterMotionSettings>();
        state.RequireForUpdate<CharacterAnimationState>();
        state.RequireForUpdate<CharacterBonePoseTarget>();
    }

    public void OnUpdate(ref SystemState state)
    {
        state.Dependency.Complete();

        var motionSettingsLookup =
            SystemAPI.GetComponentLookup<CharacterMotionSettings>(isReadOnly: true);
        var animationStateLookup =
            SystemAPI.GetComponentLookup<CharacterAnimationState>(isReadOnly: true);

        foreach (var (transform, poseTarget) in
                 SystemAPI.Query<RefRW<LocalTransform>, RefRO<CharacterBonePoseTarget>>())
        {
            var motionEntity = poseTarget.ValueRO.MotionEntity;
            if (motionEntity == Entity.Null
                || !motionSettingsLookup.HasComponent(motionEntity)
                || !animationStateLookup.HasComponent(motionEntity))
            {
                continue;
            }

            var settings = motionSettingsLookup[motionEntity];
            if (!settings.Motion.IsCreated)
            {
                continue;
            }

            var animation = animationStateLookup[motionEntity];
            ref var motion = ref settings.Motion.Value;
            var boneIndex = poseTarget.ValueRO.BoneIndex;
            if (boneIndex < 0 || boneIndex >= motion.BoneCurves.Length)
            {
                continue;
            }

            var pose = CharacterMotionSampler.Sample(
                ref motion,
                boneIndex,
                animation.LocalTime);

            transform.ValueRW = LocalTransform.FromPositionRotationScale(
                pose.LocalPosition,
                pose.LocalRotation,
                pose.LocalScale);
        }
    }
}

ここでは、BlobAssetから指定時刻の姿勢を取り出す処理をCharacterMotionSampler.Sampleにまとめています。実際には、Baking時に保存したキーフレーム列から現在時刻に対応する2フレームを選び、各ボーンのローカル姿勢を補間します。

最後に、Entities Graphicsのdeformationが読むSkinMatrixを更新します。SkinMatrixはアニメーションそのものではなく、描画側へ渡すスキニング用の行列です。サンプルでは、LocalToWorldが計算されたあとのPresentationSystemGroupで更新しています。

SkinMatrixは、ボーンの現在の姿勢をメッシュの頂点へ適用するために、描画側へ渡す行列です。各ボーンの現在のLocalToWorldだけではなく、Meshが持つbindposeと組み合わせることで、「初期姿勢のメッシュ頂点を、現在のボーン姿勢へどう動かすか」を表します。今回の実装では、rootから見たボーンの行列にbindposeを掛けたものをSkinMatrixとして書き込んでいます。

[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial struct CharacterSkinMatrixSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<SkinMatrix>();
        state.RequireForUpdate<CharacterRootBone>();
        state.RequireForUpdate<CharacterBoneEntity>();
        state.RequireForUpdate<CharacterBindPose>();
    }

    public void OnUpdate(ref SystemState state)
    {
        state.Dependency = new UpdateSkinMatrixJob
        {
            LocalToWorldLookup =
                SystemAPI.GetComponentLookup<LocalToWorld>(isReadOnly: true)
        }.ScheduleParallel(state.Dependency);
    }

    private partial struct UpdateSkinMatrixJob : IJobEntity
    {
        [ReadOnly]
        public ComponentLookup<LocalToWorld> LocalToWorldLookup;

        private void Execute(
            ref DynamicBuffer<SkinMatrix> skinMatrices,
            in DynamicBuffer<CharacterBindPose> bindPoses,
            in DynamicBuffer<CharacterBoneEntity> bones,
            in CharacterRootBone root)
        {
            var rootEntity = root.Value;
            if (rootEntity == Entity.Null || !LocalToWorldLookup.HasComponent(rootEntity))
            {
                return;
            }

            var rootWorldToLocal = math.inverse(LocalToWorldLookup[rootEntity].Value);
            var count = math.min(skinMatrices.Length, math.min(bindPoses.Length, bones.Length));
            for (var i = 0; i < count; i++)
            {
                var boneEntity = bones[i].Value;
                if (boneEntity == Entity.Null || !LocalToWorldLookup.HasComponent(boneEntity))
                {
                    continue;
                }

                var boneRootMatrix = math.mul(
                    rootWorldToLocal,
                    LocalToWorldLookup[boneEntity].Value);
                var skinMatrix = math.mul(boneRootMatrix, bindPoses[i].Value);

                skinMatrices[i] = new SkinMatrix
                {
                    Value = new float3x4(
                        skinMatrix.c0.xyz,
                        skinMatrix.c1.xyz,
                        skinMatrix.c2.xyz,
                        skinMatrix.c3.xyz)
                };
            }
        }
    }
}

以上が今回のサンプル実装の紹介になります。

まとめ

Unity ECSでスキンメッシュをアニメーションさせるには、従来のAnimatorやAnimator Controllerが隠してくれていた役割を、データとSystemに分けて考える必要があります。

固定データ、Entityごとの状態、実行時の更新順序に分けて見ると、どこで何をしているのかは追いやすくなります。サンプルを見る時も、コードの細部より先に「どの責務をどのデータとSystemに置いているか」を確認すると理解しやすいと思います。

元々は簡単なアニメーションをUnity ECSで作るにはどうすればいいかな?という勉強目的でしたが、Unity ECSの設計思想と、それに沿った実装の置き場所を学べてかなりいい機会でした。 みなさんも、ぜひ興味があれば、何かしらの仕組みをUnity ECSではどう作るんだろう?という視点で、小さく試してみてはいかがでしょうか。

著者

住田 直樹のプロフィール画像

(Sumida Naoki)

2018年にサイバーエージェントに新卒入社。その後、QualiArtsにて新規プロジェクトのUnityエンジニアとして開発に携わる。現在は、運用プロジェクトのUnityリードエンジニアとして従事。ゲーム・エンターテイメント事業部(SGE)全体のUnityの技術促進を目的とする横軸組織「Unityコミュニティ」の責任者としても活動している。

この記事をシェア

目次