[Astro #35] VRMアバターの動的着せ替え実装におけるトラブルと仕様の壁

[Astro #35] VRMアバターの動的着せ替え実装におけるトラブルと仕様の壁

概要

React Three Fiber (R3F) 環境において、@pixiv/three-vrm を用いて VRM アバターの衣装を動的に変更(素体に別モデルの衣装をバインド)する実装を検証した。 本稿では、実装過程で発生したエラーとその原因、および仕様上の制約についてまとめる。

1. 宣言順序による初期化エラー (Temporal Dead Zone)

発生した問題:突然のホワイトアウトとReferenceError

衣装チェンジの実装に着手し、まずは useLoader を用いてスチームパンクドレスの GLTF モデルを読み込むコードを書き足しました。そして、いざブラウザで確認してみると……

画面は無慈悲なホワイトアウト。コンソールを開くと、そこには赤文字で強烈なエラーメッセージが刻まれていました。

Uncaught ReferenceError: Cannot access 'gltf' before initialization (初期化前に ‘gltf’ にアクセスすることはできません)

これまで正常に動いていたメインアバターすら表示されなくなり、完全に処理がストップ。

[Astro #35] VRMアバターの動的着せ替え実装におけるトラブルと仕様の壁: Uncaught ReferenceError: Cannot access 'gltf' before initialization

原因:Temporal Dead Zone(一時的死角)の罠

エラーメッセージが示す通り、この問題の根本原因はReactコンポーネント内における変数宣言の「順序」にありました。

JavaScript(特にES6以降の letconst)には、Temporal Dead Zone (TDZ) と呼ばれる「変数は宣言されているが、まだ値が代入(初期化)されていないため、アクセスするとエラーになる区間」が存在します。

当時の私のコードは、おおよそ以下のような構造になっていました。

export const WiredAvatar = () => {
  // 【問題の箇所】useEffect が先に定義されている
  useEffect(() => {
    // ここで gltf 変数にアクセスしようとしている
    if (!gltf) return;
    // ...衣装チェンジのロジック...
  }, [costumePath, gltf]); // 依存配列にも gltf が指定されている

  // ... (数百行のコード) ...

  // 【変数宣言】gltf の定義がずっと下にある
  const gltf = useLoader(GLTFLoader, modelPath, (loader) => {
    // ...モデルのロード処理...
  });

  return ( /* ...描画処理... */ );
};

React の関数コンポーネントは、上から下へと順に実行されます。 処理が useEffect の行に到達した時点では、下の方にある const gltf = ... という宣言は「存在することは認識されているが、まだ実行(初期化)されていない状態(TDZ内)」です。

それにもかかわらず、useEffect の依存配列 [costumePath, gltf] によってその未初期化の変数を評価しようとしてしまったため、「まだアクセスできない!」とJavaScriptエンジンから怒られ、処理がクラッシュしてしまったわけです。

解決策:宣言を上部へホイスティング(移動)する

原因が分かれば、解決策は非常にシンプルです。 「使う前に宣言する」という大原則に従い、useLoader によるリソースの読み込み宣言を、それを参照するすべての useEffect よりも上部(コンポーネント定義の先頭付近)へ移動させます。

export const WiredAvatar = () => {
  // 1. まず Ref や State を定義
  const vrmRef = useRef<any>(null);
  const [costumePath, setCostumePath] = useState<string | null>(/*...*/);

  // 2. ★修正:useLoader を useEffect よりも上に配置する!
  const gltf = useLoader(GLTFLoader, modelPath, (loader) => {
    // ...モデルのロード処理...
  });

  const vrmaGltf = useLoader(GLTFLoader, animPath, (loader) => {
    // ...アニメーションのロード処理...
  });

  // 3. その後に useEffect を記述する
  useEffect(() => {
    if (!gltf || !vrmRef.current) return;
    // ...衣装チェンジのロジック...
  }, [costumePath, gltf]); // ここに到達した時点で gltf は初期化済みなので安全

  return ( /* ...描画処理... */ );
};

これにより、フックの実行順序が正しく整い、ReferenceError によるホワイトアウトは解消されました。

これは React 初学者がフックを多用し始めた頃に必ず一度は踏むミスですが、複雑な 3D ロジックに気を取られていると、こうした初歩的な落とし穴を見落としがちになります。 「useLoader のような非同期リソースの宣言は、コンポーネントの可能な限り上部にまとめる」という教訓を得た瞬間でした。

2. メッシュの非表示処理が機能しない問題

発生した問題:着替えないアバター

TDZ(初期化順序)のエラーを乗り越え、ついにコンソールには COSTUME_CHANGE_PROTOCOL: SUCCESSFUL_BIND の文字が表示されました。コード上は、メインアバターの骨(Skeleton)と衣装のメッシュが間違いなく結合(bind)されたことを示しています。

しかし、画面上の Lain は元の青い服を着たまま、全く見た目が変わりませんでした。

具体的には以下の2つの問題が起きていました。

  1. 追加したはずの「スチームパンクドレス(新しい衣装)」が描画されない。
  2. メインアバターの「元の服」が非表示にならない。
[Astro #35] VRMアバターの動的着せ替え実装におけるトラブルと仕様の壁: 2 メッシュの非表示処理が機能しない問題

原因の特定:コードではなく「データ」の壁

当初、私はプログラム側からメッシュの名前(obj.name)で判定を行い、「服のメッシュを消して、肌のメッシュを残す」という制御を行う予定でした。

// 失敗したアプローチ:名前に 'Body' が含まれるか否かで判別
vrm.scene.traverse((obj) => {
  if (obj.isSkinnedMesh) {
    // 'Body', 'Face', 'Hair' 以外は「服」とみなして隠す想定
    const isHuman = ['Body', 'Face', 'Hair'].some(n => obj.name.includes(n));
    if (!isHuman) obj.visible = false;
  }
});

このロジック自体は間違っていません。しかし、何度リロードしても服は消えませんでした。 原因を探るべく、読み込んだモデルが内部でどのようなメッシュ名を持っているのか、全ノードを走査(traverse)してコンソールに出力する「デバッグの儀式」を行いました。

[Astro #35] VRMアバターの動的着せ替え実装におけるトラブルと仕様の壁: 2 コードではなく「データ」の壁

コンソール出力結果(一部抜粋):

  • メインアバター: Facebaked, Bodybaked, Hair001
  • 衣装アバター: Face (merged), Body (merged)

ログを見た瞬間、絶望しました。 DressOutfit といった服を示すメッシュが存在せず、素体の肌も服もすべて「Body」という単一のメッシュにまとめられていたのです。

VRoid Studio の「メッシュ統合」仕様

これは、VRMモデルを出力したソフトウェア(今回は VRoid Studio)の最適化仕様によるものです。

VRoid Studio はパフォーマンスを向上させるため、エクスポート時にデフォルトで「メッシュを統合する」、あるいは服の下にある肌を消す「透明メッシュを削除する」といった処理を行います。 これにより、モデルの軽量化には大きく貢献する反面、「服と肌が完全に一体化した1枚のポリゴン」として出力されてしまいます。

肌と服が結合されてしまっている以上、プログラム(JavaScript)側から「服のメッシュだけを非表示にする」というアプローチは物理的に不可能だったのです。コードのロジックが間違っていたのではなく、データ構造の壁に阻まれていました。

解決策(モデリング時の対応)

この問題を解決するには、コードの修正ではなく「モデルの再出力」が必要です。

Webブラウザ上で動的な着せ替え(メッシュの差し替え)を行うことを前提とする場合、VRoid Studio からベースとなる素体モデルをエクスポートする際に、以下の設定を行う必要があります。

  1. 「透明メッシュを削除する」のチェックを外す
  2. (バージョンや要件によっては)ポリゴン削減機能によるメッシュ結合を無効化する

これにより、肌(Body)と服(Outfit/Clothes)が別々のメッシュとして出力され、プログラム側から個別に visible = false の制御を行えるようになります。3D開発においては、コードだけでなく「元の3Dデータの仕様」を深く理解していなければならないという手痛い洗礼でした。

[Astro #35] VRMアバターの動的着せ替え実装におけるトラブルと仕様の壁: 2  VRoid Studio の「メッシュ統合」仕様

3. ボーン同期 (Bone Sync) 方式への移行とポリゴン破綻

実装アプローチの変更:肉体を捨て、魂(Bone)を同期する

メッシュ結合によるアプローチがVRoidの「メッシュ統合仕様」という壁に阻まれたため、私は実装方針を根本から見直すことにしました。

新しいアプローチは「ボーン同期(Bone Sync)方式」です。

これは、「メインアバターは顔だけ残して体を消す」「衣装アバターは首から下を残して顔を消す」という状態を作り、毎フレーム(useFrame)でメインアバターの骨(Bone)の動きを、衣装側の骨に完コピさせるという力技です。 VTuberのトラッキングアプリなどでもよく用いられる、比較的安全とされる手法のはずでした。

// 当初書いた(そして爆発した)同期コードのイメージ
useFrame(() => {
  // メインの骨の回転を、そのまま衣装の骨にコピーする
  Object.keys(mainHumanoid.humanBones).forEach((boneName) => {
    const mainNode = mainHumanoid.getRawBoneNode(boneName);
    const costNode = costHumanoid.getRawBoneNode(boneName);
    if (mainNode && costNode) {
      costNode.quaternion.copy(mainNode.quaternion);
    }
  });
});

「これでいける!」 そう確信してブラウザをリロードした私の目に飛び込んできたのは、期待していたスチームパンクドレスのLainではなく、深淵から呼び覚まされたようなおぞましいクリーチャー…。

[Astro #35] VRMアバターの動的着せ替え実装におけるトラブルと仕様の壁:  3. ボーン同期 (Bone Sync) 方式への移行とポリゴン破綻

画面上では、ドレスのポリゴンがあらゆる方向へ引き裂かれるように破綻し(いわゆるポリゴン爆発)、しかもモデル全体がTポーズのままピクリとも動かないという、地獄のような惨状が繰り広げられていました。 実装開始から10時間以上が経過していた私の精神(San値)は、この光景によって限界を迎えました。

なぜ、比較的安全なはずのボーン同期でこのような爆発が起きたのでしょうか。原因を紐解くと、そこには Three.js と VRM特有の仕様 が複雑に絡み合う3つの罠が潜んでいました。

原因1:ボーンインデックスの不一致による同期の「空振り」

まず、「Tポーズのまま動かない」原因は、同期処理の空振りにありました。

[Astro #35] VRMアバターの動的着せ替え実装におけるトラブルと仕様の壁:  3.ボーンインデックスの不一致による同期の「空振り」

vrm.humanoid.humanBones に対して Object.keys 等を用いた反復処理で同期を行おうとしましたが、VRMを読み込むThree.jsのバージョンやパーサーの実装によっては、このリストの取得順序や構造がメインモデルと衣装モデルで完全に一致するとは限りません。

結果として、意図したノード同士の紐付けが行われず、回転情報(quaternion)のコピーが正常に発火していなかったのです。

【解決策】 VRMの仕様で定義されている主要なボーン名(hips, spine, chest, leftShoulder など)の配列をプログラム内で明示的に定義し、そのリストに基づいて getRawBoneNode() で双方のノードを確実に取り出してコピーする設計に変更する必要があります。

原因2:VRM 0.x系モデルの「180度回転」仕様

最も致命的だった「ポリゴン爆発」の主因はこれです。

実は、VRM 0.x系のモデルは、初期状態でZ軸に対して180度回転している(つまり最初から後ろを向いている)という厄介な仕様があります。 そのため、 @pixiv/three-vrm ライブラリには、モデルを正面に向け直すための VRMUtils.rotateVRM0(vrm) という便利なメソッドが用意されています。

しかし、当時の私は「メインアバターには rotateVRM0 をかけたが、衣装アバターにはかけ忘れていた(あるいは二重にかけていた)」のです。

これにより、メインアバターと衣装アバターとで「正面」の基準となるワールド座標系が完全に反転してしまいました。基準が逆の状態でボーンの回転情報を無理やりコピーしたため、関節が反対方向にへし折られ、ポリゴンが宇宙の彼方へ吹き飛んでしまったわけです。

原因3:Frustum Culling(視錐台カリング)による描画の消失

[Astro #35] VRMアバターの動的着せ替え実装におけるトラブルと仕様の壁:  3.Frustum Culling(視錐台カリング)による描画の消失

ボーンの破綻をなんとか直しても、まだ衣装がチラついたり、特定の角度で見えなくなったりする現象に悩まされました。

これは Three.js の最適化機能である Frustum Culling(視錐台カリング) の仕業です。 カリングとは、「カメラの枠(画面)から外れたオブジェクトの描画をスキップして、処理を軽くする機能」です。

本来、SkinnedMesh の描画判定は、そのメッシュを包む「バウンディングボックス」の位置で計算されます。しかし、今回のように「プログラムの力で、衣装の頂点(骨)だけをメインアバターの位置に強制的に移動させる」というハックを行った場合、Three.js のエンジンは「バウンディングボックスは元の(画面外の)位置にある」と誤認してしまうことがあります。

その結果、「目の前に衣装があるのに、エンジンは画面外だと判定して描画を消してしまう」という現象が起きていました。

【解決策】 この強引な着せ替え手法をとる場合、対象となる衣装のメッシュに対して、以下の一行を追加し、強制的に描画を維持させる(カリングを無効にする)必要があります。

// 衣装のメッシュに対してカリングを無効化する
mesh.frustumCulled = false;

結論:Wiredでアバターを着せ替えるための「4つの絶対条件」

React Three Fiber 環境において、VRM アバターの動的着せ替えを破綻なく実装するためには、以下の要件をすべて満たさなければならない。

  1. 素体専用モデルの準備(モデリング層) VRoid Studio側で「透明メッシュの削除」をオフにし、ポリゴン削減によるメッシュ統合が行われていない、服と肌が完全に分離されたベースモデルを用意すること。
  2. ボーンツリーの厳密な同期処理(ロジック層) humanBones の単純なループではなく、同期すべき主要なボーン名を明示的に指定し、各ノードの quaternion(回転)と position(腰などの位置)を毎フレーム確実にコピーすること。
  3. シーングラフと座標系の管理(Three.js層) VRM 0.x系の「初期状態で180度反転している」仕様を理解し、親要素や rotateVRM0 の適用漏れ・二重適用による座標系の崩壊を防ぐこと。
  4. Frustum Culling の無効化(レンダリング層) 骨格同期によって強制的に頂点を移動させる都合上、Three.js の描画最適化による「画面からの消失」を防ぐため、対象メッシュの frustumCulledfalse に設定すること。

おわりに

これらの仕様上の制約とワークアラウンドを適切に実装できれば、理論上は Web ブラウザ上での完全な動的着せ替えが可能となる。

しかし、要求される3Dの低レイヤーな制御と、エラー発生時の原因切り分け(モデルデータ起因か、Three.js起因か、React起因か)の難易度が極めて高く、今回はシステム安定化を優先して実装のロールバック(見送り)を決定した。