[Astro #50] VRMアバターの自律インタラクションと空間UI(WiredSpeechBubble)の実装
はじめに
前回の記事でIndexedDBによるアセットの永続キャッシュ機構が完成し、重量級の3Dリソースを扱う上でのインフラは整えました。
今回はその基盤の上で、VRMアバターとのインタラクション(タッチイベント)と、セリフを描画するための空間UIを実装していきます。
前回の記事:
[Astro #49] IndexedDBを活用した3Dアセットの完全キャッシュ化とバージョニング管理 // PROTOCOL.LAIN
WebXRプロジェクトのロード時間を改善するため、Dexie.jsでアセット管理基盤を構築しました。JSONのバージョン情報を「正解リスト」としてDBと比較し、自動でパージ・更新を行う堅牢な仕組みを解説します。
lain-lab.comスクショ:
NOTE:
WebXRとブラウザキャッシュ:技術の積み重ねで作る「快適な」3D空間|lain
最近の私の開発は、これまで積み上げてきた技術の「再構成」と「最適化」が中心になっています。 派手な見た目を作ることも楽しいですが、それをいかにストレスなく、かつ多様なデバイスで動かすか? 資産をキャッシュし、ロード時間を削る サイトのトップページには多くの3Dアセットが配置されていますが、最大の課題は「ロードの重さ」でした。 これを解決するために、昨年の夏ごろ、JavaScriptの学習を開始した当初、DOMを主体としたカードゲーム開発をしてた際に実装した、IndexedDB。 今回もアセットキャッシュとバージョン管理をIndexedDBで導入しました。 IndexedD
note.comYouTube:
[PC/WebXR] Mouse & XR Laser Dual Input! Touch-Triggered VRM Talk Events [R3F / Astro]
Using Astro and Three.js, I implemented an interactive dialogue system (Touch-Triggered Talk Events) with a VRM character that runs directly in the web brows...
www.youtube.com動画(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のライフサイクルが正しく発火し、スムーズなクロスフェード復帰が実現しました。