Bakulog

獏の夢日記的な何か。

Perception Neuron MocapとUnityで通常の3Dモデルキャラを動かす方法

意外と苦労したのでメモ兼ねて公開です。
2018/07/07:スクリプトで平行移動成分をコピーする方法を追記しました。

 

本記事は低価格モーションキャプチャ系として昨年あたりから注目されているPerception Neuron MocapをUnityと組み合わせて使う方法の紹介になります。といっても基本的な使い方自体はUnity用ライブラリに付属のドキュメントが(英語ですが)用意されているので、本記事ではドキュメントに載ってない問題を扱います。

なお本記事の内容は実機のNeuronデバイスが手元に無くても再現できるので、購入検討中で興味ある方も是非お試しください。

 

対象読者は下記のような方です。

  • Perception Neuron Mocapを購入済みか購入予定
  • Unity/C#の基本は分かってる
  • Neuron Mocapで自作モデル/既存モデルをリアルタイムに動かしたい!

また対象外読者というか本記事で触れない内容も先に断っておきます。

  • リアルタイムでNeuronを使う予定はなく、モーションアニメーションを撮るためにNeuronが使いたい人

それと言い訳ですが、私はUnity初心者なのでコードの美しさは保障しません。C#6.0書けないの辛い

 

もくじ

問題: 通常の3DモデルはPerception Neuronでリアルタイムに扱えない?Unityプロジェクトの準備と初期配置その1: NeuronRobotを動かそう!その2(失敗例): ユニティちゃんを動かしたいけど…その3: よろしい、ならば座標変換だ

 

問題: 通常の3DモデルはPerception Neuronでリアルタイムに扱えない?

まず今回記事を書く理由となった問題を紹介します。トラブルの内容を絵的に理解したい方はとりあえずここを読み飛ばし、下の方にある「その2(失敗例): ユニティちゃんを動かしたいけど…」まで進めてみるのでも構いません。

[expand title="つづき(クリックで展開)"]

低価格がウリのPerception Neuron Mocapですが、単に価格が安いだけでなくてソフトウェアが公開されているので購入前の環境チェックや仮想的な試用が可能です。その一環としてUnity連携用パッケージ(ここからダウンロード可能)が公開されているので、Neuronを使いたい人の多くはこちらをダウンロードしてドキュメンテーションを読む所からスタートする事になります。で、その中に次のような記載があります。

The following is a guide for how to configure a new character model to receive real-time motion data by Perception Neuron. Before we start you need to be aware of a few things:
  1. Your model needs to be rigged on a humanoid skeleton.
  2. The bones of your rig can not have any existing rotations in them and their axis orientation needs to be identical with Unity’s axis system.
  3. Your model needs to be rigged in a T-Pose.
  4. If you’re model is not rigged with our skeleton setup you can only use real-time motion data without displacement for now.

日本語に直します。

以下ではあなたのモデルを動かすために、Perception Neuronを使ってリアルタイムのモーションデータを受信する方法を説明します。始める前に、いくつかの注意です。
  1. モデルはHumanoid SkeletonへのRigがついていること
  2. Rigの各ボーンはRotationが0でなければならず、軸の方向はUnityの座標軸と一致していること
  3. モデルはTポーズの形でRigづけされてること
  4. もし私たちが指定した方法でスケルトンが調整されていない場合、現時点ではDisplacement無しの状態でしかモデルを動かせません。

…なんだか変な制約が付いてますね。一般のモデルでは上の赤字で書いた「回転ゼロで軸の向きが云々」という条件は成り立ちません。例えばユニティちゃんでもこの条件は満たしません。いやあ厳しい。ドキュメンテーションによれば「モデルが適合しない場合は上の条件をクリアするようモデルのメッシュを貼りなおせ」とあるのですが、私のような3Dモデリングできないマンとしてはそう言われても途方に暮れてしまいますし、そうでなくとも既存モデルが再利用しづらければ困る人は少なくないハズです(※後注)。

 

しかし「困った」と愚痴って終わりにするのはつまらないので、なんとかして既存の3DモデルにPerception Neuronのリアルタイムデータを流し込むことに挑戦してみましょう。ここでは例としてユニティちゃんを使ってみますが他のモデルでもだいたい同じ手順で何とかなると思います。

 

ここから先は具体的な操作手順になります。私の動作確認環境はWindows10とUnity 5.2.2のみで、実機のNeuronデバイスは使っていません。

[/expand]

 

Unityプロジェクトの準備と初期配置

環境整備も含めて。

[expand title="つづき(クリックで展開)"]

まずNeuron MocapのダウンロードページからUnity IntegrationのパッケージとAxis Neuronをダウンロードし、Axis Neuronについてはインストーラを実行してインストールまで済ませます。また今回使うユニティちゃんの3Dデータアセットをこちらからダウンロードします。Axis Neuronについては以下の手順でずっと使うので、インストールが終わったらそのまま起動しっぱなしにしてください。起動直後に出る解説ダイアログは消して構いません。

 

準備が整ったらUnityのプロジェクトを適当に作成し、ユニティちゃんのアセットとNeuronのアセット(PerceptionNeuronUnityIntegration.unitypackage)をインストールします。適当にTerrainやらLightやらカメラやらを配置してアセットからキャラを配置したら初期配置はおしまいです。

  • Neuron -> Prefabs -> NeuronRobot
  • UnityChan -> Prefabs -> unitychan

unity_axis_start

[/expand]

 

その1: NeuronRobotを動かそう!

どうせなので公式PDFに載ってるサンプル相当のチュートリアルを書いておきます。自力でPDF読んで試す(試した)方は飛ばしてくださって大丈夫です。

[expand title="つづき(クリックで展開)"]

まずAxis Neuron側の準備として「File->Open File」からサンプルファイルを開きます。サンプルファイルが入ってるディレクトリは通常の設定でWindowsにインストールした場合「(マイドキュメント)\Noitom\Axis Neuron\Motion Files」です。

適当なサンプルファイルをロードするとAxis Neuronの画面に3Dモデルが表示されます。このモーションデータは操作バーで再生したり巻き戻したりすることができる(バーが非表示の場合は「Windows -> Replay Controller」をクリック)ので、再生ボタンを押してモデルが動く事を確認します。

install_axis_neuron

次にモーションの配信機能をオンにします。「File -> Settings...」を選び、Broadcastingの設定でBVHの所にチェックを入れます。

axis_settings

Axis Neuronは一旦これで放置し、次にUnity側の設定をします。

第一に、モーキャプで動かしてるモデル同士が衝突しない設定を仕込みます。「Edit->Project Settings -> Tags and Layers」を選び、ユーザーレイヤーとして適当な名前("Body"とか)を付けた新しいレイヤーを定義します。そしたら今度は「Edit -> Project Settings -> Physic」で出てくる設定画面でLayer Collision Matrixというのがあるので、そこで"Body"/"Body"の衝突をオフにします。

setting_physics

第二にNeuronRobotの設定をします…といっても実際は確認作業だけです。NeuronRobotに「Neuron Animator Instance」というスクリプトが適用されているので、ここの設定にTCPサーバの接続先があることやConnect to Axisという接続状態の管理フラグ設定があることを確認します。

neuron_script

以上で準備完了したのでUnity側を実行します。実行した時点ではNeuronRobotは動きませんが、ここでAxis Neuron側のモーション再生ボタンを押し、ふたたびUnity側に戻ってみるとNeuronRobotが動くことが確認できます。

synchro_axis

以上が基本の流れです。下準備が出来たらデバッグ作業は3つの手順を繰り返すだけになります。

  1. Unityを実行
  2. Axis Neuronでモーションを再生
  3. Unity側に戻って動きをチェックしつつデバッグ

[/expand]

 

その2(失敗例): ユニティちゃんを動かしたいけど…

本記事の目標は「うまくモデルを動かす方法」の紹介ですが、折角なのでヘタにやった場合の悲惨な挙動も確認しておきます。

[expand title="つづき(クリックで展開)"]

NeuronRobotと同じようなNeuronベースの挙動を取らせるにはそれ用のスクリプトを適用します。Assetから

Neuron->Scripts->Mocap->NeuronAnimatorInstance

を持ってきてユニティちゃんに適用し、コンポーネントの設定で"Connect To Axis"をオンにします。またユニティちゃんの他のスクリプトとアニメーターは外します。

neuron_unitychan_setting

さあ準備が出来ました!これでうまく動くといいのですが…。

fail_neuron

はい、ダメですね。こうなった原因はスクリプトの実装を見ると分かるのですが、さっき適用したスクリプトではHumanoidのリギングに頼るのではなく、ボーンのRotationにオイラー角(EulerAngle)由来の回転をほぼ直接設定することでキャラの動きをつけています。このようにRotationをそのまま使ってしまうとAnimatorが行うはずの「ボーンの違いを吸収する」機能が生かされないので事故が起きます。

[/expand]

 

その3: よろしい、ならば座標変換だ

問題解決という意味ではこっからが本番です。

[expand title="つづき(クリックで展開)"]

最初のほうで触れた内容の繰り返しですが、Neuronのデータを用いるには「モデルにTポーズを取らせたとき、ボーンが無回転かつボーン座標軸が Unityの座標軸と同じ方向に向いてないとダメ!」という条件があり、これは通常の3Dモデルでは成り立っていません。

この条件を既存の3Dモデルで無 理やりクリアさせる方法として、キャラのボーンに対して仮想的に「Unityの座標軸と同じ構成の座標軸」的なベクトルを計算します。やり方はこんな感じ。

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

public class ConnectToNeuronRobot : MonoBehaviour
{

    //このオブジェクト自身のアニメーター
    private Animator animator;

    //初期状態でボーンの回転が非ゼロのとき、その回転をキャッシュ
    private Dictionary<HumanBodyBones, Quaternion> initialRotations;

    //仮想的な「Unity XYZ軸の向きを向いたボーンの座標軸」のキャッシュ
    private Dictionary<HumanBodyBones, Vector3> pseudXaxis;
    private Dictionary<HumanBodyBones, Vector3> pseudYaxis;
    private Dictionary<HumanBodyBones, Vector3> pseudZaxis;

    // Use this for initialization
    void Start ()
    {
        animator = GetComponent<Animator>();
        InitializeLocalRotations();
    }

    //...

    private void InitializeLocalRotations()
    {
        initialRotations = targetBones.ToDictionary(
            b => b,
            b => animator.GetBoneTransform(b).localRotation
            );

        //このrootTの軸がUnityのXYZ軸に一致してればOK(ダメな場合は頑張って工夫してください)
        //またキャラがTポーズ取ってることも必要だが、Prefabから出した後でTポーズ取らせても(たぶん)問題なし。
        var rootT = animator.GetBoneTransform(HumanBodyBones.Hips).root;

        pseudXaxis = targetBones.ToDictionary(
            b => b,
            b =>
            {
                var t = animator.GetBoneTransform(b);
                return new Vector3(
                    Vector3.Dot(t.right, rootT.right),
                    Vector3.Dot(t.up, rootT.right),
                    Vector3.Dot(t.forward, rootT.right)
                    );
            });

        pseudYaxis = targetBones.ToDictionary(
            b => b,
            b =>
            {
                var t = animator.GetBoneTransform(b);
                return new Vector3(
                    Vector3.Dot(t.right, rootT.up),
                    Vector3.Dot(t.up, rootT.up),
                    Vector3.Dot(t.forward, rootT.up)
                    );
            });

        pseudZaxis = targetBones.ToDictionary(
            b => b,
            b =>
            {
                var t = animator.GetBoneTransform(b);
                return new Vector3(
                    Vector3.Dot(t.right, rootT.forward),
                    Vector3.Dot(t.up, rootT.forward),
                    Vector3.Dot(t.forward, rootT.forward)
                    );
            });


    }

    //コピー対象になるボーン一覧(もちろん足したり削ったりしてOK)
    private static HumanBodyBones[] targetBones = new[]
    {
        HumanBodyBones.Hips,

        HumanBodyBones.RightUpperLeg,
        HumanBodyBones.RightLowerLeg,
        HumanBodyBones.RightFoot,

        //...
    };

}

Vector3.Dotを使ってる部分の計算で本来のローカル座標軸から見た仮想XYZ軸のローカルベクトルを取得します。コレを保持しておくことでAxis Neuronから飛んでくるデータを再整形する準備が出来ました。

 

次にデータを再整形する方法ですが、今回は分かりやすい方法として既存のNeuronRobotの動きを追従するようにしてみます。

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

public class ConnectToNeuronRobot : MonoBehaviour
{
    //マネ対象のアニメータークラス
    public Animator targetAnimator;

    //--さっき定義したprivate変数とStartメソッドがこの辺に置いてある--

    void Update ()
    {
        if (targetAnimator != null && animator != null)
        {
            CopyRotations(targetAnimator, animator);
        }       
    }

    private void CopyRotations(Animator src, Animator dest)
    {
        foreach (var bone in targetBones)
        {
            //ボーンがデフォでWorldのXYZに沿ってる場合の回転を表すパラメタをまず拾う
            float angle;
            Vector3 axis;
            src.GetBoneTransform(bone).localRotation.ToAngleAxis(out angle, out axis);

            //回転軸をローカル座標上の値に直す: Matrix使っても書けそうだけど原始的に。
            Vector3 axisInLocalCoordinate = 
                axis.x * pseudXaxis[bone] + 
                axis.y * pseudYaxis[bone] + 
                axis.z * pseudZaxis[bone];

            Quaternion modifiedRotation = Quaternion.AngleAxis(angle, axisInLocalCoordinate);

            dest.GetBoneTransform(bone).localRotation =
                initialRotations[bone] *
                modifiedRotation;
        }
    }

    //...
}

クオータニオンをいったん「回転軸と角度」の形に直し、回転軸をローカル座標用の成分表記に直すことで全体の整合性を保っています。これでボーンのズレが無事に吸収できました。

 

以上の二つを統合するとこういう感じのコードが完成します。

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

public class ConnectToNeuronRobot : MonoBehaviour
{
    //マネ対象のアニメータークラス
    public Animator targetAnimator;

    //このオブジェクト自身のアニメーター
    private Animator animator;

    //初期状態の回転情報キャッシュ
    private Dictionary<HumanBodyBones, Quaternion> initialRotations;
    private Dictionary<HumanBodyBones, Vector3> pseudXaxis;
    private Dictionary<HumanBodyBones, Vector3> pseudYaxis;
    private Dictionary<HumanBodyBones, Vector3> pseudZaxis;

    // Use this for initialization
    void Start ()
    {
        animator = GetComponent<Animator>();
        InitializeLocalRotations();
    }

    //対象のアニメーションで計算された結果を真似するようにする
    void Update ()
    {
        if (targetAnimator != null && animator != null)
        {
            CopyRotations(targetAnimator, animator);
        }       
    }

    private void CopyRotations(Animator src, Animator dest)
    {
        foreach (var bone in targetBones)
        {
            //ボーンがデフォでWorldのXYZに沿ってる場合の回転を表すパラメタをまず拾う
            float angle;
            Vector3 axis;
            src.GetBoneTransform(bone).localRotation.ToAngleAxis(out angle, out axis);

            Vector3 axisInLocalCoordinate = axis.x * pseudXaxis[bone] + axis.y * pseudYaxis[bone] + axis.z * pseudZaxis[bone];

            Quaternion modifiedRotation = Quaternion.AngleAxis(angle, axisInLocalCoordinate);

            dest.GetBoneTransform(bone).localRotation =
                initialRotations[bone] *
                modifiedRotation;
        }
    }

    private void InitializeLocalRotations()
    {
        initialRotations = targetBones.ToDictionary(
            b => b,
            b => animator.GetBoneTransform(b).localRotation
            );

        var rootT = animator.GetBoneTransform(HumanBodyBones.Hips).root;

        pseudXaxis = targetBones.ToDictionary(
            b => b,
            b =>
            {
                var t = animator.GetBoneTransform(b);
                return new Vector3(
                    Vector3.Dot(t.right, rootT.right),
                    Vector3.Dot(t.up, rootT.right),
                    Vector3.Dot(t.forward, rootT.right)
                    );
            });

        pseudYaxis = targetBones.ToDictionary(
            b => b,
            b =>
            {
                var t = animator.GetBoneTransform(b);
                return new Vector3(
                    Vector3.Dot(t.right, rootT.up),
                    Vector3.Dot(t.up, rootT.up),
                    Vector3.Dot(t.forward, rootT.up)
                    );
            });

        pseudZaxis = targetBones.ToDictionary(
            b => b,
            b =>
            {
                var t = animator.GetBoneTransform(b);
                return new Vector3(
                    Vector3.Dot(t.right, rootT.forward),
                    Vector3.Dot(t.up, rootT.forward),
                    Vector3.Dot(t.forward, rootT.forward)
                    );
            });


    }

    //コピー対象になるボーン一覧(もちろん足したり削ったりしてOK)
    private static HumanBodyBones[] targetBones = new[]
    {
        HumanBodyBones.Hips,

        HumanBodyBones.RightUpperLeg,
        HumanBodyBones.RightLowerLeg,
        HumanBodyBones.RightFoot,
        HumanBodyBones.LeftUpperLeg,
        HumanBodyBones.LeftLowerLeg,
        HumanBodyBones.LeftFoot,

        HumanBodyBones.Spine,
        HumanBodyBones.Chest,
        HumanBodyBones.Neck,
        HumanBodyBones.Head,

        HumanBodyBones.RightShoulder,
        HumanBodyBones.RightUpperArm,
        HumanBodyBones.RightLowerArm,
        HumanBodyBones.LeftShoulder,
        HumanBodyBones.LeftUpperArm,
        HumanBodyBones.LeftLowerArm,

    };

}

こちらを適当な名前(以下では"ConnectToNeuronRobot.cs")で保存してアセットに加え、ユニティちゃんに適用します。さっき適用した"Neuron Animator Instance"はもう不要なので外してください。新しいスクリプトの設定にtargetAnimatorという項目があるので、動きの追従対象としてNeuronRobotを指定すれば準備完了です。

unitychan_retry_setting

今度はどうでしょうか…?

success_neuron

おおかた大丈夫そうですね。ここでは紹介してませんがプロ生ちゃんとか東北ずん子ちゃんの3Dモデルでも同様に動かせることを確認しています。

 

もちろん実際はコレだけじゃ足りなくて「平行移動も追従させたい」とか「NeuronRobotを経由せず直接的にモーションを適用したい」とかいった問題はあるのですが、本記事で紹介した方法でモデルの軸の問題さえ解決してしまえばどうにか出来ると思います。

[/expand]

 

まとめ

Perception Neuronの任意モデルへの対応は公式だとちょっと難しい事になってますが、実際には計算処理でうまくカバーすればどうにかなるよ、という話でした。それでは快適なモーションキャプチャ生活をお過ごしください。

 

 

追記(2018/07/07)

今回紹介したスクリプトへのリクエストで「平行移動のコピー例も欲しい」というのが複数あったため、追加します。
Hipsボーンの移動位置をコピーするとうまくいきます。
ゲームオブジェクト自体の位置ではない点に注意が必要です。

[expand title="スクリプト(クリックで展開)"]

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

public class ConnectToNeuronRobot : MonoBehaviour
{
    //マネ対象のアニメータークラス
    public Animator targetAnimator;

    //このオブジェクト自身のアニメーター
    private Animator animator;

    //初期状態の回転情報キャッシュ
    private Dictionary<HumanBodyBones, Quaternion> initialRotations;
    private Dictionary<HumanBodyBones, Vector3> pseudXaxis;
    private Dictionary<HumanBodyBones, Vector3> pseudYaxis;
    private Dictionary<HumanBodyBones, Vector3> pseudZaxis;

    // ADD: 平行移動の量を求める
    private Vector3 _targetInitialPosition;
    private Vector3 _initialPosition;

    // Use this for initialization
    void Start()
    {
        animator = GetComponent<Animator>();
        InitializeLocalRotations();
        // ADD: 位置の初期状態もチェック
        InitializePositions();
    }

    //対象のアニメーションで計算された結果を真似するようにする
    void Update()
    {
        if (targetAnimator != null && animator != null)
        {
            CopyRotations(targetAnimator, animator);
            // ADD: 位置もコピー
            CopyPositions(targetAnimator, animator);
        }
    }

    private void CopyRotations(Animator src, Animator dest)
    {
        foreach (var bone in targetBones)
        {
            //ボーンがデフォでWorldのXYZに沿ってる場合の回転を表すパラメタをまず拾う
            float angle;
            Vector3 axis;
            src.GetBoneTransform(bone).localRotation.ToAngleAxis(out angle, out axis);

            Vector3 axisInLocalCoordinate = axis.x * pseudXaxis[bone] + axis.y * pseudYaxis[bone] + axis.z * pseudZaxis[bone];

            Quaternion modifiedRotation = Quaternion.AngleAxis(angle, axisInLocalCoordinate);

            dest.GetBoneTransform(bone).localRotation =
                initialRotations[bone] *
                modifiedRotation;
        }
    }

    private void InitializeLocalRotations()
    {
        initialRotations = targetBones.ToDictionary(
            b => b,
            b => animator.GetBoneTransform(b).localRotation
            );

        var rootT = animator.GetBoneTransform(HumanBodyBones.Hips).root;

        pseudXaxis = targetBones.ToDictionary(
            b => b,
            b =>
            {
                var t = animator.GetBoneTransform(b);
                return new Vector3(
                    Vector3.Dot(t.right, rootT.right),
                    Vector3.Dot(t.up, rootT.right),
                    Vector3.Dot(t.forward, rootT.right)
                    );
            });

        pseudYaxis = targetBones.ToDictionary(
            b => b,
            b =>
            {
                var t = animator.GetBoneTransform(b);
                return new Vector3(
                    Vector3.Dot(t.right, rootT.up),
                    Vector3.Dot(t.up, rootT.up),
                    Vector3.Dot(t.forward, rootT.up)
                    );
            });

        pseudZaxis = targetBones.ToDictionary(
            b => b,
            b =>
            {
                var t = animator.GetBoneTransform(b);
                return new Vector3(
                    Vector3.Dot(t.right, rootT.forward),
                    Vector3.Dot(t.up, rootT.forward),
                    Vector3.Dot(t.forward, rootT.forward)
                    );
            });


    }

    //ADD: 位置 ( = Hips position) をコピーする
    private void CopyPositions(Animator src, Animator dest)
    {
        dest.GetBoneTransform(HumanBodyBones.Hips).position = 
            _initialPosition + 
            (src.GetBoneTransform(HumanBodyBones.Hips).position - _targetInitialPosition);
    }

    //ADD: オフセットベースの計算のために、位置 ( = Hips position) の初期値を記録する
    private void InitializePositions()
    {
        _targetInitialPosition = targetAnimator.GetBoneTransform(HumanBodyBones.Hips).position;
        _initialPosition = animator.GetBoneTransform(HumanBodyBones.Hips).position;
    }

    //コピー対象になるボーン一覧(もちろん足したり削ったりしてOK)
    private static HumanBodyBones[] targetBones = new[]
    {
        HumanBodyBones.Hips,

        HumanBodyBones.RightUpperLeg,
        HumanBodyBones.RightLowerLeg,
        HumanBodyBones.RightFoot,
        HumanBodyBones.LeftUpperLeg,
        HumanBodyBones.LeftLowerLeg,
        HumanBodyBones.LeftFoot,

        HumanBodyBones.Spine,
        HumanBodyBones.Chest,
        HumanBodyBones.Neck,
        HumanBodyBones.Head,

        HumanBodyBones.RightShoulder,
        HumanBodyBones.RightUpperArm,
        HumanBodyBones.RightLowerArm,
        HumanBodyBones.LeftShoulder,
        HumanBodyBones.LeftUpperArm,
        HumanBodyBones.LeftLowerArm,

    };

}

[/expand]

 

 

※後注: UnityとNeuronの名誉のために言っておきますが、本記事で扱っていない「事前にモーションキャプチャを使ってFBXアニメーションを作り、それをHumanoid AnimationとしてUnityに持ってくる」というシナリオでNeuronを利用する場合は本記事でやった面倒な処理は不要です。FBXアニメーションを使う場合はUnityのMecanim機構がボーンの座標系の差を自動でどうにかしてくれるからです。