[Astro/WebXR #47] VRMアバターのTポーズ問題を解消する 〜AnimationMixerの安全なクロスフェードと警告との戦い〜

[Astro/WebXR #47] VRMアバターのTポーズ問題を解消する 〜AnimationMixerの安全なクロスフェードと警告との戦い〜

はじめに:遷移の狭間に現れる「虚無」

WebXR空間でVRMアバターを自律的に動かしていると、必ずぶつかる壁がある。アニメーション(モーション)を切り替える瞬間に、一瞬だけアバターが初期状態である「Tポーズ(またはAポーズ)」に戻ってしまう現象。

過去、2回この問題に挑み挫折していますが、今回は、根本から設計を見直して作り直す試みを改めてAIと壁打ちして作戦を練り、 Three.jsの AnimationMixer を正しく制御し、このTポーズ地獄から抜け出すための具体的なアプローチと、その過程で立ちはだかったライブラリの警告(仕様)との果てしない戦いの記録を残しておきます。

前回の記事:

原因:急激なアクションの切断と、AnimationMixerの仕様

WebXR空間などでVRMアバターを動かす際、アニメーションの切り替え時に一瞬だけアバターが「Tポーズ(またはAポーズ)」という初期姿勢に戻ってしまう現象があります。この問題の根本的な原因は、Three.jsの AnimationMixer の仕様と、アクション(AnimationAction)の急激な停止処理にあります。

当初の実装において、Tポーズが発生してしまったコードのロジックは以下のようなものでした。

  1. 新しいアニメーションの指示が入る(例:IDLEからジャンプへ)
  2. 現在再生中のアクションを stop()reset() で強制的に停止させる
  3. 新しいアクションを play() で再生開始する

一見すると論理的で正しい手順に思えますが、実はこの「ステップ2」に大きな落とし穴が存在します。

AnimationMixerとアバターの姿勢の仕組み

Three.jsにおいて、AnimationMixer は複数のアニメーションを統合・管理し、アバター(3Dモデル)の骨格(ボーン)に対して「どのように動くべきか」という影響力(ウェイト)を与え続ける役割を持っています。

再生中の AnimationAction(例えば待機モーション)は、アバターの各ボーンに対して「今の状態はこの角度だ」と常に指示を出し続けています。この影響力が存在するからこそ、アバターは自然な姿勢を保つことができています。

stop() がもたらす「影響力の喪失」

ここで、現在のアクションに対して急激に stop() メソッドを呼び出すとどうなるでしょうか。

stop() が実行された瞬間、そのアクションがアバターに与えていた「影響力」は即座に 0(ゼロ) にリセットされます。つまり、アバターは現在のアニメーションからの指示を完全に失ってしまうのです。

この「古いアクションの影響力が0になった瞬間」から、「新しいアクションが十分に影響力を発揮する(ウェイトが1になる)瞬間」までの間に、ごくわずかな空白の時間が生まれます。

この空白の時間、アバターはどのモーションからも指示を受けていない状態になります。指示がない場合、3Dモデルはデフォルトの初期姿勢である「Tポーズ(またはAポーズ)」をとる仕様になっています。

これが、アニメーション切り替え時に「一瞬だけ素のTポーズが露出してしまう」現象の正体です。

なぜ「一瞬」なのか?

プログラムの処理は非常に高速で行われるため、古いアクションを停止して新しいアクションを再生するまでのラグは、時間にしてほんの数ミリ秒から数十ミリ秒程度です。しかし、人間の目は非常に敏感であり、特にVRのような高い没入感が求められる空間では、この一瞬の「チラつき(Tポーズの露出)」が致命的な違和感となってユーザーに伝わってしまいます。

つまり、Tポーズ問題を解決するためには、「影響力が0になる瞬間(空白の時間)」をシステム上から完全に排除する仕組みが必要不可欠となるのです。

解決策:安全なクロスフェード関数(playAnimation)の統合

Tポーズ問題を根本から解決するための絶対法則、それは「古いモーションをゆっくりとフェードアウトさせながら、同時に新しいモーションをフェードインさせる(クロスフェード)」ことです。アニメーションがモデルに与える影響力を、一瞬たりとも「ゼロ」に落としてはなりません。

この処理をシステム全体で安全かつ確実に実行するため、すべてのアニメーション再生処理を司る単一の専用関数 playAnimation を実装しました。

アニメーション管理の一元化

これまでは、アクションを切り替えるたびに各所で個別に mixer.clipAction() を呼び出していましたが、これでは状態管理が複雑になり、予期せぬバグやTポーズの温床になります。 そこで、アニメーションの挙動(ループ設定など)もすべてこの関数内で一元管理できる堅牢な設計に変更しました。

// ★ アニメーションを安全に切り替える専用関数
const playAnimation = useCallback((clip: THREE.AnimationClip, mode: 'LOOP' | 'ONCE_AND_RETURN' | 'ONCE_AND_HOLD', duration = 0.3) => {
  if (!mixerRef.current) return;
  const mixer = mixerRef.current;

  // 新しいアクションを生成
  const newAction = mixer.clipAction(clip);

  // モードに応じたループ設定
  if (mode === 'ONCE_AND_RETURN' || mode === 'ONCE_AND_HOLD') {
    newAction.setLoop(THREE.LoopOnce, 1);
    newAction.clampWhenFinished = (mode === 'ONCE_AND_HOLD'); // 再生終了後にその姿勢を保持するかどうか
  } else {
    newAction.setLoop(THREE.LoopRepeat, Infinity);
    newAction.clampWhenFinished = false;
  }

  newAction.reset();

  // ★ 核心部:古いアクションがあればフェードアウト、新しいアクションをフェードイン
  if (currentActionRef.current && currentActionRef.current !== newAction) {
    const oldAction = currentActionRef.current;
    // ❌ stop や uncacheAction は絶対に使わない!
    // ⭕ 自然にフェードアウトさせる
    oldAction.fadeOut(duration);
    newAction.fadeIn(duration).play();
  } else {
    // 初回再生時
    mixer.stopAllAction();
    newAction.fadeIn(duration).play();
  }

  currentActionRef.current = newAction;
}, []);

コードの重要ポイント

  1. 3つの再生モード(mode)による柔軟な制御 アニメーションには「歩行」のようにずっと繰り返すものと、「座る」のように一度だけ再生して姿勢を固定するものがあります。これを LOOPONCE_AND_RETURNONCE_AND_HOLD という3つのモードに分類しました。 特に clampWhenFinished を適切に制御することで、最終フレームの姿勢を綺麗に保持(ホールド)したまま待機させることが可能になっています。
  2. fadeOutfadeIn による影響力のリレー(核心部) 現在再生中のアクション(oldAction)が存在する場合、決して stop() を呼ばず、代わりに fadeOut(duration) を実行します。これにより、指定した時間(デフォルト0.3秒)をかけて古いモーションの影響力が徐々に弱まっていきます。 それと全く同じタイミングで、新しいモーションを fadeIn(duration).play() させることで、影響力がシームレスにバトンタッチされ、アバターが極めて滑らかに姿勢を変えてくれます。

システム全体の安定化

この専用関数を用意したことで、アバターに対するあらゆる命令を安全な非同期処理として捌けるようになりました。

キーボード入力(Sキーでの座り動作)はもちろんのこと、VRヘッドセットの高さ情報をリアルタイムに検知して発火する「自律的なジャンプ」や「しゃがみ動作」なども、すべてこの playAnimation を経由させています。

「現在の状態が何であれ、この関数を通せば安全に次のモーションへ遷移できる」という共通規格ができたことで、どのような複雑な動きが連続しても、もう二度とアバターがTポーズの虚無に落ちることはなくなりました。

番外編:終わらない警告メッセージとの死闘

Tポーズ問題の解決と並行して、コンソールを埋め尽くす不快な警告メッセージの根絶にも取り組んだ。これらは動作自体には影響しないものの、精神衛生上非常によろしくない。

1. VRMLookAtQuaternionProxy is not found. の謎

@pixiv/three-vrm-animation を使ってアニメーションを生成する際、常にこの警告が出ていた。 結論から言えば、これは「シーンの中にプロキシを追加しろ」という意味ではなく、「createVRMAnimationClip 関数の第3引数に、手動で作成したプロキシを直接渡せ」という非常にシンプルな仕様だった。

修正前:

const clip = createVRMAnimationClip(vrma.userData.vrmAnimations[0], vrm);

修正後(完全解):

// 安全な options オブジェクトを作り、第3引数に渡す
const options = vrm.lookAt ? { lookAtQuaternionProxy: new VRMLookAtQuaternionProxy(vrm.lookAt) } : undefined;
const clip = createVRMAnimationClip(vrma.userData.vrmAnimations[0], vrm, options);

これで、プロキシに関する無限ループの警告は完全に消滅した。

2. specVersion of the VRMA is not defined. の追及

もう一つの厄介な警告。これはこちらのコードの問題ではなく、読み込んでいる .vrma ファイル自体に「これはバージョン1.0規格です」という署名メタデータが欠落しているために発生していた。

UnityのAIモーション変換ツール(C#)が出力するバイナリファイルのお作法に不備があったのだ。

FBXからの自作変換スクリプト(Node.js)では正しく署名を付与できていたため、最終的にはUnity側のアセット書き出し処理(AnimationClipToVrmaCore.cs)を直接ハックし、強制的に署名を刻み込むことで解決を見た。

// Unity側のエクスポート処理をハック
if (data.Gltf.extensions.VRMC_vrm_animation != null)
{
    data.Gltf.extensions.VRMC_vrm_animation.specVersion = "1.0";
}

(※最終手段として、どうしても消えないViteのキャッシュやライブラリの過保護なインフォメーションに対しては、console.warn をオーバーライドしてノイズを物理的に遮断する「パワープレイ」もシステム設計の一環として導入した。)


まとめ

  • アバターのTポーズ化は、AnimationMixerの fadeOut / fadeIn を使った安全なクロスフェード処理を徹底することで防げる。
  • Three-vrm関連の警告は、オプションの渡し方(第3引数)と、VRMAファイル自体の仕様(メタデータの完全性)を理解することで対処可能。
  • 外部ツールの不備は、自分の手でパッチを当てて(あるいは握り潰して)システムを浄化すべし。