Галактика — рендеримо 80 000 зірок без просідання FPS

Спіральна галактика з 80 000 зірок звучить неможливо при 60 FPS — доки не відкриєш для себе InstancedMesh і власні інстансовані шейдери. Повна історія того, як я скоротив кількість викликів рендерингу GPU з 80 000 до 1.

Проблема: 80 000 викликів рендерингу

Наївний підхід до рендерингу великої кількості об'єктів у Three.js такий: створити Mesh для кожного, додати його на сцену. Для 80 000 зірок це означає 80 000 об'єктів Mesh, 80 000 викликів рендерингу на кадр і приблизно 4 FPS на відеокарті середнього рівня.

Виклик рендерингу (draw call) — це команда CPU→GPU, яка каже «відрендери цю геометрію з цим матеріалом». Кожен виклик має накладні витрати — зміни стану, прив'язки буферів, валідація API. Сучасні GPU можуть обробляти мільйони трикутників за кадр, але лише близько 1 000–3 000 викликів рендерингу, перш ніж вузьким місцем стає CPU.

InstancedMesh — один виклик рендерингу на всіх

InstancedMesh з Three.js вирішує цю проблему, пакуючи всі дані інстансів (позицію, обертання, масштаб, колір) у буфери GPU, а потім виконуючи один-єдиний інстансований виклик рендерингу. GPU самостійно обробляє варіації між інстансами повністю у вершинному шейдері.

const starGeometry = new THREE.SphereGeometry(0.05, 4, 4); // крихітна сфера
const starMaterial = new THREE.MeshBasicMaterial();
const stars = new THREE.InstancedMesh(starGeometry, starMaterial, STAR_COUNT);

const dummy = new THREE.Object3D();
const color = new THREE.Color();

for (let i = 0; i < STAR_COUNT; i++) {
  // Розподіл спіральної галактики
  const arm = Math.floor(Math.random() * 4);
  const angle = arm * (Math.PI / 2) + r * 2.5 + noise(r);
  const r = Math.random() ** 0.5 * RADIUS;

  dummy.position.set(
    Math.cos(angle) * r,
    (Math.random() - 0.5) * r * 0.1,
    Math.sin(angle) * r
  );
  dummy.updateMatrix();
  stars.setMatrixAt(i, dummy.matrix);

  // Колір на основі температури (гарячий синій → холодний помаранчевий)
  const temp = Math.random();
  color.setHSL(0.6 - temp * 0.5, 0.9, 0.7 + temp * 0.2);
  stars.setColorAt(i, color);
}
stars.instanceMatrix.needsUpdate = true;
stars.instanceColor.needsUpdate = true;

Продуктивність до і після

Підхід Виклики рендерингу FPS (RTX 3060) FPS (вбудована Intel)
80 000 × Mesh 80 000 4 1
Points (BufferGeometry) 1 60 38
InstancedMesh (сфери) 1 60 45

Points (рендеринг точок на GPU) — теж робочий підхід і навіть трохи швидший, але InstancedMesh дозволяє кожній зірці бути справжньою 3D-геометрією — можна змусити їх світитися, анімувати окремо чи змінювати розмір залежно від спектрального класу зорі.

Власні інстансовані шейдери

InstancedMesh працює зі стандартними матеріалами Three.js, але для галактики я хотів власні ефекти для кожного інстансу: подібний до bloom ореол, варіацію розміру за спектральним класом і легке мерехтіння. Для цього знадобився власний ShaderMaterial, що читає матрицю інстансу.

Three.js автоматично інжектить матрицю інстансу як instanceMatrix, коли використовується USE_INSTANCING. Доступ до кольору кожного інстансу можна отримати через instanceColor у вершинному шейдері за допомогою дефайну USE_INSTANCING_COLOR.

Розподіл спіральної галактики

Справжні спіральні галактики мають рукави, що йдуть за логарифмічними спіралями. Розподіл позицій, який я використав:

fbm_noise (фрактальний броунівський рух) додає органічної неправильності рукавам, щоб результат не виглядав механічним.

Подивіться на неї в /galaxy/ — покрутіть камеру, щоб побачити диск з ребра або подивитися вздовж галактичної осі.