[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
VRM のポーズを作って、並べて、VRMA にするブラウザアプリ。
lain-lab.comスクリーンショット
ポーズ作成、保存、アニメーション作成
動画
動画(WebXR/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 にまとめています。