凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

uLipSync にアニメーションベイク機能を追加してみた

はじめに

前回は uLipSync で、リップシンクの解析結果のベイク機能の追加とタイムラインのサポートを行いました。

tips.hecomi.com

今回は、このベイク結果を元に、ブレンドシェイプを動かすアニメーションへと変換する仕組みを作りました。アニメーションとして保存できれば他のアニメーションとの組み合わせもしやすく、既存のワークフローにも組み込め、また後でキーを動かして調整もしやすくなります。本記事では使い方や仕組みなどを紹介します。

デモ

07. Animation Bake というシーンがサンプルシーンになります。

変換ウィンドウ

変換には、Animator コンポーネント、どの母音がどのブレンドシェイプに対応しているかセットアップした uLipSyncBlendShape、そして Bake したデータのリストが必要です。

f:id:hecomi:20220216003902p:plain

作成されたアニメーション

サンプリングレートや変化として記録するしきい値が指定できるので、キーを間引いた形のアニメーションも作成できます。

f:id:hecomi:20220216003214g:plain

再生

生の解析結果を再生するよりも適当に間引いた結果ローパスがかかったような形になり滑らかになります。

f:id:hecomi:20220216003932g:plain

© Unity Technologies Japan/UCL

ダウンロード

github.com

v2.1.0 で機能追加を行いました。

使い方

Window > uLipSync > Animation Clip Generator を選択し、uLipSync Animation Clip Generator ウィンドウを開きます。

f:id:hecomi:20220216231453p:plain

アニメーションベイクをするには、uLipSync のセットアップを行ったシーンを開いておく必要があります。その上で、シーンにあるコンポーネントをセットしていきます。

  • Animator
    • シーンに存在する Animator コンポーネントを選択します。
    • この Animator を起点としたアニメーションクリップが作成されます。
  • Blend Shape
  • Baked Data List
    • アニメーションクリップに変換したいBakedData アセットを選択します。
  • Sample Frame Rate
    • キーを打つサンプリングレートを指定します。
    • 60 なら 60 フレームで、30 なら 30 フレームでサンプリングを行います。
  • Threshold
    • ウェイトがこの値だけ変化したときのみキーを打ちます
    • ウェイトの最大値は 100 になりますので、10 なら 10% 変化したら、という意味合いになります。
  • Output Directory
    • ベイクしたアニメーションクリップを出力するディレクトリを指定します。
    • 空の場合は Assets 以下に作成します。

セットアップ例です。

f:id:hecomi:20220216004618p:plain

Threshold を 0、10、20 と変化させると次のようになります。

f:id:hecomi:20220216235118g:plain

仕組み

以下の記事を参考にさせていただきました。

mebiustos.hatenablog.com

Unity でエディタ拡張からアニメーションを作成するには、次のような手順をたどります。

  1. AnimationClip を作成
  2. EditorCurveBinding でどの項目にキーを打つかを指定
  3. AnimationCurve にキーを打ち必要に応じて傾きも調整
  4. 2 と 3 をセットにして AnimationClip に登録、必要な数だけこれを繰り返す
  5. AssetDatabase を使ってアセットとして保存

となります。具体的に本アセットでアニメーションクリップを作成する流れは以下のような形になります(わかりやすいように一部改変)。

internal class AnimationCurveBindingData
{
    public EditorCurveBinding binding;
    public AnimationCurve curve;
}

public class AnimationWizard : ScriptableWizard
{
    ...
    // 書き込みには / 区切りでルート要素からのオブジェクト名が入る
    // 例えばユニティちゃんなら Character1_Reference/Character1_Hips/...(略).../MTH_DEF といった具合
    // これをルート要素(Animtor)を指定して得るためのメソッド
    string GetRelativeHierarchyPath(GameObject target, GameObject parent)
    {
        if (!target || !parent) return "";

        string path = "";
        var gameObj = target;
        while (gameObj && gameObj != parent)
        {
            if (!string.IsNullOrEmpty(path)) path = "/" + path;
            path = gameObj.name + path;
            gameObj = gameObj.transform.parent.gameObject;
        }

        if (gameObj != parent) return "";

        return path;
    }

    void OnWizardOtherButton()
    {
        ...
        var bindingBase = new EditorCurveBinding();
        bindingBase.path = GetRelativeHierarchyPath(
            blendShape.skinnedMeshRenderer.gameObject, 
            animator.gameObject);
        bindingBase.type = typeof(SkinnedMeshRenderer);
        ...
        foreach (var bakedData in bakedDataList)
        {
            ...
            // 最終的に保存されるアセットとなる
            // ここにキーを登録したカーブを登録していく
            var clip = new AnimationClip();

            // 各カーブの情報を一時保存しておくリスト
            var dataList = new Dictionary<int, AnimationCurveBindingData>();

            // 登録されているブレンドシェイプ
            foreach (var bs in blendShape.blendShapes)
            {
                if (bs.index < 0) continue;

                // ブレンドシェイプ毎の EditorCurveBinding を作成
                var mesh = blendShape.skinnedMeshRenderer.sharedMesh;
                var name = mesh.GetBlendShapeName(bs.index);
                var binding = bindingBase;
                binding.propertyName = "blendShape." + name;

                // カーブと組み合わせておく
                dataList.Add(bs.index, new AnimationCurveBindingData()
                {
                    binding = binding,
                    curve = new AnimationCurve(),
                });
            }

            blendShape.OnAnimationBakeStart();

            // サンプリング / 間引き用の情報
            float dt = 1f / sampleFrameRate;

            for (float time = 0f; time <= bakedData.duration; time += dt)
            {
                // BakedData からブレンドシェイプの値を取得
                var frame = bakedData.GetFrame(time);
                var info = BakedData.GetLipSyncInfo(frame);
                blendShape.OnAnimationBakeUpdate(info, dt);
                var weights = blendShape.GetAnimationBakeBlendShapes();

                // 登録しておいた各ブレンドシェイプ毎に見ていく
                // 実際はここに threshold に応じた間引き処理が含まれるので
                // 興味ある方はコードを覗いてみてください
                foreach (var kv in dataList)
                {
                    var index = kv.Key;
                    var curve = kv.Value.curve;
                    curve.AddKey(time, weights[index]);
                }
            }

            blendShape.OnAnimationBakeEnd();

            foreach (var kv in dataList)
            {
                var data = kv.Value;
                var binding = data.binding;
                var curve = data.curve;

                for (int j = 0; j < curve.length; ++j)
                {
                    var key = curve[j];
                    key.weightedMode = WeightedMode.Both;
                    key.inWeight = key.outWeight = 1f / 3f;

                    // 端点や上下ではカーブの傾きは 0 にする
                    if (j == 0 || j == curve.length - 1 || key.value < 1f || key.value > 99f)
                    {
                        key.inTangent = key.outTangent = 0f;
                    }
                    else
                    {
                        var prevKey = curve[j - 1];
                        var nextKey = curve[j + 1];

                        // 山や谷となっている点の傾きは 0 にする
                        if ((key.value - prevKey.value) * (nextKey.value - key.value) < 0f)
                        {
                            key.inTangent = key.outTangent = 0f;
                        }
                        // それ以外は前後のキーを見て傾きを求める
                        else
                        {
                            var a = (nextKey.value - prevKey.value) / (nextKey.time - prevKey.time);
                            key.inTangent = key.outTangent = a;
                        }
                    }

                    curve.MoveKey(j, key);
                }

                // 作成したカーブを指定したバインディング(どのブレンドシェイプか)と紐付けて
                // アニメーションクリップに登録する
                AnimationUtility.SetEditorCurve(clip, data.binding, data.curve);
            }

            // アセットとして保存
            var path = $"{outputDirectory}/{bakedData.name}.anim";
            path = AssetDatabase.GenerateUniqueAssetPath(path);
            AssetDatabase.CreateAsset(clip, path);
        }
        ...
        AssetDatabase.SaveAssets();
        AssetDatabase.Refresh();
    }
    ...
}

おわりに

これで一通り考えていた機能がとりあえず揃いました。あとは MFCC の類似度判定部分の改善や、併せてキャリブなしでもそれなりに動くプロファイル作成などを行いたいですが、またしばらくして気力が出てきたらやろうかな、と思います。