[Astro] #198 VRMA MAKER — VRM のポーズを作って、並べて、VRMA にする(IK・タイムライン・足の固定・VR)

[Astro] #198 VRMA MAKER — VRM のポーズを作って、並べて、VRMA にする(IK・タイムライン・足の固定・VR)

はじめに

「VRMA MAKER」は、VRM のポーズを作って、タイムラインに並べて、間を自動で補間して、VRMA(VRM Animation)として書き出すアプリです。既存のモーションを直す VRMA EDIT と違い、ゼロから作ります。編集中は VRMA を一切使わず、書き出しのときだけ VRMA にします。

  • ポーズは、骨の回転・腰の位置・表情で持つ
  • 手足の先、肘・膝、腰を、動かして作れる(IK)
  • ポーズの一覧(サムネイル付き)、キーフレーム、補間の種類
  • 腰を動かしても、足は地面に固定できる
  • ポーズだけを、透過 PNG で書き出せる
  • Quest などの VR で、球をつかんで体を動かせる(WebXR)
  • 描画は three.js 0.160.0、VRM は @pixiv/three-vrm 3.2.0 です

スクリーンショット

ポーズ作成、保存、アニメーション作成

VRMA MAKER — ポーズ作成、保存、アニメーション作成 VRMA MAKER — ポーズ作成

動画

VRMA MAKER — ポーズ作成、保存、アニメーション作成

動画(WebXR/VR)

VRMA MAKER — ポーズ作成、保存、アニメーション作成(VR)

1. ポーズの持ち方

ポーズは、正規化ヒューマノイドの骨(getNormalizedBoneNode)のローカル回転で持ちます。回転が単位でない骨だけを保存します。

{ v: 1, model, bones: { 骨名: [x,y,z,w] }, hips: [x,y,z], expr: { 名前: 値 } }

VRM 0.x のモデルは、VRMUtils.rotateVRM0 で向きを直したあとも、正規化した骨の回転が、VRM 1.0 とは x と z の符号が逆です(VRMA に書き出すとき、three-vrm-animation が同じ反転をします)。そこで、ポーズは常に VRM 1.0 の座標系で持ち、VRM 0.x のときは、取り出し・設定のときに反転します。

const fq = (q) => (v0 ? [-q[0], q[1], -q[2], q[3]] : q);   // 回転: (-x, y, -z, w)
// 腰の位置: (-x, y, -z)

これで、ポーズの JSON は、モデルの版によらず、同じ意味になります。左右反転(MIRROR)は、左右の骨を入れ替えて、回転を (x, -y, -z, w) にします。この反転と上の反転は、順序を入れ替えても同じ結果になります。

2. 腕を下ろす・握る向き

VRM 0.x と 1.0 で、同じ軸の回転が、腕を下ろす向きになるか上げる向きになるかが違います。モデルを読んだときに、実際に回して確かめます。

up.quaternion.setFromAxisAngle(Z, 0.5);              // ローカル Z まわりに少し回す
armSign[side] = worldY('Hand') < y0 ? 1 : -1;        // 手の高さが下がる向きが、下ろす向き

指の握りも、人差し指の根元を回して、先の高さが下がる向きを、握る向きにします。角度は、根元 1.0、中 1.2、先 0.7 rad、親指は半分です。

3. 書き出し用の骨組み

モデルを読んだら、resetNormalizedPose() のあと、骨ごとに、親(humanoid の骨のうち、最も近い祖先)と、親からの休めの位置を取っておきます。VRM 0.x のときは、位置も x・z を反転します。

skel = { order: [親が先の骨名...], parent: { 骨名: 親の骨名 | null }, rest: { 骨名: [x,y,z] } }

4. キーと補間

キーは { id, t, ease, pose } です。時刻は 1/30 秒に丸めます。補間は、時刻 t を挟む 2 つのキー A、B から、A.ease で重みを変えて、骨ごとに slerp します。

const w = ease(A.ease, (t - A.t) / (B.t - A.t));
// 骨: 両方の和集合。片方に無い骨は、単位の回転として扱う
// 腰の位置、表情: 線形補間

動き方は、LINEAR、IN-OUT(smoothstep)、IN(x²)、OUT、HOLD(次のキーまで止める)です。slerp は、内積が負のとき反転して、最短の経路を取ります。

キーへの書き込み

キーがあるとき、ポーズを変える操作(回転・IK・握り・表情)が終わったら、再生位置のキーに書き込みます。その時刻にキーが無ければ、新しいキーを挿入します。間のフレームの不自然さを直す操作は、これだけで済みます。

function commit() { if (keys.length && !playing) writeBackKey(); pushUndo(); }

元に戻す

ポーズ、キー全部、再生位置を、まとめて JSON のスナップショットにして積みます(80 件)。キーの移動・削除・EASE の変更も、同じ仕組みで戻せます。

5. ポーズ一覧とサムネイル

一覧は、ポーズの JSON と、サムネイルの JPEG(data URL)を、localStorage の pose.lib.v1 に保存します。

サムネイルは、画面の描画を使わずに、96×128 の WebGLRenderTarget に、別のカメラで描きます。骨の印、ギズモ、床、霧は隠して、背景色を固定します。

renderer.setRenderTarget(rt); renderer.clear(); renderer.render(scene, thumbCam);
renderer.readRenderTargetPixels(rt, 0, 0, TW, TH, buf);   // 上下が逆なので、行を反転して Canvas へ

キーのサムネイルは、1 フレームに 1 つずつ作り直します。キー数が多くても、操作が止まらないようにするためです。指定したポーズで作るときは、今のポーズを退避して、描いたあとに戻します。

6. VRMA の書き出し

VRM の骨の親子と休めの位置から、VRMA を組み立てます。

  • 節点: 骨ごとに 1 つ。休めの回転は、すべて単位(回転なし)。位置は、親からの休めの位置
  • 回転のトラック: ポーズの回転が、そのまま、正規化した回転になります
  • 腰の位置: 絶対値の translation のトラック
  • 表情: 表情ごとに、節点を 1 つ作って、translation.x に値を入れます(VRMC_vrm_animation の expressions の preset / custom)
ext = { specVersion: '1.0', humanoid: { humanBones }, expressions: { preset, custom } };

30fps で全フレームをサンプリングして、optimize で、許容誤差の範囲で間引きます(回転 0.0015 rad、位置 0.0008)。VRMA の読み書きと間引きは、VRMA EDIT の vrma-io.js を、そのまま使っています。

7. 2 ボーン IK

手足の先を動かすと、肘・膝が付いてきます。上腕・前腕(大腿・下腿)の 2 ボーンの解析解(余弦定理)です。

const cosA = clamp((l1*l1 + d*d - l2*l2) / (2*l1*d), -1, 1);
_elb = S + dir * l1*cosA + pole * l1*sinA;
aim(up, lo, _elb);  aim(lo, end, T);

肘・膝が曲がる向き(極ベクトル)は、今の「肩 → 肘」のうち、「肩 → 手」に直交する成分を使います。操作の前後で、曲がる向きが変わりません。ほとんど伸びているときだけ、既定の向き(腕は後ろ下、脚は前)を使います。

手首・足首の向きは、解く前のワールド回転を取っておいて、解いたあとに、親のワールド回転で割って、ローカルに戻します(KEEP HAND/FOOT ANGLE)。

8. 腰を動かすとき、足を固定する

腰(hips)を動かす・回すと、全身がついてくるので、足が浮きます。ドラッグを始めたときに、両足のワールドの位置と回転を取っておいて、腰が動くたびに、両足を IK で、その位置へ戻します。

pins = { leftFoot: { p, q }, rightFoot: { p, q } };            // ドラッグ開始時
// objectChange: 腰が動いたあと
for (const n of Object.keys(pins)) solveIk(n, pins[n].p, pins[n].q);

腰を下げると、膝が曲がります。足が届かない高さまで上げたときは、足が浮きます(d を腕の長さの 99.9% までに制限)。

9. 肘・膝を動かす

肘・膝は、骨の長さを変えずに、位置を自由に動かすことはできません。手(足)を固定したまま、肘(膝)が動ける範囲は、肩と手を結ぶ軸のまわりの円周だけです。そこで、肘をドラッグしたら、その位置を極ベクトルにして、IK を解き直します。

const pole = proxy.position - upperWorldPos;     // 肩 → ドラッグした位置
solveIk(chain, endPos, endQuat, pole);           // 手の位置・向きは、ドラッグ開始時のまま

回転でも、肘は動かせますが、手も一緒に動きます。この方法は、手が動かない点だけが違います。

10. VR で作る

PC では、カメラを回して、いろいろな角度から見ないと、手足の奥行きが分かりません。VR では、モデルを見ながら、体を動かすだけで済みます。WebXR(immersive-vr、local-floor)で、VRM を 2.0 m 先に置きます。

rig.position.set(0, 0, 2.0);   // VRM と、IK の目標の球を入れるグループ。プレイヤーは原点

球

VR の間は、ギズモと骨の点を隠して、球だけを出します。球は、PC の IK と同じ処理につなぎます。

球半径動き
水色0.05 m手・足。solveIk の目標
黄色0.04 m肘・膝。手足を固定して、極ベクトルにする
緑0.07 m腰。PIN FEET が ON なら、足を固定

つかむ

つかんだ点を、球のローカル座標で取っておきます。手を動かすと、そのローカルの点が手に重なるように、球を動かして、IK を解き直します。

grab.local = ball.worldToLocal(handPos.clone());   // つかんだとき
// 毎フレーム: grab.local が handPos に重なる位置へ ball を動かし、solveIk
  • グリップ: 0.2 m 以内の、近い球
  • トリガー: レーザーの先にある球(Raycaster)
  • 素手: 人差し指の先と親指の先の、中点でつまむ(近ければ、つまんだ点。遠ければ、レーザー)
  • 両手で、別々の球を、同時につかめる

離したときに commit() を呼びます。キーがあれば、再生位置のキーに書き込みます。Ctrl+Z と同じ履歴に積まれます。

ボタン

ボタン動き
Aキーを置く
B元に戻す
X再生 / 停止
Yやり直し
左スティック歩く
右スティック(左右)再生位置

手のモデルは、LEAD の public/tool/lead/hand/ を、そのまま使います。TRACKING のときだけ hand-tracking を要求します。

描画で気を付けたこと

  • サムネイルの作り直しは、VR の間は止めます(renderer.xr.isPresenting のとき processThumbs を呼ばない)。描画ターゲットの切り替えが、XR の描画と衝突するためです
  • レーザーの交差判定の前に、vrHandles.updateMatrixWorld(true) を呼びます。球の matrixWorld が、描画の前は古いままだったためです

11. 操作

キー動き
G移動(腰・両手・両足は IK、肘・膝は向きの回転)
R回転
X / Y / Zその軸だけにする(もう一度で解除)
Space再生 / 停止
← / →1 フレーム
Deleteキーの削除
Ctrl+Z / Ctrl+Y元に戻す / やり直し

ファンクションキーは F1(ヘルプ)だけです。軸の固定は、TransformControls の showX / showY / showZ で切り替えます。

12. 間違いの記録

VRMA の読み戻しの確認で、回転がずれて見えた 書き出した VRMA を、three-vrm-animation で VRM に当てて比べたとき、最初は 65° や 40° のずれが出ました。実装の誤りではなく、確認のやり方が原因でした。

  • AnimationMixer は、前のフレームから値が変わらないトラックを、書き込みません。比べるたびに resetNormalizedPose() を挟むと、変わらない骨が、初期の姿勢のままになります
  • クリップの長さちょうどの時刻に setTime すると、ループして先頭に戻ります

比べるときは、リセットを挟まず、終端の少し手前の時刻を使います。

VR 用のコードの変数が、IK の変数と重なった VR の処理で、一時ベクトルを _T と名付けたところ、IK の _T と重複して、ページ全体が起動しませんでした(const の再宣言)。VR 側を _gT / _gUp に変えました。ヘッドレスのテストで気付きました。

肘・膝の「移動」は、最初は、回転と区別がつかなかった 手を固定する点が、回転との違いです。手や足が付いてきてよい場面では、これまでどおり回転で足ります。

13. ヘッドレスでの確認

Playwright の Chromium(SwiftShader)で、次を確認しました。

  • 3 つのキー(回転・腰の位置・表情)を書き出して、VRM に読み戻した結果が、補間したポーズと、回転で最大 0.4°、腰で 0.0005 m、表情で 0.001 以内で一致(VRM 0.x と 1.0 の両方)

  • IK の目標との誤差が 0.000003 m。マウスで矢印を引いて、腕の回転が変わる

  • 間のフレームで IK を動かすと、新しいキーが挿入される

  • 腰を 0.2 m 下げても、両足の位置が動かない

  • 肘を動かしても、手の位置が動かない

  • ポーズ一覧への登録、キーのドロップ、キーの移動、EASE・TIME の変更、再生、元に戻す

  • VR(疑似コントローラー): 手の球を引くと、手が目標へ(誤差 0)。肘を動かしても手は動かない。腰を 0.2 m 下げても足は動かない。レーザーで遠くの球をつかめる。離すと、元に戻す履歴が積まれる

実機の Chrome(Windows)で、操作と VRMA の書き出しを確認しました。VR は Quest で、球をつかんで動かせることを確認しました。

14. 制約

項目内容
足の固定腰を動かしている間だけ。キーの間の補間で、足が滑るのは、直せない
肘・膝の移動手(足)を固定した向きの回転だけ
補間骨ごとの slerp。キーを複数選んで一括で動かすことは、できない
VRMA の読み込み編集に使わない(書き出しだけ)。編集の続きは、.json で保存
表情キーの間を線形補間。まばたきの自動は無い
VRポーズ一覧・タイムライン・EASE の画面は、VR の中に無い(PC で触る)。胴・頭・指の回転は、球が無いのでできない
確認したモデルAvatarSample_A(VRM 0.x)、VRM1_Constraint_Twist_Sample(VRM 1.0)、自作の VRM(lain05)

関連

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

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