InstancedMesh: 1 000 000 обʼєктів при 60 FPS
Малюєте 10 000 окремих мешів? Очікуйте ~10 000 викликів відмалювання й
одноцифрові FPS.
THREE.InstancedMesh рендерить усі їх за
один виклик відмалювання, з власною позицією,
обертанням, масштабом і кольором для кожного екземпляра. Цей посібник
охоплює налаштування, GPU-вибір, відсікання за пірамідою видимості,
анімацію та масштабування до 1 мільйона екземплярів.
1. Чому інстансинг важливий
Кожен THREE.Mesh породжує окремий виклик відмалювання до
GPU. Виклики відмалювання дорогі: процесор має встановити уніформи
шейдера, привʼязати буфери вершин і видати команду GPU. За ~5 мкс на
виклик на сучасному настільному ПК 10 000 мешів = 50 мс лише накладних
витрат CPU — це нищить ваш кадровий бюджет.
Інстансований рендеринг завантажує одну геометрію та один матеріал плюс масив даних для кожного екземпляра (матриці трансформації 4×4) і каже GPU: «намалюй це N разів, кожен раз з іншою матрицею». Результат: 1 виклик відмалювання незалежно від N.
| Підхід | Виклики відмалювання | Вартість CPU | Вартість GPU |
|---|---|---|---|
| Окремі меші | N | O(N) — вузьке місце | O(N × вершин) |
| Обʼєднана геометрія | 1 | O(1) | O(N × вершин) — величезний VBO |
| InstancedMesh | 1 | O(1) | O(вершин + N) |
BatchedMesh (Three.js r160+) або обʼєднуйте групи вручну.
2. Базове налаштування InstancedMesh
const COUNT =
100_000;
const geometry =
new THREE.BoxGeometry(1, 1,
1); const material
= new THREE.MeshStandardMaterial({ color:
0xffffff });
// Створюємо InstancedMesh з максимальною кількістю екземплярів
const mesh =
new THREE.InstancedMesh(geometry, material, COUNT);
// Обовʼязково задайте початкові матриці (типові — нульові →
невидимі!)
const matrix =
new THREE.Matrix4();
for (let i =
0; i < COUNT; i++) { matrix.setPosition( (Math.random() -
0.5) * 500,
(Math.random() -
0.5) * 500,
(Math.random() -
0.5) * 500 );
mesh.setMatrixAt(i, matrix); }
// КРИТИЧНО: позначте буфер матриць екземплярів як такий, що
потребує завантаження
mesh.instanceMatrix.needsUpdate = true;
scene.add(mesh);
mesh.instanceMatrix.needsUpdate = true
після зміни матриць. Без цього буфер GPU ніколи не оновлюється, і
екземпляри залишаються в початку координат. Встановлюйте це один раз
після пакетних оновлень або щокадру, якщо анімуєте.
3. Трансформації екземплярів (setMatrixAt)
Кожен екземпляр має повну матрицю трансформації 4×4, що кодує позицію,
обертання та масштаб. Використовуйте THREE.Matrix4 для
компонування трансформацій, а потім записуйте через
setMatrixAt(index, matrix).
const dummy =
new THREE.Object3D();
for (let i =
0; i < COUNT; i++) {
// Позиція
dummy.position.set( positions[i *
3], positions[i *
3 + 1],
positions[i * 3 +
2] );
// Обертання (кути Ейлера або кватерніон)
dummy.rotation.set( rotations[i *
3], rotations[i *
3 + 1],
rotations[i * 3 +
2] );
// Масштаб (нерівномірний дозволено)
dummy.scale.set(scales[i], scales[i],
scales[i]);
// Компонуємо TRS-матрицю
dummy.updateMatrix(); mesh.setMatrixAt(i, dummy.matrix); } mesh.instanceMatrix.needsUpdate =
true;
Object3D.updateMatrix() компонує позицію/обертання/масштаб
у Matrix4. Це зручно, але всередині викликає
Matrix4.compose(). Для максимальної пропускної здатності
пишіть безпосередньо в mesh.instanceMatrix.array
(Float32Array з 16 чисел з рухомою комою на екземпляр): так ви повністю
уникаєте виділення Matrix4.
// Прямий запис у буфер (найшвидший шлях)
const arr = mesh.instanceMatrix.array;
for (let i =
0; i < COUNT; i++) {
const off = i * 16;
// Лише одиничний масштаб + зсув (без обертання):
// 4x4 у стовпцевому порядку:
arr[off + 0] = 1;
arr[off + 1] = 0;
arr[off + 2] = 0;
arr[off + 3] = 0;
arr[off + 4] = 0;
arr[off + 5] = 1;
arr[off + 6] = 0;
arr[off + 7] = 0;
arr[off + 8] = 0;
arr[off + 9] = 0;
arr[off + 10] = 1;
arr[off + 11] = 0;
arr[off + 12] = x; arr[off +
13] = y; arr[off +
14] = z; arr[off +
15] = 1; }
mesh.instanceMatrix.needsUpdate =
true;
4. Колір кожного екземпляра
InstancedMesh з коробки підтримує колір для кожного
екземпляра через setColorAt() (Three.js r138+). Під
капотом це створює InstancedBufferAttribute у властивості
instanceColor.
const color =
new THREE.Color();
for (let i =
0; i < COUNT; i++) { color.setHSL(i / COUNT, 0.8,
0.5); mesh.setColorAt(i, color); }
// Позначаємо буфер кольорів для завантаження
mesh.instanceColor.needsUpdate = true;
Власні атрибути екземплярів
Потрібно більше даних на екземпляр (непрозорість, розмір, температура
тощо)? Додайте до геометрії власні
InstancedBufferAttribute:
// Додаємо float-атрибут "temperature" для кожного екземпляра
const temps =
new Float32Array(COUNT);
for (let i =
0; i < COUNT; i++) temps[i] = Math.random(); geometry.setAttribute('aTemperature', new THREE.InstancedBufferAttribute(temps, 1) );
// У вершинному шейдері власного ShaderMaterial:
// attribute float aTemperature;
// varying float vTemp;
// void main() { vTemp = aTemperature; ... }
5. GPU-вибір для інстансованих обʼєктів
Кидання променя по InstancedMesh працює з
THREE.Raycaster (починаючи з r126), але обмежене
продуктивністю CPU за великих кількостей. Для масштабних сцен
використовуйте GPU-вибір: відрендерте кожен екземпляр
з унікальним ID, закодованим як RGB-колір, а потім зчитайте піксель під
курсором.
// 1. Створюємо ціль рендерингу та матеріал для вибору
const pickTarget =
new THREE.WebGLRenderTarget(1, 1);
const pickMaterial =
new THREE.ShaderMaterial({ vertexShader:
` void main() { gl_Position = projectionMatrix * modelViewMatrix
* instanceMatrix * vec4(position, 1.0); } `, fragmentShader:
` flat varying float vInstanceId; void main() { // Encode
instance ID as RGB24 (supports up to 16 777 216 instances) float
id = vInstanceId; gl_FragColor = vec4( mod(id, 256.0) / 255.0,
mod(floor(id / 256.0), 256.0) / 255.0, floor(id / 65536.0) /
255.0, 1.0 ); } `
});
// 2. Рендеримо піксель 1×1 у позиції курсора
function
gpuPick(mouseNDC, camera, renderer, scene) {
// Налаштовуємо камеру рендерити лише піксель під курсором
camera.setViewOffset(
renderer.domElement.width, renderer.domElement.height, mouseNDC.x *
renderer.domElement.width * 0.5 +
renderer.domElement.width * 0.5,
-mouseNDC.y * renderer.domElement.height *
0.5 + renderer.domElement.height *
0.5, 1,
1
);
// Підміняємо матеріал, рендеримо в ціль вибору
mesh.material = pickMaterial; renderer.setRenderTarget(pickTarget); renderer.render(scene,
camera);
// Зчитуємо піксель
const pixel =
new Uint8Array(4);
renderer.readRenderTargetPixels(pickTarget,
0, 0,
1, 1, pixel);
// Декодуємо ID
const id = pixel[0]
+ pixel[1] * 256 +
pixel[2] * 65536;
// Відновлюємо
mesh.material = material; renderer.setRenderTarget(null); camera.clearViewOffset();
return id; }
gl_InstanceID напряму у вершинному шейдері (GLSL 300 es)
з flat-varying, щоб передати його до фрагментного шейдера — це усуває
потребу у власному атрибуті екземпляра. Потрібен
THREE.WebGLRenderer з контекстом WebGL2.
6. Ручне відсікання за пірамідою видимості
Three.js відсікає весь InstancedMesh як єдиний
обмежувальний обʼєм — тож якщо видно хоча б один екземпляр,
рендеряться усі екземпляри. Для великих світів це марнує
fill-rate GPU на екземплярах поза екраном.
Рішення: реалізувати поекземплярне відсікання за пірамідою видимості, динамічно регулюючи кількість видимих екземплярів і впорядковуючи масив матриць так, щоб видимі екземпляри стояли спереду.
const frustum =
new THREE.Frustum();
const projScreenMatrix =
new THREE.Matrix4();
const sphere =
new THREE.Sphere();
const pos =
new THREE.Vector3();
function
cullInstances(camera) {
projScreenMatrix.multiplyMatrices(
camera.projectionMatrix, camera.matrixWorldInverse ); frustum.setFromProjectionMatrix(projScreenMatrix);
let visible = 0;
const dummy =
new THREE.Matrix4();
for (let i =
0; i < totalCount; i++) {
// Витягуємо позицію зі збережених матриць
pos.set(allPositions[i*3], allPositions[i*3+1], allPositions[i*3+2]); sphere.set(pos, instanceRadius);
if (frustum.intersectsSphere(sphere)) {
// Копіюємо матрицю цього екземпляра у видимий слот
dummy.setPosition(pos.x, pos.y, pos.z);
mesh.setMatrixAt(visible, dummy); visible++;
} } mesh.count = visible;
// малюємо лише видимі екземпляри!
mesh.instanceMatrix.needsUpdate = true;
}
7. Анімація екземплярів
Щоб анімувати трансформації екземплярів щокадру, оновлюйте масив
матриць і встановлюйте
needsUpdate = true. Для максимальної продуктивності пишіть
безпосередньо у Float32Array:
const arr = mesh.instanceMatrix.array;
const STRIDE = 16;
function animate(t)
{ for (let i =
0; i < COUNT; i++) {
const off = i * STRIDE;
// Проста орбітальна анімація: x = R·cos(ωt + φ_i), z = R·sin(ωt +
φ_i)
const phase = i *
0.001; const R =
50 + i * 0.005;
const omega = 0.5 /
(R * 0.02);
// Стовпці зсуву (індекси 12, 13, 14 у стовпцевому порядку)
arr[off + 12] = R * Math.cos(omega * t + phase); arr[off + 13] =
(Math.sin(t * 0.3 +
phase) * 20); arr[off +
14] = R * Math.sin(omega * t + phase); } mesh.instanceMatrix.needsUpdate =
true; renderer.render(scene, camera);
requestAnimationFrame(animate); }
requestAnimationFrame(animate);
Анімація на боці GPU (вершинний шейдер)
Для найкращої продуктивності перенесіть анімацію у вершинний шейдер, використовуючи власні атрибути (фаза, швидкість, радіус) та уніформу часу. Це уникає будь-яких оновлень матриць на CPU:
// Вершинний шейдер (GLSL 300 es) uniform
float uTime; attribute float aPhase; attribute float aRadius; void
main() { float angle = uTime * 0.5 +
aPhase; vec3 offset = vec3(aRadius * cos(angle),
0.0, aRadius * sin(angle)); vec4 worldPos =
modelMatrix * vec4(position + offset, 1.0);
gl_Position = projectionMatrix * viewMatrix * worldPos; }
8. Бенчмарк: 10K → 100K → 1M
Протестовано на настільному ПК середнього рівня (RTX 3060, Ryzen 5600X) з простою геометрією сфери (32 сегменти) та MeshStandardMaterial, при 1080p:
| Кількість екземплярів | Виклики відмалювання | Час кадру (мс) | FPS |
|---|---|---|---|
| 10 000 (окремі Mesh) | 10 000 | 42 мс | ~24 |
| 10 000 (InstancedMesh) | 1 | 2.1 мс | >400 |
| 100 000 (InstancedMesh) | 1 | 6.8 мс | ~147 |
| 500 000 (InstancedMesh) | 1 | 12.4 мс | ~80 |
| 1 000 000 (InstancedMesh, низькополігональний) | 1 | 15.2 мс | ~66 |
Чек-лист оптимізації
- Зменшуйте складність геометрії: ікосаедр із 6 трикутників замість сфери зі 128 трикутників. Кількість вершин × кількість екземплярів = загальна кількість вершин GPU.
- Використовуйте MeshBasicMaterial, якщо вам не потрібне освітлення — він удвічі швидший за MeshStandardMaterial.
- LOD-інстансинг: використовуйте різні InstancedMesh для близьких/середніх/далеких — низькополігональну геометрію для відстані.
- Уникайте needsUpdate щокадру, якщо екземпляри статичні — завантажте один раз під час ініціалізації.
- Переходьте на instanceColor замість окремого матеріалу для кожного екземпляра, якщо вам потрібна лише варіація кольору.
- Розгляньте WASM / обчислювальний шейдер для оновлення позицій на боці CPU за >500K екземплярів.
Усього вершин = кількість_екземплярів × вершин_геометрії
Безпечний бюджет: < 20 млн заповнених вершин @ 60 FPS на GPU середнього рівня
1 млн екземплярів × 12 трик. (ікосаедр) = 36 млн вершин → потрібен LOD або проста геометрія
1 млн екземплярів × 2 трик. (білборд-квад) = 6 млн вершин → нормально @ 60 FPS