⚡ Урок · Three.js · Продуктивність
📅 Березень 2026 ⏱ 18 хв 🎓 Середній рівень

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)
Коли використовувати InstancedMesh: Усі екземпляри мають спільну геометрію та матеріал. Якщо вам потрібні різні геометрії, використовуйте 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; }
Альтернатива для WebGL2: використовуйте 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; }
Компроміс продуктивності: поекземплярне відсікання за пірамідою видимості обмежене CPU — ітерування 1 млн екземплярів за кадр у JS займає ~3 мс. Для щільних сцен із частинками, де більшість екземплярів завжди видимі, ці накладні витрати не варті того. Для розріджених відкритих світів (дерева в лісі) це може вдвічі зменшити навантаження на GPU.

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

Чек-лист оптимізації

Емпіричне правило пропускної здатності вершин GPU:

Усього вершин = кількість_екземплярів × вершин_геометрії
Безпечний бюджет: < 20 млн заповнених вершин @ 60 FPS на GPU середнього рівня

1 млн екземплярів × 12 трик. (ікосаедр) = 36 млн вершин → потрібен LOD або проста геометрія
1 млн екземплярів × 2 трик. (білборд-квад) = 6 млн вершин → нормально @ 60 FPS