[Astro] #199 MIRROR — VR の頭と両手を VRM に写して録画する(基準の取り方・IK・間引き・VRMA)

[Astro] #199 MIRROR — VR の頭と両手を VRM に写して録画する(基準の取り方・IK・間引き・VRMA)

はじめに

「MIRROR」は、VRMA MAKER の VR の中で、自分の頭と両手の動きを、VRM に写して、録画するモードです。録った動きは、タイムラインにキーとして入り、VRMA で書き出せます。

  • Quest が取れる、頭と両手(コントローラー、または素手)の位置と向きだけを使う
  • 頭 → 頭・首・胸の向き、頭の上下左右 → 腰、両手 → 手の 2 ボーン IK、両足は固定
  • 自分の右手を VRM の左手に写す「鏡」と、右手を右手に写す「操り人形」を切り替えられる
  • 録画は、1/20 秒ごとにポーズを取り、止めたときに間引いて、キーにする
  • 実装は、pose.js の VR のブロックの中です(新しいファイルは、増やしていません)

VRMA MAKER 全体の実装は、#198 にあります。この記事は、MIRROR の部分だけです。

動画

バージョンアップで新たにミラーモードを追加。
左ステックを押し込むとMIRRORモードになり、簡易モーションキャプチャーが可能。

VRMA MAKER — ポーズ作成、保存、アニメーション作成(VR/モーションキャプチャー)

1. 全体の流れ

MIRROR に入った瞬間の、頭・両手の姿勢と、VRM の姿勢を、基準として取ります。以後、毎フレーム、基準からの差を、VRM に足します。

  1. 頭の移動を、腰の位置にする
  2. 頭の向きの差を、胸・首・頭の向きにする
  3. 両足を、基準の位置に IK で戻す
  4. 両手の位置と向きを、手の IK の目標にする
  5. トリガー・グリップの値を、握りにする
  6. 録画中なら、ポーズを溜める

順序が重要です。腰を動かして、胸・首・頭を回してから、足と手の IK を解きます。腕は、胸の向きで肩の位置が変わるので、胸を回したあとに解く必要があります。

2. 基準

mir.cal = {
  camP, camQ,          // 頭(カメラ)の、ワールドの位置・回転
  headP, hipsP,        // VRM の頭・腰の、ワールドの位置
  s,                   // 身長の比 = VRM の頭の高さ ÷ 自分の目の高さ(0.5〜1.5)
  bones: { chest, neck, head },    // VRM の、ワールドの回転
  feet:  { leftFoot, rightFoot },  // VRM の足の、ワールドの位置・回転
  hands: { left, right },          // コントローラーの回転 q0、VRM の手の基準 vq0
};

手の基準 vq0 は、ここでは決めません。次の、手首の節で決めます。

3. 鏡と操り人形

VRM は +z を向き、プレイヤーは -z を向いて、2 m 先に立つ前提です。世界のベクトルと、回転の差を、VRM 側へ、次のように写します。

位置のベクトル回転の差 (x, y, z, w)手の対応
鏡(x, y, −z)(−x, −y, z, w)右手 → VRM の左手
操り人形(−x, y, −z)(−x, y, −z, w)右手 → VRM の右手

鏡は、z の平面での反射です。反射 M のもとで、回転 R は M R M になります。回転の軸は、擬ベクトル(反射した上で、符号が反転する)なので、軸の x・y の符号が変わり、z は残ります。操り人形は、y 軸まわりに 180° 回した場合です。

const mapVec = (d, out) => (mir.swap ? out.set(d.x, d.y, -d.z) : out.set(-d.x, d.y, -d.z));
const mapDelta = (q) => (mir.swap ? q.set(-q.x, -q.y, q.z, q.w) : q.set(-q.x, q.y, -q.z, q.w));

体の向きを変えても、この対応は、変わりません。向きを変えて動くと、手の対応が、ずれます。

4. 頭 → 腰・胸・首・頭

腰: 頭の移動を、mapVec で VRM 側へ写して、身長の比 s を掛け、基準の腰の位置に足します。水平は ±0.35 m、下は −0.7 m、上は +0.12 m で止めます。

const d = mapVec(camP.clone().sub(c.camP), new THREE.Vector3()).multiplyScalar(c.s);
const hz = Math.hypot(d.x, d.z); if (hz > 0.35) { d.x *= 0.35 / hz; d.z *= 0.35 / hz; }
d.y = clamp(d.y, -0.7, 0.12);
hips.position.copy(hips.parent.worldToLocal(c.hipsP.clone().add(d)));

向き: 頭の回転の差 D = camQ · camQ0⁻¹ を mapDelta で写して、1.7 rad で頭打ちにします。胸・首・頭に、異なる割合で配ります。

const MIR_BONES = { chest: 0.25, neck: 0.5, head: 1.0 };
// 割合 f の回転: slerp(単位, D, f)。基準のワールド回転に掛けて、親のワールド回転で割る
setBoneWorldQ(name, slerp(I, D, f).multiply(c.bones[name]));

ワールドの回転を直接指定して、親のワールド回転で割ってローカルに戻します。親から順(胸 → 首 → 頭)に設定します。

5. 足の固定

腰を動かすと足が浮くので、基準の位置・回転へ、足を IK で戻します(#198 の PIN FEET と同じ)。

for (const [n, f] of Object.entries(c.feet)) solveIk(n, f.p, f.q);

しゃがむと、腰が下がり、膝が曲がります。足が届かない高さでは、IK の距離制限で、足が浮きます。

6. 手

手の目標は、「VRM の頭の目標位置」に、「頭から見たコントローラーの相対位置」を足して作ります。

const headT = c.headP.clone().add(d);                                   // 腰の移動を含めた、VRM の頭の目標
const target = mapVec(handPos.sub(camP), new THREE.Vector3()).multiplyScalar(c.s).add(headT);

肘の向きは、極ベクトル (0.3·sg, −1, −0.5)(sg は、左 +1・右 −1)に固定します。#198 の、今の向きを保つ方式にすると、手を上げたあとで、肘が上がったままになるためです。

solveIk(vs + 'Hand', target, endQuat, new THREE.Vector3(0.3 * sg, -1, -0.5));

vs は、VRM 側の左右です(鏡のとき、物理の手と逆)。IK で、手首の向きを指定するため、S.keepRot を、このフレームだけ true にします。

手首の基準

コントローラーの回転の差を、VRM の手首に足します。差を足す先(基準 vq0)は、入った瞬間に、手首をまっすぐにして手の位置へ IK を解いた結果の向きにします。

if (!cal.vq0) {
  const hb = boneNode(vs + 'Hand'); hb.quaternion.identity(); S.keepRot = false;
  solveIk(vs + 'Hand', target, undefined, pole); S.keepRot = true;     // 手首は、前腕の延長
  hb.updateMatrixWorld(true); cal.vq0 = hb.getWorldQuaternion(new THREE.Quaternion());
  continue;
}
const dq = mapDelta(handQ.multiply(cal.q0.clone().invert()));          // コントローラーの回転の差
const endQuat = dq.multiply(cal.vq0);                                  // 基準に掛ける

mirrorCal() の最後で mirrorStep(0) を 1 回呼んで、基準を決めます。手首が、正規化したボーンの休めの姿勢では、前腕の延長になることを使っています。

7. 握り

トリガーとグリップの値の大きい方を、握りの強さ(0〜1)にして、#198 の applyGrip(side, g) に渡します。

const g = Math.max(gp.buttons[0].value, gp.buttons[1].value);
if (g > 0 || h.gripWas) applyGrip(vs, g);       // 離したときに 0 へ戻すため、1 回余分に呼ぶ

8. 録画

1/20 秒ごとに、getPose() を溜めます。止めたときに、間引いて、キーにします。

if (mir.acc >= 0.05 || !mir.frames.length) { mir.acc = 0; mir.frames.push({ t: mir.recT, pose: getPose() }); }

間引き

時間方向の Douglas–Peucker です。両端のフレームの間を blend で補間して、中間のフレームとの誤差を測り、最も大きいフレームを残して、再帰します。

// 誤差 = max( 全ての骨の回転のずれ[°] ÷ 2.0 ,  腰の位置のずれ[m] ÷ 0.015 )
if (worst > 1) { keep.add(wi); stack.push([a, wi], [wi, b]); }

回転は 2°、腰は 1.5 cm 以内で、間のフレームを、補間で置き換えます。録った動きの細かさにより、3 秒で 30 個前後のキーが残ります。

キーへ

キーは linear で、タイムラインの終わりの 0.5 秒後(キーが無ければ 0 秒)から足します。putKey を使うので、サムネイルの作り直し・元に戻すも、そのまま効きます。

const base = keys.length ? snapT(lengthOf(keys) + 0.5) : 0;
for (const f of fr) putKey(base + f.t, f.pose, 'linear');
setTime(base, true); pushUndo();

9. 操作と画面

入力動き
左スティックの押し込み(buttons[3])MIRROR の入り / 切り
右スティックの押し込み鏡 / 操り人形。切り替えると、基準を取り直す
A録画の開始 / 停止
B基準の取り直し

MIRROR の間は、球は隠して、つかむ・歩く・再生は止めます。セッションが終わるときは、mirrorToggle(false) で、録画を止めてから戻します。

VR の中の表示は、640×160 の canvas を CanvasTexture にして、カメラの子の板(0.5 × 0.125 m、位置 (0, −0.3, −0.9))に貼ります。MIRROR の状態、録画の秒数、ボタンを出します。文字が変わったときだけ、描き直します。

10. 間違いの記録

手首が、前に出しても、下を向いたままだった 手の基準を、「入った瞬間の VRM の手の向き」にしていました。腕を下ろした休めの姿勢は、指が下を向いているので、腕を前に伸ばして入ると、コントローラーの回転の差が 0 のまま、指が下を向きました。実機で見つかりました。基準を、入った瞬間に、手首をまっすぐにして IK を解いた向きに変えました(上の「手首の基準」)。

ワールドの回転の変換で、同じ変数を使い回した setBoneWorldQ の中で、親のワールド回転を入れる一時変数が、引数と同じオブジェクトで、結果が壊れました。専用の一時変数に分けました。

11. ヘッドレスでの確認

Playwright の Chromium(SwiftShader)で、疑似コントローラーを使って確認しました。

  • 右手を前上へ → VRM の左手が上がる(鏡)。操り人形では、右手が上がる
  • 頭を 0.3 m 下げる → 腰が 0.27 m(身長の比を掛けた値)下がり、足は 0 ずれ
  • 頭を 0.6 rad 回す → VRM の頭が、鏡の向きで −0.6 rad
  • 腕を前に伸ばして入る → 指が前を向く。コントローラーを −90° 倒す → 指が下、+45° → 斜め上
  • 3 秒の録画(45 フレーム)→ 約 30 個のキー → ⇩ VRMA が出る

実機(Quest 3)で、鏡の動き、手首をひねって手のひらを返す・手を振る動きを確認しました。

12. 制約

項目内容
写るもの頭と両手だけ。足・胴のひねり・指(握り以外)・表情は、写らない
体の向き変えると、手の位置の対応が、ずれる
精度3 点だけで姿勢を作るので、細かい動きは、出ない。録ったあとに、キーで整える
足基準の位置に固定。WebXR の標準では、足は取れない
立ち位置VRM の 2 m 先を前提にしている

関連

実装メモは、docs/vrma-maker.md にまとめています。

  • VRMA MAKER の実装: #198
  • VRM の手を引っ張って歩かせる: LEAD
  • VRM のモーションを編集する: VRMA EDIT