同じ箱を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日確認)。