[Astro #26] React Three Fiberとthree-vrmを用いた自律アバターの実装と視線制御
はじめに
本記事では、React Three Fiber (R3F) と @pixiv/three-vrm を用いて、ブラウザ上でVRMアバターを稼働させる実装について解説。
GitHub - pixiv/three-vrm: Use VRM on Three.js
Use VRM on Three.js. Contribute to pixiv/three-vrm development by creating an account on GitHub.
github.com単なるモデルの描画にとどまらず、マウスへの視線追従、リップシンク(前回の音声解析との連動)、空間上のドラッグ移動、および右クリックメニューによるプロトコル制御を統合しています。
前回の記事:
[Astro #25] ReactとVOICEVOXを利用したWired風ターミナルと音声UIの実装 // PROTOCOL.LAIN
以前開発したChrome拡張機能のコードをベースに、Web Audio APIを用いた音声再生と、ReactによるWired風ターミナルの構築方法を解説します。
lain-lab.comYoutube動画:
[Astro / Three.js] VRM × VOICEVOX // NAVI_OS BOOT_SEQUENCE (PROTOCOL.LAIN)
Built a Navi with its own presence on the browser using Astro, Three.js, and the VOICEVOX API.Features real-time lip-sync driven by audio analysis, adaptiv...
www.youtube.com動画:
アセットと権利情報
本実装において使用したモデルおよびアニメーションデータは、以下の制作者様によるものを利用しています。
- 3Dモデル: solaris-extern 様 [BOOTH]
ペルソナ - solaris-extern - BOOTH
※通常版と支援版の内容物は同一です。 内容物 ・VRM形式のペルソナ3Dモデル ・VRM形式のペルソナVar.α3Dモデル あなたのデータ集めのナビゲーター ペルソナ 当3DモデルはVroid Studioのサンプルである千駄ヶ谷渋ちゃんを素体として、 lainTTL利用ガイドライン/利用規約に則って作成された serial experiments lain の二次創作作品です。 lainTTL利用ガイドライン/利用規約に関しては、 serial experiments lainのファンの皆様へ二次創作に関してのお知らせ -
booth.pm- アニメーション: VRoid Project 様 [BOOTH]
VRMアニメーション7種セット(.vrma) - VRoid Project - BOOTH
VRoid Hubのアニメーション再生新機能「撮影ブース」、そして新しいアニメーションフォーマット「VRMアニメーション(.vrma)」のリリースを記念して、VRMアニメーション7種を無料配布いたします。 本ページからダウンロードしたVRMアニメーションファイルは、VRoid Hubの撮影ブースや、各種対応アプリケーションで利用できます。 利用規約を確認の上、写真・動画の撮影はもちろん、動作確認やテストにご活用ください! ▼詳しくはこちら https://vroid.com/news/6HozzBIV0KkcKf9dc1fZGW
vroid.booth.pmまた、本サイトの世界観(Navi / Copland OS)および一部のデザインは、「serial experiments lain」の二次創作ガイドライン[lainTTL利用ガイドライン]を遵守し、非営利目的の実験的実装として構築しています。
serial experiments lain TTL 2019-2028
serial experiments lainのファンの皆様へ 二次創作に関してのお知らせ
www.nbcuni.co.jpクレジット表記は、トップページ右下に記載済みです。
1. Sceneの構築とレイヤー管理
アバターを表示する Canvas は、背後のUI(時計やターミナル)へのクリックイベントを透過しつつ、自身(アバターのメッシュ)へのイベントのみを捕捉できるよう pointerEvents を制御する。
// src/components/WiredScene.tsx
import React, { Suspense, useState } from 'react';
import { Canvas } from '@react-three/fiber';
import { WiredAvatar } from './WiredAvatar';
import { WiredAvatarMenu } from './WiredAvatarMenu';
export const WiredScene = () => {
const [menu, setMenu] = useState<{ x: number, y: number } | null>(null);
const [scale, setScale] = useState(0.5);
const [modelPath, setModelPath] = useState('/models/Persona_0.9.7.vrm');
const [isLookAtEnabled, setIsLookAtEnabled] = useState(true);
const [isMouseIn, setIsMouseIn] = useState(true);
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY });
};
return (
<div
className="avatar-container"
style={{
position: 'fixed',
bottom: 0, left: 0, inset: 0,
zIndex: 9,
pointerEvents: 'none' // 親コンテナはマウスイベントを透過
}}
>
<Canvas
style={{ pointerEvents: 'auto' }} // Canvas自体はイベントを受け取る
camera={{ position: [0, 0, 4], fov: 35 }}
onContextMenu={handleContextMenu}
onPointerLeave={() => setIsMouseIn(false)}
onPointerEnter={() => setIsMouseIn(true)}
>
<ambientLight intensity={1.5} />
<pointLight position={[10, 10, 10]} />
<Suspense fallback={null}>
<WiredAvatar
scale={scale}
modelPath={modelPath}
isLookAtEnabled={isLookAtEnabled}
isMouseIn={isMouseIn}
/>
</Suspense>
</Canvas>
{menu && (
<WiredAvatarMenu
x={menu.x} y={menu.y} scale={scale} currentModel={modelPath}
isLookAtEnabled={isLookAtEnabled}
onClose={() => setMenu(null)}
onScaleChange={setScale}
onModelChange={setModelPath}
onLookAtToggle={() => setIsLookAtEnabled(!isLookAtEnabled)}
/>
)}
</div>
);
};
2. VRMアバターのロードと視線(LookAt)制御
@pixiv/three-vrm の仕様上、vrm.lookAt.target に Object3D を設定することで視線を追従させることができる。
しかし、デフォルトの設定(outputScale)では可動域が狭く、引きのカメラでは瞳の動きが認識しづらい。そのため、モデルが持つ BoneApplier のプロパティへ直接アクセスし、可動域を強制的にブースト(オーバードライブ)するハックを実装した。
// src/components/WiredAvatar.tsx
import React, { useEffect, useRef } from 'react';
import { useFrame, useLoader, useThree } from '@react-three/fiber';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm';
import { VRMAnimationLoaderPlugin, createVRMAnimationClip } from '@pixiv/three-vrm-animation';
import * as THREE from 'three';
const AvetarHeight = -1.2;
const DefaultRightPos = 0.5;
const AvaterAnime = '/animations/VRMA_01.vrma';
export const WiredAvatar = ({ scale, modelPath, isLookAtEnabled, isMouseIn }: any) => {
const vrmRef = useRef<any>(null);
const mixerRef = useRef<THREE.AnimationMixer | null>(null);
const lookAtTargetRef = useRef(new THREE.Object3D());
const { viewport, scene } = useThree();
const stateRef = useRef({
posX: DefaultRightPos,
isDragging: false,
});
const gltf = useLoader(GLTFLoader, modelPath, (loader) => {
loader.register((parser) => new VRMLoaderPlugin(parser));
loader.register((parser) => new VRMAnimationLoaderPlugin(parser));
});
const vrmaGltf = useLoader(GLTFLoader, AvaterAnime, (loader) => {
loader.register((parser) => new VRMAnimationLoaderPlugin(parser));
});
useEffect(() => {
if (gltf && vrmaGltf) {
const vrm = gltf.userData.vrm;
const vrmAnimations = vrmaGltf.userData.vrmAnimations;
VRMUtils.rotateVRM0(vrm);
vrmRef.current = vrm;
// ターゲットを世界(scene)に追加し、VRMに紐付ける
scene.add(lookAtTargetRef.current);
if (vrm.lookAt) {
vrm.lookAt.target = lookAtTargetRef.current;
vrm.lookAt.autoUpdate = true;
const applier = vrm.lookAt.applier;
if (applier) {
// BoneApplierの場合、各方向のoutputScale(最大可動角)をブーストする
if ("rangeMapHorizontalInner" in applier) {
(applier as any).rangeMapHorizontalInner.outputScale = 35;
(applier as any).rangeMapHorizontalOuter.outputScale = 35;
(applier as any).rangeMapVerticalDown.outputScale = 25;
(applier as any).rangeMapVerticalUp.outputScale = 25;
console.log("EYE_PROTOCOL: OVERDRIVE_ACTIVE");
}
}
}
const mixer = new THREE.AnimationMixer(vrm.scene);
const clip = createVRMAnimationClip(vrmAnimations[0], vrm);
mixer.clipAction(clip).play();
mixerRef.current = mixer;
vrm.scene.position.set(stateRef.current.posX, AvetarHeight, 0);
return () => { scene.remove(lookAtTargetRef.current); };
}
}, [gltf, vrmaGltf, scene]);
useFrame((state, delta) => {
// 1. まずアニメーションを更新(これより後に視線計算をしないと目が合わない)
if (mixerRef.current) {
mixerRef.current.update(delta);
}
if (vrmRef.current) {
// 2. ドラッグ位置の計算
if (stateRef.current.isDragging) {
stateRef.current.posX = (state.mouse.x * viewport.width) / 2;
}
const t = state.clock.getElapsedTime();
const hoverX = stateRef.current.isDragging
? stateRef.current.posX
: stateRef.current.posX + Math.sin(t * 0.2) * 0.05;
const hoverY = AvetarHeight + Math.cos(t * 0.8) * 0.05;
vrmRef.current.scene.position.x = THREE.MathUtils.lerp(vrmRef.current.scene.position.x, hoverX, 0.1);
vrmRef.current.scene.position.y = hoverY;
vrmRef.current.scene.scale.set(scale, scale, scale);
// 3. 視線の「正面」をアバターの現在座標に合わせる
const headHeight = 1.4 * scale;
let targetX = vrmRef.current.scene.position.x;
let targetY = vrmRef.current.scene.position.y + headHeight;
// 追従有効時にマウス座標を取得
if (isLookAtEnabled && isMouseIn) {
targetX = (state.mouse.x * viewport.width) / 2;
targetY = (state.mouse.y * viewport.height) / 2;
}
// lerp を用いてターゲットを移動させることで、マウスアウト時に「スッ」と正面に戻る余韻を持たせる
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);
lookAtTargetRef.current.position.z = 1.5;
// 4. VRMの更新
vrmRef.current.update(delta);
// 5. リップシンク(音声APIと連動)
const volume = window.wiredVoiceVolume || 0;
vrmRef.current.expressionManager.setValue('aa', Math.min(volume * 6, 1.0));
}
});
return (
<primitive
object={gltf.scene}
onPointerDown={(e: any) => {
e.stopPropagation();
// R3Fでドラッグを追従させるため、nativeEventのtargetにキャプチャを設定
(e.nativeEvent.target as HTMLElement).setPointerCapture(e.pointerId);
stateRef.current.isDragging = true;
}}
onPointerUp={(e: any) => {
e.stopPropagation();
(e.nativeEvent.target as HTMLElement).releasePointerCapture(e.pointerId);
stateRef.current.isDragging = false;
}}
/>
);
};
3. アバターのコントロールメニュー
右クリックで展開されるメニューコンポーネント。親コンポーネントへコールバックを渡し、状態(モデルのパス、スケール、視線追従のトグル)を管理する。
// src/components/WiredAvatarMenu.tsx
import React from 'react';
// Propsの定義は省略(scale, currentModel, onClose, onScaleChange, onModelChange, isLookAtEnabled, onLookAtToggle等)
export const WiredAvatarMenu = ({ x, y, scale, currentModel, onClose, onScaleChange, onModelChange, isLookAtEnabled, onLookAtToggle }: any) => {
return (
<div
className="terminal-card info-card"
style={{
position: 'fixed', left: x, top: y, zIndex: 10000,
width: '200px', padding: '15px', fontSize: '0.8rem',
pointerEvents: 'auto', border: '1px solid var(--copland-blue)',
background: 'rgba(0, 0, 0, 0.9)',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ color: 'var(--accent)', marginBottom: '10px', borderBottom: '1px solid' }}>AVATAR_SETTINGS</div>
{/* モデルのラジオボタン */}
<div style={{ marginBottom: '15px' }}>
<div style={{ color: '#aaa', marginBottom: '5px' }}>MODEL_SELECTOR</div>
<label style={{ display: 'block', cursor: 'pointer' }}>
<input type="radio" checked={currentModel === '/models/Persona_0.9.7.vrm'} onChange={() => onModelChange('/models/Persona_0.9.7.vrm')} /> Standard
</label>
<label style={{ display: 'block', cursor: 'pointer' }}>
<input type="radio" checked={currentModel === '/models/Persona_0.9.7α.vrm'} onChange={() => onModelChange('/models/Persona_0.9.7α.vrm')} /> Alpha (Shadow)
</label>
</div>
{/* 視線追従のトグル */}
<div style={{ marginBottom: '15px' }}>
<div style={{ color: '#aaa', marginBottom: '5px' }}>SYSTEM_PROTOCOL</div>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', gap: '8px' }}>
<input type="checkbox" checked={isLookAtEnabled} onChange={onLookAtToggle} />
EYE_TRACKING [{isLookAtEnabled ? "ON" : "OFF"}]
</label>
</div>
{/* スケールのレンジ入力 */}
<div style={{ marginBottom: '15px' }}>
<div style={{ color: '#aaa', marginBottom: '5px' }}>SCALE_ADJUST: {scale.toFixed(1)}</div>
<input
type="range" min="0.2" max="1.5" step="0.1" value={scale}
style={{ width: '100%', cursor: 'pointer' }}
onChange={(e) => onScaleChange(parseFloat(e.target.value))}
/>
</div>
<button onClick={onClose} style={{ background: 'none', border: '1px solid var(--copland-blue)', color: 'var(--copland-blue)', width: '100%', cursor: 'pointer' }}>
CLOSE_MENU
</button>
</div>
);
};
トラブルシューティングの要点
- LookAtApplierの階層:
three-vrmのバージョンやモデルの仕様により、視線制御の設定(
rangeMapHorizontalInnerなど)はvrm.lookAt直下ではなく、vrm.lookAt.applier内部に格納されている場合がある(特にBoneApplierを使用するモデル)。プロパティへのアクセス時はin演算子等で階層と型を検証する必要がある。 - ドラッグイベントの捕捉:
React Three Fiberの
onPointerDownイベント内において、DOM側のsetPointerCaptureを呼び出す際はe.nativeEvent.targetを経由して Canvas 要素を指定しなければ正常に機能しない。 - 更新順序:
useFrame内でmixer.update(アニメーション)を先に実行し、最後にvrm.updateを実行することで、アニメーションによるポーズの上書きを回避し、正しい視線座標を計算させることができる。