Galaktyka — renderowanie 80 000 gwiazd bez spadku wydajności

Spiralna galaktyka z 80 000 gwiazd brzmi niemożliwie przy 60 FPS — dopóki nie odkryjesz InstancedMesh i własnych shaderów instancjonowanych. Pełna historia tego, jak zredukowałem liczbę wywołań rysowania GPU z 80 000 do 1.

Problem: 80 000 wywołań rysowania

Naiwne podejście do renderowania wielu obiektów w Three.js jest takie: stwórz Mesh dla każdego z nich, dodaj do sceny. Dla 80 000 gwiazd oznacza to 80 000 obiektów Mesh, 80 000 wywołań rysowania na klatkę i około 4 FPS na karcie graficznej średniej klasy.

Wywołanie rysowania to polecenie CPU→GPU, które mówi „narysuj tę geometrię z tym materiałem". Każde wywołanie ma narzut — zmiany stanu, podpięcia buforów, walidację API. Nowoczesne GPU obsłużą miliony trójkątów na klatkę, ale tylko ~1 000–3 000 wywołań rysowania, zanim CPU stanie się wąskim gardłem.

InstancedMesh — jedno wywołanie rysowania dla wszystkich

InstancedMesh z Three.js rozwiązuje to, pakując wszystkie dane instancji (pozycję, rotację, skalę, kolor) do buforów GPU, a następnie wykonując jedno instancjonowane wywołanie rysowania. GPU obsługuje różnicowanie per instancja całkowicie w shaderze wierzchołków.

const starGeometry = new THREE.SphereGeometry(0.05, 4, 4); // mała sfera
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++) {
  // Rozkład spiralnej galaktyki
  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);

  // Kolor zależny od temperatury (niebieski gorący → pomarańczowy chłodny)
  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;

Wydajność przed i po

Podejście Wywołania rysowania FPS (RTX 3060) FPS (Intel iGPU)
80 000 × Mesh 80 000 4 1
Points (BufferGeometry) 1 60 38
InstancedMesh (sfery) 1 60 45

Points (renderowanie punktów GPU) to też prawidłowe podejście, nawet nieco szybsze, ale InstancedMesh pozwala, by każda gwiazda była prawdziwą geometrią 3D — możesz sprawić, że będą świecić, animować pojedyncze z nich lub zmieniać ich rozmiar w zależności od klasy widmowej.

Własne shadery instancjonowane

InstancedMesh współpracuje ze standardowymi materiałami Three.js, ale dla galaktyki chciałem własnych efektów per instancja: aureoli przypominającej bloom, zróżnicowania rozmiaru według klasy gwiazdowej i subtelnego migotania. Wymagało to własnego ShaderMaterial, który odczytuje macierz instancji.

Three.js automatycznie wstrzykuje macierz instancji jako instanceMatrix, gdy używasz USE_INSTANCING. Dostęp do koloru per instancja uzyskasz przez instanceColor w shaderze wierzchołków, z definicją USE_INSTANCING_COLOR.

Rozkład spiralnej galaktyki

Prawdziwe galaktyki spiralne mają ramiona podążające za spiralami logarytmicznymi. Użyty rozkład pozycji:

fbm_noise (fraktalny szum Browna) dodaje organiczną nieregularność ramion, by wynik nie wyglądał mechanicznie.

Zobacz ją pod adresem /galaxy/ — obróć kamerę, by zobaczyć dysk z boku lub spojrzeć wzdłuż osi galaktycznej.