Спіральні рукави галактик — хвилі густини й 80 000 зірок у Three.js
Чому спіральні рукави зберігаються мільярди років, попри те що окремі зірки обертаються з різними швидкостями? Відповідь — теорія хвиль густини — також пояснює, як відрендерити переконливу процедурну галактику лише з кількох математичних рівнянь.
1. Будова Чумацького Шляху
Чумацький Шлях — це спіральна галактика з перемичкою (тип SBbc у послідовності Хаббла). Її основні структурні компоненти, важливі для нашої симуляції:
- Балдж: щільна, приблизно сферична центральна область (~10 000 св. р. радіусом). Старі жовто-червоні зірки, дуже висока густина. Містить центральну перемичку.
- Диск: тонка пласка область (~100 000 св. р. діаметром, ~1 000 св. р. завтовшки). Тут живуть спіральні рукави. Містить молоді блакитно-білі зірки та газ.
- Спіральні рукави: 4 основні рукави (Персея, Косинця, Стрільця, Щита-Центавра). Рукави — це області підвищеної густини зірок, а не фіксований набір зірок.
- Гало: сферична область старих червоних/жовтих зірок і кулястих скупчень, що простягається до ~300 000 св. р. У симуляціях ми зазвичай це пропускаємо.
2. Парадокс закручування
Зірки, ближчі до центру галактики, обертаються швидше за зірки на периферії (диференціальне обертання). Якби спіральні рукави були фіксованими візерунками з тих самих зірок, диференціальне обертання закрутило б їх у тонке кільце за кілька сотень мільйонів років — крихітну частку від 10-мільярдного віку галактики.
Проте ми спостерігаємо галактики з виразними дворукавними спіралями. Розв'язання цього парадоксу закручування полягає в тому, що рукави — це не ті самі зірки; це хвилі густини, схожі на затор, що зберігається ще довго після того, як крізь нього проїхали окремі автомобілі.
3. Теорія хвиль густини (Лін і Шу, 1964)
Чіа-Чіао Лін і Френк Шу запропонували, що спіральні рукави — це квазістаціонарні хвилі густини в галактичному диску. Зірки (і газ) рухаються злегка еліптичними орбітами; коли ці орбіти орієнтовані відповідним чином, вони періодично скупчуються, утворюючи рукавоподібні згущення.
Ключове розуміння для симуляції: замість того щоб моделювати гравітаційну динаміку N тіл (що потребувало б мільйонів частинок при вартості O(N²)), ми наближаємо стаціонарний візерунок густини параметричним розподілом:
- Розмістити більшість зірок уздовж заздалегідь визначеного каркаса логарифмічної спіралі.
- Додати випадкове розсіювання навколо кожної точки каркаса — менше розсіювання поблизу піків рукавів, більше — у міжрукавних областях.
- Призначити вік зіркам: молоді гарячі блакитні зірки скупчуються в рукавах (нещодавно там народжені); старі червоні зірки рівномірно розподілені по всьому диску.
Це дає візуально переконливі галактики без жодної реальної орбітальної механіки. Симуляція галактики в цьому проєкті використовує саме такий підхід.
4. Рівняння логарифмічної спіралі
Спостережувані спіральні галактики добре описуються логарифмічними спіралями — кривими, у яких кут зростає лінійно з логарифмом радіуса. У полярних координатах:
або еквівалентно:
θ = (1/b) · ln(r/a)
a — масштабний коефіцієнт (радіус при θ=0)
b — коефіцієнт стиснення (визначає, як швидко розкривається рукав)
θ — кут (радіани)
Для Чумацького Шляху кут закрутки (кут між дотичною до рукава й колом)
становить ≈ 12–15°, що відповідає
b ≈ tan(13°) ≈ 0.231. Тугіші спіралі (як галактики Sa)
мають менший кут закрутки, тоді як вільніші (Sc) — більший.
Щоб згенерувати декартові координати зірки на 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. Розширення та вдосконалення
- Гравітаційна симуляція N тіл: замінити параметричний розподіл реальними гравітаційними силами між зірковими частинками. Навіть 5 000 частинок демонструють реалістичне формування рукавів із початкової умови у вигляді диска. Для швидкості потребує алгоритму Барнса–Гата O(N log N). Див. статтю про задачу N тіл.
-
Постобробка Bloom: додайте
THREE.UnrealBloomPass, щоб яскраве ядро й гарячі зірки світилися. Критично для рендерів галактики кіноякості. Симуляція галактики вже це використовує — див. симуляцію галактики на сайті. - Спіраль із перемичкою: змініть балдж так, щоб він утворював центральну видовжену перемичку. Зірки в межах r < 15 одиниць розміщуються вздовж повернутого лінійного скупчення, а потім два рукави виходять із кінців перемички.
- Злиття галактик: змоделюйте дві галактичні системи, що зближуються. Навіть без справжньої фізики N тіл анімація другого InstancedMesh на кеплерівській траєкторії прольоту виглядає вражаюче й ілюструє припливні сили.
- GPU-інстансована анімація: замість обертання всієї сітки напишіть власний вершинний шейдер, що зчитує орбітальні параметри кожного екземпляра з текстури, обчислюючи положення на GPU щокадру.
🌌 Спіральна галактика
Жива симуляція галактики рендерить 80 000 зірок із процедурними спіральними рукавами, анімованим обертанням і постобробкою bloom.