[Astro #46] WebXRでのVRMアバター姿勢制御 - しゃがみ・ジャンプと自作アニメーションの適用

[Astro #46] WebXRでのVRMアバター姿勢制御 - しゃがみ・ジャンプと自作アニメーションの適用

はじめに

前回の[Astro #45]では、アバターの自律的な追従移動(Autonomous Movement)を実装しました。

今回はその発展として、WebXR環境下におけるユーザーの物理的な動き(Y軸方向の座標変化)を検知し、アバターに「しゃがみ」「立ち上がり」「ジャンプ」のアニメーションを同期させる実装について解説します。

NOTE:

YouTube:

動画(VR):

1. モーションデータの事前ロード

このセクションでは、React Three Fiber(R3F)環境において、アバターのアニメーション切り替え時に発生する遅延(ラグ)を防ぐための「アセットの事前読み込み(Pre-loading)」を実装しています。

実装のコアとなる技術要素とその役割は以下の通りです。

1.1. 実装の目的

WebXR環境では、ユーザーの物理的な動き(しゃがむ、ジャンプする等)に対して、即座にアバターのモーションを同期させる必要があります。もし状態が変化した瞬間に都度ファイルをフェッチ(HTTPリクエスト)すると、通信とパースのパース時間による遅延が発生し、体験が損なわれます。 コンポーネントのマウント時に useLoader を用いてすべての .vrma ファイルをメモリ上にキャッシュしておくことで、ステート遷移時に即座にアニメーションを再生(crossFadeToplay)できる状態を担保しています。

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)判定のロジック

しゃがみは「動作」ではなく「姿勢の維持」であるため、ジャンプとは異なる制御を行っています。

  • 姿勢のホールド(保持): animModeONCE_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空間での身体性のシンクロ度を高めることができました。

VRMAアニメーションの自作について