ARKit 3のMotion CaptureでVRMを動かすために、関節の回転差分を計算する


前置き

ARKit3では、これまでの表情のキャプチャに加えて全身の姿勢や関節の回転を推定するモーションキャプチャが実装されました。
モーションキャプチャの簡単な解説は、WWDCの「Bringing People into AR」が分かりやすいです。

Bringing People into AR
https://developer.apple.com/videos/play/wwdc2019/607/

ARKit3 のモーションキャプチャでは、91点の関節を認識し3Dアバターの動きと同期することができます。

動きを同期する上で一番簡単な方法は、各関節ノードの名前とtransformが指定された値になったアバターを利用することです。
これは公式サンプルの「Capturing Body Motion in 3D」に含まれているrobot.usdzを見ると分かりやすいです。
https://developer.apple.com/documentation/arkit/capturing_body_motion_in_3d

では、自前のモデルなど関節名やtransformの異なるモデルを操るにはどうすれば良いでしょうか?
一つは @nkjzm さんの記事のように、対応する構造にエクスポートする方法があります

ARKit 3のMotion CaptureでVRMを動かす【Unity】
https://qiita.com/nkjzm/items/d4379d5fd018de67a082

そしてもう一つは、上記記事でも触れられていますが各関節の差分から回転を調整して適用する方法です。

今回はVR向け3DアバターファイルフォーマットであるVRMに対して、ARKitで取得した動きを同期する方法を解説します。

ARSkelton3DとVRMの違い

ARSkelton3DはARKitを用いて取得できる人体の構造です。VRMは同じく3Dアバターの構造ですが、両者はいくつかの異なる点が存在します。

骨格数

肩や首などを表す骨格数は

ARSkelton3D VRM
91 54

と異なります。背骨や首が細かく分けられているARSkelton3Dに対してキャラクターを扱うVRMはシンプルな構造になっています

各関節の名称

VRMでは各関節は、自由に名称を付けることができます。
モデルによって右肩一つ取ってもRightSholderやSholderRなど製作者によって名称が異なります。
これらの関節位置と名前はボーンマッピングによって決定されます。

一方のARSkelton3Dは、全ての名称が決まっており次のような文字列で取得することができます。

T-Poseの角度

T-Poseは、一般的には両手を水平にあげた状態のことを指します。

VRMでは、それぞれの関節のtransformがidentity(つまり初期状態)の時に、このT-Poseとなります。

ARSkelton3Dの場合、全ての関節のtransformをidentityにすると次のような体制になります。

https://forum.unity.com/threads/example-rig-for-3d-human-skeleton.696512/#post-5158382

そして、この体制に


ARSkeletonDefinition.defaultBody3D.neutralBodySkeleton3D

を適用すると、T-Poseになります。
これはつまり、ARSkelton3DがT-Poseの時に各関節がidentityではないということを表しています。

pivotの位置

全体のノードの中心を表すpivotは、VRMの場合は足下が、ARSkelton3Dの場合はhipsが中心になります。

localTransform / modelTransformの違い

ARSkelton3Dからは、各関節のtransformが取得できるのですが、localTransform / modelTransformの2種類を得ることができます。

modelTransform

modelTransformは、pivotであるhipsからの移動と回転を持つtransformです。
ここで注意しなくてはならないのは、modelTransformは移動量を持つためそのままアバターに適用すると、腕や背格好が実際の人間と同じ割合になってしまいます。
VRMの場合はキャラクターを扱うことが多く、実際の体型と同じとは限らないのでこのtransformを扱うのはあまり適さないかと思います。

localTransform

localTransformは、ある関節ノードの親ノードに対する回転情報のみを持ったtransformです。
基本的にはこのtransformを利用しますが、関節数が異なる箇所に関しては正しく差分を出さないとpivotから遠ざかるほどに誤差が生まれ最終的な姿勢が全く異なるものになってしまうので注意が必要です。

関節の回転差分の出し方

ARSkelton3DのlocalTransformは、純粋な回転量(ここではar_quartanionとします。)+ニュートラル状態に持っていくための回転量の合成であると考えられます。

localTransform = ar_quartanion * neutral_quartanion

では、vrmの関節はneutral_quartanionを引いたar_quartanionだけを適用すれば良いでしょうか?
実はそうではありません。
ar_quartanionで与えられる回転は初期状態に依存するので、T-Pose時点で関節がidentityであるvrmと、neutralが与えられているARSkelton3Dでは合成結果が異なってしまうのです。

そこで、一度各関節がARSkelton3Dのtransformになるようにquartanionを与え、その上でar_quartanionを与えて、最後にARSkelton3Dのquartanion分を戻すことでvrmでも同じ方向に回転を与えることができます。
回転を戻すにはinverseを合成してあげれば良いです。

arOrientaion = neutralOrientation.inverse * transformOrientation
target.simdOrientation = vrmNeutralOrienation * arOrientation * vrmNeutralOrienation.inverse

では、各関節をARSkelton3Dのtransformにするにはどのような計算をすれば良いでしょうか。
単純にlocalTransformを与えると、親やその親の回転を考慮出来ないため愚直に全てのノードの親を辿って計算する必要があります。
ただ、この計算は初回に1度だけしておけば良いのでレンダリングへの影響はほとんどありません。
親のtransformのindexはparentIndicesで取得できるので利用します。
parentIndexが存在しない場合は-1が返ってきます。
VRMはpivotの回転がARSkelton3Dと前後逆なので、この時点でy軸180度の回転をかけてあげます。

lazy var vrmNeutralJointLocalTransforms: [simd_float4x4] = {
        let defaultBody3D = ARSkeletonDefinition.defaultBody3D
        let neutralJointLocalTransforms = defaultBody3D.neutralBodySkeleton3D!.jointLocalTransforms
        var vrmNeutralJointLocalTransforms: [simd_float4x4] = []
        for (var index, localTransform) in neutralJointLocalTransforms.enumerated() {
            var transforms: [simd_float4x4] = [localTransform]
            while let parentIndex = defaultBody3D.parentIndices[safe: index], parentIndex > 0 {
                let parentLocalTransform = neutralJointLocalTransforms[parentIndex]
                transforms.insert(parentLocalTransform, at: 0)
                index = parentIndex
            }
            masterRotation: do {
                let rotate = simd_quaternion(.pi, simd_float3(0, 1, 0))
                transforms[0] = simd_matrix4x4(rotate) * transforms[0]
            }
            vrmNeutralJointLocalTransforms.append(transforms.reduce(simd_float4x4(1), *))
        }
        return vrmNeutralJointLocalTransforms
    }()

完成!

以上の計算を行うことで、ARBodyTranckingでVRMを動作させることが出来るようになりました。

VRMを使った配信アプリvearでは、これらの処理を行ってモーションキャプチャを実装しています。(v1.2から実装)
興味のある方は是非使ってみてください!
https://apps.apple.com/us/app/vear/id1490697369