[Astro #26] React Three Fiberとthree-vrmを用いた自律アバターの実装と視線制御

[Astro #26] React Three Fiberとthree-vrmを用いた自律アバターの実装と視線制御

はじめに

本記事では、React Three Fiber (R3F) と @pixiv/three-vrm を用いて、ブラウザ上でVRMアバターを稼働させる実装について解説。

単なるモデルの描画にとどまらず、マウスへの視線追従、リップシンク(前回の音声解析との連動)、空間上のドラッグ移動、および右クリックメニューによるプロトコル制御を統合しています。

前回の記事:

Youtube動画:

動画:

アセットと権利情報

本実装において使用したモデルおよびアニメーションデータは、以下の制作者様によるものを利用しています。

  • 3Dモデル: solaris-extern 様 [BOOTH]
  • アニメーション: VRoid Project 様 [BOOTH]

また、本サイトの世界観(Navi / Copland OS)および一部のデザインは、「serial experiments lain」の二次創作ガイドライン[lainTTL利用ガイドライン]を遵守し、非営利目的の実験的実装として構築しています。

クレジット表記は、トップページ右下に記載済みです。

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.targetObject3D を設定することで視線を追従させることができる。 しかし、デフォルトの設定(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>
  );
};

トラブルシューティングの要点

  1. LookAtApplierの階層: three-vrmのバージョンやモデルの仕様により、視線制御の設定(rangeMapHorizontalInner など)は vrm.lookAt 直下ではなく、vrm.lookAt.applier 内部に格納されている場合がある(特にBoneApplierを使用するモデル)。プロパティへのアクセス時は in 演算子等で階層と型を検証する必要がある。
  2. ドラッグイベントの捕捉: React Three Fiberの onPointerDown イベント内において、DOM側の setPointerCapture を呼び出す際は e.nativeEvent.target を経由して Canvas 要素を指定しなければ正常に機能しない。
  3. 更新順序: useFrame 内で mixer.update(アニメーション)を先に実行し、最後に vrm.update を実行することで、アニメーションによるポーズの上書きを回避し、正しい視線座標を計算させることができる。