[Astro #52] VRMアバターの魔法詠唱・収束エフェクトと音声キャッシュプロトコルの実装

[Astro #52] VRMアバターの魔法詠唱・収束エフェクトと音声キャッシュプロトコルの実装

はじめに

前回の記事([Astro #51])では、VRMアバターへの武装デマテリアライズおよび3連射の銃撃シーケンスについて解説しました。

今回は、そのシステムをさらに要塞化し、時間の緩急(タメ・ツメ)を制御するステートマシン、3次元空間における骨格座標ベースの発射位置サンプリング、漫画的な収束を表現する動的動チャージエフェクト、そして多重音声のIndexedDBプリロードキャッシュ機構を実装した「サイバー魔術プロトコル」について具体的な実装をまとめます。

前回の記事:

Youtube:

Video(PC):

Video(VR):

1. 詠唱・放出タイムラインのステートマシン設計

魔法の発動は、単一のアニメーション再生ではなく、「タメ(詠唱)」「ツメ(臨界点での放出)」「ポーズのキメ(余韻・硬直)」という3つのフェーズで構成される時間の緩急が重要となります。

本システムでは、setTimeoutsetInterval を組み合わせ、以下のタイムラインでステートを遷移させています。

  • 0ms(トリガー時):

  • isCasting = true

  • 詠唱アニメーション(SYS_MAGIC_CAST)をループ再生します。

  • 450ms周期でチャージSE(多重音声オーディオ)を重ねて再生します。

  • wired-magic-start イベントを発火し、足元に4枚の魔法陣をフェードイン展開します。

  • 3000ms(詠唱完了・リリース時):

  • チャージSEのインターバルをクリアし、重なっていた音声を一括で一時停止・破棄します。

  • 放出ポーズアニメーション(SYS_MAGIC_RELEASE)へ遷移します(ONCE_AND_HOLD)。

  • 4000ms(モーションピーク・射出時 / リリースから1000ms後):

  • 放出SE(magic_shoot.mp3)をトリガーします。

  • wired-magic-release イベントを発火し、エネルギー弾を射出、魔法陣をフェードアウト消滅プロセスへ移行します。

  • 4800ms(キメの余韻終了 / リリースから1800ms後):

  • isCasting = false

  • revertToIdle() を呼び出し、キャラクターを通常の待機状態(Idle)へと戻します。

この射出の瞬間からさらに800msの「硬直時間の余韻」を持たせることで、キャラクターのモーションが急激にリセットされる不自然さを排除し、重量感のある演出を成立させています。

2. 3次元空間における両手中点と正面ベクトルの計算

エネルギー弾およびチャージエフェクトの発生源は、アバターの体内(ローカル原点)ではなく、突き出した両手の中心付近でなければなりません。また、VR環境とPC環境(親グループを旋回させる操作)の双方で位置のズレを無くすため、完全なワールド座標系でのサンプリングが必要となります。

両手の中点(ミッドポイント)の特定

VRMのhumanoidから左右の手のボーンノード(rightHand, leftHand)を取得し、それぞれの現在のワールド座標を抽出、その幾何学的平均(中点)を算出します。

const bulletSpawnPosition = new THREE.Vector3();
const rightHandNode = vrm.humanoid?.getNormalizedBoneNode('rightHand');
const leftHandNode = vrm.humanoid?.getNormalizedBoneNode('leftHand');

if (rightHandNode && leftHandNode) {
  const rightHandWorldPos = new THREE.Vector3();
  const leftHandWorldPos = new THREE.Vector3();

  // 左右の手のボーンの真のワールド座標を抽出
  rightHandNode.getWorldPosition(rightHandWorldPos);
  leftHandNode.getWorldPosition(leftHandWorldPos);

  // ベクトル加算の後、0.5を乗算して中点を特定 (A + B) / 2
  bulletSpawnPosition.addVectors(rightHandWorldPos, leftHandWorldPos).multiplyScalar(0.5);
}

正面ベクトルの反転とオフセット処理

VRMアバターの仕様上、getWorldDirection() で取得できる正面ベクトル(forward)は、プログラム内部的にはモデルの「背中側」を指します。そのため、エネルギー弾をアバターの正面(画面奥)に撃ち出す、およびエフェクトを胸のめり込みから前方に押し出すには、このベクトルにマイナスを掛けて補正する必要があります。

const forward = new THREE.Vector3();
playerRoot.getWorldDirection(forward);

// 正面ベクトルを反転(-0.2)させ、手の中心座標から20cm前方へオフセット
bulletSpawnPosition.addScaledVector(forward, -0.2);


3. 漫画的収束チャージエフェクト(Focus Lines)

90年代アニメのような「周囲の空間からエネルギーが中心の光球へと吸い込まれる」表現を、テクスチャ画像や重いカスタムシェーダーを使用せず、Three.jsの基本ジオメトリと不透明度制御だけで軽量に実装しました。

構造定義

エフェクトの本体は、加算合成(THREE.AdditiveBlending)を適用した白いコア光球、その周囲を直交して高速回転する2枚の極細リング(TorusGeometry、太さ 0.002)、および80本の放射状の細長い板ポリゴン(PlaneGeometry)で構成されます。

動的吸い込みアルゴリズム

それぞれの板ポリゴンの userData に「放射方向(direction)」「現在の中心からの距離(distance)」「吸い込まれる速度(speed)」を持たせ、毎フレーム更新します。

// 生成時(startCast内)
for (let i = 0; i < lineCount; i++) {
  const lineLength = 0.5 + Math.random() * 0.1;
  const lineWidth = 0.01;
  const lineGeo = new THREE.PlaneGeometry(lineWidth, lineLength);
  lineGeo.translate(0, lineLength * 0.5, 0); // 原点を根本に移動

  const lineMesh = new THREE.Mesh(lineGeo, lineMaterial.clone());

  // 球面座標からランダムな放射方向ベクトルを生成
  const phi = Math.random() * Math.PI * 2;
  const theta = Math.random() * Math.PI;
  const normal = new THREE.Vector3().setFromSphericalCoords(1, phi, theta);
  lineMesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), normal);

  lineMesh.userData = {
    direction: normal,
    distance: 1.0 + Math.random() * 1.0, // 外側の初期配置
    speed: 2.0 + Math.random() * 4.0     // 中心への移動速度
  };
  this.chargeGroup.add(lineMesh);
  this.focusLines.push(lineMesh);
}

// 毎フレーム更新時(update内)
this.focusLines.forEach((line) => {
  const data = line.userData;

  // 中心に向かって距離を減算
  data.distance -= delta * data.speed;

  // 臨界点(中心付近)まで吸い込まれたら外側にワープさせてリスポーン
  if (data.distance < 0.2) {
    data.distance = 2.0 + Math.random() * 1.0;
  }

  // 座標を再計算して適用
  line.position.copy(data.direction).multiplyScalar(data.distance);

  // ランダムな不透明度の明滅によって不安定な高エネルギー感を演出
  (line.material as THREE.MeshBasicMaterial).opacity = Math.random() * 0.6;
});

4. 多重音声のライフサイクルとIndexedDBキャッシュ

SE(効果音)のベタ書きによるパス依存を排除し、環境移行時の不整合を防ぐため、音声ファイル(mp3)のプリロード化および IndexedDB キャッシュシステムを構築しました。

プリロードとBlob URL化

音声は再生の瞬間に非同期で取得すると数ミリ秒の遅延が発生し、操作の手応え(フィードバック)を著しく損ないます。そのため、フック(useWiredAnimation)の初期化時に一括で IndexedDB(またはストア)からバイナリを取得し、URL.createObjectURL によってメモリ上に Blob URL としてストックします。

useEffect(() => {
  let isMounted = true;
  const resolvedUrls: Record<string, string> = {};

  const preloadSE = async () => {
    await Promise.all(
      SE_ASSETS.map(async (se) => {
        // IndexedDB キャッシュシステム経由でURLを解決(Blob化)
        const url = await getCachedAssetUrl(db.items, se);
        resolvedUrls[se.id] = url;
      })
    );
    if (isMounted) setSeUrls(resolvedUrls);
  };

  preloadSE();

  return () => {
    isMounted = false;
    // メモリリーク防止のため生成したBlob URLを明示的に解放
    Object.values(resolvedUrls).forEach((url) => {
      if (url.startsWith('blob:')) URL.revokeObjectURL(url);
    });
  };
}, []);

多重再生の制御

詠唱中の450ms周期のチャージSEの重なりは、配列(activeAudios)でインスタンスを管理し、3秒後のリリース(放出)の瞬間に一括で pause() および src = "" を流し込んで全スレッドの音声強制シャットダウンとガベージコレクションの最適化を行っています。


5. 設計のクリーン化(依存性の注入)

ロジックを司る純粋なクラスである MagicEffectSystem の内部に、アセットの物理パス(文字列)を直接ハードコードすることは結合度を高め、メンテナンス性を低下させます。

これを解決するため、テクスチャや各種設定パスはコンストラクタの引数、およびメソッドのメタデータとして外部から注入(依存性の注入: DI)する設計へとリファクタリングしました。

// クラス側は純粋な抽象ロジックに徹する
export class MagicEffectSystem {
  constructor(scene: THREE.Scene, texturePath: string) {
    this.scene = scene;
    const loader = new THREE.TextureLoader();
    loader.load(texturePath, (tex) => {
      tex.colorSpace = THREE.SRGBColorSpace;
      this.texture = tex;
    });
  }
}

これにより、描写ロジックとインフラ層(アセットサーバーや IndexedDB キャッシュ層)が完全に分離され、要塞としての堅牢性と拡張性が極めて高いレベルで保証されました。