[Astro #50] VRMアバターの自律インタラクションと空間UI(WiredSpeechBubble)の実装

[Astro #50] VRMアバターの自律インタラクションと空間UI(WiredSpeechBubble)の実装

はじめに

前回の記事でIndexedDBによるアセットの永続キャッシュ機構が完成し、重量級の3Dリソースを扱う上でのインフラは整えました。

今回はその基盤の上で、VRMアバターとのインタラクション(タッチイベント)と、セリフを描画するための空間UIを実装していきます。

前回の記事:

スクショ:

[Astro #50] VRMアバターの自律インタラクションと空間UI(WiredSpeechBubble)の実装 [Astro #50] VRMアバターの自律インタラクションと空間UI(WiredSpeechBubble)の実装

NOTE:

YouTube:

動画(PC/VR):

WebGL空間に構築するメッセージウィンドウ

キャラクターが喋るセリフを表示する際、通常であればHTMLのDOMエレメント(<div>など)を3Dキャンバスの上にオーバーレイ表示するのが簡単だ。しかし、「PROTOCOL.LAIN」の世界観において、UIもまた電子世界を構成するノードの一部でなければならない。

そこで今回は、HTMLを一切使わず、React Three Fiber (@react-three/fiber) と Drei (@react-three/drei) を駆使して、 WebGL空間内に直接メッシュとしてメッセージウィンドウ(WiredSpeechBubble.tsx)を構築 しました。

ジオメトリのエッジ描画によるサイバー表現

ウィンドウのデザインは、黒い半透明の背景にネオングリーンの輪郭線が浮かび上がるシンプルなものを採用した。これを実現するコアとなるのが、Dreiの <Edges> コンポーネント。

import { useFrame } from '@react-three/fiber';
import { Text, RoundedBox, Edges } from '@react-three/drei';
import * as THREE from 'three';

// ...

<group ref={groupRef} position={position}>
  <RoundedBox args={[1.5, 0.5, 0.01]} radius={0.05} smoothness={4} position={[0, 0, 0]}>
    {/* 黒い半透明の背景 */}
    <meshBasicMaterial color="#000000" transparent opacity={0.9} />

    {/* ジオメトリの輪郭線のみを抽出して描画 */}
    <Edges linewidth={2} color="#00ffcc" />
  </RoundedBox>

  {/* ドットフォントによるテキスト描画 */}
  <Text
    font="/assets/font/DotGothic16-Regular.ttf"
    fontSize={0.05}
    color="#00ffcc"
    position={[0, 0, 0.02]}
    maxWidth={1.4}
  >
    {text}
  </Text>
</group>

<Edges> は、親となるジオメトリ(今回は <RoundedBox>)のポリゴンの輪郭線を自動的に計算し、綺麗な1本線として描画してくれる。テクスチャを使わずに、プログラムだけでこのソリッドな質感を表現できるのは非常に強力。

視線追従(ビルボード処理)

3D空間内に配置された文字は、カメラの角度によっては読めなくなってしまう。そのため、毎フレームカメラのワールド座標を取得し、常にウィンドウがカメラの方向を向くように lookAt で視線追従(ビルボード)させています。

useFrame(({ camera }) => {
  if (groupRef.current) {
    const lookPos = new THREE.Vector3();
    camera.getWorldPosition(lookPos);
    groupRef.current.lookAt(lookPos);
  }
});

これにより、VR空間内をどのように移動しても、テキストは常に正確にレンダリングされ続けます。

データ駆動のリアクション設計

アバターの部位(頭、体、足)をタッチした際のリアクションは、ロジック内にハードコーディングせず、touchTalk.json として外部データに切り出しました。

{
  "HEAD": [
    { "text": "なでなで...?", "animation": "IDLE_CURIOUS", "duration": 3000 },
    { "text": "存在は、認識されて初めて定義されるんだよ。", "animation": "IDLE_DEFAULT", "duration": 4500 }
  ],
  "BODY": [
    { "text": "……何?", "animation": "IDLE_ANGRY", "duration": 2000 }
  ]
}

これにより、Astroのソースコードを触ることなく、キャラクターの「語彙」や「性格」をJSONの編集だけでスケールさせることが可能になります。

アニメーションステートの罠(Tポーズ問題の解決)

実装中、タッチリアクションのアニメーション(例: SYS_TALK)が終了し、元の待機アニメーション(IDLE_DEFAULT)に復帰する際、キャラクターが初期姿勢(Tポーズ)で固まってしまう現象に遭遇しました。

原因は、Reactのステート(animPath)と、Three.jsのアニメーションミキサーのライフサイクルがすれ違っていたことにあります。

直接アニメーションをフックして再生した際、React側の animPath は古い待機パスを保持したままになる。そのため、終了時に「元の待機パスに戻れ」と setAnimPath を呼び出しても、React側が「すでにそのパスになっている」と検知し、再レンダリングをスキップしてしまっていたせい。

これを防ぐため、割り込みでアニメーションを再生する際は、一度明示的にステートを空にする処理を加えました。

const playAnimationById = useCallback((id: string, mode, duration) => {
  // ... VRMアニメーションの生成 ...

  // 一度ステートを空にすることで、終了時の revertToIdle で確実に変更を検知させる
  setAnimPath("");
  currentAnimPathRef.current = null;

  playAnimation(clip, mode, duration);
}, [...]);

このわずか2行の追加でReactのライフサイクルが正しく発火し、スムーズなクロスフェード復帰が実現しました。