Астрофізика · Космос
📅 Березень 2026 ⏱ ≈ 12 хв читання 🎯 Середній рівень · Останнє оновлення: 28 травня 2026 р.

Спіральні рукави галактик — хвилі густини й 80 000 зірок у Three.js

Чому спіральні рукави зберігаються мільярди років, попри те що окремі зірки обертаються з різними швидкостями? Відповідь — теорія хвиль густини — також пояснює, як відрендерити переконливу процедурну галактику лише з кількох математичних рівнянь.

1. Будова Чумацького Шляху

Чумацький Шлях — це спіральна галактика з перемичкою (тип SBbc у послідовності Хаббла). Її основні структурні компоненти, важливі для нашої симуляції:

Нагадування про масштаб: 1 світловий рік ≈ 9,46 × 10¹⁵ м. Чумацький Шлях має ~100 000 св. р. упоперек. Наша симуляція стискає це до сцени Three.js завширшки 200 одиниць — 1 одиниця = 500 св. р. є зручним масштабом. Фізичною точністю жертвуємо заради візуальної ясності.

2. Парадокс закручування

Зірки, ближчі до центру галактики, обертаються швидше за зірки на периферії (диференціальне обертання). Якби спіральні рукави були фіксованими візерунками з тих самих зірок, диференціальне обертання закрутило б їх у тонке кільце за кілька сотень мільйонів років — крихітну частку від 10-мільярдного віку галактики.

Проте ми спостерігаємо галактики з виразними дворукавними спіралями. Розв'язання цього парадоксу закручування полягає в тому, що рукави — це не ті самі зірки; це хвилі густини, схожі на затор, що зберігається ще довго після того, як крізь нього проїхали окремі автомобілі.

Аналогія із затором: на трасі автомобілі сповільнюються у вузькому місці, утворюючи щільне скупчення. Окремі автомобілі рухаються далі й проїжджають; скупчення (сам «затор») залишається на місці. Галактичні спіральні рукави працюють так само із зірками та газом.

3. Теорія хвиль густини (Лін і Шу, 1964)

Чіа-Чіао Лін і Френк Шу запропонували, що спіральні рукави — це квазістаціонарні хвилі густини в галактичному диску. Зірки (і газ) рухаються злегка еліптичними орбітами; коли ці орбіти орієнтовані відповідним чином, вони періодично скупчуються, утворюючи рукавоподібні згущення.

Ключове розуміння для симуляції: замість того щоб моделювати гравітаційну динаміку N тіл (що потребувало б мільйонів частинок при вартості O(N²)), ми наближаємо стаціонарний візерунок густини параметричним розподілом:

Це дає візуально переконливі галактики без жодної реальної орбітальної механіки. Симуляція галактики в цьому проєкті використовує саме такий підхід.

4. Рівняння логарифмічної спіралі

Спостережувані спіральні галактики добре описуються логарифмічними спіралями — кривими, у яких кут зростає лінійно з логарифмом радіуса. У полярних координатах:

r = a · e^(b·θ)

або еквівалентно:
θ = (1/b) · ln(r/a)

a — масштабний коефіцієнт (радіус при θ=0)
b — коефіцієнт стиснення (визначає, як швидко розкривається рукав)
θ — кут (радіани)

Для Чумацького Шляху кут закрутки (кут між дотичною до рукава й колом) становить ≈ 12–15°, що відповідає b ≈ tan(13°) ≈ 0.231. Тугіші спіралі (як галактики Sa) мають менший кут закрутки, тоді як вільніші (Sc) — більший.

Щоб згенерувати декартові координати зірки на n-му рукаві, повернутому на кут зсуву:

Для рукава i з N рукавів:
θ_arm = (2π / N) · i ← однаковий кутовий інтервал

Для точки на радіусі r на цьому рукаві:
θ = (1/b) · ln(r/a) + θ_arm
x = r · cos(θ)
y = r · sin(θ)

Потім розсіювання: x += gaussian(0, σ(r)), y += gaussian(0, σ(r))
де σ(r) = r · scatter_factor (розсіювання зростає з радіусом)
// Генерація точки рукава
function armPoint(radius, armIndex, numArms, tightness, scatter) {
  const a = 1.0;
  const b = tightness;                          // ~0.2 – 0.4
  const armOffset = (Math.PI * 2 / numArms) * armIndex;
  const theta = (1 / b) * Math.log(radius / a) + armOffset;

  const s = scatter * radius;
  const dx = gaussRand() * s;
  const dy = gaussRand() * s;

  return {
    x: radius * Math.cos(theta) + dx,
    y: radius * Math.sin(theta) + dy,
    z: gaussRand() * radius * 0.004  // тонкий диск
  };
}

// Перетворення Бокса–Мюллера для гаусових випадкових чисел
function gaussRand() {
  let u, v;
  do { u = Math.random(); } while (u === 0);
  do { v = Math.random(); } while (v === 0);
  return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}

5. Three.js: InstancedMesh для 80 000 зірок

Рендеринг 80 тис. окремих об'єктів THREE.Mesh спричинив би тисячі викликів відмалювання за кадр і поклав би GPU. Рішення — InstancedMesh: одна геометрія + один матеріал, із трансформаціями кожного екземпляра, збереженими в буфері матриць 4×4. Один виклик відмалювання на всі 80 тис. зірок.

import * as THREE from 'three';

const STAR_COUNT = 80_000;
const NUM_ARMS   = 2;
const TIGHTNESS  = 0.28;
const SCATTER    = 0.22;
const GALAXY_R   = 100;  // одиниці сцени

// Крихітна сфера як геометрія зірки (або Points для ще кращої продуктивності)
const geo = new THREE.SphereGeometry(0.12, 4, 4);
const mat = new THREE.MeshBasicMaterial({ vertexColors: true });
const mesh = new THREE.InstancedMesh(geo, mat, STAR_COUNT);
// Увімкнути колір для кожного екземпляра
mesh.instanceColor = new THREE.InstancedBufferAttribute(
  new Float32Array(STAR_COUNT * 3), 3
);

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

for (let i = 0; i < STAR_COUNT; i++) {
  const arm = i % NUM_ARMS;
  const t   = (i / STAR_COUNT);

  // Зірки балджа (внутрішні 15%)
  if (Math.random() < 0.15) {
    dummy.position.set(
      gaussRand() * 10,
      gaussRand() * 10,
      gaussRand() * 3
    );
    color.setHSL(0.07, 0.6, 0.7);  // теплий жовто-помаранчевий
  } else {
    const r = 4 + Math.pow(Math.random(), 0.5) * (GALAXY_R - 4);
    const pt = armPoint(r, arm, NUM_ARMS, TIGHTNESS, SCATTER);
    dummy.position.set(pt.x, pt.z, pt.y);  // вісь y вгору у Three.js
    setStarColor(color, r / GALAXY_R);
  }

  dummy.updateMatrix();
  mesh.setMatrixAt(i, dummy.matrix);
  mesh.setColorAt(i, color);
}

mesh.instanceMatrix.needsUpdate = true;
mesh.instanceColor.needsUpdate  = true;
scene.add(mesh);

Повільне обертання

Галактика обертається з диференціальною швидкістю: внутрішні зірки швидше, зовнішні повільніше. У циклі анімації зміщуйте кутове положення кожної зірки на невелику покадрову дельту, пропорційну 1/r:

function animate() {
  requestAnimationFrame(animate);

  // Повільно обертати всю галактику навколо осі Y
  mesh.rotation.y += 0.00012;

  renderer.render(scene, camera);
}
Порада щодо продуктивності: використовуйте THREE.Points із круглою спрайтовою текстурою замість InstancedMesh + SphereGeometry, щоб відрендерити 200 тис.+ зірок без растеризації реальної геометрії. GPU рендерить один квад на точку й відкидає пікселі поза колом через альфа-текстуру.

6. Кольори зірок і діаграма Герцшпрунга–Рассела

Справжні зірки охоплюють діапазон кольорів від блакитно-білих гарячих зірок класів O/B до прохолодних червоних гігантів класу M. Діаграма Герцшпрунга–Рассела зіставляє світність із температурою. Для нашої симуляції галактики нас цікавить лише головна послідовність (90% зірок), а колір ми наближаємо за температурою:

Спектральний клас Температура (K) Колір Частка RGB у симуляції
O, B 10 000–50 000 Блакитно-білий 0,1% rgb(200, 210, 255)
A 7 500–10 000 Білий 0,6% rgb(232, 238, 255)
F 6 000–7 500 Жовто-білий 3% rgb(255, 252, 232)
G (Сонце) 5 200–6 000 Жовтий 7% rgb(255, 232, 147)
K 3 700–5 200 Помаранчевий 12% rgb(255, 192, 96)
M 2 400–3 700 Червоний 76% rgb(255, 100, 64)

У симуляції ми спрощуємо це до правила на основі відтінку: зірки в рукавах (нещодавно утворені з щільних газових хмар) тяжіють до блакитно-білого; старі зірки диска та балджа тяжіють до помаранчево-червоного. Радіальне положення слугує приблизним показником віку зірки:

function setStarColor(color, normalizedRadius) {
  // Молоді зірки поблизу рукавів: блакитно-білі. Старі дифузні зірки: тепло-помаранчеві.
  const isArm = Math.random() < (1.0 - normalizedRadius * 0.5);
  if (isArm && Math.random() < 0.08) {
    // Гаряча молода зірка O/B у рукаві
    color.setRGB(0.79, 0.88, 1.0);
  } else if (Math.random() < 0.25) {
    // Тип F/G — жовто-білий
    color.setHSL(0.13, 0.8, 0.82 + Math.random()*0.12);
  } else {
    // Червоний гігант класу M — основна маса зірок
    const hue = 0.05 + Math.random() * 0.04;
    color.setHSL(hue, 0.7, 0.55 + Math.random()*0.2);
  }
}

7. Процедурні туманності та пилові смуги

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

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

// Шар туманності — адитивні спрайти вздовж рукава
const NEBULA_COUNT = 200;
const nebulaGeo = new THREE.BufferGeometry();
const nebPos = new Float32Array(NEBULA_COUNT * 3);
const nebColor = new Float32Array(NEBULA_COUNT * 3);

for (let i = 0; i < NEBULA_COUNT; i++) {
  const arm = i % NUM_ARMS;
  const r   = 8 + Math.random() * (GALAXY_R * 0.7);
  const pt  = armPoint(r, arm, NUM_ARMS, TIGHTNESS, 0.06);

  nebPos[i*3]   = pt.x;
  nebPos[i*3+1] = pt.z * 0.5;  // сплющено
  nebPos[i*3+2] = pt.y;

  // Поєднати рожеві області HII та блакитно-біле випромінювання
  const pinkEmission = Math.random() < 0.6;
  if (pinkEmission) {
    nebColor[i*3]   = 0.9;
    nebColor[i*3+1] = 0.3;
    nebColor[i*3+2] = 0.5;
  } else {
    nebColor[i*3]   = 0.3;
    nebColor[i*3+1] = 0.5;
    nebColor[i*3+2] = 1.0;
  }
}

nebulaGeo.setAttribute('position',
  new THREE.BufferAttribute(nebPos, 3));
nebulaGeo.setAttribute('color',
  new THREE.BufferAttribute(nebColor, 3));

const nebMat = new THREE.PointsMaterial({
  size: 8.0,
  vertexColors: true,
  blending: THREE.AdditiveBlending,
  transparent: true,
  opacity: 0.18,
  depthWrite: false,
  sizeAttenuation: true,
  map: nebulaSpriteTexture,  // радіальний градієнт → біле коло
});

scene.add(new THREE.Points(nebulaGeo, nebMat));

Створення спрайтової текстури

// Процедурна текстура туманності — Canvas + DataTexture
function makeNebulaTexture(size = 64) {
  const canvas = document.createElement('canvas');
  canvas.width = canvas.height = size;
  const ctx = canvas.getContext('2d');
  const r   = size / 2;
  const grad = ctx.createRadialGradient(r, r, 0, r, r, r);
  grad.addColorStop(0.0, 'rgba(255,255,255,1)');
  grad.addColorStop(0.4, 'rgba(255,255,255,0.4)');
  grad.addColorStop(1.0, 'rgba(255,255,255,0)');
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, size, size);
  return new THREE.CanvasTexture(canvas);
}

8. Розширення та вдосконалення

🌌 Спіральна галактика

Жива симуляція галактики рендерить 80 000 зірок із процедурними спіральними рукавами, анімованим обертанням і постобробкою bloom.

Запустити симуляцію →