Unityでアニメーションのキーフレームリダクション

セガゲームス 開発技術部 プログラマーの樽美です。

普段は弊社独自の3D中間データ構造である「HND」の仕様策定やSDK作成、それに関連するツールの作成、
データパイプラインの整備、またSoftimageやMayaなどDCCツールのプラグインなどを作成したりしています。
最近ではUnityを触ることが多いです。

今回このブログを担当させていただくことになりました。
よろしくお願いいたします。


早速ですが、どんなネタを記事にすればよいのかいろいろ考えた末、割と役に立ちそうである
Unityでのアニメーションのキーフレームリダクションを行った時のことについて書いていきたいと思います。

動機

UnityでFBXファイルをインポートすると、DCCツールでFBXをBakeせずに出力しても、
インポート時にBakeされたアニメーションになるようです。

そして、UnityのFBXインポートオプションのAnimationsで「Keyframe Reduction」や
「Keyframe Reduction and Compression」を選択しても、
元のデータより打ってあるキーが増えてしまうことがあります。


Maya上でのキー
f:id:sgtech:20161118213448p:plain

Unityで「Keyframe Reduction」を指定してインポートした時のキー
f:id:sgtech:20161118213449p:plainf:id:sgtech:20161118213450p:plain
画像では少し見難いですが、X,Y,Zそれぞれで13個のキーに増えてしまっています。
※Xのカーブが上下反転しているのはMayaとUnityで座標系が異なるためです。

この増えてしまったキーをUnity上で削除しても特に問題はないようで、
Unity的にポジションやスケールなどは、X,Y,Zの3軸が揃っていなければならない訳ではなさそうです。


ローテーションについてはUnity内部でクオータニオンに変換されているようなので、
キーが3軸が揃ってしまうのは致し方ないかと思いますが、ポジションやスケールなど、
さらにキーを削除できる余地があるとなれば、ランタイムで使用するメモリ量の削減や
アセットファイルのデータ量削減などが期待できるので、出来れば不要なキーを削除したいですよね。


とはいえ、不要なキーを削るにしてもどのようにすればよいのでしょうか。
自分はひとまずこのように考えてみました。

はじめに

FBXをUnityで読み込むと、アニメーションは読み取り専用になっていますので、
これをduplicate(Ctrl+D)し、編集可能なAnimationにします。
f:id:sgtech:20161118213451p:plainf:id:sgtech:20161118213452p:plain
このduplicateされたAnimationファイルに対しキーフレームリダクションを行います。

キーフレームリダクションを行ってみる

その方法ですがまず、最初と最後のキーを補間した値とその間にあるキーの値を比較し、
その差が一番大きい位置のキーを残します。

その次に最初と残したキーを補間した値と、その間にあるキーの値を比較というように
これを順次繰り返し、必要なキーを残していくようにしました。
f:id:sgtech:20161118213515p:plain
f:id:sgtech:20161118213516p:plain
もう一つの例を見てみます。
f:id:sgtech:20161118213518p:plain
f:id:sgtech:20161118213519p:plain
f:id:sgtech:20161118213520p:plain
上記の例を見てみると必要なキーと不要となるキーの判別が上手くいっているように思えるのですが、
これだけでは確実に不要なキーを消すことや、必要なキーを残すことができませんでした。


以下の場合を見てみます。

f:id:sgtech:20161118213548p:plain
これまで通り、最初と最後のキーから補間した値と、その間にあるキーの値を比較し、
一番値の差が大きいキーをピックアップします。


f:id:sgtech:20161118213550p:plain
そこからまた次に残すキーをピックアップします。


f:id:sgtech:20161118213551p:plain
ここでもともと打たれていなかったキーで、本来ならば削除したいキーなのですが、
一番値が離れているのでピックアップされてしまいます。


f:id:sgtech:20161118213552p:plain
さらに次にも削除したいキーがピックアップされてしまいます。
このままピックアップされたキーを適用してしまうと理想とするMayaと同じキーの結果を得ることができません。


これらの問題を解決するために、以下のロジックを追加しました。

f:id:sgtech:20161118213553p:plain
ピックアップされてきたキーの値と前後のキーから補間した値を比較し、その差が大きい場合はキーを必ず残します。
またその際にはピックアップされてきたキーの場所のみ調べるのではなく、前後のキーの間も調べます。
(※ハンドルの傾きによってたまたま値が一致している場合があるため。)

そうでない場合は、残すかもしれないキーとして一時保留しておきます。


これで大体は判別が出来るようになりましたが、これだけではまだピックアップされたキーが
(しきい値にもよりますが)補間された値に近い場合に、不要なキーとして判別されてしまうことがありました。
f:id:sgtech:20161118213628p:plain

そのため最初に判定を行った後に、補間した値とすべてのキーが同じであった場合、
そのキーと次のキー間の値を比較するようにしました。
f:id:sgtech:20161118213630p:plain

そして一通りのキーが調査出来たら、残すことが確定しているキーと一時保留しているキーのみで
順次チェックを行い、一時保留しているキーが本当に必要であるかを判別します。
f:id:sgtech:20161118213632p:plain
f:id:sgtech:20161118213633p:plain
f:id:sgtech:20161118213635p:plain
f:id:sgtech:20161118213700p:plain
f:id:sgtech:20161118213701p:plain
だいぶいい感じにキーフレームのリダクションが行えました。

しかしここにきてこれまでの事を振り返ってみて、ふと思いました。
実はピックアップすることなど最初から必要なく、
順次キーが必要かどうかを判定していくだけでよかったのではないかと。


早速、試してみました。
f:id:sgtech:20161118213703g:plain
なんということでしょう!
思ったとおりでした(汗)

この方法では最初の方法よりも記述するコードがはるかに少なく、
しかもフルキーのデータに対しても良い結果が得られました。


最終結果です
f:id:sgtech:20161118213712p:plainf:id:sgtech:20161118213713p:plain
今回の例では、Maya上で作成したアニメーションのキーの数と全く同じにすることが出来ました。

コード一式

今回作成したコードはAnimationClipファイルの右クリックメニューから呼び出す方法で実装してみました。
そしてAnimationClipファイル内のすべてのAnimationCurveに対してキーリダクションを行っています。
また、同じ値とするかどうかのしきい値は0.0001fで固定にしています。

※以下のコードはあくまでサンプルコードです。Unity Version 5.4.0f3での動作を確認。

using System.IO;
using System.Linq;
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;

public class KeyReductionProcessor : AssetPostprocessor
{
    [MenuItem ("Assets/Key Reduction")]
    static void KeyReduction()
    {
        Debug.Log ("Key Reduction...");
        foreach (UnityEngine.Object obj in Selection.GetFiltered(typeof(AnimationClip), SelectionMode.Editable))
        {
            string path = AssetDatabase.GetAssetPath(obj);
            // AssetPathよりAnimationClipの読み込み
            AnimationClip anim_clip = (AnimationClip)AssetDatabase.LoadAssetAtPath(path, typeof(AnimationClip));

            foreach (var binding in AnimationUtility.GetCurveBindings(anim_clip).ToArray())
            {
                // AnimationClipよりAnimationCurveを取得
                AnimationCurve curve = AnimationUtility.GetEditorCurve(anim_clip, binding);
                // キーリダクションを行う
                AnimationCurveKeyReduction(curve);
                // AnimationClipにキーリダクションを行ったAnimationCurveを設定
                AnimationUtility.SetEditorCurve(anim_clip, binding, curve);
            }
            // AnimationClip名の作成
            string anim_clip_name = Path.GetDirectoryName(path) + "/" + Path.GetFileNameWithoutExtension(path);
            // AnimationClipファイルの書き出し
            WriteAnimationCurve(anim_clip, anim_clip_name);
        }
    }

    // AnimationClipファイルの書き出し
    static private void WriteAnimationCurve(AnimationClip anim_clip, string anim_clip_name)
    {
        string tmp_name = anim_clip_name + "_tmp.anim"; // テンポラリファイル名
        // AnimationClipのコピーを作成
        var copyClip = Object.Instantiate(anim_clip);
        // テンポラリAnimationClipファイルの作成
        AssetDatabase.CreateAsset(copyClip, tmp_name);
        // テンポラリファイルから移し替え
        FileUtil.ReplaceFile(tmp_name, anim_clip_name + ".anim"); // コピー先ファイルがなければ自動で生成される。
        // テンポラリAnimationClipファイルの削除
        AssetDatabase.DeleteAsset(tmp_name);
        // データベースの更新
        AssetDatabase.Refresh();
    }

    // 2つのキーから、指定した時間の値を取得する
    static private float GetValueFromTime(Keyframe key1, Keyframe key2, float time)
    {
        float t;
        float a,b,c;
        float kd,vd;

        if (key1.outTangent == Mathf.Infinity) return key1.value; // コンスタント値

        kd = key2.time - key1.time;
        vd = key2.value - key1.value;
        t = (time - key1.time) / kd;

        a = -2 * vd + kd * (key1.outTangent + key2.inTangent);
        b =  3 * vd - kd * (2 * key1.outTangent + key2.inTangent);
        c = kd * key1.outTangent;

        return key1.value + t * (t * (a * t + b) + c);
    }

    // 指定したキーの値はKey1とKey2から得られた補間値と同じ値であるかを調べる
    static private bool IsInterpolationValue(Keyframe key1, Keyframe key2, Keyframe comp, float eps)
    {
        // 調査するキーのある位置
        var val1 = GetValueFromTime(key1, key2, comp.time);

        // 元の値からの差分の絶対値がしきい値以下であるか?
        if (eps < System.Math.Abs(comp.value - val1)) return false;

        // key1からcompの間
        var time = key1.time + (comp.time - key1.time) * 0.5f;
        val1 = GetValueFromTime(key1, comp, time);
        var val2 = GetValueFromTime(key1, key2, time);

        // 差分の絶対値がしきい値以下であるか?
        return (System.Math.Abs(val2 - val1) <= eps) ? true : false;
    }

    // 削除するインデックスリストの取得する。keysは3つ以上の配列
    static public IEnumerable<int> GetDeleteKeyIndex(Keyframe[] keys, float eps)
    {
        for (int s_idx = 0, i = 1; i < keys.Length-1; i++) {
            // 前後のキーから補間した値と、カレントのキーの値を比較
            if (IsInterpolationValue(keys[s_idx], keys[i+1], keys[i], eps)) {
                yield return i; // 削除するインデックスを追加
            }
            else {
                s_idx = i; // 次の先頭インデックスに設定
            }
        }
    }

    // 入力されたAnimationCurveのキーリダクションを行う
    static public void AnimationCurveKeyReduction(AnimationCurve in_curve, float eps = 0.0001f)
    {
        if (in_curve.keys.Length <= 2) return; // Reductionの必要なし

        // 削除インデックスリストの取得
        var del_indexes = GetDeleteKeyIndex(in_curve.keys, eps).ToArray();

        // 不要なキーを削除する
        foreach (var del_idx in del_indexes.Reverse()) in_curve.RemoveKey(del_idx);
    }
}

ファイルへの書き出し

次にこのキーフレームリダクションした結果をファイルに出力しなければなりませんが、
何も考えずに既存のAnimationClipファイルに対し上書きすると、
それまで利用していたGameObjectとのリンクが途絶えてしまいます。
f:id:sgtech:20161118213725p:plain

これをを防ぐためには、一度テンポラリのAnimationClipファイルを作成し、
既存のAnimationClipファイルに対してReplaceFile()を行えば、リンクが途絶えることがないようです。

真面目に行うならユニークなファイル名を生成するところですが、
今回はベースとなる名前の後ろに”_tmp”を付随しているだけです。

余談ですがReplaceFile()でファイルを更新すれば、AnimationClipファイルだけではなく
他のアセットファイルやprefabファイルの更新の時にMissingになってしまうことも防げるようです。

しかしここでまた衝撃的な事実が。
キーフレームリダクションを行い、キーの数は確実に減っているのですが、そのデータをファイルに書き出すと
元のAnimationClipファイルよりファイルサイズが大きくなってしまうことがありました。

特に「Keyframe Reduction and Compression」を指定してインポートしたデータの場合と比べると
キーの数が少なくなっているはずなのにファイルサイズがかなり大きくなってしまいました。

何が原因なのかとリダクションする前のAnimationClipファイルを開いてみると
m_EditorCurves:の項目が出力されない事があるためにファイルサイズが小さく済んでいるようです。
(※出力される場合もあり、この辺りのUnityの挙動がよく分かっていません。)

UnityのAPIのAssetDatabase.CreateAsset()でファイルを作成したときにもこのファイルフォーマットで
出力できればよいのですが、どうも現状ではその方法はなさそうです。


と、その前にX,Y,Zそれぞれをキーリダクションしてもファイルに出力すると必ず3軸揃った
データも出力されていることがわかりました。

  m_PositionCurves:
  - curve:
      serializedVersion: 2
      m_Curve:
      - time: 0
        value: {x: -0, y: 0, z: 0}
        inSlope: {x: -0.00000008580087, y: 0.061224494, z: 0.0000000031700533}
        outSlope: {x: -0.00000008580087, y: 0.061224494, z: 0.0000000031700533}
        tangentMode: 0

※出力したAnimationClipファイルより一部抜粋

X,Y,Zすべて削除している場合には、確かにその時間のキーは無くなってはいるのですが、
ひとつでもキーが残っていると結局3軸分出力されるようで、なんかちょっとショックです。

まとめ

始めに考えたコードはまったく無用のものになってしまい、だいぶ遠回りをしてしまいましたが、
キーリダクションそのものについては大体良い結果が得られるようになったと思います。
しかし、ファイルサイズ削減についてはインポートした結果よりデータが増えてしまう事があるという残念な結果に。
それでも、あれこれ試行錯誤している時はとても楽しかったです。
カーブによってはこのロジックだけでは対応しきれないかもしれませんが、
この記事がこれからアニメーションのキーフレームリダクションを行いたいと思われる方の参考になれば幸いです。


それでは次回の更新をお楽しみに。

Powered by はてなブログ