[Astro] #202 横向きの寝姿を VRMA で読み込む(自動設置・寝返りのクロスフェード・VPD→VRMA 変換)
はじめに
#201 の添い寝モードは、寝姿が仰向けだけでした。体全体を後ろへ倒すだけの作りです。ここに、横向きの寝姿を足しました。
- 横向きのポーズは、1 フレームだけの
.vrma(public/animations/anim/sleep_beside.vrma)で持つ - 寝るときに、立ち姿からクロスフェードで寝かせる
- 置き場所(接地・頭の向き)は、ポーズの骨の位置から自動で決める。微調整のスライダーも付ける
- 寝きってから一定時間ごとに、仰向けと横向きを、3.5 秒かけて入れ替える(寝返り)
- ポーズは、MMD のポーズファイル(
.vpd)からも作れるように、VMD→VRMA 変換ツールに VPD の読み込みを足した
使ったバージョンは、#201 と同じです(three 0.183、@pixiv/three-vrm 3.5.5)。
1. ポーズの読み込み
.vrma は、GLTFLoader に VRMA のプラグインを入れて読みます。1 フレームだけのクリップは、duration が 0 になります。
const clip = createVRMAnimationClip(vs, vrm);
const act = mixer.clipAction(clip);
act.play(); act.setEffectiveWeight(0); // 重み 0 で再生しておく
if (clip.duration <= 0) act.paused = true; // 1 コマだけのポーズ
- アバターごと(普段着・パジャマ)に、クリップと
AnimationActionを持ちます - ファイルが無い、または読めないときは、
side = nullにして、仰向けだけで動きます - 目と表情は、アプリ側(まばたき・笑顔)で制御するため、VRMA の表情トラックは使いません。ボーンだけを使います
2. 置き場所を自動で決める
ポーズによって、体の向き・厚み・長さが違います。ポーズ後の骨の位置を測って、置き場所を計算します。
測る骨は、腰・背骨・胸・首・頭・肩・腕・手・脚・足首・つま先の 22 本です。
function measureSide(av: Av) {
idle?.setEffectiveWeight(0); s.act.setEffectiveWeight(1);
vrm.scene.position.set(0, 0, 0); vrm.scene.rotation.set(0, -av.rig.facing, 0); // モデルの前を +z にそろえる
mixer.update(0);
vrm.scene.updateMatrixWorld(true);
// 親(部屋の root)の座標系に直す
const inv = new THREE.Matrix4();
if (vrm.scene.parent) { vrm.scene.parent.updateWorldMatrix(true, false); inv.copy(vrm.scene.parent.matrixWorld).invert(); }
s.pts = SIDE_BONES.map((n) => vrm.humanoid.getNormalizedBoneNode(n).getWorldPosition(new THREE.Vector3()).applyMatrix4(inv));
// 腰 → 頭の向きを、水平面の角度として持つ
s.a = Math.atan2(head.x - hips.x, head.z - hips.z);
}
置くときは、頭が枕側(-z)を向くように回して、骨の外形(バウンディングボックス)から位置を決めます。
const psi = Math.PI - s.a + cfg.sideYaw; // 頭を -z へ
// 回した骨の x・z の範囲と、最小の y から
outPos.set(
BED_AVATAR_X + cfg.sideX - (minX + maxX) / 2, // 左右の中心をマスコットの寝る場所へ
BED_TOP_Y + SIDE_GROUND + cfg.sideY - minY, // 一番低い関節を、マットレスの上へ(体の厚み 0.04m)
PILLOW_Z + (maxZ - minZ) / 2 + cfg.sideZ - (minZ + maxZ) / 2, // 頭側の端を枕へ
);
outQ.setFromAxisAngle(Y_AXIS, psi - av.rig.facing);
測定は、寝るときに 1 回だけ(pts が空のとき)です。微調整は sideX / sideY / sideZ / sideYaw で、設定パネルのスライダー(VR は −/+)から、自動で合わせた位置からのずれとして足します。
測定を、部屋の座標で行う
getWorldPosition はワールド座標です。VR では、部屋(root)ごと動かして、頭の位置に合わせているため、ワールド座標のまま測ると、部屋の移動分が骨の位置に乗ります。結果は、横向きのポーズが天井の高さまで持ち上がる形でした。PC では、部屋が原点なので、気づきませんでした。
親の matrixWorld の逆行列を掛けて、親の座標系に直してから、位置を決めます(前節のコードの inv)。vrm.scene.position は親の座標系の値なので、同じ座標系で測る必要があります。
3. 立ち姿・仰向け・横向きの混ぜ方
重みは、次のようにします。
| 対象 | 重み |
|---|---|
| アイドル(立ち姿)のアクション | 1 - e |
| 横向きのポーズのアクション | e * lb |
| 体の位置・向き | 仰向けの値と横向きの値を lb で混ぜ、さらに e で立ち位置から混ぜる |
e は寝る進み具合(0〜1)、lb は横向きの割合(0 = 仰向け、1 = 横向き)です。
_lie.set(BED_AVATAR_X, BED_TOP_Y + AVATAR_ABOVE_BED, PILLOW_Z + h * 0.98); // 仰向けの位置
_q.copy(Q_TILT).multiply(_qy.setFromAxisAngle(Y_AXIS, -rigRef.current.facing)); // 仰向けの向き
if (lb > 0 && sideTransform(cur, cfg, _head, sideQ.current)) { _lie.lerp(_head, lb); _q.slerp(sideQ.current, lb); }
vrm.scene.position.lerp(_lie, e);
vrm.scene.quaternion.slerp(_q, e);
仰向けのときだけ必要な処理(腕を体の横へ下ろす、顔を自分の方へ向ける)は、lb < 1 / lb === 0 のときだけ行います。
4. ミキサーが書かないボーンへ、毎コマ回転を足してはいけない
横向きのとき、胸の呼吸(chest を前後に回す)を、ポーズの上に重ねました。
chest.quaternion.multiply(_qy.setFromAxisAngle(_sp.set(1, 0, 0), -br)); // ✗ 毎コマ掛け続ける
結果、胸が毎コマ回り続けて、腰から腹筋運動をするように起き上がりました。AnimationMixer は、毎コマ、そのボーンを元の値に書き戻してくれるとは限りません。ミキサーが書かないコマでは、前のコマの回転の上に、さらに掛かります。
対策は、重ねた回転を記録して、次のコマの mixer.update の前に外すことです。
// 毎コマの頭
for (const o of addOff.current) o.node.quaternion.multiply(o.q.invert()); // (base * off) * off⁻¹ = base
addOff.current.length = 0;
mixer.update(dt);
// ... 回転を足したら記録する
const o = new THREE.Quaternion().setFromAxisAngle(axis, angle);
node.quaternion.multiply(o);
addOff.current.push({ node, q: o });
ミキサーが書いたときは、書いた値で上書きされるので、外す操作は無害です。書かなかったときは、元の値に戻ります。どちらでも、積み重なりません。
その後、横向きの胸の動き自体を止めました。体を横にしたときの「胸の前後の回転」は、体の向きから見て、起き上がる動きに見えるためです。音(寝息)は、そのまま鳴ります。頭を撫でたときの頷きだけ、同じ仕組み(加算して、次のコマで外す)で残しています。
5. 寝返り
寝きっている間(e > 0.99)の経過時間を数えて、設定した秒数ごとに、仰向けと横向きを入れ替えます。
lieT.current += dt;
if (lieT.current >= cfg.autoSec) { lieT.current = 0; lieNow.current = lieNow.current === 'side' ? 'back' : 'side'; }
const sideTarget = sideOk && lieNow.current === 'side' && cur.side.pts ? 1 : 0;
lieBlend.current += Math.sign(sideTarget - lieBlend.current) * Math.min(Math.abs(sideTarget - lieBlend.current), dt / LIE_SEC); // 3.5 秒、一定の速さ
const lb = ease(lieBlend.current); // ゆっくり始まり、ゆっくり止まる
| 設定 | 既定 | 範囲 |
|---|---|---|
寝返りで入れ替える(auto) | ON | ON / OFF |
間隔(autoSec) | 120 秒 | 20〜600 秒 |
寝姿(lie) | 横向き | 仰向け / 横向き(手で選ぶと、時間を数え直す) |
DOM の T パネルと、VR パネル(AUTO TURN OVER と、間隔の −/+)の両方から、変えられます。
6. 顔と胸の扱い
仰向けのときは、顔を自分の方へ向けるために、頭のボーンの回転を直接書いています(HEAD_TURN)。横向きでは、これをやめて、ポーズに入っている顔の向きのままにします。
ポーズの .vrma に UpperChest のトラックが無かったため、胸のひねりは、Spine と Chest の 2 本にしか配れません。
7. VPD → VRMA 変換
横向きのポーズは、VRM のポーズエディターで作りました。MMD のポーズファイル(.vpd)も使えるように、VMD→VRMA 変換ツール(vmd-to-vrma)に、VPD の読み込みを足しています。
7.1 VPD を VMD にする
VPD はテキスト(Shift_JIS)で、ボーンごとに名前・位置・回転が並びます。
Bone12{センター
0.000000,0.000000,0.000000;
0.000000,0.000000,0.000000,1.000000;
}
VMD の 2 フレーム(0 と 30)に変換して、既存のパスへ流します。ボーン名は、ブラウザに Shift_JIS のエンコーダーが無いため、バイト列のまま、VMD の 15 バイトの名前欄へ入れます。
7.2 寝たポーズで崩れる原因と、補正
立ち姿のポーズは動いても、寝たポーズで崩れました。直した点は、次のとおりです。
| 症状 | 原因 | 補正 |
|---|---|---|
| 体が宙に浮く・床に埋まる | 「全ての親」を回したとき、腰の高さの計算が、回転の中心(pivot)の移動を見ていなかった | 腰の位置に qrot(root, pivot) - pivot を足す |
| 膝が床の下・足が逆向き | 膝の向きを決める基準の向き(ポールベクトル)が、固定の「前」だった | 太ももの振りに合わせて、基準の向きも回す。縮退したときは腰の上方向へ戻す |
| 太ももを前に出すと足がねじれる | 同上。太ももが前を向くと、固定の前方向と平行になって、基準が定まらなかった | 同上 |
| 別モデル用の VPD で足が壊れる | 足IK親(足 IK の親ボーン)の位置が、無視されていた | 左足IK親 / 右足IK親 を読み、足首の目標位置に足す |
膝の向きは、2 本の骨(太もも・すね、長さは同じ 4.5)の足首の目標位置から、解析的に解いています。基準の向きを、太ももの回転に連れて回すのが、寝たポーズでの要点でした。
const pole = qrot(qFromTo(qrot(Hrot, [0, -1, 0]), u), qrot(Hrot, [0, 0, -1]));
Hrot は腰の回転、u は腰から足首の目標への向きです。
7.3 MMD のプレビューは別の計算
変換ツールの MMD 側のプレビューは、three.js の MMDAnimationHelper(IK あり)で、変換の計算とは独立です。変換側を直しても、プレビューは変わりません。モデルの PMX に 足IK親 が無いと、プレビューは VPD の 足IK親 の位置を無視する、と見ています(PMX エディターでの確認は未実施)。
7.4 配布ポーズ
ニコニコ動画などで配布されているポーズ集では、モデルが違うため、IK 親の有無・全ての親の使い方がまちまちで、すべてが正しく変換できるわけではありません。確認できたのは、手元で作ったポーズと、一部の配布ポーズです。
8. 作ったポーズの検査
エディターで作ったポーズが、仕様どおりか、疑ったときの確認方法です。.vrma の glTF から、ノードの回転を読んで、順運動学で関節の位置を計算します。
- glTF の JSON(
animations[0].channelsとsamplers)から、各ノードのrotationを、バイナリのアクセサーから読む - ノードの階層(
children)に沿って、ワールドの位置を計算する - 腰・太もも・膝・足首の位置から、膝の角度(太ももと、すねのなす角)と、腰の線と肩の線のねじれを出す
この確認で、次のことが分かりました。
- ファイルは壊れていませんでした。腰の高さは 0.1m、頭と足が x 軸方向に並ぶ、横向きの姿勢でした
- 最初の版は、左膝の角度が約 143° で、足が曲がりすぎていました
- 胸のボーン 1 本に、約 39° のひねりが集中していました。
SpineとChestに半分ずつ(約 17° ずつ)配り直した版を使っています - ノードの回転は、
.vrmaのアニメーションのバイナリ側の値です。ノードに書かれたrotation(立ち姿の初期値)を見ると、立ったポーズに見えるので、注意が必要です
9. 間違いの記録
- 「ファイルが壊れている」と疑った: 実際は、ファイルの構造は正常で、ポーズの値が極端でした。ノードの初期値だけを見て、立ち姿と判断したのが原因でした。アニメーションのバイナリの値まで読んでから判断します
- 加算した回転が積み重なった:
AnimationMixerが、毎コマ、すべてのボーンを書き戻すと決めてかかりました - 測定をワールド座標で行った: VR で、部屋が動くことを考えていませんでした
- VPD の変換: 手元にあったポーズファイルは、ボーン名の文字化けで、検証用に別のデータで作り直しました。実際のファイルでの結果は、ユーザーの確認が必要でした
10. 制約
| 項目 | 内容 |
|---|---|
| 寝返りの途中 | 仰向け(体全体を倒す)と横向き(骨のポーズ)は、作りが違うため、混ぜている間、体が一時的にベッドから浮いて見える |
| 横向きの胸 | 呼吸の動きは付けていない(音のみ) |
| ポーズ | 1 つだけ。左右反転は、ポーズ側で行う |
| VPD の変換 | 配布ポーズの一部は、足が崩れる |
| ワンピース型のパジャマ | 足を曲げた寝姿で、裾がめくれて見えることがある。実機で全パジャマの確認が必要 |
関連
実装メモは、docs/sleep-together.md にまとめています。
- 添い寝モードの土台: #201
- ポーズの作成: #198 VRMA MAKER
- VRMA の編集: #196 VRMA EDIT