[Astro #51] VRMアバターの武装具現化と発砲シークエンスの完全同期
はじめに
前回(#50)のVRMインタラクションとUI実装に続き、今回はアバターに「武装(銃)」をマテリアライズさせ、3連射の発砲シークエンスを実装しました。
ただアニメーションを再生するだけでなく、「音(SFX)」「光(マズルフラッシュ)」「実体の顕現(メッシュの表示)」の3つを、VR空間で破綻なく同期させるための泥臭い格闘の記録です。
前回の記事:
[Astro #50] VRMアバターの自律インタラクションと空間UI(WiredSpeechBubble)の実装 // PROTOCOL.LAIN
PROTOCOL.LAINの空間に溶け込む自律型VRMアバターのUI実装。HTML要素に頼らずDreiのEdgesを用いてThree.js空間へ直接吹き出しを描画する手法や、アニメーションステートの罠(Tポーズ問題)の解決策をまとめた技術アーカイブ。
lain-lab.com銃のモデル制作者:
3D Model: Yu_wl
TT pistol - Download Free 3D model by Yu_wl
Low poly model for mobile game - TT pistol - Download Free 3D model by Yu_wl
sketchfab.com{
"id": "handgun_01",
"name": "TT pistol",
"type": "firearm",
"modelUrl": "tt_pistol.glb",
"scale": [0.05, 0.05, 0.05],
"attachPoint": "rightHand",
"creator": "Yu_wl",
"url": "https://sketchfab.com/yu_wl",
"offset": {
"position": [0.04, -0.02, 0.0],
"rotation": [1.57, 0,3.14]
}
}
スクショ:
NOTE:
アバターの武装具現化と発砲シークエンスの実装プロセス|lain
今回のWebXRプロジェクト(PROJECT.LAIN)のアップデートでは、VRMアバターに武器を持たせ、実際に発砲するまでのシークエンスを実装しました。 技術的な細かな解説は技術ブログに譲るとして、この記事では今回追加した機能と、外部アセットを活用した実装のフローについて軽くまとめます。 1. IndexedDBのキャッシュリセット機能 まず、システム側の小さなアップデートとして、MENU画面にIndexedDBのリセット機能を実装しました。 3DモデルなどのアセットをキャッシュするIndexedDBは、開発を進めているとデータが肥大化しがちです。そのため、ユー
note.comYoutube:
[WebXR/Three.js] VRM Avatar Weapon Manifestation & Shooting Sequence Demo | React Three Fiber
Demonstration of weapon manifestation and shooting sequences for a VRM avatar in a WebXR project built with Astro and React Three Fiber.Triggered via termina...
www.youtube.comVideo(PC):
Video(VR):
1. 神経の混線:Maximum call stack size exceededとの戦い
ターミナルからのコマンド入力、VRメニューのボタン、そして物理キーボード(Spaceキー)。これらすべてのトリガーを一本化し、スマートな「発砲プロトコル」を構築しようとした矢先、コンソールが真っ赤に染まりました。エラーログに無数に並んだのは Uncaught RangeError: Maximum call stack size exceeded の文字。プログラムが自己呼び出しの無限ループに陥り、システムがパンクした際の悲鳴です。
原因は「鏡合わせのイベント発火」
当初、ターミナルはVRメニューからの遠隔操作を受け取るために wired-remote-command というイベントを監視していました。一方で、ターミナルに fire と入力した際にも、アバターに動作を伝えるために「同じ名前のイベント」を発信してしまったのです。
結果として、「自分で発信した発砲命令を、自分自身が受信して再度発信し続ける」という光の速さのキャッチボールが発生し、ブラウザがクラッシュしてしまいました。
解決策:イベントの分離(受信と発動の分化) この神経の混線を解くため、「外からの命令を受け取るためのイベント」と「アバターの筋肉を動かすための専用イベント」を明確に分離しました。
- 入力受付用:
wired-remote-command(VRメニュー ➔ ターミナル) - アニメーション発動用:
wired-action-fire(ターミナル・Spaceキー ➔ アバター)
ターミナル側は、コマンドを処理した後に wired-action-fire を発信するように修正し、アバター(アニメーション制御側)はそちらだけをリッスンするように変更しました。入力と出力の「神経」を綺麗に分けることで、イベントの逆流を完全に防ぎ、安定した動作を取り戻すことができました。
2. デジタル武装の具現化:Reactレンダリングを回避する最適化
発砲シークエンスにおいて、「普段は手ぶらだが、コマンドを実行した瞬間だけ手元に銃が顕現する」という、ワイヤードならではのデジタルな演出を実現したいと考えました。
ReactでUIの表示・非表示を切り替える際、最も一般的な手法は useState で真偽値(boolean)を管理することです。しかし、このアプローチをReact Three Fiber(WebGL)の世界にそのまま持ち込むと深刻な問題が生じます。状態が更新されるたびにコンポーネントツリーの再レンダリングが走り、特に描画負荷の高いVR環境下では、一瞬のスタッター(カクつき)やフレーム落ちを引き起こし、体験の没入感を大きく損なってしまうのです。
かといって、useRef に状態を持たせて <group visible={stateRef.current.showWeapon}> のようにJSX内で宣言しても、再レンダリングが起きない限り画面の表示は更新されません。
解決策:useFrameによる直接操作(Reactライフサイクルのバイパス) そこで、Reactのライフサイクルを意図的に無視し、Three.jsのレイヤーで直接オブジェクトを操作するアプローチを採用しました。
具体的には、親コンポーネントで管理している状態(stateRef)を子である装飾品コンポーネントに渡し、@react-three/fiber の毎フレームの描画ループである useFrame 内で監視させます。
useFrame(() => {
if (groupRef.current && stateRef?.current) {
if (item.id === 'handgun_01') {
groupRef.current.visible = !!stateRef.current.showWeapon;
}
}
});
このように、Three.jsのオブジェクト(groupRef.current.visible)のプロパティを毎フレーム直接書き換えることで、Reactのレンダリング機構を完全にバイパスできます。
この設計により、XR空間でのパフォーマンスを一切犠牲にすることなく、コマンド入力の瞬間に0フレームの遅延で武器を実体化させ、モーション終了(Idle状態への回帰)と同時にスッと消失させる、シームレスな武装シークエンスが完成しました。
3. 座標の深淵:Blenderでの歪み修正とJSONによる微調整
今回の実装で泥臭い作業aが、アバターの手元にピタリと銃を収めるための座標調整でした。
外部から調達した3Dモデル(今回は tt_pistol.glb)は、そのままThree.jsで読み込むと、原点(Origin)がズレていたり、予期せぬ回転がかかっていたりすることが多々あります。アバターの rightHand ボーンにアタッチした際、銃が手首に食い込んだり、明後日の方向を向いてしまう現象を解決するため、まずは一度Blenderにモデルを取り込み、根本的な座標の歪みやスケール感を修正する地道な作業が必要でした。
さらに、最終的な「握り」のリアリティを出すため、アクセサリ管理用の accessories.json を用意し、コードを触らずに数値を微調整できる仕組みを構築しています。
// accessories.json の一部
"attachPoint": "rightHand",
"scale": [0.05, 0.05, 0.05],
"offset": {
"position": [0.04, -0.02, 0.0],
"rotation": [1.57, 0, 3.14]
}
この position: [0.04, -0.02, 0.0] というオフセット値。平面モニタでは気にならない数センチのズレも、VR空間では致命的な「偽物感」に直結します。
JSONの数値を書き換えてはブラウザをリロードし、XRモードに入ってあらゆる角度から握り具合を確認する。この0.01単位の現物合わせこそが、開発者の根気を最も削り、同時にクオリティを決定づける重要な工程でした。
4. 泥臭い同期の美学:setTimeoutによるタイミング制御
その他、時間を要したのが、アニメーションの「引き金を引く瞬間」と「銃声(mp3)」の同期です。
VRMアニメーション(.vrma)のタイムラインを監視して発火させる「設計として美しい」方法もありますが、ブラウザ上の遅延やVRの描画負荷を考慮すると実装が過剰に複雑になってしまいます。
最終的に採用したのは、最も泥臭い setTimeout によるミリ秒単位の手打ち調整でした。
setTimeout(playShot, 880); // 1発目
setTimeout(playShot, 1450); // 2発目
setTimeout(playShot, 2050); // 3発目
美しくない実装で、フレーム落ちすると発砲のタイミングがズレますが、今回はこれで諦めました。
5. 閃光の座標調整:デバッグ用の「赤いマーカー」
銃声が重なる瞬間に、アバターの周囲を一瞬だけ照らし出す「光(マズルフラッシュ)」。これを実装するため、銃の先端に pointLight を配置し、発砲タイミングに合わせて50msだけ発光させる処理を追加しました。
しかし、ここで「設定したはずの光が全く見えない」という問題に直面します。原因は、指定した初期座標が銃の3Dモデルの内部に位置しており、光がメッシュに遮られていた(モデルの中に埋もれていた)ためでした。
デバッグ手法:
暗闇の中で見えない光源の座標をカンで探り当てるのは至難の業です。そこで、光源と全く同じ座標に「絶対に光る赤い球体(Mesh)」を仮置きし、光源の中心点を物理的に可視化するという泥臭いデバッグを行いました。
この赤いマーカーを目印にして position の数値を少しずつ動かし、銃口からわずか数センチ先という絶妙な位置へと追い込んでいきます。
結果として、[X: 0, Y: 0.05, Z: 0.18] という最適な空間座標を特定。役目を終えたデバッグ用マーカーを消去してXR空間で発砲すると、暗闇の中で3連射の閃光がアバターの顔や制服を鮮やかに照らし出す、非常にドラマチックで実存感のあるエフェクトを完成させることができました。
おわりに:VRがもたらす「視線」の重圧
完成したシークエンスをXRモードで確認すると、平面モニタとは全く異なる実存感がありました。 視線追従(LookAt)により、自分が横に避けても必ず銃口がこちらを向き直し、正確に3連射が撃ち込まれます。自分が構築したシステムに追い詰められ、拒絶されるようなこの感覚は、VRならではの強烈な体験です。
泥臭い調整の果てに、また一つ、ワイヤードの解像度が上がりました。