Three.jsでVRMを軽量化する話

に公開

この記事は WebXR Advent Calendar 2025 の2日目の記事です。

はじめに

みなさん1日目の記事は読みましたか?

XRiftの未来にわくわくが止まりません✨

ところで、WebXRでアバターをたくさん表示してしかもQuestとかでもFPS出したいと思ったら、真っ先に気になるのは描画負荷ですよね?
でも一般ユーザーの中にはアバター最適化なんか知らないぜ😎みたいな人も一定数いると思います。そこで、サービス側でVRMモデルを最適化するアプローチを考えて実装してみました。

(現状かなりExperimentalな感じなので本格利用はおすすめしません🥺)

リポジトリはこちら👇

やること

今回の戦略はアバター一体あたりのメッシュやマテリアルをなるべくくっつけてドローコールを減らそうという感じです。人多いときに効いてくると思います👨‍👩‍👧‍👦

  1. テクスチャアトラス化: 複数のテクスチャを1枚のテクスチャにまとめる
  2. マテリアル統合: 複数のマテリアルを単一のマテリアルに集約する
  3. メッシュ結合: メッシュを結合してドローコールを減らす

ついでにVRM0.x -> 1.0への変換もやっているので、これがちゃんと動けばIKの話でやっていた分岐とかも消せそう。

処理の流れについて

こんな感じでやっています👇

1. マテリアルとテクスチャの解析

  • モデル内の全 MToonMaterial を収集
  • マテリアルごとに使用されているテクスチャの組み合わせ(パターン)を分析し、ユニークなパターンを抽出する

重複排除しないと同じテクスチャがアトラスに複数回出てきて面積がもったいない😣です

export function buildPatternMaterialMappings(
  materials: MToonMaterial[],
): PatternMaterialMapping[] {
  const mappings: PatternMaterialMapping[] = []

  for (let i = 0; i < materials.length; i++) {
    const material = materials[i]
    const pattern = extractTexturePattern(material)

    // 既存のパターンと一致するか確認
    const existingMapping = mappings.find((m) =>
      isSamePattern(m.pattern, pattern),
    )

    if (existingMapping) {
      existingMapping.materialIndices.push(i)
    } else {
      // 新しいパターンとして追加
      mappings.push({
        pattern,
        materialIndices: [i],
        textureDescriptor: /* ... */,
      })
    }
  }
  return mappings
}

2. テクスチャパッキング

  • 抽出したテクスチャパターンを1枚のアトラス画像にどう配置するか計算する
  • 矩形詰め込み(Bin Packing)アルゴリズムの利用 (rectpack-tsを使用)
  • canvas API等を使用して実際にアトラス画像を生成する (これはテクスチャスロット単位)
// パッキングの計算
export function pack(
  patternMappings: PatternMaterialMapping[],
): ResultAsync<PackingLayouts, OptimizationError> {
  // パターンごとのテクスチャディスクリプタを収集
  const textureDescriptors = patternMappings.map((m) => m.textureDescriptor)

  // テクスチャパッキングを実行(パターン数分)
  return await packTextures(texturesToPack)
}

// アトラス画像の生成
export function generateAtlasImagesFromPatterns(/* ... */) {
    // ...
    const atlas = yield* composeImagesToAtlas(layers, {
        width: 2048,
        height: 2048,
        colorSpace: MTOON_TEXTURE_SLOT_COLOR_SPACES[slot],
    })
    // ...
}

パッキングするとこんな感じに🖼️

パッキング前 パッキング後

工夫ポイント
rectpack-tsはパッキングが解像度基準なのではみ出すとエラーになるし小さいと余白が出ます。そこで二分探索してできるだけぎちぎち😶‍🌫️に詰めています。

3. UV座標の再計算

  • 各メッシュのUV座標を、アトラス上の新しい配置に合わせて変換(スケールとオフセットの適用)
  • geometry.attributes.uv を直接書き換える
export function applyPlacementsToGeometries(
  rootNode: Object3D,
  materialPlacementMap: Map<MToonMaterial, OffsetScale>,
): Result<void[], OptimizationError> {
  rootNode.traverse((obj) => {
    if (!(obj instanceof Mesh)) return
    // ...
    const placement = materialPlacementMap.get(material)

    // ジオメトリを複製して独立させる
    const clonedGeometry = obj.geometry.clone()
    obj.geometry = clonedGeometry

    // UVを再計算
    remapGeometryUVs(obj.geometry, placement)
  })
  // ...
}

以下のようにUVが移動されて重ならなくなっています🙂

UV編集前 UV編集後

つまづきポイント
UVが0-1範囲外にある場合、そのまま移動するとおかしくなるのでfracみたいな処理を入れます

4. メッシュの結合

  • マテリアルのレンダリングモード(Opaque, Cutout, Transparent)ごとにグループ分けを行う
  • 同じグループ内のメッシュを BufferGeometry レベルで結合する
  • VRMExpression で制御されているメッシュ(主に顔)は、結合するとアニメーションが破綻する可能性があるため、結合対象から除外する (将来的にはやりたい)

Opaque, Transparentなど描画モードが違うものはどのみち1回でドローできないので分けておきます。
実際のバッファの結合はBufferGeometryUtils.mergeGeometriesで一発です。

// レンダーモードごとにグループ化
const groupedInfos = groupMaterialInfoByRenderMode(materialInfos)

for (const renderMode of renderModeOrder) {
    // ...
    // マテリアルスロットアトリビュートを追加してジオメトリ結合
    const mergedGeometries = yield* mergeGeometriesWithSlotAttribute(
      meshesForMerge,
      meshToSlotIndex,
      opts.slotAttributeName,
    )

    // 結合メッシュの作成
    const { mesh: combinedMesh } = createCombinedMesh(
      mergedGeometry,
      atlasMaterial,
      meshesForMerge,
      `CombinedMToonMesh_${renderMode}`,
    )
    // ...
}

オブジェクトが減っていますね🐤

メッシュ結合前 メッシュ結合後

つまづきポイント

  • mergeGeometriesはSkinWeight用のアトリビュートも結合してくれますが、同じSkeleton🦴を使っている場合でも個々のメッシュで使うボーンしかbones配列に含まれてない場合はindexがずれるので自前でindexを再構築する必要があります。
  • MorphTarget😆があると結合できません。MorphTargetつきメッシュを結合したい場合、MorphTargetの配列は自分で処理する必要があります。

5. マテリアルの差し替え

  • 結合されたメッシュに対して、アトラス化されたテクスチャを持つ新しいマテリアル(MToonAtlasMaterial)を適用する
// MToonAtlasMaterialの作成
const atlasMaterial = createMToonAtlasMaterial(
  materials[0],
  parameterTexture,
  materials.length,
  opts.texelsPerSlot,
  opts.slotAttributeName,
  renderMode,
)

// ...

function createMToonAtlasMaterial(/* ... */): MToonAtlasMaterial {
  // ...
  // パラメータテクスチャディスクリプタを構築
  const parameterTextureDescriptor: ParameterTextureDescriptor = {
    texture: parameterTexture,
    slotCount,
    texelsPerSlot,
    atlasedTextures,
  }

  // MToonAtlasMaterialを作成
  return new MToonAtlasMaterial({
    parameterTexture: parameterTextureDescriptor,
    slotAttribute,
  })
}

MToonAtlasMaterialって何

今回の最適化のために作成した、THREE.ShaderMaterialを継承したカスタムマテリアルです。
中身はほとんどMToonMaterialですが、一般的なマテリアルパラメータの代わりに以下の仕組みになっています

  1. パラメータテクスチャによるプロパティ管理:
    • 複数のマテリアルのパラメータ(色、各種係数など)を「パラメータテクスチャ」と呼ばれるデータテクスチャに焼き込んでいます。
    • これにより、1つのドローコール内で頂点ごとに異なるマテリアルパラメータを適用できます。
  2. スロット属性によるマテリアル識別:
    • 各頂点に mtoonMaterialSlot という属性(Attribute)を持たせ、自分がどのマテリアル(パラメータテクスチャのどの行)を参照すべきかを指定します。

パラメータテクスチャはこんな感じ👇

つまり、「見た目は複数のマテリアルだけど、実態は1つのマテリアル」 として振る舞うことができるマテリアルです。これにより、元々マテリアルが異なるメッシュ同士でも1つのドローコールにまとめることができます。

ちなみに、three-vrmが提供するMToonMaterialにはMToonNodeMaterialというTSL対応版があるんですが今回のXRiftの環境ではまだ使えない😢ので普通にShaderMaterialで作りました。

VRM/Three.js系の落とし穴などなど

そもそもなんでThree.jsでやったのか

実は最初はgltf-Transformとか使ってVRMをglTFとして加工するのにトライしていたんですが、VRM0.xと1.0の差異を自分で吸収しないといけないことやそもそもVRMのメタデータが吹き飛ぶなどの問題があり、この辺をいい感じにやってくれる@pixiv/three-vrmを使うのが楽ちんです😊
ただし、これにはエクスポート機能がないためそこは自前で実装しています。

Node.jsでThree.jsやCanvasを動かすのはだるい

テスト環境用にNode.jsでも最適化処理を実行できるようにしようとしていたんですがあんまり上手くいかないので素直にVitestのブラウザモード🌏を使ったほうがいいです。WSLではヘッドレスブラウザのWebGLが動かなくてソフトウェアレンダリング(SwiftShader)の設定を入れました

Three.jsの共有バッファに注意

UVの変形をしているときに複数回変形が適用されておかしくなる問題がありましたがとりあえずBufferを全部cloneしたら直りました。そのまま同じWebGLコンテキストでデータを使い続ける場合はリソースリークに注意です😈

VRM0.xから1.0へのマイグレーション

VRM0.xと1.0ではかなり色々仕様が異なります。が、多くの部分をthree-vrmが吸収してくれるのでこれさえ使っていればかなり楽です。ただし、Z軸方向のキャラクターの向きが逆なので全てのボーンと頂点座標を180度回して再バインドする処理を入れました

最適化処理の勘所

最適化処理はやろうと思えばどこまでもできてしまいますが、今回はとにかく楽にさくっとできて効果あるところから作っています。TypeScriptは裾野が広いだけあって意外とモデル最適化に使えるライブラリが揃っていたので助かりました😙
最初はやはりメッシュ、マテリアル、テクスチャのマージです。
VRChat向けモデルだとユーザビリティ重視でオブジェクト細かく分かれてますし、マテリアルも細かく分かれてるとドローコールがとんでもない😱ことになるのでここは一番なんとかしたい部分だと思います。
テクスチャはパッキング時に解像度もまとめて調節できるのでその点でもいいですね。
メッシュのマージについては顔メッシュ🙂ごと1つにまとめてしまうこともできるんですがThree.jsはモーフターゲットの配列長がそのメッシュの頂点数とおなじになるのでモーフターゲットがたくさんあった場合バッファが重くなるのでここは分けたほうがいいと思います。

今後やりたいこと

ポリゴン削減はやりたいです。meshoptimizerを試してみようと思います。デスクトップだとあんまり気にならないとは思うんですが1アバター30万ポリゴンとかあるとモバイルだと結構バッファサイズが洒落にならん感じだと思います😵‍💫。VR的にはLODで近くに来たときはハイポリ版を表示するとかもできたら嬉しいですね。
あとはテクスチャのKTX2圧縮はVRAM削減に効くのでぜひやりたいんですがAIにブラウザだと重すぎるぞ😠と結構脅されたのでできないかも…。

おわりに

最適化周りは実装は結構楽しいんですが基本的に見た目を維持したまま中身をどう削るかって話なので記事的にはあんまり映えないなと思いました。もっとキラキラ系の記事も書くぞ~✨

明日はwakufactoryさんです!

Discussion