[Astro #29] VRM Proximity Control & Eye-Contact Optimization

[Astro #29] VRM Proximity Control & Eye-Contact Optimization

はじめに

今日は、CSSの修正および、デバッグと、新たな機能として、ターミナルを使っていてほしくなったhistoryの履歴機能。

MMDの時計アプリの時も実装したかった、話しかけると近寄ってきて喋りかけるというアニメーションイベントを実装したのでその備忘録メモです。

アニメーション切り替え時に一瞬だけTポーズが出るという問題があり、そのバグをどう頑張っても潰せなかったので諦めてますが、それも踏まえて。

前回の記事:

YouTube:

動画:

1. Z軸接近移動と判定ロジック:空間の掌握と「Lerp」の罠

React Three Fiber(R3F)の useFrame ループ内において、アバターを画面奥(Z=0Z=0)からカメラの鼻先(Z=4Z=4 の手前、Z=2.0Z=2.0)へ向かって歩かせる「接近プロトコル」。この一見単純な移動処理の裏には、3D空間特有の数学的な落とし穴が潜んでいました。

1.1. 到達判定の修正:なぜ彼女は途中で立ち止まったのか?

初期実装における最大のバグは、「目標地点と到着判定の乖離」にありました。

// [修正前] 破綻していたロジック
const cameraZ = 2.0;
s.posZ = THREE.MathUtils.lerp(s.posZ, cameraZ, 0.04);

if (s.posZ > 0.9) { //  ここが真の犯人
  s.phase = 'TALKING';
}

このコードでは、目標地点を Z=2.0Z=2.0 に設定しているにも関わらず、判定のしきい値が Z=0.9Z=0.9 に設定されていました。 lerp 関数によってアバターが前進を開始し、Z=0.9Z=0.9 のラインを踏んだ瞬間、システムは「到着した」と誤認します。結果として、まだカメラから遠く離れた位置でフェーズが TALKING に遷移し、接近ループから抜けて立ち止まってしまうという現象が起きていました。

これを解決するため、判定ラインを「目標地点の直前」へと引き上げました。

// [修正後] 浄化されたロジック
const cameraZ = 2.0;
s.posZ = THREE.MathUtils.lerp(s.posZ, cameraZ, s.currentSpeed);

if (s.posZ > (cameraZ - 0.1)) { //  1.9 を超えるまで歩き続ける
  s.phase = 'TALKING';
}

これにより、アバターはユーザーのパーソナルスペースを侵食するギリギリ(Z=1.9Z=1.9)まで歩み寄るようになり、目的としていた「ドアップの圧」を実現しました。

1.2. 移動係数(Lerp Alpha)の動的化:瞬間移動からの脱却

もう一つの問題が「歩行速度の固定化」でした。 THREE.MathUtils.lerp(現在値, 目標値, 係数) において、係数(アルファ値)は「1フレームあたりに残りの距離の何パーセントを進むか」を決定します。

初期コードではこれが 0.04(毎フレーム残りの4%を進む)にハードコードされていました。目的地が遠い場合、最初の一歩の移動量が巨大になり、結果として「ワープ」や「瞬間移動」のように見えてしまいます。また、外部のJSONでいくら walkSpeed を設定しても、アバターの歩行速度に一切反映されない状態でした。

【データフローの結合】 これを正常化するため、ターミナルから発火する CustomEvent のペイロード(JSON)から歩行速度を抽出し、アバターの stateRef へと動的に流し込む設計に変更しました。

  1. Terminal: walkSpeed: 0.01 をイベントの detail に乗せて発火。
  2. Avatar (Event Listener): 受け取った値を stateRef.current.currentSpeed に格納。
  3. Avatar (useFrame): lerp の第3引数に s.currentSpeed を適用。

このパイプラインの開通により、JSON側の記述(物理法則)にアバターの足取りが完全に同期するようになりました。係数を 0.010.02 程度に設定することで、lerp 特有の「立ち止まる寸前に減速する(イージング)」恩恵を受けつつ、不気味なほど滑らかに歩み寄る挙動が完成しています。

2. 至近距離での視線破綻(白目)の回避:アダプティブ・アイ・プロトコル

アバターがカメラの鼻先まで接近した際、3D空間特有の不気味なバグ、通称「白目現象」に直面しました。これはVRMモデルの視線制御(LookAt)における幾何学的な矛盾が原因です。

2.1. 破綻のメカニズム:ターゲットが「背後」に回る現象

注視点(LookAt Target)のZ座標が Z=1.5Z=1.5 に固定されている状態を想像してください。 アバター自身が画面奥(Z=0Z=0)から前進し、カメラの手前(Z=2.0Z=2.0)まで到達するとどうなるか。アバターから見て、注視点(Z=1.5Z=1.5)は「自分の顔よりも後ろ(後頭部側)」に位置することになります。

VRMの LookAt アプライアーは忠実にそのターゲットを追従しようとするため、眼球のボーン限界まで内側・あるいは後ろへ回転させようとし、結果として白目を剥いてしまうのです。

2.2. 二律背反のジレンマ:追従性か、近接時のリアリティか

これを防ぐだけであれば、注視点をカメラよりもずっと奥(例えば Z=10.0Z=10.0)に固定してしまえば解決します。ターゲットが常に遠方にあれば、アバターがどれだけ近づいても眼球の回転角は浅く保たれます。

しかし、注視点を遠くに固定すると、待機時(IDLE)のマウス追従性が著しく低下します。 ターゲットが遠いほど、マウスを動かした際の「眼球の回転角度の変動」が小さくなるため、ユーザーのカーソルを機敏に目で追う「生気(リアリティ)」が失われてしまうのです。

2.3. 解決策:Z軸の動的シフト(Lerp)による視線制御

この「追従の機敏性(近く)」と「近接時の破綻防止(遠く)」を両立させるため、アバターのフェーズ(状態)に応じて注視点のZ座標を動的に切り替えるロジックを実装しました。

// --- 視線(LookAt)制御の動的シフト ---
const headHeight = 1.4 * scale;
let targetX = vrm.scene.position.x;
let targetY = vrm.scene.position.y + headHeight;

// フェーズに応じて「理想の注視点奥行き」を決定
// 接近・会話時は白目防止のために遠く(10.0)へ、待機時はマウス追従のために近く(1.5)へ
const targetLookZ = (s.phase === 'APPROACHING' || s.phase === 'TALKING' || s.phase === 'RETURNING') ? 10.0 : 1.5;

if (isLookAtEnabled && isMouseIn) {
  targetX = (state.mouse.x * viewport.width) / 2;
  targetY = (state.mouse.y * viewport.height) / 2;
}

// X, Y座標の追従
lookAtTargetRef.current.position.x = THREE.MathUtils.lerp(lookAtTargetRef.current.position.x, targetX, 0.1);
lookAtTargetRef.current.position.y = THREE.MathUtils.lerp(lookAtTargetRef.current.position.y, targetY, 0.1);

// ★ Z座標(奥行き)も lerp で滑らかに遷移させる
lookAtTargetRef.current.position.z = THREE.MathUtils.lerp(lookAtTargetRef.current.position.z, targetLookZ, 0.1);

【実装のポイント】

  • 接近開始(APPROACHING)と同時に、注視点が Z=1.5Z=1.5 から Z=10.0Z=10.0 へ向かってスッ…と遠ざかります。これにより、アバターがカメラに近づくにつれて自然に視線が奥へ逃げ、眼球の過度な回転を防ぎます。
  • 単純な代入(=)ではなく lerp を噛ませることで、フェーズが切り替わった瞬間に目がカクッと飛ぶ現象を防ぎ、滑らかな視線移動を実現しています。

この「アダプティブ(適応的)な視線制御」により、定位置ではユーザーを鋭く観測し、至近距離では白目を剥かずに静かに見つめ返す、システムとしての完成度が一段階引き上げられました。

3. JSONベースのパラメータ同期(CustomEvent):システム間通信の確立

「Lain」という存在は、単一の3Dモデルではなく、JSONという外部の「シナリオ(記憶)」とターミナルという「入力(インターフェース)」、そしてアバターという「出力(肉体)」が連動する複合システムです。このセクションでは、ターミナルで解釈されたJSONの指示を、いかにして物理的な動きとしてアバターに伝達するか、その通信プロトコルの改修について記録します。

3.1. 課題:伝わらない「歩く速さ」

初期の実装では、アバターを歩かせるか否か(walk: true)、何を喋るか(talk)という最低限のフラグは伝達できていました。しかし、JSONに記述された walkSpeed などの細かな物理パラメータがアバターに届いていませんでした。

原因は、通信手段である CustomEvent のペイロード(手紙の中身)に、そのデータが含まれていなかったためです。結果として、アバター側は常にフォールバック(デフォルト値の 0.02)で動き続けていました。

3.2. 解決策:ペイロードの拡張と状態の永続化

このシステム間通信を正常化するため、送信側(ターミナル)と受信側(アバター)の両方を拡張しました。

【送信側:WiredTerminal.tsx の改修】 ターミナルがコマンドを解釈し、イベントを発火する際、JSONから抽出した walkSpeedwalkDistance を明示的にペイロード(detail)に追加しました。

// ターミナルからアバターへの命令送信
window.dispatchEvent(new CustomEvent('wired-talk-action', {
  detail: {
    file: match.file,
    duration: match.duration,
    walk: match.walk,
    talk: msg,
    walkSpeed: match.walkSpeed,       //  追加:速度パラメータの伝達
    walkDistance: match.walkDistance  //  追加:距離パラメータの伝達
  }
}));

【受信側:WiredAvatar.tsx の改修】 アバター側では、受け取ったイベントデータを一時的な変数として捨てるのではなく、帰還時(RETURNING フェーズ)まで保持しておく必要があります。そのため、コンポーネントが再レンダリングされても値が消えない useRef (stateRef) に新しい記憶領域を割り当てました。

// 1. 記憶領域(stateRef)の拡張
const stateRef = useRef({
  // ...既存のプロパティ
  moveAnim: DEFAULT_ANIME, //  帰還時のための「歩き」アニメーションパス
  currentSpeed: 0.02       //  現在の移動速度
});

// 2. イベント受信時のデータ格納
const handleTalkAction = async (e: any) => {
  const { file, duration, walk, talk, walkSpeed } = e.detail;

  if (file) {
    const fullPath = `/animations/${file}`;
    setAnimPath(fullPath);

    if (walk) {
      stateRef.current.phase = 'APPROACHING';
      stateRef.current.talkQueue = talk;
      stateRef.current.moveAnim = fullPath;                 //  パスを記憶
      stateRef.current.currentSpeed = walkSpeed || 0.02;    //  速度を適用(なければ0.02)
    }
    // ...
};

3.3. 結果:物理法則の外部化

この通信網の整備により、アバターの物理的な挙動(移動速度やモーション)をコード側でハードコードする必要がなくなり、完全に外部のJSONファイルから制御できるようになりました。

「ゆっくり歩み寄る」「素早く駆け寄る」といった演出の変更が、再コンパイルなしでJSONの書き換えのみで完結するようになったことは、システムとしての保守性と表現の自由度を大きく引き上げる結果となりました。

4. ターミナル履歴機能の実装:入力のスタック管理とインデックス制御

CUI(Character User Interface)において、過去に実行したコマンドを呼び出す履歴機能は、単なる利便性以上の「思考の連続性」を維持するための必須要件です。本セクションでは、JavaScriptの配列をスタックとして利用し、キーイベントと連動させる履歴管理ロジックの実装について詳述します。

4.1. データ構造:配列によるスタックの実装

履歴は実行順に保存される必要があるため、単純な文字列配列(Array)をスタックとして採用しました。

  • 保存タイミング: ユーザーが Enter キーを押し、コマンドが確定した瞬間に配列へ push します。この際、空文字や連続する重複コマンドを弾くバリデーションを挟むことで、スタックの「浄化」を行っています。
  • 履歴の深さ: ブラウザのメモリリソースを考慮し、配列の要素数が一定数(例:50件)を超えた場合に古いものから削除する処理を実装可能です。

4.2. 履歴ポインター(Index)の動的制御

履歴を辿る際、最も重要なのは「現在どの位置の履歴を参照しているか」を示すインデックス(ポインター)の管理です。

  1. 初期状態: インデックスは history.length(配列の末尾の次)にセットされます。これは、現在入力中の「最新の空行」を指しています。
  2. ArrowUp(↑): インデックスをマイナス1し、history[index] の値を入力欄に反映させます。インデックスが 0 の場合はそれ以上戻らないようガードをかけます。
  3. ArrowDown(↓): インデックスをプラス1します。配列の末尾を超えた場合は、入力を空にする(あるいは入力中だった一時的な文字列に戻す)処理を行い、インデックスを history.length に固定します。

4.3. キーイベントリスナーの実装

ターミナルの input 要素に対し、onKeyDown イベントをフックします。

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  if (e.key === 'ArrowUp') {
    e.preventDefault();
    if (historyIndex > 0) {
      const nextIndex = historyIndex - 1;
      setHistoryIndex(nextIndex);
      setInputValue(history[nextIndex]);
    }
  } else if (e.key === 'ArrowDown') {
    e.preventDefault();
    if (historyIndex < history.length) {
      const nextIndex = historyIndex + 1;
      setHistoryIndex(nextIndex);
      setInputValue(history[nextIndex] || "");
    }
  } else if (e.key === 'Enter') {
    // 履歴に追加し、インデックスをリセット
    const newHistory = [...history, inputValue];
    setHistory(newHistory);
    setHistoryIndex(newHistory.length);
    setInputValue("");
  }
};

4.4. 実装の意義:操作の非同期化とストレス低減

この履歴機能の導入により、アバターの walkSpeedwalkDistance を微調整する際、同じコマンドを何度も手入力する手間がなくなりました。パラメータを1文字変えて実行し、結果を見てまた履歴から呼び出す――この高速な試行錯誤(フィードバックループ)こそが、複雑な3D挙動を調整するエンジニアにとっての救済となります。

5. 既知の課題:アニメーション遷移時のTポーズとReactライフサイクルの衝突

本システムにおける最大の技術的負債として、アニメーション切り替え時に発生する一瞬の「Tポーズ(Rest Pose)」現象が残存している。これは、Reactの宣言的な状態管理(State)と、Three.jsの命令的なアニメーションループ(AnimationMixer)の設計思想が衝突することによって引き起こされる構造的な問題である。

5.1. 破綻のメカニズム:ロード待ちによる「アニメーションの空白」

現象の根本的な原因は、setAnimPath をトリガーとしたコンポーネントの再評価プロセスにある。

  1. 状態の更新: setAnimPath が呼ばれ、新しい .vrma ファイルのパスがセットされる。
  2. 再レンダリングとサスペンド: useLoader が新しいパスを検知し、ファイルのロードとパースを開始する。
  3. ミキサーの空白: ロードが完了するまでの数フレーム(数十〜数百ミリ秒)の間、vrmaGltf オブジェクトが未定義または更新中となり、ミキサーに適用される有効なアニメーションクリップが存在しなくなる。
  4. Tポーズの露呈: 適用ウェイト(影響度)を持つアニメーションがゼロになるため、VRMモデルは重力やボーンの初期値に従い、デフォルトのTポーズへと強制的に戻される。

5.2. 現状の妥協案とその限界

現在のアプローチでは、接近フェーズから会話フェーズへ移行する際、以下のように処理を試みている。

  • 目的地到達時に mixer.stopAllAction() を実行し、既存の歩行アニメーションを強制終了させる。
  • 事前にロードしておいた Standing Pose を即座にミキサーに渡し、play() を実行する。

しかし、この命令的(Imperative)なミキサー操作と、Reactの setAnimPath による状態(Declarative)の更新が同じライフサイクル内で混在しているため、競合が発生する。ミキサーを直接操作しても、直後にReactの再レンダリングが走ることでコンテキストが一瞬リセットされ、完全なシームレス遷移(クロスフェード)には至っていない。

5.3. 今後の課題:アーキテクチャのリファクタリング方針

この「0.1秒の空白」をシステムから完全に排除するためには、状態管理のアプローチを根本から見直す必要がある。今後のアップデート要件として以下の2案を検討する。

  • 方針A:完全なPre-caching(事前ロード)プロトコル useLoader に対して動的なパスを渡す設計を廃止する。コンポーネントのマウント時に、使用する全てのアニメーション(待機、歩行、ポーズなど)の配列を一括で useLoader に渡し、メモリ上にClipとして常駐させる。状態(State)によるパスの切り替えを撤廃し、useFrame 内でインデックスを用いた直接的なClip再生に一本化する。

  • 方針B:Vanilla Three.js ミキサーへの分離 アニメーションのミキサー管理をReactのライフサイクルから完全に切り離す。ミキサーと現在のアクションを useRef でグローバルに保持し、状態の変更ではなく、Vanilla Three.js の標準メソッドである Action.crossFadeTo() などを明示的に呼び出して遷移させる。これにより、Reactの再レンダリングによる不測のミキサー初期化を物理的に防ぐ。

総括:

R3Fにおける3Dモデルの制御は、UI構築の利便性と引き換えに、低レイヤーのフレーム単位の制御(ミキサーのウェイト管理など)をブラックボックス化しやすい。次回のフェーズでは、この抽象化の壁を越え、VRMの完全な物理制御を取り戻すためのリファクタリングを実行する。