Урок · Three.js · WebGL · Продуктивність
📅 Березень 2026 ⏱ ≈ 20 хв 🎯 Середній – Просунутий 🔧 Three.js r160

Оптимізація продуктивності Three.js — рендеринг 100k об'єктів на 60 fps

Різниця між прототипом, що смикається, і максимально плавною симуляцією зазвичай зводиться до кількох вибраних можливостей API. Цей урок крок за кроком розглядає оптимізації з найбільшим впливом: InstancedMesh, відсікання за пірамідою видимості, рівень деталізації, фізику у Web Worker та усунення найпоширеніших витоків пам'яті.

1. Спершу профілювання

Ніколи не оптимізуйте наосліп. Відкрийте Chrome DevTools → вкладку Performance, запишіть 3 секунди й шукайте:

// Додайте до циклу анімації під час розробки
const info = renderer.info;
const debugEl = document.getElementById('debug');
if (debugEl) debugEl.textContent =
  `Draw calls: ${info.render.calls} | Tris: ${info.render.triangles
} | Textures: ${info.memory.textures}`;

Додайте Stats.js для панелі fps/мс/мб у реальному часі. Ціль: <5 мс CPU на кадр на обладнанні середнього класу.

2. InstancedMesh — один виклик відмалювання для тисяч об'єктів

Вузьке місце продуктивності №1 у сценах Three.js — це надто багато викликів відмалювання. Кожен Mesh — це окремий виклик відмалювання. Сучасні GPU можуть обробляти мільйони трикутників за виклик, але застрягають через накладні витрати CPU при ~100–1000 окремих викликах відмалювання на кадр.

InstancedMesh згортає N однакових об'єктів в один виклик відмалювання, розміщуючи матриці перетворень для кожного екземпляра у буфер GPU:

import * as THREE from 'three';

const COUNT = 100_000;
const geo  = new THREE.SphereGeometry(0.1, 6, 4); // низькополігональна сфера
const mat  = new THREE.MeshLambertMaterial({ color: 0x88aaff });
const mesh = new THREE.InstancedMesh(geo, mat, COUNT);
scene.add(mesh);

// Задаємо початкові перетворення
const dummy = new THREE.Object3D();
for (let i = 0; i < COUNT; i++) {
  dummy.position.set(Math.random()*200-100, Math.random()*200-100, Math.random()*200-100);
  dummy.updateMatrix();
  mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;

// Оновлення кожного кадру (наприклад, з фізики):  
function updateInstances(positions) {
  for (let i = 0; i < COUNT; i++) {
    dummy.position.copy(positions[i]);
    dummy.updateMatrix();
    mesh.setMatrixAt(i, dummy.matrix);
  }
  mesh.instanceMatrix.needsUpdate = true; // вивантаження на GPU
}
Підхід 100k сфер Виклики відмалювання
Окремі меші ~1 fps 100 000
Об'єднана BufferGeometry 30–60 fps 1 (лише статичні)
InstancedMesh 55–60 fps 1 (динамічні перетворення)
Колір для кожного екземпляра: задається через mesh.setColorAt(i, color) і вивантажується за допомогою mesh.instanceColor.needsUpdate = true. Це додає другий буфер GPU, але не збільшує кількість викликів відмалювання.

3. Відсікання за пірамідою видимості та LOD

Відсікання за пірамідою видимості

Three.js автоматично відсікає за пірамідою видимості окремі об'єкти Mesh — але не окремі екземпляри всередині InstancedMesh. Для великих симуляцій, де видима лише частина екземплярів, реалізуйте ручне відсікання екземплярів:

const frustum = new THREE.Frustum();
const matrix  = new THREE.Matrix4();
const sphere  = new THREE.Sphere();

function cullInstances(camera, positions, radius) {
  matrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
  frustum.setFromProjectionMatrix(matrix);

  let visibleCount = 0;
  for (let i = 0; i < positions.length; i++) {
    sphere.center.copy(positions[i]);
    sphere.radius = radius;
    if (frustum.intersectsSphere(sphere)) {
      dummy.position.copy(positions[i]);
      dummy.updateMatrix();
      mesh.setMatrixAt(visibleCount++, dummy.matrix);
    }
  }
  mesh.count = visibleCount; // рендеримо лише видимі екземпляри
  mesh.instanceMatrix.needsUpdate = true;
}

Рівень деталізації (LOD)

const lod = new THREE.LOD();

const high   = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 32), mat);
const medium = new THREE.Mesh(new THREE.SphereGeometry(1, 12, 8),  mat);
const low    = new THREE.Mesh(new THREE.SphereGeometry(1, 4,  3),  mat);

lod.addLevel(high,   0);   // 0–30 одиниць від камери
lod.addLevel(medium, 30);  // 30–100 одиниць
lod.addLevel(low,    100); // 100+ одиниць
scene.add(lod);
// LOD.update(camera) викликається автоматично, якщо додано до сцени

4. BufferGeometry та потокове оновлення атрибутів

Симулюючи деформівні об'єкти (тканину, меш поверхні рідини), оновлюйте атрибути геометрії напряму замість того, щоб перебудовувати геометрію кожного кадру:

// НЕПРАВИЛЬНО: перебудова геометрії щокадру (постійно виділяє пам'ять GPU)
function badUpdate(vertices) {
  scene.remove(mesh);
  mesh.geometry.dispose();
  mesh.geometry = new THREE.BufferGeometry();
  mesh.geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
  scene.add(mesh);
}

// ПРАВИЛЬНО: повторно використовуємо буфер, ставимо прапорець needsUpdate
const positions = new Float32Array(N * 3);
const attr = new THREE.BufferAttribute(positions, 3);
attr.usage = THREE.DynamicDrawUsage; // підказка драйверу GPU
geometry.setAttribute('position', attr);

function goodUpdate(newPositions) {
  positions.set(newPositions); // копіювання типізованого масиву — швидко
  attr.needsUpdate = true;      // вивантаження на GPU при наступному рендері
  geometry.computeBoundingSphere(); // щоб працювало відсікання за пірамідою видимості
}

5. Web Workers для фізики

Симуляція фізики блокує головний потік. Перенесіть її у Web Worker і спілкуйтеся через SharedArrayBuffer (SAB) для передачі без копіювання:

// main.js
const N = 10_000;
const sharedBuf = new SharedArrayBuffer(N * 3 * 4); // xyz float32 на частинку
const positions  = new Float32Array(sharedBuf);

const worker = new Worker('physics.worker.js');
worker.postMessage({ sharedBuf, N }); // спільний буфер — без копіювання

function animate() {
  requestAnimationFrame(animate);
  // positions[] вже оновлено воркером у фоні
  positionAttr.needsUpdate = true;
  renderer.render(scene, camera);
}

// physics.worker.js
let positions, N;
onmessage = ({ data }) => {
  positions = new Float32Array(data.sharedBuf);
  N = data.N;
  physicsLoop();
};
function physicsLoop() {
  setInterval(() => {
    for (let i = 0; i < N; i++) {
      positions[i*3+1] -= 0.01; // крок гравітації
    }
  }, 1000/60);
}
SharedArrayBuffer вимагає заголовків COOP/COEP: ваш сервер має надсилати Cross-Origin-Opener-Policy: same-origin та Cross-Origin-Embedder-Policy: require-corp. Без цих заголовків SharedArrayBuffer вимкнено браузерами.

6. Оптимізація текстур і матеріалів

7. Запобігання витокам пам'яті

Ресурси GPU у Three.js (геометрія, матеріали, текстури, цілі рендерингу) не очищуються збирачем сміття автоматично. Завжди викликайте .dispose():

function removeMesh(mesh) {
  scene.remove(mesh);
  mesh.geometry.dispose();         // звільняємо вершинні буфери GPU
  if (mesh.material.map) mesh.material.map.dispose(); // текстура
  mesh.material.dispose();          // звільняємо шейдерні програми
}

// Цілі рендерингу
renderTarget.dispose();

// При заміні рендерера (наприклад, при HMR / гарячому перезавантаженні):
renderer.dispose();
renderer.forceContextLoss();

Використовуйте вкладку Memory у Chrome DevTools — робіть знімки купи до і після видалення об'єктів. Якщо лічильники WebGLBuffer та WebGLTexture не зменшуються, у вас витік.

8. Чек-лист продуктивності