Урок
⏱️ ~45 хвилин 🎓 Середній рівень 🛠️ Three.js · JavaScript · GLSL

Оптимізація продуктивності симуляцій

Браузерні симуляції часто стартують швидко, але потім сповільнюються, коли кількість об'єктів зростає. Цей урок охоплює практичні техніки для утримання 60 к/с: InstancedMesh для тисяч об'єктів, типізовані масиви для зменшення навантаження на збирач сміття, просторове хешування для широкофазного виявлення зіткнень, веб-воркери для перенесення фізики з головного потоку та трюки на боці GPU.

Спершу визначте вузьке місце

Перш ніж оптимізувати, виміряйте. Панель Performance у Chrome DevTools та помічник Stats у three.js підкажуть, чи ви обмежені CPU, чи GPU:

import Stats from 'https://cdn.jsdelivr.net/npm/three@0.168/examples/jsm/libs/stats.module.js';
const stats = new Stats();
stats.showPanel(0); // 0 = к/с, 1 = мс, 2 = МБ
document.body.appendChild(stats.dom);

// У animate():
stats.begin();
renderer.render(scene, camera);
stats.end();
Симптом Ймовірне вузьке місце Напрямок виправлення
Час кадру JS > 10 мс CPU — фізика / оновлення на JS Типізовані масиви, WASM, веб-воркери
Багато викликів відмалювання (>500) CPU — пакетування в потоці рендерингу InstancedMesh, об'єднання геометрії
Високе навантаження на GPU, низьке на CPU GPU — складність фрагментів Зменшити вартість шейдера, знизити роздільність
Сплески збирача сміття (графік мс «смикається») Виділення пам'яті в купі JS Пулінг об'єктів, типізовані масиви

InstancedMesh — один виклик відмалювання на тип об'єкта

Рендеринг 10 000 окремих об'єктів Mesh = 10 000 викликів відмалювання. InstancedMesh відмальовує їх усі за один виклик:

const COUNT = 10_000;
const geo  = new THREE.SphereGeometry(0.1, 8, 8);
const mat  = new THREE.MeshStandardMaterial({ color: 0x2299ff });
const iMesh = new THREE.InstancedMesh(geo, mat, COUNT);
scene.add(iMesh);

const dummy = new THREE.Object3D();
const positions = new Float32Array(COUNT * 3); // x,y,z для кожного екземпляра

// Ініціалізуємо положення
for (let i = 0; i < COUNT; i++) {
  positions[i*3+0] = (Math.random() - 0.5) * 20;
  positions[i*3+1] = (Math.random() - 0.5) * 20;
  positions[i*3+2] = (Math.random() - 0.5) * 20;
}

function updateInstances() {
  for (let i = 0; i < COUNT; i++) {
    dummy.position.set(positions[i*3], positions[i*3+1], positions[i*3+2]);
    dummy.updateMatrix();
    iMesh.setMatrixAt(i, dummy.matrix);
  }
  iMesh.instanceMatrix.needsUpdate = true; // ← обов'язково!
}

Встановлюйте needsUpdate = true лише тоді, коли дані справді змінилися. Зайві оновлення спричиняють завантаження буфера CPU→GPU щокадру.

Типізовані масиви — усуньте паузи збирача сміття

Звичайні об'єкти JavaScript ({ x, y, z }) на кожну частинку = багато виділень у купі = паузи збирача сміття в найгірший момент. Натомість використовуйте Float32Array (або Float64Array) — пам'ять неперервна й ніколи не збирається як сміття:

// ❌ Масив об'єктів — навантаження на збирач сміття
const particles = Array.from({ length: 10000 }, () => ({
  x: Math.random(), y: Math.random(), z: Math.random(),
  vx: 0, vy: 0, vz: 0,
}));

// ✅ Типізований масив SoA (структура з масивів) — без GC, дружній до кешу
const N = 10_000;
const px = new Float32Array(N), py = new Float32Array(N), pz = new Float32Array(N);
const vx = new Float32Array(N), vy = new Float32Array(N), vz = new Float32Array(N);

// Оновлення фізики — без виділення об'єктів
for (let i = 0; i < N; i++) {
  vx[i] += 0; // гравітація, сили...
  vy[i] -= 9.8 * dt;
  px[i] += vx[i] * dt;
  py[i] += vy[i] * dt;
  pz[i] += vz[i] * dt;
}

SoA (структура з масивів) дружніша до кешу, ніж AoS (масив структур), тому що цикл обробляє одну властивість усіх частинок за раз, що відповідає тому, як працюють лінії кешу CPU.

Просторове хешування для пошуку сусідів за O(1)

Наївне виявлення зіткнень має складність O(N²) — кожна частинка перевіряє кожну іншу. Просторове хешування зводить це до ~O(N) для рівномірних розподілів частинок:

class SpatialHash {
  constructor(cellSize) {
    this.cellSize = cellSize;
    this.table = new Map();
  }
  _key(x, y, z) {
    const cx = Math.floor(x / this.cellSize);
    const cy = Math.floor(y / this.cellSize);
    const cz = Math.floor(z / this.cellSize);
    return `${cx},${cy},${cz}`;
  }
  clear() { this.table.clear(); }
  insert(i, x, y, z) {
    const k = this._key(x, y, z);
    if (!this.table.has(k)) this.table.set(k, []);
    this.table.get(k).push(i);
  }
  query(x, y, z) {
    // Повертає індекси частинок у цій самій та сусідніх комірках
    const result = [];
    const cx = Math.floor(x / this.cellSize);
    const cy = Math.floor(y / this.cellSize);
    const cz = Math.floor(z / this.cellSize);
    for (let dx = -1; dx <= 1; dx++)
    for (let dy = -1; dy <= 1; dy++)
    for (let dz = -1; dz <= 1; dz++) {
      const k = `${cx+dx},${cy+dy},${cz+dz}`;
      const cell = this.table.get(k);
      if (cell) result.push(...cell);
    }
    return result;
  }
}

// Використання: розмір комірки = 2× радіус частинки
const hash = new SpatialHash(0.2);
// Щокадру: 1) очистити 2) вставити всіх 3) запитати сусідів
hash.clear();
for (let i = 0; i < N; i++) hash.insert(i, px[i], py[i], pz[i]);
for (let i = 0; i < N; i++) {
  const neighbours = hash.query(px[i], py[i], pz[i]);
  // перевіряємо зіткнення лише із сусідами (невелика множина)
}

Фіксований часовий крок + фізика у Web Worker

Фізика має працювати з фіксованим кроком (наприклад, 1/120 с) незалежно від частоти кадрів рендерингу. Винесіть її у Web Worker, щоб фізика не блокувала рендеринг:

// main.js
const PHYSICS_STEP = 1 / 120;
let accumulator = 0;

// Воркер для фізики
const worker = new Worker('./physics-worker.js');
const posBuffer = new SharedArrayBuffer(N * 3 * 4); // Float32
const positions = new Float32Array(posBuffer);

worker.postMessage({ type: 'init', buffer: posBuffer, count: N });

// Цикл рендерингу — лише читає спільний буфер
function animate(t) {
  requestAnimationFrame(animate);
  // Лише читання — для нестрогої синхронізації блокування не потрібне
  updateInstancesFromBuffer(positions);
  renderer.render(scene, camera);
}

// physics-worker.js
self.onmessage = ({ data }) => {
  if (data.type !== 'init') return;
  const pos = new Float32Array(data.buffer);
  const vel = new Float32Array(data.count * 3);
  const dt = 1 / 120;
  setInterval(() => {
    for (let i = 0; i < data.count; i++) {
      vel[i*3+1] -= 9.8 * dt;
      pos[i*3+0] += vel[i*3+0] * dt;
      pos[i*3+1] += vel[i*3+1] * dt;
      pos[i*3+2] += vel[i*3+2] * dt;
      if (pos[i*3+1] < 0) { pos[i*3+1] = 0; vel[i*3+1] *= -0.7; }
    }
  }, dt * 1000);
};

SharedArrayBuffer вимагає заголовків Cross-Origin-Opener-Policy: same-origin та Cross-Origin-Embedder-Policy: require-corp. Для простіших випадків використовуйте postMessage зі звичайним передаваним ArrayBuffer (без копіювання).

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

Three.js автоматично виконує відсікання за пірамідою видимості для окремих об'єктів Mesh. Для інстансованих мешів відсікання відбувається на рівні виклику відмалювання (усе або нічого). Ручне відсікання для кожного екземпляра:

const frustum = new THREE.Frustum();
const projScreen = new THREE.Matrix4();

function cullInstances(iMesh, positions, count) {
  projScreen.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
  frustum.setFromProjectionMatrix(projScreen);

  const sphere = new THREE.Sphere();
  let visibleCount = 0;

  for (let i = 0; i < count; i++) {
    sphere.center.set(positions[i*3], positions[i*3+1], positions[i*3+2]);
    sphere.radius = 0.1; // радіус обмежувальної сфери
    if (frustum.intersectsSphere(sphere)) {
      // Копіюємо матрицю у видимий слот
      iMesh.getMatrixAt(i, dummy.matrix);
      iMesh.setMatrixAt(visibleCount++, dummy.matrix);
    }
  }
  iMesh.count = visibleCount; // відмальовуємо лише видимі екземпляри
  iMesh.instanceMatrix.needsUpdate = true;
}

Для складних сцен Three.js має вбудований LOD (рівень деталізації) — перемикання на простішу геометрію, коли об'єкт далеко від камери:

const lod = new THREE.LOD();
lod.addLevel(new THREE.Mesh(highPoly, mat), 0);    // <10 одиниць
lod.addLevel(new THREE.Mesh(midPoly,  mat), 10);   // 10–50 одиниць
lod.addLevel(new THREE.Mesh(lowPoly,  mat), 50);   // >50 одиниць
scene.add(lod);

Чого слід уникати

Продовжуйте навчання

🛠

Експериментуйте в Пісочниці

Заміряйте свої оптимізації — пишіть і запускайте код Three.js прямо в браузері.

Відкрити Пісочницю → Переглянути симуляцію ↗