Three.jsで目指せ60FPS : ジオメトリ結合編

## はじめに

この記事は、three.jsにおけるジオメトリ結合の

-   描画負荷に対する影響
-   モデルデータの結合方法
-   結合したジオメトリの制限と回避方法

の3点を共有するためのものです。

### 想定する環境

-   Google Chrome 102.0.5005.61
-   three.js r141

お手元のthree.jsのバージョンを確認してからお読みください。バージョンが異なる場合、記事の内容が適用できない場合があります。

### 想定する読者

-   three.jsを使ったことがある。
-   WebGLを直接書いたことはない。
-   画面描画の負荷に不満があるが、どのように改善すればいいかわからない。

この記事では、three.jsのインストール方法は取り扱いません。あらかじめご承知ください。

## ドローコールの数と描画負荷の変化

### デモ

まずはデモをご覧ください。画像をクリックすると、それぞれのデモページに遷移します。

#### ▼ デモ A : ジオメトリを結合していないデモ

::: note alert
デモAは高負荷になるよう設計しています。動作を確認したらブラウザウィンドウを閉じてください。長時間放置するとPCをクラッシュさせる恐れがあります。
:::

[![ジオメトリを結合していないデモ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9556/3889c70f-0cbd-68c5-a073-95fb05b6124f.jpeg)](https://masatomakino.github.io/threejs-lab/demo/StudyMergedGeometryNone.html)

#### ▼ デモ B : ジオメトリを結合したデモ

[![ジオメトリを結合したデモ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9556/5e480fe1-e549-93b6-2a17-919c5bb471a4.jpeg)](https://masatomakino.github.io/threejs-lab/demo/StudyMergedGeometry.html)

手元の環境で、デモAの描画速度は10FPS程度、Bは60FPSです。またデモBのBox数はデモAの約4.1倍です。

2つのデモのもっとも大きな差はドローコールの数です。

![デモAのドローコール数](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9556/93feb260-0d0a-4ca6-7a11-9fe1540968d4.jpeg)

デモAは1フレームあたり15625回のドローコールを処理しています。

![デモBのドローコール数](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9556/e456b2b7-5713-6a89-7baf-14df354cb064.jpeg)

デモBは1フレームあたり1回のドローコールしか処理していません。

### ドローコールとは？

では、パフォーマンスに大きな影響を与えたドローコールとは、いったいどんなものでしょう？
ドローコール ( Draw calls ) とは、グラフィックスAPIにおける描画命令の総称です。具体的にどんな関数を呼び出すかは、グラフィックスAPIによって変わります。

#### WebGL におけるドローコール

一例として、three.jsではWebGLの[drawArrays](https://developer.mozilla.org/ja/docs/Web/API/WebGLRenderingContext/drawArrays)関数を呼び出しています。WebGLRendererの[この箇所](https://github.com/mrdoob/three.js/blob/342946c8392639028da439b6dc0597e58209c696/src/renderers/webgl/WebGLBufferRenderer.js#L15)でdrawArrays関数を呼び出しています。`drawArrays`関数を呼び出した段階でバッファーに描画結果が書き込まれます。Meshの重なり順にあわせて複数回`drawArrays`関数を呼び出した後、WebGLは`flush`関数でバッファーを画面に転送します。

参考記事 : [wgld.org | ポリゴンのレンダリング](https://wgld.org/d/webgl/w014.html)

#### three.js でのドローコール統計情報の取得

three.jsのWebGLRendererは、レンダリングの統計情報を持っています。
[WebGLRenderer.info.render](https://threejs.org/docs/#api/en/renderers/WebGLRenderer.info)プロパティで、最終フレームの統計情報にアクセスできます。

### どうやって削減するか？

このドローコールを減らせば描画パフォーマンスが向上するということが、これまでのデモから判明してきました。それでは具体的にどうやってドローコールを減らせばいいのでしょうか？

ドローコールは、独自のジオメトリとマテリアルを持ったメッシュ1つにつき1回実行されます。デモAの場合XYZ軸にそれぞれ25、合計15625個のBoxがありますのでドローコールも15625回実行されます。

ドローコールを削減する方法はいくつかありますが、今回はジオメトリを結合して1つにするという方針を採用しました。ジオメトリを結合すれば、そのジオメトリがどんな複雑な形状でもドローコールは1回しか実行されません。

## ジオメトリの結合

### ジオメトリとアトリビュート

ジオメトリの結合方法を説明する前に、必要となる用語を整理します。

ジオメトリとは立体形状のことです。three.jsにおいては、立体形状であるジオメトリと、質感を表すマテリアルが1セットになって立体を構成しています。

アトリビュートとは、各頂点に割り当てられた属性のことです。ジオメトリは複数のアトリビュートで構成されます。three.jsにおいてはpositionもアトリビュートの一種として扱われます。頂点に個別の色を割り当てる場合はジオメトリにcolorアトリビュートを追加します。これ以外にもユーザーが任意のアトリビュートを追加できます。

### mergeBufferGeometries

three.jsには[mergeBufferGeometries](https://threejs.org/docs/#examples/en/utils/BufferGeometryUtils.mergeBufferGeometries)というユーティリティ関数があります。この関数にジオメトリを格納した配列を渡すと、結合されたジオメトリが返ってきます。

### 結合後もattributeは維持される

`mergeBufferGeometries`で結合したあとも、ジオメトリに設定したカスタムアトリビュートは維持されます。ジオメトリをインスタンス化したり、各種ローダーで読み込んだ後、結合する前にカスタムアトリビュートを設定することで、結合前のモデルを区別できます。

## 結合したジオメトリでできること / できないこと

### ⭕️BufferGeometryでも透過できる

three.jsのr127で各頂点に個別のAlpha値を割り当てる`vertexAlpha`が追加されました。

https://github.com/mrdoob/three.js/releases/tag/r127

現在のバージョンでは

1. Materialの[vertexColors](https://threejs.org/docs/#api/en/materials/Material.vertexColors)オプションをtrueにする。
2. Materialの[transparent](https://threejs.org/docs/#api/en/materials/Material.transparent)オプションをtrueにする。
3. Geometryに"color"という名前のAttributeを、itemSize : 4で追加する。

という設定をすることで、vertexAlphaが有効になります。

上記の設定3は、具体的には以下のように指定します。

```js
const count = geometry.getAttribute("position").count; //頂点の数を調べる。
/**
 * 頂点の数の4倍の固定長配列を生成して、colorという名前でGeometryのAttributeに追加する。 
 */
geometry.setAttribute( 
  'color',
  new THREE.BufferAttribute( new Float32Array(count * 4), 4 )
);

```

https://github.com/mrdoob/three.js/pull/21530

#### ▼デモC : 結合ジオメトリでのアルファ適用デモ

[![デモC : 結合ジオメトリでのアルファ適用デモ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9556/0a8bc60c-9eb7-7d2e-acaf-7c09623cccd6.jpeg)](https://masatomakino.github.io/threejs-lab/demo/StudyMergedGeometryAlpha.html)

原点からの距離でアルファを変化させるデモです。


### ❌Attributeは固定長なので頂点を後から増減できない

BufferAttributeが[格納するデータ](https://threejs.org/docs/#api/en/core/BufferAttribute.array)は固定長配列です。そのためジオメトリを後から追加したり、削り落としたりはできません。
positionを含むすべてのAttributeを再生成してセットすれば増減は可能ですが、CPUの負荷が大きくなり現実的な速度で動きません。

### ⭕️BufferGeometryでもマウスインタラクションができる

こちらの公式サンプルに、BufferGeometryでのマウスインタラクションの方法があります。

https://threejs.org/examples/?q=bufferge#webgl_interactive_buffergeometry

しかしこのサンプル1ポリゴンを対象としてマウスイベントを受け付けているため、ジオメトリ結合前のモデル単位でのマウスインタラクションができません。

BufferGeometryでマウスインタラクションをさせる場合、ジオメトリの結合前に下処理をするという方法があります。

参考記事 : [ Raycaster : Get ID of individual geometries of a mergeBufferGeometry ](https://discourse.threejs.org/t/raycaster-get-id-of-individual-geometries-of-a-mergebuffergeometry/16154)

#### ▼ デモD : インタラクティブな結合ジオメトリ
[![デモD : インタラクティブな結合ジオメトリ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9556/03decd52-8bd4-e977-31b4-9af172ba18ee.jpeg)](https://masatomakino.github.io/threejs-lab/demo/StudyMergedGeometryInteractive.html)

デモDでは、結合後のジオメトリから、結合前のIDを取り出すために以下の処理をしています。

-   結合前のジオメトリに個別のIDをAttributeとして割り当てる。
-   [Raycaster](https://threejs.org/docs/#api/en/core/Raycaster)でマウスポインター下のfaceを取り出す。
-   idAttributeのインデックス［face.(a|b|c)］にアクセスすると、結合前のジオメトリのIDを取り出せる。

[デモページのソース](https://github.com/MasatoMakino/threejs-lab/blob/main/src/StudyMergedGeometryInteractive.ts)がありますが、ここには上記の処理に関連する部分を抜粋します。

▼ カスタムAttributeにメッシュIDを格納する。

```js
// ジオメトリ生成処理。個別のIDをattributeに格納する。
const generateCube = (x: number, y: number, z: number, id: number) => {
  const geometry = new BoxGeometry();
  const positions = geometry.getAttribute("position");

  const count = positions.count;
  geometry.setAttribute(
    "mesh_id",
    new BufferAttribute(new Float32Array(count), 1)
  );
  const idAttribute = geometry.getAttribute("mesh_id");
  for (let i = 0; i < count; i++) {
    idAttribute.setX(i, id); //各頂点にIDを割り振る
  }
  geometryArray.push(geometry);
};

let idCounter = 0;
for (let x = 0; x < numCube; x++) {
  for (let y = 0; y < numCube; y++) {
    for (let z = 0; z < numCube; z++) {
      generateCube(x, y, z, idCounter);
      idCounter++;
    }
  }
}

//ジオメトリをマージする。"mesh_id"Attributeは結合後も維持される。
const mergedMesh = new Mesh(
  mergeBufferGeometries(geometryArray),
  new MeshBasicMaterial({ transparent: true, vertexColors: true })
);
```

▼ レンダリング中にマウスポインターとの交差を確認する

```js
const rendering = () => {
  this.raycaster?.setFromCamera(this.mousePoint, camera);
  const intersects = this.raycaster?.intersectObject(mergedMesh, false);

  if (intersects?.length > 0) {
    const idAttribute = geo.getAttribute("mesh_id");
    /**
     * IDアトリビュートに、Raycasterで取得したfaceのa,b,cのいずれかをインデックスとしてアクセスすると、
     * "mesh_id"が取り出せる。
     **/
    const meshID = idAttribute.getX(intersects[0].face.a);
    const count = idAttribute.count;
    for (let i = 0; i < count; i++) {
      if (idAttribute.getX(i) === meshID) {
        /**
         * ここで各頂点を操作する。colorAttributeを上書きするなど。
         * 変更したAttributeはneedUpdateを設定しないと画面に反映されないので注意。
         **/
        colorAttribute.needsUpdate = true;
      }
    }
  }
  renderer.render(scene, camera);
  requestAnimationFrame(rendering);
};
```

レンダリングループのたびにcolorAttributeを書き換えるため、CPU負荷が大きい処理です。より複雑な結合ジオメトリでのインタラクションを実装する場合は、一部処理をシェーダーに置き換えることも検討してください。

## 個人的な感想

WebGLはPCの処理性能が向上した現在でも軽い処理ではありません。豊富なリソースでゴリ押しできるようになるにはまだまだ時間がかかりそうです。それまではGPUがどのような処理を得意とするのかを把握し、設計の段階でコンテンツを得意な形に落とし込む必要があります。

## 参考記事

https://ics.media/tutorial-three/geometry_merge/

https://www.kabuku.co.jp/developers/threejstowebgl