[Astro] #196 VRMA EDIT — VRMA をブラウザで編集する(追加、編集、ポーズ/姿勢の変換)
はじめに
「VRMA EDIT」は、VRM のモーション(.vrma)を、VRM に動かして確かめながら編集するブラウザアプリです。
- 範囲を切る・消す、別の VRMA をつなぐ
- 再生位置のポーズを、骨を回して直す(前後のフレームへ、なだらかに広がる)
- 速さの変更、逆再生、左右の入れ替え、その場で動かす、キーの間引き
- ファイルは、ブラウザの中だけで処理します。アップロードはされません
- 描画は three.js 0.160.0、VRM は @pixiv/three-vrm 3.2.0、モーションの読み込みは @pixiv/three-vrm-animation 3.2.0 です
VRMA EDIT
VRM のモーション(.vrma)を、VRM で確かめながら、切る・つなぐ・ポーズを直すブラウザアプリ。
lain-lab.comコードは、次の 3 つに分けています。
| ファイル | 役割 |
|---|---|
vrma-io.js | VRMA の読み書きと、編集の計算(three.js には依存しない。編集は、新しいモデルを返す) |
editor.js | プレビュー、ポーズ編集のギズモ、タイムライン、画面 |
vrma-editor.astro | ページ |
スクリーンショット
起動画面、デフォルトでVRMと徒歩アニメーションが動きます。
I、Oキーでアニメーションの範囲選択。 DELETEキーで削除。
別のアニメーションを追加。
ポーズの編集。
動画
アニメーションの追加。
ポーズの編集。
1. VRMA の中身
VRMA は、glTF の GLB です。JSON チャンクと BIN チャンクの 2 つで、アニメーションは 1 本だけ入っています。
extensions.VRMC_vrm_animation.humanoid.humanBonesに、人体の骨の名前と、ノード番号の対応が入っているanimations[0].channelsに、骨ごとの回転トラック(クォータニオン)と、腰(hips)の位置トラックが入っている。補間はLINEAR- 表情は、表情の名前が付いたノードの、
translation.xのトラックとして入っている
walk.vrma(VMD から変換したもの)は、53 トラック(回転 52 + 腰の位置 1)、508 キー、30fps、16.731 秒で、時刻の配列(SCALAR)は、全トラックで 1 本を共有していました。
2. 読み込みと書き出し
読み込みでは、各チャンネルを、次の形にします。
{ node, path, n, times: Float32Array, values: Float32Array }
// path: 'rotation'(n=4)/ 'translation'(n=3)
GLB の JSON から、アニメーション・アクセサ・バッファビューだけを除いたものを、model.json に残します。ノードや拡張は、そのまま書き戻します。
書き出しでは、トラックごとに、時刻の配列と値の配列をバッファビューへ詰めます。時刻の配列が、既にあるものと同じなら、アクセサを共有します。編集で、キーの数が揃わなくなっても、ファイルとして成立します。
float 以外の値、byteStride 付きのバッファビュー、LINEAR 以外の補間は、読み込み時にエラーにしています。
3. プレビューは「書き出して、読み直す」
編集のたびに、モデルを GLB に書き出して、GLTFLoader + VRMAnimationLoaderPlugin で読み直し、createVRMAnimationClip でクリップにします。
const g = await parseGltf(IO.writeVrma(model));
const clip = createVRMAnimationClip(g.userData.vrmAnimations[0], vrm);
action = mixer.clipAction(clip);
action.play(); action.paused = true; // 時刻は、自分で決める
// 再生位置を変えるとき
action.time = t; mixer.update(0); vrm.update(0);
保存と同じ道を通るので、画面で見たものと、保存したファイルが、ずれません。編集のたびに、全体を書き出して読み直しますが、実行するのは、ドラッグの終わりと、ボタンを押したときだけです。数分の長さのモーションでの速さは、未確認です。
4. 休めの姿勢(rest)の変換
4.1 回転トラックは、「休めの姿勢」からの回転
VRMA の回転トラックの値は、そのファイルの骨の、休めの姿勢からの回転です。休めの姿勢は、ファイルごとに違います。walk.vrma では、leftUpperLeg のノードの回転が (0, -0.01, -1, 0)(ほぼ Z 軸まわりに 180°)で、sample.vrma では、hips が (0.5, 0.5, -0.5, 0.5) でした。
three-vrm-animation は、読み込むとき、回転トラックを、次の式で、正規化した座標(T ポーズが、回転なし)に直します(_parseAnimation)。
正規化された回転 = P · q · W⁻¹
q: トラックの値
P: 親の骨の、世界での回転(親の骨が無ければ、腰の親ノードの回転)
W: その骨のノードの、世界での回転
ノードの回転 L を使うと、W = P · L なので、P · q · L⁻¹ · P⁻¹ と書けます。休めの姿勢が回転なしのファイルなら、P・L は単位で、値は、そのまま使えます。
4.2 持っておくもの
パース時に、骨ごとの P・W を計算して、model.rest に持ちます。ノードの階層をたどって、世界の回転と位置を求めるだけです。
wq[i] = qnorm(qmul(wq[parent[i]], localRotation[i]));
// 骨ごとに: P = いちばん近い、骨である祖先の wq(無ければ、腰の親の wq)、W = wq[自分]
// 腰の位置のトラック用: hipsM(腰の親の回転)、hipsY(腰の世界の高さ)
4.3 使う場面
この変換を通さないと、次の 3 つがずれます。
ポーズ編集: ギズモで回した差分 D は、VRM 上の見た目(正規化した座標)で取れます。トラックの値は、q' = (P⁻¹ · D · P) · q で更新します(n = P q W⁻¹ に、左から D を掛けて、q について解いた式です)。
左右の入れ替え(MIRROR): 正規化した座標へ直し、(x, y, z, w) → (x, -y, -z, w) と反転し、相手の骨の座標へ戻します。腰の位置は、x を反転します。
別ファイルのつなぎ(JOIN): 骨を名前で対応させ、後ろのファイルの値を、前のファイルの休めの姿勢の座標へ置き直します(6.2)。
4.4 VRM 0.x
createVRMAnimationHumanoidTracks は、VRM 0.x のとき、回転の x・z と、位置の x・z を反転します(metaVersion === '0')。ギズモで取った差分は、この反転の後の座標なので、書き込む前に、同じ反転をします。
if (isVrm0()) d = [-d[0], d[1], -d[2], d[3]]; // 回転
if (isVrm0()) dp = [-dp[0], dp[1], -dp[2]]; // 位置
腰の位置は、three-vrm-animation が、VRM の腰の高さ ÷ モーションの腰の高さ を掛けています。書き込むときは、モーションの腰の高さ ÷ VRM の腰の高さ で、割り戻します。
5. ポーズ編集
5.1 ギズモと FALLOFF
TransformControls を、選んだ骨の正規化ノード(vrm.humanoid.getNormalizedBoneNode)に付けます。ドラッグの開始と終了(dragging-changed)で、ノードの回転を控え、差分を取ります。
D = q_new · q_old⁻¹ // 親の座標での差分
30fps のモーションの 1 コマだけを変えても、ガタつくだけです。そこで、再生位置の前後 radius(FALLOFF、初期値 0.4 秒)のキーへ、距離に応じて、差分をなだらかに掛けます。
w = smoothstep(1 - |keyTime - t| / radius) // 距離 0 で 1、radius で 0
q' = slerp(単位, D, w) · q
再生位置に、キーが無いときは、その時刻の補間した値で、キーを足します(ensureKey)。radius が 0 のときは、そのコマだけです。トラックが無い骨は、休めの値で作ります。
5.2 骨の印
ギズモを付ける骨は、体に出した白い球をクリックして選びます(22 本)。球は、depthTest: false で、体の中でも見えるようにし、Raycaster で選びます。ギズモの輪の上のクリックは、gizmo.axis を見て、選択に使いません。
OrbitControls は、ドラッグ中だけ、止めています。
6. つなぎ(JOIN)
6.1 重ね合わせ
前のファイル a の最後の blend 秒と、後ろのファイル b の最初の blend 秒を、重ねます。
off = a.duration - blend
w = smoothstep((t - off) / blend)
// 重なりの中: 回転は slerp(a, b, w)、位置は lerp(a, b, w)
腰の位置は、b の最初の x・z を、a の最後に合わせます(高さは、そのまま)。そうしないと、つなぎ目で、腰が飛びます。
6.2 別ファイルは、置き直してから混ぜる
初めは、トラックを、ノード番号(node)と path で対応させていました。同じファイルどうしなら動きますが、別のファイルでは、ノード番号も、休めの姿勢も、腰の高さも違います。reframe で、後ろのファイルを、前のファイルの座標へ置き直します。
- 骨は、名前で対応させる(前のファイルに無い骨は、捨てる)
- 回転:
n = toNorm(q, 後ろの rest)→q' = fromNorm(n, 前の rest) - 腰の位置:
前の腰の高さ ÷ 後ろの腰の高さを掛け、腰の親の回転を、掛け外す - 表情・視線のトラックは、捨てる(ノードの対応を作っていないため)
6.3 外れたコマ
VMD から変換した VRMA は、0 秒目に、初期ポーズのコマが入っていることがあります(#195 の「T ポーズ」の原因)。隣のコマとの角度差の合計 S[i] を、全回転トラックで足して、先頭・末尾が 中央値 × 6 + 0.3 を超えたら、そのコマを外します(AUTO FIX)。トラックのキー数が揃っているときだけ、判定します。つなぐときは、既定で、つなぐ前に、この処理をします。
7. そのほかの編集
| 編集 | 方法 |
|---|---|
| 範囲を残す(KEEP) | 範囲の端に、補間した値でキーを足し、範囲の外を捨てて、時刻を詰める |
| 範囲を消す(DELETE) | 前半と後半を、それぞれ切り出して、blend 秒で重ねてつなぐ |
| 速さ | 時刻を 1 / f にする |
| ループの継ぎ目(SEAM) | 最後の blend 秒を、最初へ混ぜて、その分だけ短くする。腰の位置は混ぜない(進む量が、狂うため)。継ぎ目の角度差: 0.79 rad → 0.0007 rad |
| 逆再生 | 時刻を 長さ - t にして、並びを反転 |
| その場で動く | 腰の位置を、腰の親の回転で正規化して、x・z を最初の値に固定 |
| なめらかにする | 時間窓のガウス平均。回転は、半球をそろえて、平均して、正規化 |
| 指・腰の移動を外す | 該当トラックを捨てる |
キーの間引き(OPTIMIZE)
トラックごとに、Douglas–Peucker 法で、キーを間引きます。両端を残し、間のキーのうち、両端を結んだ補間との差が、許容を超えるもののうち、最大のものを残して、2 つに分けて繰り返します。許容は、回転 0.004 rad、位置 2 mm です。
walk.vrma(16.7 秒)で、26924 キー → 4461 キー、444KB → 112KB になりました。間引き後に、元と比べた、回転の最大の差は、0.004 rad でした。
8. タイムライン
<canvas> に、次を描いています。
- 動きの量: 1/30 秒ごとに、全回転トラックの、隣との角度差を足した値
- 選んだ骨のキー: 菱形(密なときは、線)
- 範囲(IN / OUT)、再生位置、FALLOFF の範囲
操作は、クリック・ドラッグで再生位置、IN / OUT の線のドラッグ、Shift+ドラッグで範囲、ホイールでズーム、ダブルクリックで全体表示です。
9. 間違いの記録
| 起きたこと | 原因と対処 |
|---|---|
| ギズモで回した結果が、画面の骨の回転と合わない(一致度 0.9128、角度で約 0.84 rad) | VRM 0.x の反転を入れても、VRM 1.0 でも同じ値だった。原因は、VRMA の休めの姿勢の変換(4 章)を、入れていなかったこと。P⁻¹ · D · P を入れて、一致度 1.0000 |
| 別の VRMA を、つなぐと、骨がずれる | トラックを、ノード番号で対応させていた。骨の名前で対応させ、reframe で、座標を置き直した |
| VRM 0.x だけ、腰を動かした向きが逆 | 位置の x・z の反転(4.4)を、書き込む前に入れていなかった |
テストの、VRM のバイト列を、evaluate の引数で渡すと、Node が落ちる | 数 MB の Array に変換していた。setInputFiles で、ファイル入力へ渡す |
10. ヘッドレスでの確認
Playwright の Chromium(SwiftShader)で、ボタンの操作と、数値を確認しました。
- 読み込み → 書き出し → 読み直しで、
walk.vrmaの全値が一致 - ポーズ編集: 腕・脚(休めの姿勢が 180° 回っている骨)・頭を、ギズモのイベントを模擬して回し、ノードの回転と、期待値の一致度を測る(VRM 0.x の AvatarSample_A と、VRM 1.0 の VRM1_Constraint_Twist_Sample の、両方で 1.0000)。腰の位置の誤差は、0.0002
- MIRROR: 左右の骨の回転が、
(x, -y, -z, w)の関係になっている(誤差 0)。2 回で、元に戻る - JOIN: 別ファイル(
sample.vrma)を、walk.vrmaにつないだ結果の、つなぎ先の骨の姿勢が、そのファイル単体と一致 - 実機の Chrome で、カット・保存・回転の編集・ジャンプと歩きのつなぎが、動くことを確認
ギズモを、実際のマウスで動かした感触は、ヘッドレスでは確認できません。MIRROR・IN PLACE・SMOOTH・OPTIMIZE の見た目は、数値での確認だけです。
11. 制約
| 項目 | 内容 |
|---|---|
| 位置の編集 | 腰(hips)だけ。VRMA は、腰以外の骨に、位置のトラックを持てない仕様 |
| 表情・視線 | 編集できない。入っていれば、保持する。別ファイルをつなぐときは、つなぐ側のものを、捨てる |
| 補間 | LINEAR のみ |
| IK | 無い(手先を動かして、腕を曲げる操作) |
| EFFECTS | モーション全体にだけ効く |
| 長いモーション | 編集のたびに、全体を書き出して、読み直す。数分のものは、未確認 |
関連
実装メモは、docs/vrma-editor.md にまとめています。
- VMD から VRMA への変換: VMD → VRMA 変換
- VRM をクリックして押す: POKE