[Astro] #199 MIRROR — VR の頭と両手を VRM に写して録画する(基準の取り方・IK・間引き・VRMA)
はじめに
「MIRROR」は、VRMA MAKER の VR の中で、自分の頭と両手の動きを、VRM に写して、録画するモードです。録った動きは、タイムラインにキーとして入り、VRMA で書き出せます。
- Quest が取れる、頭と両手(コントローラー、または素手)の位置と向きだけを使う
- 頭 → 頭・首・胸の向き、頭の上下左右 → 腰、両手 → 手の 2 ボーン IK、両足は固定
- 自分の右手を VRM の左手に写す「鏡」と、右手を右手に写す「操り人形」を切り替えられる
- 録画は、1/20 秒ごとにポーズを取り、止めたときに間引いて、キーにする
- 実装は、
pose.jsの VR のブロックの中です(新しいファイルは、増やしていません)
VRMA MAKER
VRM のポーズを作って、並べて、VRMA にするブラウザアプリ。VR 対応。
lain-lab.comVRMA MAKER 全体の実装は、#198 にあります。この記事は、MIRROR の部分だけです。
動画
バージョンアップで新たにミラーモードを追加。
左ステックを押し込むとMIRRORモードになり、簡易モーションキャプチャーが可能。
1. 全体の流れ
MIRROR に入った瞬間の、頭・両手の姿勢と、VRM の姿勢を、基準として取ります。以後、毎フレーム、基準からの差を、VRM に足します。
- 頭の移動を、腰の位置にする
- 頭の向きの差を、胸・首・頭の向きにする
- 両足を、基準の位置に IK で戻す
- 両手の位置と向きを、手の IK の目標にする
- トリガー・グリップの値を、握りにする
- 録画中なら、ポーズを溜める
順序が重要です。腰を動かして、胸・首・頭を回してから、足と手の 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 にまとめています。