[Astro] #202 横向きの寝姿を VRMA で読み込む(自動設置・寝返りのクロスフェード・VPD→VRMA 変換)

[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)ONON / 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 から、ノードの回転を読んで、順運動学で関節の位置を計算します。

  1. glTF の JSON(animations[0].channels と samplers)から、各ノードの rotation を、バイナリのアクセサーから読む
  2. ノードの階層(children)に沿って、ワールドの位置を計算する
  3. 腰・太もも・膝・足首の位置から、膝の角度(太ももと、すねのなす角)と、腰の線と肩の線のねじれを出す

この確認で、次のことが分かりました。

  • ファイルは壊れていませんでした。腰の高さは 0.1m、頭と足が x 軸方向に並ぶ、横向きの姿勢でした
  • 最初の版は、左膝の角度が約 143° で、足が曲がりすぎていました
  • 胸のボーン 1 本に、約 39° のひねりが集中していました。Spine と Chest に半分ずつ(約 17° ずつ)配り直した版を使っています
  • ノードの回転は、.vrma のアニメーションのバイナリ側の値です。ノードに書かれた rotation(立ち姿の初期値)を見ると、立ったポーズに見えるので、注意が必要です

9. 間違いの記録

  • 「ファイルが壊れている」と疑った: 実際は、ファイルの構造は正常で、ポーズの値が極端でした。ノードの初期値だけを見て、立ち姿と判断したのが原因でした。アニメーションのバイナリの値まで読んでから判断します
  • 加算した回転が積み重なった: AnimationMixer が、毎コマ、すべてのボーンを書き戻すと決めてかかりました
  • 測定をワールド座標で行った: VR で、部屋が動くことを考えていませんでした
  • VPD の変換: 手元にあったポーズファイルは、ボーン名の文字化けで、検証用に別のデータで作り直しました。実際のファイルでの結果は、ユーザーの確認が必要でした

10. 制約

項目内容
寝返りの途中仰向け(体全体を倒す)と横向き(骨のポーズ)は、作りが違うため、混ぜている間、体が一時的にベッドから浮いて見える
横向きの胸呼吸の動きは付けていない(音のみ)
ポーズ1 つだけ。左右反転は、ポーズ側で行う
VPD の変換配布ポーズの一部は、足が崩れる
ワンピース型のパジャマ足を曲げた寝姿で、裾がめくれて見えることがある。実機で全パジャマの確認が必要

関連

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