Three.jsのパフォーマンス最適化|箱を1000個に増やして描画コールを1000→1にする

約15分
Three.jsのパフォーマンス最適化|箱を1000個に増やして描画コールを1000→1にする

みなさんこんにちは。フロントエンドエンジニアのしゅん(@shun_webdesign)です。

このシリーズではずっと1個の箱を育ててきました。質感をつけて自分のモデルに差し替えてスクロールで動かして

今回はその箱を1000個に増やします。そして、増やしたときに何が起きるかを実際に測ります。

「Three.js 重い」で検索すると、ポリゴンを減らせ、テクスチャを圧縮しろ、ジオメトリを使い回せ、といった話が出てきます。どれも正しいんですが、どれがどれくらい効くのかは書かれていないことが多い。なので今回は全部自分で測りました。

先に結論を言っておくと、僕の環境(M1 Max)では1000個程度だと3つの作り方に差が出ませんでした。 その話も含めて書きます。

「重い」の正体は、たいてい描画コール

3Dが重くなる原因はいくつもありますが、Three.jsで最初に疑うべきは描画コール(draw call)です。

描画コールというのは、CPUがGPUに出す「これを描いて」という命令のことです。1回の命令には準備が要ります。どのジオメトリを使うか、どのマテリアルか、どこに置くか。これをGPUに伝えるのに、毎回それなりの手間がかかる。

ここが直感に反するところなんですが、GPUは大量の三角形を描くのが得意で、大量の命令を受け取るのが苦手です。三角形12,000個を1回で渡されるのは平気でも、12個の三角形を1,000回に分けて渡されると詰まる。

つまり、箱を1000個置くと描画コールが1000回になる。これが「Three.jsが重い」の一番よくある正体です。

同じ見た目を、3つの作り方で

検証用に、同じ見た目の箱1000個を3パターン作って、ボタンで切り替えられるようにしました(デモ: demo/09-instancing.html)。以下のコードはそこから抜き出したものです。

A. 素直に1000個

いちばん自然に書くとこうなります。

for (const p of placements) {
  const mesh = new THREE.Mesh(
    new THREE.BoxGeometry(),                      // ← 毎回 new
    new THREE.MeshStandardMaterial({ color: ... }) // ← 毎回 new
  );
  mesh.position.copy(p.pos);
  scene.add(mesh);
}

ループの中でnewしているので、ジオメトリが1000個、マテリアルが1000個できます。

B. ジオメトリとマテリアルを使い回す

よく「使い回せ」と言われるやつです。

const geometry = new THREE.BoxGeometry();                    // ← 1個だけ
const material = new THREE.MeshStandardMaterial({ ... });    // ← 1個だけ

for (const p of placements) {
  const mesh = new THREE.Mesh(geometry, material);           // ← 使い回す
  mesh.position.copy(p.pos);
  scene.add(mesh);
}

C. InstancedMesh

「同じ形のものを、位置だけ変えてたくさん置く」ための専用クラスです。

const mesh = new THREE.InstancedMesh(
  new THREE.BoxGeometry(),
  new THREE.MeshStandardMaterial({ ... }),
  1000                                    // ← 何個ぶんか
);

const m = new THREE.Matrix4();
placements.forEach((p, i) => {
  m.compose(p.pos, quaternion, scale);
  mesh.setMatrixAt(i, m);                 // ← i番目の「置き方」
  mesh.setColorAt(i, color);              // ← i番目の色
});
mesh.instanceMatrix.needsUpdate = true;
scene.add(mesh);                          // ← sceneに入るのは1個

Meshを1000個作る代わりに、1000個ぶんの「置き方」を行列の配列としてGPUに一度で渡します。sceneに入るオブジェクトは1個だけです。

測った結果

Apple M1 Max / Chrome / three r170 / 1100×700・dpr 1。数字はフレーム1枚にかかった時間の中央値です(小さいほど速い)。

まず、普通に動かした場合(垂直同期あり)。箱1000個です。

作り方描画コールフレーム時間見かけのfps
A 1個ずつ new する1,00013.3ms75fps
B 使い回す1,00013.3ms75fps
C InstancedMesh113.3ms75fps

全部同じです。 僕のディスプレイは75Hzなので、13.3msより速く描いても表示は増えません。3つとも余裕で間に合っているから、見た目にも数字にも差が出ない。

箱1000個を3つの作り方で描き比べた画面。AとCは見分けがつかないが、Bだけ青一色になっている

見た目も、AとCは区別がつきません。Bだけ青一色なのは後で説明します。

では垂直同期を外して、素の処理時間を測ります。ここからが本題です。

1000個(5回ずつ計測)

作り方描画コールジオメトリフレーム中央値5回の実測
A 1個ずつ new する1,0001,0113.9ms3.6 / 3.9 / 4.0 / 3.8 / 3.9
B 使い回す1,000121.7ms1.7 / 1.7 / 1.7 / 1.7 / 1.7
C InstancedMesh1120.5〜1.8ms0.5 / 1.8 / 1.7 / 0.6 / 1.8

正直に言うと、この結果は予想と違いました。

InstancedMesh(C)の数字がぶれます。0.5msのときもあれば1.8msのときもある。5回やって二極化しました。1000個ぶんのGPUの仕事が軽すぎて、フレーム時間がブラウザ側のオーバーヘッドに埋もれているんだと思います。速すぎて測れない、という状態です。

一方でBは5回とも1.7msでびくともしませんでした。 1000個の規模でいちばん確実に効いたのは、InstancedMeshではなくループの中でnewをやめることだったわけです。

個数を増やすと逆転する(各1回計測)

個数作り方描画コールフレーム中央値
5,000A 1個ずつ new する5,00019.2ms
5,000B 使い回す5,0009.2ms
5,000C InstancedMesh12.0ms
20,000A 1個ずつ new する20,00082.1ms
20,000B 使い回す20,00041.7ms
20,000C InstancedMesh12.2ms

ここで話が変わります。Cだけ、個数を20倍にしても2.0 → 2.2msでほぼ動きません。 AとBは個数に正比例して重くなっていくのに。

描画コールが1本のまま変わらないからです。GPUに「この形を20,000回、この配置で描いて」と一度言うだけなので、増えたぶんの命令コストがゼロになる。

20,000個での差は 82.1ms 対 2.2ms でした。ここまで来ると誤差では説明がつきません。

ついでに読み込みも、20,000個のAはページが出るまで7.6秒(Cは0.9秒)でした。

📏 計測の但し書き:1000個は5回ずつ、5,000個と20,000個は1回ずつの計測です。1000個で繰り返したのは差が小さくて誤差と区別できなかったからで、20,000個の37倍差は1回でも十分に読み取れる大きさだと判断しました。作業中のマシンで測っているので、絶対値はそのまま鵜呑みにしないでください。比率だけ見てください。

落とし穴1:「使い回し」は描画コールを1回も減らさない

表を見て引っかかったところだと思います。

B の描画コールは、Aと同じ1,000回のままです。 1回も減っていません。

「ジオメトリを使い回すと軽くなる」という説明をよく見かけますが、描画コールの回数はMeshの数で決まります。Meshが1,000個ある限り1,000回。使い回しても変わりません。

ところが、フレーム時間は3.9ms → 1.7msと半分以下に落ちています。 描画コールは同じなのに。

減っているのはマテリアルの切り替えコストです。Aではマテリアルが1,000個別々にあるので、Three.jsは1回描くたびに「次はこのマテリアル」とGPUの状態を作り直します。Bはマテリアルが1個しかないので、その切り替えが丸ごと消える。回数は同じでも、1回あたりが軽くなるわけです。

ここは僕も測るまで分かっていませんでした。「描画コールが減らないなら効果は薄いだろう」と思っていたら、1000個の規模ではこれが一番確実な改善でした。しかも5回測って全部1.7msという安定ぶりです。

落とし穴2:マテリアルを共有すると、色を1種類しか持てない

さっきの画像でBだけ青一色だったのは、これが理由です。

マテリアルを1個に共有するということは、色も1個ということです。箱ごとに色を変えたければマテリアルを分けるしかなく、分けた瞬間にAに戻ります。

ここがInstancedMeshの効くところで、setColorAt()を使うと1つのマテリアルのまま、インスタンスごとに色を持てます

mesh.setColorAt(i, new THREE.Color().setHSL(hue, 0.6, 0.55));
mesh.instanceColor.needsUpdate = true;

つまりCは、Aの見た目を、Bより軽く出せる。3つの選択肢の中でこれだけが「見た目を諦めなくていい」やり方です。

InstancedMeshを使うときに引っかかること

便利なんですが、素直なMeshとは勝手が違います。僕が実際に詰まったところを挙げておきます。

色はsetColorAt()で入れる。 マテリアルのcolorは全インスタンス共通です。個体差をつけたいときはsetColorAt(i, color)を使います。1回でも呼ぶとinstanceColorが生えるので、その後はmesh.instanceColor.needsUpdate = trueが必要です。

動かすときは行列を書き直す。 mesh.position.x = ...のようには書けません。毎フレーム動かすなら、setMatrixAt(i, matrix)で入れ直してinstanceMatrix.needsUpdate = trueを立てます。1000個ぶん行列を組み直すとCPU側の負担になるので、動かす必要がないものは静的なままにしておくのが得策です。

カリングが効かなくなることがある。 Three.jsは画面外のオブジェクトを描画から外してくれますが、InstancedMesh1個の塊として判定されます。全体を囲む箱の一部でも画面に入っていれば、全部描かれる。広い範囲にばらまくと、かえって無駄が出ることがあります。

クリック判定はインスタンス単位になる。 Raycasterは当たったインスタンスの番号をinstanceIdで返してきます。前回のインタラクションの回のようにobjectで分岐していたコードは書き換えが要ります。

1種類の形しか入らない。 箱と球を混ぜたければ、InstancedMeshを2つ作ります。

測り方:renderer.infoを見る

推測で最適化するのが一番よくないので、測り方も書いておきます。Three.jsは自分の仕事量を教えてくれます。

console.log(renderer.info.render.calls);     // 描画コール
console.log(renderer.info.render.triangles); // 三角形の数
console.log(renderer.info.memory.geometries); // 確保しているジオメトリ

毎フレーム更新されるので、描画ループの中でHUDに出すのが手っ取り早いです。デモではそうしています。

fpsだけを見ていると騙されます。 これは今回いちばんの学びでした。ディスプレイのリフレッシュレートが上限になるので、余裕があるうちは何をしても同じ数字が出ます。fpsが落ち始めたときにはもう手遅れで、それより前に描画コールを見ておくべきなんです。

もうひとつ、平均ではなく95パーセンタイルを見るのがおすすめです。平均60fpsでも、時々30msかかるフレームが混ざっていると体感はガタつきます。デモのHUDにも入れてあります。

で、いつやるべきなのか

ここまで書いておいてなんですが、多くの場合やらなくていいです。

箱が10個や100個なら、素直にMeshを並べてください。InstancedMeshはコードが確実に複雑になります。個別に動かすのも、色を変えるのも、クリックを取るのも一手間増える。その複雑さを払う価値があるのは、実際に詰まっているときだけです。

僕の目安はこうです。

  • 描画コールが数百を超えていて、かつ体感でガタついているInstancedMeshを検討する
  • 描画コールは多いが快適に動いている → 今は触らない。ただし、想定する最低スペックの端末で確認してからにする
  • 描画コールは少ないのに重い → 原因は別のところ。テクスチャのサイズ、影の解像度、ポリゴン数、毎フレームのnewなどを疑う

最後のパターンは意外と多いです。特に毎フレームnew THREE.Vector3()しているコード。ループの外に出すだけで直ります。

描画エンジンごと替えるという手もある

描画コールの重さは、そもそもWebGLというAPIの性質でもあります。WebGPUはこのあたりが設計から見直されていて、同じシーンでもCPU側の負担が軽くなります。

Three.jsはWebGPURendererを用意していて、このシリーズの土台ならbase.jsの1行を差し替えるだけで試せます。やり方はWebGPURendererへの移行ガイドに書きました。

まとめ

  • Three.jsが重いとき、最初に見るのは描画コールrenderer.info.render.callsで分かる
  • ジオメトリ・マテリアルの使い回しは、描画コールを減らさない。効くのはメモリと初期化コスト
  • 描画コールを減らすにはMeshの数を減らす。同じ形をたくさん置くならInstancedMesh
  • ただし色・アニメーション・カリング・クリック判定で作法が変わる。必要になってから使う
  • fpsは天井に張り付くので指標にならない。描画コールと95パーセンタイルを見る

3Dの最適化って、やり始めると楽しくなってキリがないんですよね。でも本当に大事なのは、測ってから決めることだと思っています。今回みたいに「意外と差が出なかった」という結果も、測らないと分かりません。


📕 もっと手を動かして体系的に学びたい人へ

この記事のような「仕組みを理解しながら小さく動かす」を積み重ねて、作品づくりまで導くKindle本を書きました。
『Three.jsでつくる、小さなWebGL表現 ── はじめての”作品づくり”ガイド』
環境づくりから、モデルや質感を組み合わせて自分の作品にまとめ上げるところまで順を追って解説しています。

👉 Amazonで見る »


僕もXでThree.jsの作品や知見を発信しているので、よかったら@shun_webdesignを覗いてもらえると嬉しいです。それでは、よいThree.jsライフを🌊

しゅん

しゅん

フロントエンドエンジニア / Webデザイナー。 Three.js・WebGLでのWeb表現が専門。CSS Winner / Orpetron で Site of the Day 受賞、Kindle本を2冊出しています。 音楽、映画、芸術を中心としたサブカルが大好き。 お仕事のご相談は、下記サイトの連絡先からお願いします。

← 記事一覧に戻る