[Astro #46] WebXRでのVRMアバター姿勢制御 - しゃがみ・ジャンプと自作アニメーションの適用
はじめに
前回の[Astro #45]では、アバターの自律的な追従移動(Autonomous Movement)を実装しました。
今回はその発展として、WebXR環境下におけるユーザーの物理的な動き(Y軸方向の座標変化)を検知し、アバターに「しゃがみ」「立ち上がり」「ジャンプ」のアニメーションを同期させる実装について解説します。
[Astro #45] VR空間におけるアバターの自律的随伴ロジック 〜移動制御による実在感の構築〜 // PROTOCOL.LAIN
AstroとReact Three Fiber(R3F)を用いたWebXR開発において、アバターに意志を感じさせる移動ステートマシンとlerpによる滑らかな追従・帰還処理の実装ポイントをまとめました。
lain-lab.comNOTE:
WebXRでヘッドセットの高さの変化に合わせたモーション制御と、アニメーションの自作|lain
昨日、VR空間にいるアバターがユーザーの動きに追従する機能を実装しましたが、今回はさらに一歩進めて、ヘッドセットの高さの変化に合わせて「しゃがみ」や「ジャンプ」の動きにアバターを連動させるアニメーションを実装してみました。 アニメーションをゼロから自作 今回は、アバターを動かすためのアニメーション(モーションデータ)も一から自分で作ってみました。 lumis氏が制作された「VRMViewMeister」を使わせてもらってます。VRM鎮守府ポータル - VRMViewMeisterVRMにはMMDのようにその場でゼロから自由にモーションを作れるアプリがない・・・?
note.comYouTube:
[Astro / React] WebXR VRM Height Sync: Squat & Jump Motion Test
This is a technical demonstration of synchronizing a VRM avatar’s posture (squatting and jumping) based on the physical height of the WebXR headset.By implem...
www.youtube.com動画(VR):
1. モーションデータの事前ロード
このセクションでは、React Three Fiber(R3F)環境において、アバターのアニメーション切り替え時に発生する遅延(ラグ)を防ぐための「アセットの事前読み込み(Pre-loading)」を実装しています。
実装のコアとなる技術要素とその役割は以下の通りです。
1.1. 実装の目的
WebXR環境では、ユーザーの物理的な動き(しゃがむ、ジャンプする等)に対して、即座にアバターのモーションを同期させる必要があります。もし状態が変化した瞬間に都度ファイルをフェッチ(HTTPリクエスト)すると、通信とパースのパース時間による遅延が発生し、体験が損なわれます。
コンポーネントのマウント時に useLoader を用いてすべての .vrma ファイルをメモリ上にキャッシュしておくことで、ステート遷移時に即座にアニメーションを再生(crossFadeTo や play)できる状態を担保しています。
1.2. コアとなる技術要素
useLoader(React Three Fiber) R3Fが提供するフックで、Promiseを返すローダーをラップし、Reactのコンカレントモード(Suspense)と連携して非同期処理を行います。同じURLのファイルに対するリクエストは自動的にキャッシュされるため、無駄な再読み込みを防ぎます。GLTFLoader(Three.js).vrma(VRM Animation)ファイルは、内部的には標準的なGLTF 2.0フォーマットの拡張です。そのため、ベースとなるファイルの読み込みにはThree.js標準のGLTFLoaderを使用します。VRMAnimationLoaderPlugin(@pixiv/three-vrm-animation) 標準のGLTFLoaderだけでは、VRM特有のHumanoidボーンのマッピングやExpression(表情)のウェイトデータを正しく解釈できません。ローダーのコールバック関数内でloader.register(...)を用いてこのプラグインを登録することで、GLTFファイル内のVRMC_vrm_animation拡張データをパースし、VRMモデルに適用可能な形式に変換します。
1.3. 読み込まれたデータの構造
各変数(standingPoseGltf, sittingPoseGltf 等)には、パース済みのGLTFオブジェクトが格納されます。
このオブジェクト内の userData.vrmAnimations 配列に、プラグインによって抽出・変換されたアニメーションデータが保持されています。後続の処理(状態遷移ロジック)において、このデータと対象のVRMモデルを引数として createVRMAnimationClip() を呼び出すことで、最終的な THREE.AnimationClip を生成します。
1.4. 実装上の前提条件
このコードを含む WiredAvatar コンポーネントは、親コンポーネント側で <Suspense fallback="{...}"> でラップされている必要があります。useLoader はファイルの読み込みが完了するまでコンポーネントのレンダリングをサスペンド(一時停止)させる仕様であるため、この設計により「すべてのアニメーションが読み込まれてからアバターが表示される」という安全なライフサイクルが保証されます。
2. 高さ判定の閾値(Threshold)設定
このセクションでは、WebXRのトラッキングデータ(ヘッドセットの高さ)を入力値として用い、ユーザーの現在の姿勢(しゃがみ、直立、ジャンプ)を判定するロジックの根幹を解説します。
実装における技術的なポイントは以下の通りです。
2.1. ヘッドセットのワールド座標取得
アバターの姿勢を決定する基準として、ユーザーの頭の動きに完全に追従するWebXRカメラのY座標(高さ)を利用します。
ここで重要なのは、camera.position(ローカル座標)ではなく、camera.getWorldPosition(camWorldPos) を用いてワールド座標(絶対座標)を取得している点です。VR空間内では、カメラの親オブジェクトの移動や回転によってローカル座標の基準が変わる可能性があります。ワールド座標を用いることで、ユーザーがプレイスペース内のどこへ移動しても、床面を基準とした正確な絶対高さを安定して取得できます。
2.2. 閾値(Threshold)の設計意図
取得したY座標(camWorldPos.y)に対して、状態遷移のトリガーとなる3つの境界値(Threshold)を設定します。
SQUAT_THRESHOLD = -0.3(しゃがみ判定) 基準となるゼロ地点から下に0.3m(30cm)以上ヘッドセットが沈み込んだ場合に「しゃがみ」と判定します。この値は、歩行時の頭の揺れ(ボビング)や、少し下を向いた程度の浅い動きでの誤検知を防ぎつつ、意図的にしゃがんだアクションには確実に反応するためのマージンとして機能します。STAND_THRESHOLD = 0.0(立ち上がり・通常判定) しゃがみ状態から復帰したことを検知するニュートラルな基準値です。ヘッドセットの高さが0.0を上回った段階で「立ち上がった」と判定し、後述のアニメーション遷移によってアバターを元の待機(IDLE)状態へと戻します。JUMP_THRESHOLD = 0.4(ジャンプ判定) ヘッドセットが基準より0.4m(40cm)以上上方に跳ね上がった場合に「ジャンプ」と判定します。背伸びやつま先立ちでの誤爆を防ぐための値です。 (※補足: ルームスケールVR環境では、ユーザーの実際の身長や、OS側でのフロアキャリブレーションの設定によって基準となるY座標の中央値が変動する場合があります。そのため、環境に応じてこの閾値幅は微調整、あるいは動的キャリブレーションを行うのが理想的です。)
2.3. フレーム単位でのリアルタイム監視
この座標取得と閾値判定のロジックは、R3Fの useFrame フック内に記述されているため、描画の毎フレーム(VR環境下では通常72〜90fps、または120fps)実行されます。
ユーザーの物理的な上下運動(Delta値の変化)をリアルタイムかつ高頻度で監視し続けることで、次セクションの「状態遷移」へと遅延なくシグナルを渡し、VR特有の違和感(ラグ)を排除した身体同期を実現しています。
3. 状態遷移ロジックの実装(詳細解説)
このセクションは、本実装において最も重要かつ難易度の高い「Reactの宣言的UIモデルと、Three.jsの命令型アニメーション制御のハイブリッド設計」を解説しています。
実装における技術的なポイントは以下の通りです。
3.1. 宣言的制御と命令的制御の使い分け
通常、Reactでは状態(State)を更新し、その変更にフックして描画を切り替えます(setAnimPathによる宣言的制御)。しかし、ジャンプや立ち上がりのような「ユーザーの身体的アクションに瞬時に同期すべきモーション」において、Reactの再レンダリングサイクルを跨ぐと、数フレームの遅延(ラグ)やTポーズの原因となります。
そのため、ここでは setAnimPath でReact側の状態を同期させつつも、実際のモーション切り替えは AnimationMixer のAPI(mixer.clipAction().play())を直接叩く「命令型(Imperative)」のアプローチを採用しています。これにより、フレーム落ちや遅延のない即時反映を実現しています。
3.2. ① ジャンプ判定のロジック
ジャンプは滞空時間が短く、素早い移行が求められるアクションです。
- 多重トリガーの防止:
if (!animPath.includes('GirlJump') && ...)によって、すでにジャンプ中である場合は処理をスキップし、アニメーションが毎フレーム初期化されて固まるバグを防いでいます。 - フェード補間の最適化:
fadeIn(0.1)と極端に短いクロスフェード時間を設定しています。通常の歩行等は0.2〜0.3秒が自然ですが、ジャンプのような瞬発的な動作は0.1秒で強制的に上書きすることで、もたつきを排除しています。 - 再生モード制御:
setLoop(THREE.LoopOnce, 1)で1回再生に限定し、clampWhenFinished = falseとすることで、ジャンプ終了後に最後のフレームで固まることなく、自然と次の待機(IDLE)状態のウェイト計算へ戻るように設計されています。
3.3. ② しゃがみ(Sit)判定のロジック
しゃがみは「動作」ではなく「姿勢の維持」であるため、ジャンプとは異なる制御を行っています。
- 姿勢のホールド(保持):
animModeをONCE_AND_HOLDに設定しています。対応するuseEffect側(前回実装部)でclampWhenFinished = trueとして処理されるため、アニメーションの最終フレーム(完全にしゃがみ切った状態)でポーズが固定され、ユーザーが立ち上がるまでその状態を維持します。 - 表情(BlendShape)の連動: 視界が下がるという非日常的なアクションに対し、
manager.setValue('surprised', 1.0)を呼び出して表情モーフ(驚き)を連動させています。姿勢と表情をセットで制御することで、アバターの生体表現(リアリティ)を向上させています。
3.4. ③ 立ち上がり(Stand)判定のロジック
しゃがみ状態から通常の高さに戻った際の「復帰」処理です。
- 状態の絞り込み: 単に
camWorldPos.y >= STAND_THRESHOLDとするだけでは、通常立っている状態でも毎フレーム発火してしまいます。そのため、if (s.animMode === 'ONCE_AND_HOLD' && animPath.includes('sitting'))という厳密な条件式を用いて、「現在しゃがみ状態から復帰しようとしている瞬間」のみをフックしています。 - トランジション(遷移)アニメーションの挿入: 座り状態からいきなり立ち状態(IDLE)へクロスフェードすると、足のボーン軌道が破綻し、不自然にスライドして立ち上がってしまいます。これを防ぐため、明示的に
standing_up.vrma(立ち上がる過程のモーション)を挟み、0.2秒の自然なフェードで繋ぐことで、物理的におかしくない滑らかなトランジションを実現しています。同時に表情も0.0にリセットしています。
余談:VRMAアニメーションの自作について
今回はシステム側でのステート管理だけでなく、「しゃがみ」「立ち上がり」「ジャンプ」といった一連のアニメーションデータ(.vrma)自体も自作して実装に組み込んでいます。
WebXR等のインタラクティブな環境では、既存のモーションアセットをそのまま適用するだけでは、カメラの高さやアバターのスケール感と実際の挙動が噛み合わないケースが多々発生します。そのため、ボーンの可動域や滞空時間のフレーム数をプロジェクトの物理法則に合わせて細かく調整し、.vrmaフォーマットとして書き出すフローを構築しました。
このようにモデルとモーションの両軸をコントロールすることで、結果的にThree.js上のAnimationMixerでのフェード補間(fadeIn)がより自然に繋がり、VR空間での身体性のシンクロ度を高めることができました。