WebGL & Three.js

Three.jsのInstancedMeshで1万個を描画する|更新・色・クリック判定

同じ箱を1万個並べ、通常MeshとInstancedMeshのdraw call数を実測。setMatrixAtの更新通知、setColorAt、instanceIdとデータIDの対応まで動くコードで確かめます。

この記事の目次
  1. InstancedMeshにまとめられる単位を決める
  2. 個数・描画方式・選択を切り替えるデモを作る
  3. package.jsonとindex.html
  4. main.js
  5. 位置が変わらないときは、行列と更新通知を分けて見る
  6. 色とクリック結果を、同じ添字でデータへ結び付ける
  7. draw call数を確認し、残った重さを切り分ける

同じ箱を1万個並べるとき、GeometryとMaterialを共有したMeshを1万個作っても、描画の呼び出しは個別に発生します。同じ形状・同じ材質のまとまりをInstancedMeshに置き換えると、個々の位置や回転を保ちながら描画をまとめられます。

このページでは、1,000個・10,000個の箱を通常のMeshとInstancedMeshで切り替え、draw call数を比較します。その後、全体を動かす、選んだ箱の色を変える、クリックした箱からデータのIDを取り出すところまで試します。

Three.js 0.186.0で動作を確認しました。同じ形状を共有する仕組み自体はcloneとリソース共有の使い分けへ譲り、ここでは置き換えた後の更新と計測に絞ります。

InstancedMeshにまとめられる単位を決める

一つのInstancedMeshへ渡すのは、共通のGeometry、Material、インスタンス数です。個体ごとの位置・回転・拡大縮小は行列で、色はインスタンス用の色属性で持ちます。同じ箱が多数ある棚や、同じ部品を並べる表示に向いています。

形状も材質も違うものを、名前が「大量オブジェクト」だからという理由で一つに詰めるわけではありません。同じ組み合わせごとに分けます。異なる形をランダムに配置する手順は、Meshをランダムに並べる記事で扱っています。

今回そろえる条件 設定
形状 同じBoxGeometry。1個あたり12三角形
材質 白いMeshBasicMaterial。影・透過・ライト・後処理なし
配置 全個体が画面に入る格子配置
描画 1フレームにrenderer.render()を1回

色変更時だけ、通常Meshでは選択した個体へ共用の黄色Materialを割り当てます。InstancedMeshでは白いMaterialを保ち、インスタンスの色を黄色にします。まず未選択の状態で描画回数を比較してください。

スポンサーリンク

個数・描画方式・選択を切り替えるデモを作る

空の作業フォルダーに次の3ファイルを保存します。Node.js・npmとPython 3が使える環境で、npm install、npm startの順に実行し、http://127.0.0.1:5188を開きます。検証にはNode.js 24.13.0を使いました。ブラウザーはWebGL 2に対応している必要があります。

package.jsonとindex.html

{
  "private": true,
  "type": "module",
  "scripts": {
    "start": "python3 -m http.server 5188 --bind 127.0.0.1"
  },
  "dependencies": {
    "three": "0.186.0"
  }
}
<!doctype html>
<html lang="ja"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>InstancedMeshの比較</title>
<style>
body{margin:20px;font:16px/1.7 sans-serif;background:#101827;color:#eee}
main{max-width:1000px;margin:auto}label{display:inline-block;margin:0 12px 8px 0}
select,input,button{font:inherit}#stage{width:100%;height:420px}canvas{display:block;width:100%;height:100%}
output{display:block;overflow-wrap:anywhere}input[type=number]{width:6em}
</style>
<main><h1>同じ箱をまとめて描く</h1>
<label>個数 <select id="count"><option>1000</option><option>10000</option></select></label>
<label>方式 <select id="mode"><option value="instanced">InstancedMesh</option><option value="mesh">Mesh</option></select></label>
<label><input id="animate" type="checkbox">全体を回転させる</label>
<div id="stage"></div><output id="stats" aria-live="polite"></output>
<form id="pick"><label>選ぶ添字 <input id="index" type="number" value="0" min="0" step="1" required></label><button>色を変える</button></form>
<output id="selection" aria-live="polite">未選択</output></main>
<script type="module" src="./main.js"></script></html>

canvasだけではキーボードで箱を選べないため、添字を入力するフォームも付けています。10,000個のときは0〜9,999を指定できます。

main.js

import * as THREE from './node_modules/three/build/three.module.js';

const stage = document.querySelector('#stage');
const countInput = document.querySelector('#count');
const modeInput = document.querySelector('#mode');
const indexInput = document.querySelector('#index');
const animate = document.querySelector('#animate');
const stats = document.querySelector('#stats');
const selection = document.querySelector('#selection');
const renderer = new THREE.WebGLRenderer({ antialias: false });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
stage.append(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#101827');
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
camera.position.z = 20;
const geometry = new THREE.BoxGeometry(0.65, 0.65, 0.65);
const whiteMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
const yellowMaterial = new THREE.MeshBasicMaterial({ color: 0xffcc33 });
const white = new THREE.Color(0xffffff);
const yellow = new THREE.Color(0xffcc33);
const dummy = new THREE.Object3D();
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let group, instances, meshes = [], rows = [], selected = null;
let columns = 1, angle = 0;

function updateTransforms() {
  rows.forEach((row, i) => {
    dummy.position.set(row.x, row.y, 0);
    dummy.rotation.set(angle, angle, 0);
    dummy.updateMatrix();
    if (instances) instances.setMatrixAt(i, dummy.matrix);
    else {
      meshes[i].matrix.copy(dummy.matrix);
      meshes[i].matrixWorldNeedsUpdate = true;
    }
  });
  if (instances) {
    // CPU側への書き込みの後、GPUへの転送を通知する。
    instances.instanceMatrix.needsUpdate = true;
    // 変形後の当たり判定・カリングに使う境界を更新する。
    instances.computeBoundingSphere();
  }
}

function resize() {
  const width = stage.clientWidth, height = stage.clientHeight;
  const aspect = width / height;
  const halfHeight = Math.max(Math.ceil(rows.length / columns) / 2 + 1,
    (columns / 2 + 1) / aspect);
  camera.left = -halfHeight * aspect;
  camera.right = halfHeight * aspect;
  camera.top = halfHeight;
  camera.bottom = -halfHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(width, height, false);
}

function build() {
  if (group) scene.remove(group);
  instances?.dispose(); // 共用geometry/materialは最後まで残す。
  group = new THREE.Group();
  instances = null;
  meshes = [];
  selected = null;
  angle = 0;
  selection.textContent = '未選択';
  const count = Number(countInput.value);
  columns = count === 1000 ? 40 : 100;
  rows = Array.from({ length: count }, (_, i) => ({
    id: `box-${String(i + 1).padStart(5, '0')}`,
    x: i % columns - (columns - 1) / 2,
    y: Math.floor(i / columns) - (Math.ceil(count / columns) - 1) / 2,
  }));
  indexInput.max = String(count - 1);
  indexInput.value = '0';
  if (modeInput.value === 'instanced') {
    instances = new THREE.InstancedMesh(geometry, whiteMaterial, count);
    instances.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
    rows.forEach((_, i) => instances.setColorAt(i, white));
    instances.instanceColor.needsUpdate = true;
    group.add(instances);
  } else {
    meshes = rows.map((_, i) => {
      const mesh = new THREE.Mesh(geometry, whiteMaterial);
      mesh.matrixAutoUpdate = false;
      mesh.userData.rowIndex = i;
      group.add(mesh);
      return mesh;
    });
  }
  updateTransforms();
  scene.add(group);
  resize();
}

function choose(index) {
  if (!Number.isInteger(index) || index < 0 || index >= rows.length) return;
  if (instances) {
    if (selected !== null) instances.setColorAt(selected, white);
    instances.setColorAt(index, yellow);
    instances.instanceColor.needsUpdate = true;
  } else {
    if (selected !== null) meshes[selected].material = whiteMaterial;
    meshes[index].material = yellowMaterial;
  }
  selected = index;
  selection.textContent = `添字 ${index} → ${rows[index].id}`;
}

renderer.domElement.addEventListener('pointerdown', event => {
  const rect = renderer.domElement.getBoundingClientRect();
  pointer.set((event.clientX - rect.left) / rect.width * 2 - 1,
    -(event.clientY - rect.top) / rect.height * 2 + 1);
  raycaster.setFromCamera(pointer, camera);
  const hit = raycaster.intersectObject(group, true)[0];
  if (!hit) return;
  // instanceIdが0でも選べる。truthy判定にはしない。
  choose(hit.instanceId ?? hit.object.userData.rowIndex);
});
document.querySelector('#pick').addEventListener('submit', event => {
  event.preventDefault();
  choose(Number(indexInput.value));
});
countInput.addEventListener('change', build);
modeInput.addEventListener('change', build);
const observer = new ResizeObserver(resize);
build();
observer.observe(stage);
let previousTime = null;
renderer.setAnimationLoop(time => {
  const dt = previousTime === null ? 0 : Math.min((time - previousTime) / 1000, 0.05);
  previousTime = time;
  if (animate.checked) {
    angle += dt * 0.5;
    updateTransforms();
  }
  renderer.render(scene, camera); // この例は1フレーム1回だけ。
  const { calls, triangles } = renderer.info.render;
  const text = `draw calls: ${calls} / triangles: ${triangles}`;
  if (stats.textContent !== text) stats.textContent = text;
});
window.addEventListener('pagehide', event => {
  if (event.persisted) return;
  renderer.setAnimationLoop(null);
  observer.disconnect();
  instances?.dispose();
  geometry.dispose();
  whiteMaterial.dispose();
  yellowMaterial.dispose();
  renderer.dispose();
});

最初はInstancedMeshで1,000個を表示します。「方式」をMeshへ切り替え、次に個数を10,000へ変更して、画面下のdraw callsを比べます。「全体を回転させる」は、各箱がその場で回転する操作です。

位置が変わらないときは、行列と更新通知を分けて見る

setMatrixAt(i, matrix)へ渡す行列は、ダミーのObject3Dに位置と回転を設定し、updateMatrix()で作っています。位置を書いただけでは、渡すmatrixが古いままです。

さらに、setMatrixAtで書き換えた後は、instanceMatrix.needsUpdate = trueでGPUへの転送を通知します。全個体を更新するなら、ループの各周ではなく最後に1回付けます。今回のコードはアニメーションを有効にしたフレームだけ全個体の行列を更新します。

回転・移動・拡縮の後は、描画の間引きやRaycasterが使う境界にも注意が必要です。このデモはcomputeBoundingSphere()で境界球を再計算しています。見えない、クリックできないという症状が出たら、行列だけでなく境界の更新も確認してください。boundingBoxを自分の処理で使う場合は、そちらも別途更新します。

全個体の行列更新も境界球の再計算も、CPU側の仕事です。数を増やして動かす場面では、この処理が重くなることがあります。移動範囲があらかじめ決まる実装なら、その全範囲を覆う境界を用意できるか検討します。むやみに境界更新を削ると、画面内の個体まで描かれなくなる原因になります。

色とクリック結果を、同じ添字でデータへ結び付ける

setColorAt()もCPU側の値を書き換える操作なので、最後にinstanceColor.needsUpdate = trueを付けます。コードでは初期化時に全個体を白で埋め、最初の描画から色属性がある状態にしています。Materialの色とインスタンスの色は掛け合わされるため、個体色を素直に使う土台としてMaterialを白にしています。

Raycasterの結果にあるinstanceIdは、当たった個体の添字です。0番目も有効なので、if (hit.instanceId)では取りこぼします。上のコードはnullish coalescingの??を使い、InstancedMeshではinstanceId、通常MeshではuserDataに保存した添字を取り出しています。

画面の「添字0 → box-00001」を確認してください。左下の箱をクリックしても、フォームで0を指定しても同じIDになります。黄色の選択を変えると、前の箱は白へ戻ります。

instanceIdはデータベースのIDではありません。配列を並べ替えたり、削除した場所へ末尾の個体を詰めたりするなら、行列・色・rows配列の対応も一緒に更新します。クリック後に元データを編集する処理では、rows[index].idのように、添字からアプリ側のIDへ変換して使います。

draw call数を確認し、残った重さを切り分ける

Chrome 149.0.7827.55のヘッドレス環境で、画面幅390px・1440pxの両方を確認した結果です。ソフトウェアGPUを使った機能検証で、実機のFPS比較ではありません。

箱の数 通常Meshのcalls InstancedMeshのcalls 両方式のtriangles
1,000 1,000 1 12,000
10,000 10,000 1 120,000

draw callsが1になっても、三角形数は減っていません。これは「同じ形をまとめてGPUへ指示できた」という結果で、描画速度が1万倍になるという意味ではありません。複雑な形状、画面を大きく覆う描画、CPU側の更新など、別の負荷が残ります。

renderer.infoのカウントは通常、render()ごとにリセットされます。影、複数パスの後処理、複数回の描画がある画面では、この表と同じ条件になりません。必要ならrenderer.info.autoReset = falseにして測定区間を決め、区間末尾でrenderer.info.reset()を呼びます。

置き換え後は、まずcallsが想定どおり減ったかを確認します。減っているのに重いなら、アニメーションを止めた場合、個数を減らした場合、形状を簡単にした場合を分けて比べると、次に調べる負荷を絞れます。

デモを作り直すときは古いInstancedMeshをdisposeし、共用GeometryとMaterialは最後に一度だけ破棄しています。画面からremoveする操作と、GPU側の資源を解放する操作を分けて管理してください。

仕様の確認先:InstancedMesh、RaycasterのinstanceId、WebGLRendererのinfo(2026年9月12日確認)。

スポンサーリンク