은하 충돌 시뮬레이션 9종 — 서로 잡아당기는 별들로 나선팔·조석 꼬리를 만드는 캔버스
은하 충돌 시뮬레이션은 라이브러리·WebGL 없이 별 하나하나에 뉴턴 중력을 매 프레임 적분하면 두 은하가 스치며 조석 꼬리를 남기고 합쳐지는데, 새로고침 없이 눈앞에서 우주 규모의 인력이 이루어지는 장면을 그대로 복붙으로 가져갈 수 있습니다.
01 galaxy-nbody-galaxy-collide
02 galaxy-nbody-spiral-arms-form
03 galaxy-nbody-cluster-orbit
04 galaxy-nbody-dark-matter-halo
05 galaxy-nbody-merger-remnant
06 galaxy-nbody-tidal-tail
07 galaxy-nbody-launch-comet
08 galaxy-nbody-trail-history
09 galaxy-nbody-deep-field-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 두 은하 충돌 — 스치며 조석 꼬리를 남기고 합쳐지는 기본 장면
- 02 나선 은하 형성 — 원반 별들이 저절로 팔을 만드는 과정
- 03 구상 성단 공전 — 수백 개 별이 무게중심을 도는 궤도
- 04 암흑물질 헤일로 — 보이지 않는 질량을 넣자 궤도가 바뀌는 실험
- 05 합체 잔해 타원 은하 — 나선이 부서져 뭉툭해지는 결말
- 06 조석 꼬리 클로즈업 — 별 하나의 궤적으로 보는 뿜어짐
- 07 혜성 쏘기 — 드래그 각도·세기로 별을 직접 던져 넣기
- 08 궤적 히스토리 — 잔상으로 쌓이는 별의 과거
- 09 심우주 씬 — 몇 억 년을 담은 은하 진화 무대
데모
아홉 판 각각은 index.html 과 style.css 두 파일로 자립해 열자마자 움직입니다. 24프레임 녹화 구간에서 아홉 판 전부 23프레임이 움직였고(움직인프레임 23), 별 하나에서 은하 전체까지 매 프레임 다시 계산되므로 화면의 어떤 별도 정지하지 않습니다. 07번은 드래그로 직접 별을 던져 넣을 수 있고 04번은 헤일로 토글을 버튼으로 고정할 수 있으니 지나가기 전에 만져 보시길 권합니다.
| 항목 | 초점 | 누적면적(%) | 강도 평균 |
|---|---|---|---|
| 01 스침과 꼬리 | 두 원반이 스치며 꼬리를 뿜는 기본 장면 | 20.125 | 59.3 |
| 02 팔의 자라남 | 차등회전이 원반을 나선으로 감는 과정 | 31.31 | 48.0 |
| 03 무게중심 공전 | 구면 성단이 제각각의 궤도면으로 도는 모양 | 22.827 | 49.4 |
| 04 보이지 않는 질량 | 헤일로 토글로 바뀌는 외곽 회전 속도 | 37.479 | 50.5 |
| 05 두 번째 스침 이후 | 나선팔이 흩어져 타원체로 굳는 결말 | 32.382 | 48.4 |
| 06 한 별의 궤적 | 꼬리를 만드는 개별 궤도의 왕복 | 20.762 | 55.0 |
| 07 드래그 발사 | 각도와 세기를 손으로 정하는 혜성 투입 | 26.731 | 55.3 |
| 08 쌓이는 잔상 | 지워지지 않는 궤도가 만드는 나선 문양 | 32.485 | 62.6 |
| 09 심우주 무대 | 세 은하가 차례로 스치는 대문 장면 | 30.189 | 52.2 |
누적면적은 04가 37.479%로 가장 넓고 01이 20.125%로 가장 좁습니다. 01·03·06·07은 별 점 자체가 화면의 좁은 몫을 차지하는 주제라 20~27% 대에 머무는데, 이는 장면의 성격 그대로이지 계산이 덜 일어난다는 뜻이 아닙니다. 프레임 변화의 강도는 08이 62.6으로 가장 컸고 02가 48.0으로 가장 낮았으며, 04의 우측 그래프에 그려지는 평평한 회전 속도는 우리 은하 시뮬레이션을 소개할 때마다 등장하는 바로 그 곡선입니다.
충돌 전부터 후까지, 시간축으로 늘어놓은 아홉 장면
순서는 하나의 충돌이 진행되는 시간을 축으로 잡았습니다. 01이 두 원반이 스쳐 꼬리를 뿜는 첫 순간을 맡고, 02는 스침 이전의 원반이 왜 팔을 갖게 되는지를 독립 장면으로 보여 줍니다. 03은 같은 중력으로도 원반이 아닌 구면 뭉치가 전혀 다르게 도는 걸 보이고, 04는 보이지 않는 질량을 얹는 토글 하나로 외곽 궤도를 바꿔 실험의 맛을 더합니다. 이 중 04만 계산 방식이 다릅니다 — 별 상호중력을 전 쌍으로 돌리는 대신, 매 스텝 별 질량을 반경 격자 28칸에 누적한 구면 누적 질량으로 등방 근사하고, 그래서 남는 프레임 예산을 우측 상단 회전 곡선 그래프에 썼습니다. 05는 첫 스침을 이미 지난 시점, 즉 시드에서 420스텝을 미리 적분한 상태에서 시작해 나선이 부서져 타원체로 굳는 결말을 보여 주고, 06은 같은 종류의 스침을 별 340개 전체가 아니라 추적 별 하나의 굵은 궤적으로 다시 봅니다. 07에서 드디어 손이 들어옵니다 — 드래그 각도와 세기로 새 별을 직접 던져 넣습니다. 08은 지워지지 않는 잔상으로 궤도의 누적을 보여 주고, 09는 세 은하와 성운 스프라이트를 깐 심우주 구도로 대문 장면을 닫습니다. 충돌 장면부터 보고 싶으면 01, 팔이 왜 생기는지가 궁금하면 02와 08이 곧장 답이 되는 순서입니다.
항목별 코드
01두 은하 충돌 — 스치며 조석 꼬리를 남기고 합쳐지는 기본 장면

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>두 은하의 별 각각에 매 프레임 뉴턴 중력을 더해 속도를 갱신하면, 스치는 순간 바깥 별이 뿜겨 나가는 조석 꼬리가 저절로 생깁니다</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="두 은하 충돌 실험판" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">별 340개 · O(N²) 전 쌍 계산</span>
<span class="gx__meta gx__meta--dim">canvas · Newton gravity · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 두 은하 충돌(01). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(별 2N 개 상호 중력 O(N²) 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 두 은하 충돌(01) — n체 중력의 기본 장면.
// 별마다 (x, y, vx, vy) 를 두고 매 프레임 모든 쌍의 중력 가속도를 더한다(전 쌍 O(N²) —
// 근사 아님. N=340 이라 57,630 쌍/프레임, 데모 크기에서 60fps 가 유지된다).
// 적분은 semi-implicit Euler(속도 갱신 뒤 위치 갱신) — 누적 오차로 궤도가 서서히 부풀지만
// 2초 루프 안에서는 안정하다. (02 의 leapfrog 이 에너지를 더 오래 보존한다.)
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 340; // 두 은하 합계 별 수 — 변형 "별 개수"가 340→120으로
var SKIP = 0.60; // 스치는 거리(원반 반지름의 배) — 변형 "근접 시 천체항"이 0.6→0.25로
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx; // 궤적 잔상용 투명 오프스크린 캔버스
// 별 상태 — Float64Array 가 아니라 짝수 인덱스 x/y·홀수 vx/vy 쌍 배열(가독성 우선)
var N = 0, X = [], Y = [], VX = [], VY = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
function seed() {
// 두 원반 은하를 좌우 대각으로 놓고 서로를 향해 살짝 비스듬히 움직이게 한다.
// 원반 별: 반지름 r 에 비례하는 원궤도 속도 + 작은 랜덤 흔들림(시드 고정 — 결정론 렌더).
X = []; Y = []; VX = []; VY = []; N = 0;
var rnd = mulberry32(7);
var half = NSTARS >> 1;
var R = Math.min(W, H) * 0.40; // 원반 반지름 — 화면 절반을 쓴다(누적면적)
var sc = R; // 속도 스케일 — 원반 크기에 연동
placeDisk(W * 0.30, H * 0.34, R, half, 0, 0.62, -0.42, 1.0, rnd, sc); // 은하 A — 오른쪽 위로
placeDisk(W * 0.70, H * 0.66, R, NSTARS - half, Math.PI, -0.62, 0.42, 0.85, rnd, sc); // 은하 B — 왼쪽 아래로
}
// cx,cy 중심 원반. dvx,dvy 은하 자신의 이동 속도(원반 반지름 R 배율). dir 공전 방향(+1 시계반대).
function placeDisk(cx, cy, R, count, phase, dvx, dvy, dir, rnd, sc) {
// 중심 질량 M — 원반 가장자리(r=R)에서 원궤도 속도 v=sqrt(M/R) 가 나오게 잡는다
var M = R * R * 0.90;
for (var i = 0; i < count; i++) {
var r = R * Math.sqrt(rnd()); // 면밀도 균일 — r² 균등
var a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
var v = Math.sqrt(M / Math.max(r, 6)) * dir;
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r);
VX.push(dvx * sc * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
VY.push(dvy * sc * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
N++;
}
// 은하 중심 핵 별 1개(질량 큰 것은 크기로 표현 — 배열은 동일, 질량은 상수라 별도 관리 안 함)
}
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// G·질량은 단위를 캔버스 픽셀에 맞춘 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 140.0, SOFT2 = 16; // softening ε²=16 — 근접 발산 방지(뉴턴 중력의 숫자 안정 장치)
var DT = 1 / 60;
function step() {
// 전 쌍 중력 — a_i = Σ_j G·(x_j - x_i)/(|d|²+ε²)^1.5. 대칭이라 쌍당 한 번 계산해 양쪽에 반영
var AX = new Array(N), AY = new Array(N);
for (var i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (var i = 0; i < N; i++) {
for (var j = i + 1; j < N; j++) {
var dx = X[j] - X[i], dy = Y[j] - Y[i];
var d2 = dx * dx + dy * dy + SOFT2;
var inv = 1 / Math.sqrt(d2);
var f = G * inv * inv * inv; // f = G / d³ — dx·f 가 가속도 성분
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * DT; VY[i] += AY[i] * DT;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function decay() {
// 잔상 감쇠 — 궤적 캔버스를 매 프레임 조금씩 지운다(조석 꼬리가 궤도 누적으로 보이게)
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.016)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
// 별을 잔상 캔버스에 1px 점으로 찍는다 — 이전 프레임과 이어 그리면 궤도 선이 된다
if (prev) {
for (var i = 0; i < N; i++) {
chainCtx.strokeStyle = rgba(i < (N >> 1) ? C_GLOW : C_TAIL, 0.32);
chainCtx.lineWidth = 1.9;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
function draw() {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// 배경 — 관측창 바닥색으로 매 프레임 지운다(별이 아니라 잔상 캔버스가 궤적을 기억)
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
// 배경 별밭(정적) — 깊이감. 시드 고정이라 렌더마다 같다
var rnd = mulberry32(99);
ctx.fillStyle = rgba(C_GLOW, 0.35);
for (var k = 0; k < 40; k++) {
var bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 은하 A 파스텔 페리윙클 · 은하 B 파스텔 핑크. 코어 근처 별은 밝게
for (var i = 0; i < N; i++) {
var col = i < (N >> 1) ? C_GLOW : C_TAIL;
var d = Math.hypot(X[i] - W * 0.5, Y[i] - H * 0.5);
var near = Math.max(0, 1 - d / (Math.min(W, H) * 0.72));
// 글로우 원(반지름 3.8) + 코어 점(반지름 1.7) 두 겹 — 앰비언트 글로우로 면적 확보
ctx.fillStyle = rgba(col, 0.10 + near * 0.10);
ctx.beginPath();
ctx.arc(X[i], Y[i], 3.8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col, 0.55 + near * 0.45);
ctx.beginPath();
ctx.arc(X[i], Y[i], 1.7 + near * 1.0, 0, Math.PI * 2);
ctx.fill();
}
// 은하 B 코어 표식 — 앰버 점(중심 별이 아니라 코어 위치 추적 마커)
ctx.fillStyle = rgba(C_HOT, 0.9);
ctx.beginPath(); ctx.arc(X[N >> 1], Y[N >> 1], 2.2, 0, Math.PI * 2); ctx.fill();
}
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
// 2초 루프 — 설정 초(2s)의 약수 주기로 시뮬을 되감는다(결정론 렌더·무한 반복)
var loop = ((now - t0) / 1000) % 2.0;
var target = Math.floor(loop / DT);
while (simSteps < target) { step(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
decay();
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 루프 중반(꼬리가 가장 그럴듯한) 장면까지 시뮬레이션한 뒤 한 장을 고정한다
for (var s = 0; s < 150; s++) { step(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyGalaxyCollide — vanilla 판(run/322/demo/01-galaxy-nbody-galaxy-collide/index.html) 의 React 포트.
// 두 은하의 별 각각에 매 프레임 뉴턴 중력을 더해 속도를 갱신하면, 스치는 순간 바깥 별이
// 뿜겨 나가는 조석 꼬리가 저절로 생긴다. 별마다 (x, y, vx, vy) 를 두고 매 프레임 모든 쌍의
// 중력 가속도를 더한다(전 쌍 O(N²) — 근사 아님. N=340 이라 57,630 쌍/프레임).
// 적분은 semi-implicit Euler(속도 갱신 뒤 위치 갱신) — 2초 루프 안에서 안정하다.
// 궤적은 투명 누적 캔버스에 찍힌 뒤 destination-out 로 조금씩 지워진다(조성 꼬리가 궤도 누적으로 보인다).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodyGalaxyCollide.module.scss";
export type GalaxyNbodyGalaxyCollideProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 두 은하 합계 별 수 — 변형 "별 개수"가 340→120으로
};
// ── 변형 상수(기본값) — vanilla 판과 동일. 스침 거리 SKIP 은 변형 제작용 표식(기본 장면 미사용) ──
const SKIP = 0.60;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "두 은하 충돌 실험판";
// G·질량은 캔버스 픽셀에 맞춘 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 140.0, SOFT2 = 16; // softening ε²=16 — 근접 발산 방지(뉴턴 중력의 숫자 안정 장치)
const DT = 1 / 60;
// 결정론 시더 — 시드 고정이라 렌더마다 같은 장면이 나온다
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodyGalaxyCollide({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 340
}: GalaxyNbodyGalaxyCollideProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
// 별 상태 — 짝수 인덱스 x/y·홀수 vx/vy 쌍 배열(가독성 우선 — vanilla 판과 동일 구조)
type Sim2D = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[] };
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[] }) as Box<Sim2D>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
// cx,cy 중심 원반. dvx,dvy 은하 자신의 이동 속도(원반 반지름 R 배율). dir 공전 방향(+1 시계반대).
function placeDisk(cx: number, cy: number, R: number, count: number, phase: number, dvx: number, dvy: number, dir: number, rnd: () => number, sc: number): void {
const sim = simRef.current;
// 중심 질량 M — 원반 가장자리(r=R)에서 원궤도 속도 v=sqrt(M/R) 가 나오게 잡는다
const M = R * R * 0.90;
for (let i = 0; i < count; i++) {
const r = R * Math.sqrt(rnd()); // 면밀도 균일 — r² 균등
const a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
const v = Math.sqrt(M / Math.max(r, 6)) * dir;
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r);
sim.VX.push(dvx * sc * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
sim.VY.push(dvy * sc * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
sim.N++;
}
}
function seed(): void {
// 두 원반 은하를 좌우 대각으로 놓고 서로를 향해 살짝 비스듬히 움직이게 한다.
// 원반 별: 반지름 r 에 비례하는 원궤도 속도 + 작은 랜덤 흔들림(시드 고정 — 결정론 렌더).
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.N = 0;
const rnd = mulberry32(7);
const half = nstarsRef.current >> 1;
const W = wRef.current, H = hRef.current;
const R = Math.min(W, H) * 0.40; // 원반 반지름 — 화면 절반을 쓴다(누적면적)
const sc = R;
placeDisk(W * 0.30, H * 0.34, R, half, 0, 0.62, -0.42, 1.0, rnd, sc); // 은하 A — 오른쪽 위로
placeDisk(W * 0.70, H * 0.66, R, nstarsRef.current - half, Math.PI, -0.62, 0.42, 0.85, rnd, sc); // 은하 B — 왼쪽 아래로
}
function step(): void {
// 전 쌍 중력 — a_i = Σ_j G·(x_j - x_i)/(|d|²+ε²)^1.5. 대칭이라 쌍당 한 번 계산해 양쪽에 반영
const sim = simRef.current;
const N = sim.N;
const AX = new Array<number>(N), AY = new Array<number>(N);
for (let i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (let i = 0; i < N; i++) {
for (let j = i + 1; j < N; j++) {
const dx = sim.X[j] - sim.X[i], dy = sim.Y[j] - sim.Y[i];
const d2 = dx * dx + dy * dy + SOFT2;
const inv = 1 / Math.sqrt(d2);
const f = G * inv * inv * inv; // f = G / d³ — dx·f 가 가속도 성분
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
for (let i = 0; i < N; i++) {
sim.VX[i] += AX[i] * DT; sim.VY[i] += AY[i] * DT;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
}
function stamp(): void {
// 별을 잔상 캔버스에 1px 점으로 찍는다 — 이전 프레임과 이어 그리면 궤도 선이 된다
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
chainCtx.strokeStyle = rgba(i < (sim.N >> 1) ? col.glow : col.tail, 0.32);
chainCtx.lineWidth = 1.9;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
}
function decay(): void {
// 잔상 감쇠 — 궤적 캔버스를 매 프레임 조금씩 지운다(조석 꼬리가 궤도 누적으로 보이게)
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.016)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// 배경 — 관측창 바닥색으로 매 프레임 지운다(별이 아니라 잔상 캔버스가 궤적을 기억)
ctx.fillStyle = "rgb(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ")";
ctx.fillRect(0, 0, W, H);
// 배경 별밭(정적) — 깊이감. 시드 고정이라 렌더마다 같다
const rnd = mulberry32(99);
ctx.fillStyle = rgba(col.glow, 0.35);
for (let k = 0; k < 40; k++) {
const bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 은하 A 파스텔 페리윙클 · 은하 B 파스텔 핑크. 코어 근처 별은 밝게
for (let i = 0; i < sim.N; i++) {
const c = i < (sim.N >> 1) ? col.glow : col.tail;
const d = Math.hypot(sim.X[i] - W * 0.5, sim.Y[i] - H * 0.5);
const near = Math.max(0, 1 - d / (Math.min(W, H) * 0.72));
// 글로우 원(반지름 3.8) + 코어 점(반지름 1.7) 두 겹 — 앰비언트 글로우로 면적 확보
ctx.fillStyle = rgba(c, 0.10 + near * 0.10);
ctx.beginPath();
ctx.arc(sim.X[i], sim.Y[i], 3.8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(c, 0.55 + near * 0.45);
ctx.beginPath();
ctx.arc(sim.X[i], sim.Y[i], 1.7 + near * 1.0, 0, Math.PI * 2);
ctx.fill();
}
// 은하 B 코어 표식 — 앰버 점(중심 별이 아니라 코어 위치 추적 마커)
ctx.fillStyle = rgba(col.hot, 0.9);
ctx.beginPath(); ctx.arc(sim.X[sim.N >> 1], sim.Y[sim.N >> 1], 2.2, 0, Math.PI * 2); ctx.fill();
}
function stillFrame(): void {
// reduce — 루프 중반(꼬리가 가장 그럴듯한) 장면까지 시뮬레이션한 뒤 한 장을 고정한다
for (let s = 0; s < 150; s++) { step(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
// 2초 루프 — 설정 초(2s)의 약수 주기로 시뮬을 되감는다(결정론 렌더·무한 반복)
const loop = ((now - t0) / 1000) % 2.0;
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { step(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>두 은하의 별 각각에 매 프레임 뉴턴 중력을 더해 속도를 갱신하면, 스치는 순간 바깥 별이 뿜겨 나가는 조석 꼬리가 저절로 생깁니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>별 340개 · O(N²) 전 쌍 계산</span>
<span className={styles.metaDim}>canvas · Newton gravity · requestAnimationFrame</span>
</div>
</div>
</main>
);
}01 · 두 은하 충돌 — 스치며 조석 꼬리를 남기고 합쳐지는 기본 장면 — 측정 누적면적 20.125% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
별 340개(두 원반 각 170개)에 위치와 속도를 두고 모든 쌍의 중력을 매 프레임 합산하면 두 디스크가 스치는 순간 바깥 별들이 뿜어져 나가는 조석 꼬리가 저절로 생기는데, 이것이 충돌 사진에서 보던 형태의 계산적 기원이자 히어로 배경·사이언스 도해에 바로 쓰는 기본 장면입니다. 측정은 누적면적 20.125%·강도평균 59.3입니다.
모든 쌍을 한 번씩만 훑는 대칭 합산
function step() {
// 전 쌍 중력 — a_i = Σ_j G·(x_j - x_i)/(|d|²+ε²)^1.5. 대칭이라 쌍당 한 번 계산해 양쪽에 반영
var AX = new Array(N), AY = new Array(N);
for (var i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (var i = 0; i < N; i++) {
for (var j = i + 1; j < N; j++) {
var dx = X[j] - X[i], dy = Y[j] - Y[i];
var d2 = dx * dx + dy * dy + SOFT2;
var inv = 1 / Math.sqrt(d2);
var f = G * inv * inv * inv; // f = G / d³ — dx·f 가 가속도 성분
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * DT; VY[i] += AY[i] * DT;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
}
02나선 은하 형성 — 원반 별들이 저절로 팔을 만드는 과정

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>안쪽이 빠르게 도는 차등회전이 초기 밀도 요동을 늘려, 정지 원반이 적분만으로 나선팔을 자라게 합니다</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="나선 팔 형성 실험판" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">별 320개 · 레인포드 적분</span>
<span class="gx__meta gx__meta--dim">canvas · leapfrog · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 나선 팔 형성(02). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(중심 질량 + 별 상호 중력 O(N²) 레인포드 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 나선 팔 형성(02) — 정지 원반이 차등회전으로 팔을 자라게 하는 장면.
// 01 과 같은 전 쌍 O(N²) 뉴턴 중력이되 적분기를 leapfrog(개구리뛰기)로 바꾼다:
// 가속도를 절반 스텝 앞뒤로 대칭 계산해 속도·위치를 번갈아 밀면 에너지가 오래 보존되어
// 원반이 몇 바퀴을 돌아도 인위적으로 부풀지 않는다(변형 "적분기"의 본체).
// 여기에 중심 질량(그리지 않는 핵)을 크게 두고 별 상호 중력을 더하면
// 안쪽이 빠르게 도는 차등회전이 초기 밀도 요동을 감아 올려 나선 팔이 저절로 선명해진다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 320; // 원반 별 수
var METHOD = 'leapfrog'; // 'euler' 로 바꾸면 변형 "적분기: 오일러"가 된다
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
var N = 0, X = [], Y = [], VX = [], VY = [], MASS = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
// G·질량은 캔버스 픽셀 단위의 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 60.0, SOFT2 = 36;
var DT = 1 / 60;
var MHALO = 6200; // 중심 질량(핵+헤일로 합) — 그리지 않는다. 별 상호중력과 함께 가속도에 더해진다
// 가속도 합산 — 중심 질량 인력 + 별끼리의 전 쌍 O(N²). AX/AY 는 재사용 버퍼(가비지 최소화)
var AX = [], AY = [];
function accel() {
var i, j, dx, dy, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
// 중심 질량 — 원점이 아니라 원반 중심을 향한다(원반이 화면 중앙에 있으므로 W/2, H/2)
var cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < N; i++) {
dx = cx - X[i]; dy = cy - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MHALO * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
}
// 별 상호 중력 — 대칭이라 쌍당 한 번 계산해 양쪽에 반영
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MASS[i] * MASS[j] * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
function stepEuler() {
// semi-implicit Euler(변형 비교용 — 기본은 leapfrog)
accel();
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * DT; VY[i] += AY[i] * DT;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
}
var aReady = false;
function stepLeap() {
// leapfrog(속도_verlet) — a(t) 를 재사용해 절반씩 대칭으로 밀어 에너지를 보존한다
if (!aReady) { accel(); aReady = true; }
var h = DT * 0.5;
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h; // v(t+h/2) = v(t) + a(t)·h/2
X[i] += VX[i] * DT; Y[i] += VY[i] * DT; // x(t+h) = x(t) + v(t+h/2)·h
}
accel(); // a(t+h)
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h; // v(t+h) = v(t+h/2) + a(t+h)·h/2
}
}
function step() { if (METHOD === 'leapfrog') stepLeap(); else stepEuler(); }
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seed() {
// 정지 원반 — 반지름별 원궤도 속도(안쪽이 빠르다 = 차등회전)에 초기 밀도 요동(이빨)을 심는다.
// 요동은 각도 방향 사인파 2개 — 시간이 흐르면 차등회전이 이를 감아 나선으로 만든다.
X = []; Y = []; VX = []; VY = []; MASS = []; N = 0;
var rnd = mulberry32(11);
var cx = W * 0.5, cy = H * 0.5;
var R = Math.min(W, H) * 0.44;
for (var i = 0; i < NSTARS; i++) {
var far = rnd() < 0.50; // 바깥 별 — 원반 밖 넓은 궤도(외곽 궤적 누적)
var r = far
? R * (0.42 + 2.00 * rnd())
: 0.12 * R + R * 0.82 * Math.sqrt(rnd()); // 코어 밀집 + 바깥 얇게
var a = rnd() * Math.PI * 2 + 0.14 * Math.sin(r * 0.16); // 각도에 반지름 결합 사인 요동
var v = Math.sqrt(G * MHALO / Math.max(r, 8)) * (far ? 0.82 : 1.0); // 원궤도 속도 — r 이 작을수록 크다
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r * 0.90); // 살짝 눌러 타원 원반(시각적 깊이)
VX.push(-Math.sin(a) * v + (rnd() - 0.5) * 1.5);
VY.push( Math.cos(a) * v * 0.90 + (rnd() - 0.5) * 1.5);
MASS.push(0.5 + rnd() * 0.8);
N++;
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function decay() {
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.015)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
// 잔상 — 이전 프레임과 이어 그리면 궤도 누적으로 팔이 선명해진다
if (prev) {
for (var i = 0; i < N; i++) {
var r = Math.hypot(X[i] - W * 0.5, Y[i] - H * 0.5);
var col = r < Math.min(W, H) * 0.18 ? C_HOT : C_GLOW;
chainCtx.strokeStyle = rgba(col, 0.28);
chainCtx.lineWidth = 1.8;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
function draw() {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// 배경 별밭(정적·시드 고정) — 깊이감
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
var rnd = mulberry32(21);
ctx.fillStyle = rgba(C_GLOW, 0.35);
for (var k = 0; k < 40; k++) {
var bx = rnd() * W, by = rnd() * H;
ctx.globalAlpha = 0.10 + rnd() * 0.20;
ctx.fillRect(bx, by, 1.2, 1.2); // fillRect 점 — 원반이라 arc 점보다 빠르다
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 바깥 별은 파스텔 페리윙클 사각 점(밀도가 눈에 잘 들어온다), 코어는 파스텔 앰버
var R = Math.min(W, H);
for (var i = 0; i < N; i++) {
var r = Math.hypot(X[i] - W * 0.5, Y[i] - H * 0.5);
var col = r < R * 0.16 ? C_HOT : C_GLOW;
var sz = r < R * 0.16 ? 3.0 : 2.6;
ctx.globalAlpha = 0.85;
ctx.fillStyle = rgba(col, 0.16); // 글로우 겉층
ctx.fillRect(X[i] - sz, Y[i] - sz, sz * 2, sz * 2);
ctx.fillStyle = rgba(col, 0.95); // 코어
ctx.fillRect(X[i] - sz * 0.40, Y[i] - sz * 0.40, sz * 0.80, sz * 0.80);
}
ctx.globalAlpha = 1;
// 중심 질량 위치 표식 — 그리지 않는 핵의 위치(원반 중심)를 앰버 점으로만 알린다
ctx.fillStyle = rgba(C_HOT, 0.9);
ctx.fillRect(W * 0.5 - 1.8, H * 0.5 - 1.8, 3.6, 3.6);
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 3.0; // 3초 루프 — 팔이 자라는 초반을 반복 보여준다
var target = Math.floor(loop / DT);
while (simSteps < target) { step(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
decay();
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 팔이 어느 정도 자란 장면까지 적분한 뒤 한 장을 고정한다
for (var s = 0; s < 240; s++) { step(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null; aReady = false;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodySpiralArmsForm — vanilla 판(run/322/demo/02-galaxy-nbody-spiral-arms-form/index.html) 의 React 포트.
// 정지 원반이 차등회전으로 팔을 자라게 하는 장면. 01 과 같은 전 쌍 O(N²) 뉴턴 중력이되
// 적분기를 leapfrog(개구리뛰기)로 바꾼다: 가속도를 절반 스텝 앞뒤로 대칭 계산해
// 속도·위치를 번갈아 밀면 에너지가 오래 보존되어 원반이 몇 바퀴을 돌아도 부풀지 않는다.
// 중심 질량(그리지 않는 핵)을 크게 두고 별 상호 중력을 더하면 안쪽이 빠르게 도는
// 차등회전이 초기 밀도 요동을 감아 올려 나선 팔이 저절로 선명해진다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodySpiralArmsForm.module.scss";
export type GalaxyNbodySpiralArmsFormProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 원반 별 수
method?: "leapfrog" | "euler"; // 적분기 — 'euler' 로 바꾸면 변형 "적분기: 오일러"
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "나선 팔 형성 실험판";
// G·질량은 캔버스 픽셀 단위의 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 60.0, SOFT2 = 36;
const DT = 1 / 60;
const MHALO = 6200; // 중심 질량(핵+헤일로 합) — 그리지 않는다. 별 상호중력과 함께 가속도에 더해진다
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodySpiralArmsForm({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 320,
method = "leapfrog"
}: GalaxyNbodySpiralArmsFormProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const methodRef = useRef(method) as Box<string>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
type SimM = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[]; MASS: number[] };
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[], MASS: [] as number[] }) as Box<SimM>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const aReadyRef = useRef(false);
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
// 가속도 합산 — 중심 질량 인력 + 별끼리의 전 쌍 O(N²). AX/AY 는 재사용 버퍼(가비지 최소화)
function accel(): void {
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
const N = sim.N;
const W = wRef.current, H = hRef.current;
let i: number, j: number, dx: number, dy: number, d2: number, inv: number, f: number;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
// 중심 질량 — 원점이 아니라 원반 중심을 향한다(원반이 화면 중앙에 있으므로 W/2, H/2)
const cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < N; i++) {
dx = cx - sim.X[i]; dy = cy - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MHALO * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
}
// 별 상호 중력 — 대칭이라 쌍당 한 번 계산해 양쪽에 반영
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = sim.X[j] - sim.X[i]; dy = sim.Y[j] - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * sim.MASS[i] * sim.MASS[j] * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
function stepEuler(): void {
// semi-implicit Euler(변형 비교용 — 기본은 leapfrog)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
accel();
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * DT; sim.VY[i] += AY[i] * DT;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
}
function stepLeap(): void {
// leapfrog(속도_verlet) — a(t) 를 재사용해 절반씩 대칭으로 밀어 에너지를 보존한다
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
if (!aReadyRef.current) { accel(); aReadyRef.current = true; }
const h = DT * 0.5;
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * h; sim.VY[i] += AY[i] * h; // v(t+h/2) = v(t) + a(t)·h/2
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT; // x(t+h) = x(t) + v(t+h/2)·h
}
accel(); // a(t+h)
for (let j = 0; j < sim.N; j++) {
sim.VX[j] += AX[j] * h; sim.VY[j] += AY[j] * h; // v(t+h) = v(t+h/2) + a(t+h)·h/2
}
}
function step(): void {
if (methodRef.current === "leapfrog") stepLeap();
else stepEuler();
}
function seed(): void {
// 정지 원반 — 반지름별 원궤도 속도(안쪽이 빠르다 = 차등회전)에 초기 밀도 요동(이빨)을 심는다.
// 요동은 각도 방향 사인파 — 시간이 흐르면 차등회전이 이를 감아 나선으로 만든다.
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.MASS = []; sim.N = 0;
const rnd = mulberry32(11);
const W = wRef.current, H = hRef.current;
const cx = W * 0.5, cy = H * 0.5;
const R = Math.min(W, H) * 0.44;
for (let i = 0; i < nstarsRef.current; i++) {
const far = rnd() < 0.50; // 바깥 별 — 원반 밖 넓은 궤도(외곽 궤적 누적)
const r = far
? R * (0.42 + 2.00 * rnd())
: 0.12 * R + R * 0.82 * Math.sqrt(rnd()); // 코어 밀집 + 바깥 얇게
const a = rnd() * Math.PI * 2 + 0.14 * Math.sin(r * 0.16); // 각도에 반지름 결합 사인 요동
const v = Math.sqrt(G * MHALO / Math.max(r, 8)) * (far ? 0.82 : 1.0); // 원궤도 속도 — r 이 작을수록 크다
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r * 0.90); // 살짝 눌러 타원 원반(시각적 깊이)
sim.VX.push(-Math.sin(a) * v + (rnd() - 0.5) * 1.5);
sim.VY.push(Math.cos(a) * v * 0.90 + (rnd() - 0.5) * 1.5);
sim.MASS.push(0.5 + rnd() * 0.8);
sim.N++;
}
}
function decay(): void {
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.015)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function stamp(): void {
// 잔상 — 이전 프레임과 이어 그리면 궤도 누적으로 팔이 선명해진다
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
const r = Math.hypot(sim.X[i] - W * 0.5, sim.Y[i] - H * 0.5);
const c = r < Math.min(W, H) * 0.18 ? col.hot : col.glow;
chainCtx.strokeStyle = rgba(c, 0.28);
chainCtx.lineWidth = 1.8;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// 배경 별밭(정적·시드 고정) — 깊이감
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(21);
ctx.fillStyle = rgba(col.glow, 0.35);
for (let k = 0; k < 40; k++) {
const bx = rnd() * W, by = rnd() * H;
ctx.globalAlpha = 0.10 + rnd() * 0.20;
ctx.fillRect(bx, by, 1.2, 1.2); // fillRect 점 — 원반이라 arc 점보다 빠르다
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 바깥 별은 파스텔 페리윙클 사각 점(밀도가 눈에 잘 들어온다), 코어는 파스텔 앰버
const R = Math.min(W, H);
for (let i = 0; i < sim.N; i++) {
const r = Math.hypot(sim.X[i] - W * 0.5, sim.Y[i] - H * 0.5);
const c = r < R * 0.16 ? col.hot : col.glow;
const sz = r < R * 0.16 ? 3.0 : 2.6;
ctx.globalAlpha = 0.85;
ctx.fillStyle = rgba(c, 0.16); // 글로우 겉층
ctx.fillRect(sim.X[i] - sz, sim.Y[i] - sz, sz * 2, sz * 2);
ctx.fillStyle = rgba(c, 0.95); // 코어
ctx.fillRect(sim.X[i] - sz * 0.40, sim.Y[i] - sz * 0.40, sz * 0.80, sz * 0.80);
}
ctx.globalAlpha = 1;
// 중심 질량 위치 표식 — 그리지 않는 핵의 위치(원반 중심)를 앰버 점으로만 알린다
ctx.fillStyle = rgba(col.hot, 0.9);
ctx.fillRect(W * 0.5 - 1.8, H * 0.5 - 1.8, 3.6, 3.6);
}
function stillFrame(): void {
// reduce — 팔이 어느 정도 자란 장면까지 적분한 뒤 한 장을 고정한다
for (let s = 0; s < 240; s++) { step(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 3.0; // 3초 루프 — 팔이 자라는 초반을 반복 보여준다
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { step(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
methodRef.current = method;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
aReadyRef.current = false;
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null; aReadyRef.current = false;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, method]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>안쪽이 빠르게 도는 차등회전이 초기 밀도 요동을 늘려, 정지 원반이 적분만으로 나선팔을 자라게 합니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>별 320개 · 레인포드 적분</span>
<span className={styles.metaDim}>canvas · leapfrog · requestAnimationFrame</span>
</div>
</div>
</main>
);
}02 · 나선 은하 형성 — 원반 별들이 저절로 팔을 만드는 과정 — 측정 누적면적 31.31% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
정지 원반에 중심 질량(그리지 않는 MHALO 6200) 인력과 별 상호중력만 주고 궤도 속도로 돌리면 안쪽이 빠르게 도는 차등회전이 초기 밀도 요동을 감아 올려 나선 팔이 저절로 선명해지는데, 이 판에서 적분기를 오일러 대신 에너지를 오래 보존하는 leapfrog으로 바꾼 로딩 씬·교육 도해용 장면입니다. 측정은 누적면적 31.31%·강도평균 48.0입니다.
중심 질량과 별 상호중력을 한 번에 모으는 가속도 합산
function accel() {
var i, j, dx, dy, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
// 중심 질량 — 원점이 아니라 원반 중심을 향한다(원반이 화면 중앙에 있으므로 W/2, H/2)
var cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < N; i++) {
dx = cx - X[i]; dy = cy - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MHALO * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
}
// 별 상호 중력 — 대칭이라 쌍당 한 번 계산해 양쪽에 반영
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MASS[i] * MASS[j] * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
03구상 성단 공전 — 수백 개 별이 무게중심을 도는 궤도

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>구형으로 흩뿌린 별들이 무게중심을 제각각의 각도로 돌며 은하 원반과 달리 안쪽으로 뭉치지 않습니다</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="구상 성단 공전 실험판" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">별 280개 · 무게중심 자체 계산</span>
<span class="gx__meta gx__meta--dim">canvas · center of mass · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 구상 성단 공전(03). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(구면으로 흩뿌린 별 280개 무게중심 계산 + O(N²) 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 구상 성단 공전(03) — 원반이 아닌 구면 분포가 어떻게 도는지 보는 장면.
// 별 280개를 3차원 구(시선 방향 z 를 원근 스케일로 투영)로 흩뿌리고 전 쌍 O(N²) 중력을 적분한다.
// 은하 원반(02)과 달리 각도가 제각각이라 차등회전이 나선을 만들지 못하고,
// 별 각각은 매 프레임 다시 계산한 무게중심(com) 주위를 제각각의 궤도면으로 돈다 —
// 성단 전체가 느리게 뒤집히는 관측 성질이 그대로 드러난다.
// z 투영은 원근(가까운 별 크게)뿐 아니라 계산도 3차원으로 한다 — 화면은 그 투영일 뿐.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 280; // 성단 별 수
var SPIN = 0.55; // 초기 집단 회전(전체 궤도면 기울기 스케일)
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
// 3차원 상태 — 화면은 (x, y) 투영. z 는 원근 스케일에만 쓰지 않고 계산에 그대로 들어간다
var N = 0, X = [], Y = [], Z = [], VX = [], VY = [], VZ = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 26.0, SOFT2 = 60;
var DT = 1 / 60;
var FOCAL = 420; // 투영 초점거리 — z 깊이에 비례해 화면 좌표를 확대/축소
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function com() {
// 무게중심 — 매 스텝 다시 계산한다(별이 날아가도 성단이 따라간다).
// 계산 자체가 데모의 주제: 중심을 고정하지 않고 합이 0이 되게 두면 성단 전체가 유영한다
var cx = 0, cy = 0, cz = 0;
for (var i = 0; i < N; i++) { cx += X[i]; cy += Y[i]; cz += Z[i]; }
return [cx / N, cy / N, cz / N];
}
function seed() {
// 구면 등밀도 흩뿌리기 + 완만한 집단 회전. 궤도 속도는 구 대신 접선 방향 랜덤 —
// 각자의 궤도면이 달라 원반처럼 정렬되지 않는다(구상 성단의 핵심 성질)
X = []; Y = []; Z = []; VX = []; VY = []; VZ = []; N = 0;
var rnd = mulberry32(33);
var R = Math.min(W, H) * 0.78;
for (var i = 0; i < NSTARS; i++) {
var u = rnd() * 2 - 1, ph = rnd() * Math.PI * 2;
var sq = Math.sqrt(1 - u * u);
var rr = R * 0.94 * Math.cbrt(rnd()); // 구 등밀도 — r³ 균등
var dx = sq * Math.cos(ph), dy = sq * Math.sin(ph), dz = u;
X.push(W * 0.5 + dx * rr);
Y.push(H * 0.5 + dy * rr * 0.92);
Z.push(dz * rr);
// 접선 속도 — 위치와 수직인 방향 중 하나를 골라 원궤도 스케일로. 궤도면 제각각
var tx = -dy, ty = dx, tz = 0;
var tl = Math.hypot(tx, ty) || 1;
var vv = Math.sqrt(G * NSTARS / Math.max(rr, 12)) * (0.55 + rnd() * 0.5);
VX.push((tx / tl) * vv + SPIN * 14 * dy + (rnd() - 0.5) * 6);
VY.push((ty / tl) * vv + SPIN * 14 * dx + (rnd() - 0.5) * 6);
VZ.push((rnd() - 0.5) * vv * 1.2);
N++;
}
}
var AX = [], AY = [], AZ = [];
function step() {
// 전 쌍 O(N²) 3차원 중력 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
var i, j, dx, dy, dz, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; AZ[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i]; dz = Z[j] - Z[i];
d2 = dx * dx + dy * dy + dz * dz + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f; AZ[i] += dz * f;
AX[j] -= dx * f; AY[j] -= dy * f; AZ[j] -= dz * f;
}
}
for (i = 0; i < N; i++) {
VX[i] += AX[i] * DT; VY[i] += AY[i] * DT; VZ[i] += AZ[i] * DT;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT; Z[i] += VZ[i] * DT;
}
}
function project(i) {
// 3D → 2D 투영. 초점거리로 원근(가까운 별이 크고 빠르다)
var s = FOCAL / (FOCAL + Z[i]);
return [W * 0.5 + (X[i] - W * 0.5) * s, H * 0.5 + (Y[i] - H * 0.5) * s, s];
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function decay() {
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.013)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
// 잔상 — 투영 좌표로 궤도를 남긴다(궤도면이 제각각이라 곡선 뭉치가 쌓인다)
if (prev) {
for (var i = 0; i < N; i++) {
var p = project(i);
chainCtx.strokeStyle = rgba(C_GLOW, 0.26);
chainCtx.lineWidth = 1.7;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(p[0], p[1]);
chainCtx.stroke();
}
}
if (!prev) { prev = [[], []]; }
prev[0].length = 0; prev[1].length = 0;
for (var i = 0; i < N; i++) { var p = project(i); prev[0].push(p[0]); prev[1].push(p[1]); }
}
function draw() {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
// 배경 별밭(정적·시드 고정)
var rnd = mulberry32(44);
for (var k = 0; k < 40; k++) {
ctx.globalAlpha = 0.10 + rnd() * 0.20;
ctx.fillStyle = rgba(C_GLOW, 0.6);
ctx.beginPath(); ctx.arc(rnd() * W, rnd() * H, rnd() * 0.7 + 0.3, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 무게중심 표식 — 매 프레임 계산한 com 을 앰버 십자로 찍는다(성단이 유영하는 게 보인다)
var c = com();
var cz = FOCAL / (FOCAL + c[2]);
var mx = W * 0.5 + (c[0] - W * 0.5) * cz, my = H * 0.5 + (c[1] - H * 0.5) * cz;
// 별 — 원근 스케일로 크기·밝기 변화. z 가 가까울수록 크고 밝다
for (var i = 0; i < N; i++) {
var p = project(i);
var s = p[2];
var col = s > 1.06 ? C_HOT : C_GLOW; // 앞쪽 별은 앰버, 뒷쪽은 페리윙클
ctx.fillStyle = rgba(col, 0.12 * s);
ctx.beginPath(); ctx.arc(p[0], p[1], 4.0 * s, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col, 0.66 * s);
ctx.beginPath(); ctx.arc(p[0], p[1], (1.4 + 1.1 * s) * s, 0, Math.PI * 2); ctx.fill();
}
ctx.strokeStyle = rgba(C_HOT, 0.85);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(mx - 5, my); ctx.lineTo(mx + 5, my);
ctx.moveTo(mx, my - 5); ctx.lineTo(mx, my + 5);
ctx.stroke();
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 3.0;
var target = Math.floor(loop / DT);
while (simSteps < target) { step(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
decay();
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 궤도 뭉치가 쌓인 장면까지 적분한 뒤 한 장 고정
for (var s = 0; s < 200; s++) { step(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyClusterOrbit — vanilla 판(run/322/demo/03-galaxy-nbody-cluster-orbit/index.html) 의 React 포트.
// 구상 성단 공전 — 원반이 아닌 구면 분포가 어떻게 도는지 보는 장면.
// 별 280개를 3차원 구(시선 방향 z 를 원근 스케일로 투영)로 흩뿌리고 전 쌍 O(N²) 중력을 적분한다.
// 은하 원반(02)과 달리 각도가 제각각이라 차등회전이 나선을 만들지 못하고,
// 별 각각은 매 프레임 다시 계산한 무게중심(com) 주위를 제각각의 궤도면으로 돈다.
// z 투영은 원근(가까운 별 크게)뿐 아니라 계산도 3차원으로 한다 — 화면은 그 투영일 뿐.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodyClusterOrbit.module.scss";
export type GalaxyNbodyClusterOrbitProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 성단 별 수
spin?: number; // 초기 집단 회전(전체 궤도면 기울기 스케일)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "구상 성단 공전 실험판";
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 26.0, SOFT2 = 60;
const DT = 1 / 60;
const FOCAL = 420; // 투영 초점거리 — z 깊이에 비례해 화면 좌표를 확대/축소
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodyClusterOrbit({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 280,
spin = 0.55
}: GalaxyNbodyClusterOrbitProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const spinRef = useRef(spin) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
// 3차원 상태 — 화면은 (x, y) 투영. z 는 원근 스케일에만 쓰지 않고 계산에 그대로 들어간다
type Sim3D = { N: number; X: number[]; Y: number[]; Z: number[]; VX: number[]; VY: number[]; VZ: number[] };
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], Z: [] as number[], VX: [] as number[], VY: [] as number[], VZ: [] as number[] }) as Box<Sim3D>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const AZRef = useRef<number[]>([]) as Box<number[]>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
function com(): [number, number, number] {
// 무게중심 — 매 스텝 다시 계산한다(별이 날아가도 성단이 따라간다).
// 계산 자체가 데모의 주제: 중심을 고정하지 않고 합이 0이 되게 두면 성단 전체가 유영한다
const sim = simRef.current;
let cx = 0, cy = 0, cz = 0;
for (let i = 0; i < sim.N; i++) { cx += sim.X[i]; cy += sim.Y[i]; cz += sim.Z[i]; }
return [cx / sim.N, cy / sim.N, cz / sim.N];
}
function seed(): void {
// 구면 등밀도 흩뿌리기 + 완만한 집단 회전. 궤도 속도는 구 대신 접선 방향 랜덤 —
// 각자의 궤도면이 달라 원반처럼 정렬되지 않는다(구상 성단의 핵심 성질)
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.Z = []; sim.VX = []; sim.VY = []; sim.VZ = []; sim.N = 0;
const rnd = mulberry32(33);
const W = wRef.current, H = hRef.current;
const R = Math.min(W, H) * 0.78;
for (let i = 0; i < nstarsRef.current; i++) {
const u = rnd() * 2 - 1, ph = rnd() * Math.PI * 2;
const sq = Math.sqrt(1 - u * u);
const rr = R * 0.94 * Math.cbrt(rnd()); // 구 등밀도 — r³ 균등
const dx = sq * Math.cos(ph), dy = sq * Math.sin(ph), dz = u;
sim.X.push(W * 0.5 + dx * rr);
sim.Y.push(H * 0.5 + dy * rr * 0.92);
sim.Z.push(dz * rr);
// 접선 속도 — 위치와 수직인 방향 중 하나를 골라 원궤도 스케일로. 궤도면 제각각
const tx = -dy, ty = dx, tz = 0;
const tl = Math.hypot(tx, ty) || 1;
const vv = Math.sqrt(G * nstarsRef.current / Math.max(rr, 12)) * (0.55 + rnd() * 0.5);
sim.VX.push((tx / tl) * vv + spinRef.current * 14 * dy + (rnd() - 0.5) * 6);
sim.VY.push((ty / tl) * vv + spinRef.current * 14 * dx + (rnd() - 0.5) * 6);
sim.VZ.push((rnd() - 0.5) * vv * 1.2);
sim.N++;
}
}
function step(): void {
// 전 쌍 O(N²) 3차원 중력 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current, AZ = AZRef.current;
const N = sim.N;
let i: number, j: number, dx: number, dy: number, dz: number, d2: number, inv: number, f: number;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; AZ[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = sim.X[j] - sim.X[i]; dy = sim.Y[j] - sim.Y[i]; dz = sim.Z[j] - sim.Z[i];
d2 = dx * dx + dy * dy + dz * dz + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f; AZ[i] += dz * f;
AX[j] -= dx * f; AY[j] -= dy * f; AZ[j] -= dz * f;
}
}
for (i = 0; i < N; i++) {
sim.VX[i] += AX[i] * DT; sim.VY[i] += AY[i] * DT; sim.VZ[i] += AZ[i] * DT;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT; sim.Z[i] += sim.VZ[i] * DT;
}
}
function project(i: number): [number, number, number] {
// 3D → 2D 투영. 초점거리로 원근(가까운 별이 크고 빠르다)
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
const s = FOCAL / (FOCAL + sim.Z[i]);
return [W * 0.5 + (sim.X[i] - W * 0.5) * s, H * 0.5 + (sim.Y[i] - H * 0.5) * s, s];
}
function decay(): void {
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.013)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function stamp(): void {
// 잔상 — 투영 좌표로 궤도를 남긴다(궤도면이 제각각이라 곡선 뭉치가 쌓인다)
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
const p = project(i);
chainCtx.strokeStyle = rgba(col.glow, 0.26);
chainCtx.lineWidth = 1.7;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(p[0], p[1]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [[], []];
const pv = prevRef.current as [number[], number[]];
pv[0].length = 0; pv[1].length = 0;
for (let i = 0; i < sim.N; i++) { const p = project(i); pv[0].push(p[0]); pv[1].push(p[1]); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
// 배경 별밭(정적·시드 고정)
const rnd = mulberry32(44);
for (let k = 0; k < 40; k++) {
ctx.globalAlpha = 0.10 + rnd() * 0.20;
ctx.fillStyle = rgba(col.glow, 0.6);
ctx.beginPath(); ctx.arc(rnd() * W, rnd() * H, rnd() * 0.7 + 0.3, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 무게중심 표식 — 매 프레임 계산한 com 을 앰버 십자로 찍는다(성단이 유영하는 게 보인다)
const c = com();
const cz = FOCAL / (FOCAL + c[2]);
const mx = W * 0.5 + (c[0] - W * 0.5) * cz, my = H * 0.5 + (c[1] - H * 0.5) * cz;
// 별 — 원근 스케일로 크기·밝기 변화. z 가 가까울수록 크고 밝다
for (let i = 0; i < sim.N; i++) {
const p = project(i);
const s = p[2];
const cc = s > 1.06 ? col.hot : col.glow; // 앞쪽 별은 앰버, 뒷쪽은 페리윙클
ctx.fillStyle = rgba(cc, 0.12 * s);
ctx.beginPath(); ctx.arc(p[0], p[1], 4.0 * s, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(cc, 0.66 * s);
ctx.beginPath(); ctx.arc(p[0], p[1], (1.4 + 1.1 * s) * s, 0, Math.PI * 2); ctx.fill();
}
ctx.strokeStyle = rgba(col.hot, 0.85);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(mx - 5, my); ctx.lineTo(mx + 5, my);
ctx.moveTo(mx, my - 5); ctx.lineTo(mx, my + 5);
ctx.stroke();
}
function stillFrame(): void {
// reduce — 궤도 뭉치가 쌓인 장면까지 적분한 뒤 한 장 고정
for (let s = 0; s < 200; s++) { step(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 3.0;
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { step(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
spinRef.current = spin;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, spin]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>구형으로 흩뿌린 별들이 무게중심을 제각각의 각도로 돌며 은하 원반과 달리 안쪽으로 뭉치지 않습니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>별 280개 · 무게중심 자체 계산</span>
<span className={styles.metaDim}>canvas · center of mass · requestAnimationFrame</span>
</div>
</div>
</main>
);
}03 · 구상 성단 공전 — 수백 개 별이 무게중심을 도는 궤도 — 측정 누적면적 22.827% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
별 280개를 3차원 구면으로 흩뿌리고 접선 방향의 랜덤 궤도 속도를 주면 원반과 달리 중심으로 뭉치지 않고 제각각의 궤도면으로 무게중심 주위를 돌며 성단 전체가 느리게 뒤집히는데, 화면은 초점거리 420의 원근 투영일 뿐 계산 자체는 세 축에서 일어나는 우주 테마 배경·별자리 인터랙티브용 장면입니다. 측정은 누적면적 22.827%·강도평균 49.4입니다.
z 축까지 전 쌍으로 도는 3차원 순회
function step() {
// 전 쌍 O(N²) 3차원 중력 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
var i, j, dx, dy, dz, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; AZ[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i]; dz = Z[j] - Z[i];
d2 = dx * dx + dy * dy + dz * dz + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f; AZ[i] += dz * f;
AX[j] -= dx * f; AY[j] -= dy * f; AZ[j] -= dz * f;
}
}
for (i = 0; i < N; i++) {
VX[i] += AX[i] * DT; VY[i] += AY[i] * DT; VZ[i] += AZ[i] * DT;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT; Z[i] += VZ[i] * DT;
}
}
04암흑물질 헤일로 — 보이지 않는 질량을 넣자 궤도가 바뀌는 실험

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>보이지 않는 질량 헤일로를 켜면 바깥 별의 회전 속도가 떨어지지 않고 유지됩니다 — 은하 가장자리가 빠른 이유</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="암흑물질 헤일로 실험판" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
<button class="gx__halo" id="haloBtn" type="button" aria-pressed="false">
<span id="haloOffLbl">헤일로 꺼짐</span><span id="haloOnLbl" hidden>헤일로 켜짐</span>
</button>
</div>
<div class="gx__bar">
<span class="gx__meta">별 300개 · 격자 질량 근사</span>
<span class="gx__meta gx__meta--dim">canvas · grid approximation · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 암흑물질 헤일로(04). 토글 버튼 하나로 헤일로 질량을 넣고 빼는 장면 —
// 격자 질량 근사(별 질량을 반경 빈에 누적 → 구면 M(r)) + leapfrog 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field { cursor: default !important; }
}
// 토글 버튼 — 캔버스 위 오버레이. 눌림 상태는 aria-pressed 로 표현
.gx__halo {
position: absolute; left: 8px; bottom: 8px;
padding: $sp-1 $sp-2;
border: 1px solid $gx-text; border-radius: $r-pill;
background: rgba($gx-ink, .72);
font-weight: 700; font-size: 10px; line-height: 1.4; font-family: inherit; color: $gx-text;
cursor: pointer;
transition: border-color .18s , color .18s ;
}
.gx__halo:hover { border-color: $gx-glow; color: #fff; }
.gx__halo[aria-pressed="true"] { border-color: $gx-hot; color: $gx-hot; }
.gx__halo:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}
.gx__halo {
position: absolute;
left: 8px;
bottom: 8px;
padding: 4px 8px;
border: 1px solid #d8d4e6;
border-radius: 999px;
background: rgba(11, 10, 19, 0.72);
font-weight: 700;
font-size: 10px;
line-height: 1.4;
font-family: inherit;
color: #d8d4e6;
cursor: pointer;
transition: border-color 0.18s, color 0.18s;
}
.gx__halo:hover {
border-color: #b7c8ff;
color: #fff;
}
.gx__halo[aria-pressed=true] {
border-color: #ffdf9e;
color: #ffdf9e;
}
.gx__halo:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}// 암흑물질 헤일로(04) — 토글 한 번으로 은하 회전 곡선이 바뀌는 장면.
// ★ 근사 명시: 이 항목은 별 상호중력을 전 쌍 O(N²) 로 계산하지 않는다.
// 매 스텝 별 질량을 반경 빈(격자)에 누적해 구면 누적 질량 M(r) 을 만들고,
// 각 별은 자기 반경 안쪽 질량만큼 중심 방향으로 끌린다(등방 근사 — 방향 정보를 버린 격자 질량 근사).
// 정확한 전 쌍 계산은 01~03 참고. 근사로 남은 프레임 예산을 회전 곡선 그래프(우상단)에 쓴다.
// 헤일로 ON: 같은 격자에 보이지 않는 질량 프로파일(ρ ∝ 1/(r²+rc²))을 얹어 M(r) 을 불린다 —
// 바깥 별의 원궤도 속도가 유지된다(플랫 회전 곡선). OFF: 바깥이 결합을 못 이겨 팽창한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 300; // 원반 별 수
var HALOBOOST = 2.6; // 헤일로 질량 / 별 총질량 — 변형 "헤일로 질량"이 2.6→5.0으로
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
var haloBtn = document.getElementById('haloBtn');
var haloOnLbl = document.getElementById('haloOnLbl');
var haloOffLbl = document.getElementById('haloOffLbl');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
var N = 0, X = [], Y = [], VX = [], VY = [], MASS = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 60.0, SOFT2 = 90;
var DT = 1 / 60;
var HALO = false; // 헤일로 상태 — 자동 루프가 3초마다 전환, 버튼 클릭 시 고정
var autoHalo = true;
// ── 격자 질량 근사 상태 ──
var K = 28; // 반경 빈(격자) 수
var RMAX = 10, RSTEP = 1; // fit() 에서 원반 크기로 결정
var binMass = [], cumMass = [], haloMass = [];
function buildHalo() {
// 헤일로 프로파일 — 고정 분포(별과 충돌하지 않는 차가운 암흑물질 가정).
// ρ ∝ 1/(r²+rc²) 를 격자 빈에 적분해 담아 둔다(한 번만 계산)
var rc = Math.min(W, H) * 0.15;
var mstar = 0;
for (var i = 0; i < N; i++) mstar += MASS[i];
var raw = [];
for (var k = 0; k < K; k++) {
var r0 = rEdge(k), r1 = rEdge(k + 1);
raw.push((Math.atan(r1 / rc) - Math.atan(r0 / rc)) * rc); // ∫ r²/(r²+rc²) dr 근사 형태
}
var sum = 0;
for (k = 0; k < K; k++) sum += raw[k];
for (k = 0; k < K; k++) haloMass[k] = raw[k] / sum * mstar * HALOBOOST;
}
function rEdge(k) { return RMAX * Math.pow(k / K, 0.8); } // 중심 촘촘 격자
function binOf(r) {
var k = Math.ceil(K * Math.pow(Math.min(r, RMAX) / RMAX, 1 / 0.8)) - 1;
return k < 0 ? 0 : (k >= K ? K - 1 : k);
}
var AX = [], AY = [];
function accel() {
// 격자 재구성 — 별 질량을 빈에 누적하고 구면 누적 질량 CM[k] 를 만든다
var i, k;
for (k = 0; k < K; k++) binMass[k] = 0;
var cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < N; i++) {
var rr = Math.hypot(X[i] - cx, Y[i] - cy);
binMass[binOf(rr)] += MASS[i];
}
var acc = 0;
for (k = 0; k < K; k++) {
var hm = HALO ? haloMass[k] : 0; // 토글 — 헤일로 질량을 같은 격자에 얹는다
binMass[k] += hm;
acc += binMass[k];
cumMass[k] = acc;
}
// 각 별의 가속도 — 안쪽 누적 질량 M(r) 만큼 중심 방향(등방 근사: 방향 정보 폐기)
for (i = 0; i < N; i++) {
var dx = cx - X[i], dy = cy - Y[i];
var r = Math.hypot(dx, dy);
var bk = binOf(r);
var Mr = cumMass[bk] - binMass[bk] * 0.5; // 빈 중간값 근사
var d3 = Math.pow(r * r + SOFT2, 1.5);
var f = G * Mr / d3;
AX[i] = dx * f; AY[i] = dy * f;
}
}
var aReady = false;
function stepLeap() {
// leapfrog — 격자 근사 가속도로도 절반 스텝 대칭으로 밀어 에너지를 보존한다
if (!aReady) { accel(); aReady = true; }
var h = DT * 0.5;
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
accel();
for (var j = 0; j < N; j++) {
VX[j] += AX[j] * h; VY[j] += AY[j] * h;
}
}
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seed() {
// 원반 — 속도는 헤일로 ON 프로파일 기준 원궤도(플랫에 가깝게)로 심는다.
// OFF 구간에서는 결합이 못 미쳐 바깥 별이 팽창한다(토글 차이가 곧바로 보인다)
X = []; Y = []; VX = []; VY = []; MASS = []; N = 0;
var rnd = mulberry32(5);
var cx = W * 0.5, cy = H * 0.5;
var R = Math.min(W, H) * 0.40;
RMAX = R * 1.6;
for (var i = 0; i < NSTARS; i++) {
var r = 0.10 * R + R * 0.90 * Math.sqrt(rnd());
var a = rnd() * Math.PI * 2;
MASS.push(0.5 + rnd() * 0.8);
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r * 0.88);
N++;
}
buildHalo();
// 속도는 위치가 다 심어진 뒤 M(r) 격자로 계산(누적 질량 기준 원궤도)
HALO = true; accel(); HALO = false;
for (i = 0; i < N; i++) {
var dx = X[i] - cx, dy = Y[i] - cy;
var v = Math.sqrt(Math.max(AX[i] * -dx + AY[i] * -dy, 1e-6) * Math.hypot(dx, dy));
var tl = Math.hypot(dx, dy) || 1;
VX.push(-dy / tl * v + (rnd() - 0.5) * 1.2);
VY.push( dx / tl * v * 0.88 + (rnd() - 0.5) * 1.2);
}
aReady = false;
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function decay() {
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.020)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
if (prev) {
for (var i = 0; i < N; i++) {
var r = Math.hypot(X[i] - W * 0.5, Y[i] - H * 0.5);
chainCtx.strokeStyle = rgba(r < Math.min(W, H) * 0.16 ? C_HOT : C_GLOW, 0.24);
chainCtx.lineWidth = 1.2;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
function draw() {
var ctx = cv.getContext('2d'); // CanvasRenderingContext2D — 아래 그리기 전부 이 문맥
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
var rnd = mulberry32(6);
ctx.fillStyle = rgba(C_GLOW, 0.4);
for (var k = 0; k < 34; k++) {
ctx.globalAlpha = 0.10 + rnd() * 0.20;
ctx.beginPath(); ctx.arc(rnd() * W, rnd() * H, rnd() * 0.7 + 0.3, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 코어는 파스텔 앰버, 바깥은 파스텔 페리윙클. 글로우 두 겹
var cx = W * 0.5, cy = H * 0.5, R = Math.min(W, H);
for (var i = 0; i < N; i++) {
var r = Math.hypot(X[i] - cx, Y[i] - cy);
var col = r < R * 0.15 ? C_HOT : C_GLOW;
ctx.fillStyle = rgba(col, 0.12);
ctx.beginPath(); ctx.arc(X[i], Y[i], 3.4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col, 0.6 + 0.35 * (1 - r / (R * 0.42)));
ctx.beginPath(); ctx.arc(X[i], Y[i], 1.3, 0, Math.PI * 2); ctx.fill();
}
// 헤일로 켜짐 — 보이지 않는 질량의 반경 스케일을 옅은 링으로만 암시한다
if (HALO) {
ctx.strokeStyle = rgba(C_TAIL, 0.30);
ctx.lineWidth = 1;
ctx.setLineDash([3, 5]);
ctx.beginPath(); ctx.arc(cx, cy, R * 0.52, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
}
// 회전 곡선 인셋 — 빈별 평균 접선속도 실측. OFF: 하강 / ON: 플랫
var gx0 = W - 104, gy0 = 12, gw = 88, gh = 62;
ctx.fillStyle = rgba(C_INK, 0.78);
ctx.fillRect(gx0, gy0, gw, gh);
ctx.strokeStyle = rgba(C_GLOW, 0.35);
ctx.lineWidth = 1;
ctx.strokeRect(gx0 + 0.5, gy0 + 0.5, gw, gh);
var vsum = [], vcnt = [];
for (k = 0; k < K; k++) { vsum[k] = 0; vcnt[k] = 0; }
var vmax = 1;
for (i = 0; i < N; i++) {
var dx = X[i] - cx, dy = Y[i] - cy;
var vt = Math.abs(-dy * VX[i] + dx * VY[i]) / Math.max(Math.hypot(dx, dy), 1);
var bk = binOf(Math.hypot(dx, dy));
vsum[bk] += vt; vcnt[bk]++;
if (vt > vmax) vmax = vt;
}
ctx.strokeStyle = rgba(HALO ? C_TAIL : C_GLOW, 0.95);
ctx.lineWidth = 1.6;
ctx.beginPath();
var started = false;
for (k = 2; k < K; k++) {
if (!vcnt[k]) continue;
var vx = gx0 + 6 + (k / K) * (gw - 12);
var vy = gy0 + gh - 8 - (vsum[k] / vcnt[k] / vmax) * (gh - 18);
if (!started) { ctx.moveTo(vx, vy); started = true; }
ctx.lineTo(vx, vy);
}
ctx.stroke();
ctx.fillStyle = rgba(C_HOT, 0.8);
ctx.font = '9px sans-serif';
ctx.fillText(HALO ? 'flat' : 'kepler', gx0 + 6, gy0 + 11);
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
function setHalo(on) {
HALO = on;
haloBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
haloOnLbl.hidden = !on;
haloOffLbl.hidden = on;
}
haloBtn.addEventListener('click', function () { autoHalo = false; setHalo(!HALO); });
field.addEventListener('click', function (e) {
if (e.target === haloBtn || haloBtn.contains(e.target)) return;
autoHalo = false; setHalo(!HALO);
});
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 6.0; // 6초 루프 — 3초 꺼짐 / 3초 켜짐 자동 전환
if (autoHalo) setHalo(loop < 3.0);
var target = Math.floor(loop / DT);
while (simSteps < target) { stepLeap(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
decay();
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 헤일로 ON 장면까지 적분한 뒤 한 장 고정
setHalo(true);
for (var s = 0; s < 240; s++) { stepLeap(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null; aReady = false;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyDarkMatterHalo — vanilla 판(run/322/demo/04-galaxy-nbody-dark-matter-halo/index.html) 의 React 포트.
// 암흑물질 헤일로 — 토글 한 번으로 은하 회전 곡선이 바뀌는 장면.
// ★ 근사 명시: 이 항목은 별 상호중력을 전 쌍 O(N²) 로 계산하지 않는다.
// 매 스텝 별 질량을 반경 빈(격자)에 누적해 구면 누적 질량 M(r) 을 만들고,
// 각 별은 자기 반경 안쪽 질량만큼 중심 방향으로 끌린다(등방 근사 — 방향 정보를 버린 격자 질량 근사).
// 헤일로 ON: 같은 격자에 보이지 않는 질량 프로파일(ρ ∝ 1/(r²+rc²))을 얹어 M(r) 을 불린다 —
// 바깥 별의 원궤도 속도가 유지된다(플랫 회전 곡선). OFF: 바깥이 결합을 못 이겨 팽창한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodyDarkMatterHalo.module.scss";
export type GalaxyNbodyDarkMatterHaloProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 원반 별 수
haloboost?: number; // 헤일로 질량 / 별 총질량 — 변형 "헤일로 질량"이 2.6→5.0으로
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type SimM = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[]; MASS: number[] };
const FIELD_LABEL = "암흑물질 헤일로 실험판";
const LABEL_OFF = "헤일로 꺼짐";
const LABEL_ON = "헤일로 켜짐";
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 60.0, SOFT2 = 90;
const DT = 1 / 60;
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodyDarkMatterHalo({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 300,
haloboost = 2.6
}: GalaxyNbodyDarkMatterHaloProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const boostRef = useRef(haloboost) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [haloOn, setHaloOn] = useState(false); // 버튼 라벨 상태 — 시뮬 상태(HALORef)와 동기
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[], MASS: [] as number[] }) as Box<SimM>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const aReadyRef = useRef(false) as Box<boolean>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
const HALORef = useRef(false) as Box<boolean>; // 헤일로 상태 — 자동 루프가 3초마다 전환, 버튼 클릭 시 고정
const autoHaloRef = useRef(true) as Box<boolean>;
// ── 격자 질량 근사 상태 ──
const K = 28; // 반경 빈(격자) 수
const rmaxRef = useRef(10) as Box<number>;
const binMassRef = useRef<number[]>([]) as Box<number[]>;
const cumMassRef = useRef<number[]>([]) as Box<number[]>;
const haloMassRef = useRef<number[]>([]) as Box<number[]>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
function rEdge(k: number): number { return rmaxRef.current * Math.pow(k / K, 0.8); } // 중심 촘촘 격자
function binOf(r: number): number {
const k = Math.ceil(K * Math.pow(Math.min(r, rmaxRef.current) / rmaxRef.current, 1 / 0.8)) - 1;
return k < 0 ? 0 : (k >= K ? K - 1 : k);
}
function buildHalo(): void {
// 헤일로 프로파일 — 고정 분포(별과 충돌하지 않는 차가운 암흑물질 가정).
// ρ ∝ 1/(r²+rc²) 를 격자 빈에 적분해 담아 둔다(한 번만 계산)
const sim = simRef.current;
const rc = Math.min(wRef.current, hRef.current) * 0.15;
let mstar = 0;
for (let i = 0; i < sim.N; i++) mstar += sim.MASS[i];
const raw: number[] = [];
for (let k = 0; k < K; k++) {
const r0 = rEdge(k), r1 = rEdge(k + 1);
raw.push((Math.atan(r1 / rc) - Math.atan(r0 / rc)) * rc); // ∫ r²/(r²+rc²) dr 근사 형태
}
let sum = 0;
for (let k = 0; k < K; k++) sum += raw[k];
for (let k = 0; k < K; k++) haloMassRef.current[k] = raw[k] / sum * mstar * boostRef.current;
}
function accel(): void {
// 격자 재구성 — 별 질량을 빈에 누적하고 구면 누적 질량 CM[k] 를 만든다
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
const binMass = binMassRef.current, cumMass = cumMassRef.current, haloMass = haloMassRef.current;
const W = wRef.current, H = hRef.current;
let i: number, k: number;
for (k = 0; k < K; k++) binMass[k] = 0;
const cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < sim.N; i++) {
const rr = Math.hypot(sim.X[i] - cx, sim.Y[i] - cy);
binMass[binOf(rr)] += sim.MASS[i];
}
let acc = 0;
for (k = 0; k < K; k++) {
const hm = HALORef.current ? haloMass[k] : 0; // 토글 — 헤일로 질량을 같은 격자에 얹는다
binMass[k] += hm;
acc += binMass[k];
cumMass[k] = acc;
}
// 각 별의 가속도 — 안쪽 누적 질량 M(r) 만큼 중심 방향(등방 근사: 방향 정보 폐기)
for (i = 0; i < sim.N; i++) {
const dx = cx - sim.X[i], dy = cy - sim.Y[i];
const r = Math.hypot(dx, dy);
const bk = binOf(r);
const Mr = cumMass[bk] - binMass[bk] * 0.5; // 빈 중간값 근사
const d3 = Math.pow(r * r + SOFT2, 1.5);
const f = G * Mr / d3;
AX[i] = dx * f; AY[i] = dy * f;
}
}
function stepLeap(): void {
// leapfrog — 격자 근사 가속도로도 절반 스텝 대칭으로 밀어 에너지를 보존한다
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
if (!aReadyRef.current) { accel(); aReadyRef.current = true; }
const h = DT * 0.5;
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * h; sim.VY[i] += AY[i] * h;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
accel();
for (let j = 0; j < sim.N; j++) {
sim.VX[j] += AX[j] * h; sim.VY[j] += AY[j] * h;
}
}
function seed(): void {
// 원반 — 속도는 헤일로 ON 프로파일 기준 원궤도(플랫에 가깝게)로 심는다.
// OFF 구간에서는 결합이 못 미쳐 바깥 별이 팽창한다(토글 차이가 곧바로 보인다)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.MASS = []; sim.N = 0;
const rnd = mulberry32(5);
const W = wRef.current, H = hRef.current;
const cx = W * 0.5, cy = H * 0.5;
const R = Math.min(W, H) * 0.40;
rmaxRef.current = R * 1.6;
for (let i = 0; i < nstarsRef.current; i++) {
const r = 0.10 * R + R * 0.90 * Math.sqrt(rnd());
const a = rnd() * Math.PI * 2;
sim.MASS.push(0.5 + rnd() * 0.8);
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r * 0.88);
sim.N++;
}
buildHalo();
// 속도는 위치가 다 심어진 뒤 M(r) 격자로 계산(누적 질량 기준 원궤도)
HALORef.current = true; accel(); HALORef.current = false;
for (let i = 0; i < sim.N; i++) {
const dx = sim.X[i] - cx, dy = sim.Y[i] - cy;
const v = Math.sqrt(Math.max(AX[i] * -dx + AY[i] * -dy, 1e-6) * Math.hypot(dx, dy));
const tl = Math.hypot(dx, dy) || 1;
sim.VX.push(-dy / tl * v + (rnd() - 0.5) * 1.2);
sim.VY.push(dx / tl * v * 0.88 + (rnd() - 0.5) * 1.2);
}
aReadyRef.current = false;
}
function decay(): void {
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.020)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function stamp(): void {
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
const r = Math.hypot(sim.X[i] - W * 0.5, sim.Y[i] - H * 0.5);
chainCtx.strokeStyle = rgba(r < Math.min(W, H) * 0.16 ? col.hot : col.glow, 0.24);
chainCtx.lineWidth = 1.2;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(6);
ctx.fillStyle = rgba(col.glow, 0.4);
for (let k = 0; k < 34; k++) {
ctx.globalAlpha = 0.10 + rnd() * 0.20;
ctx.beginPath(); ctx.arc(rnd() * W, rnd() * H, rnd() * 0.7 + 0.3, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 코어는 파스텔 앰버, 바깥은 파스텔 페리윙클. 글로우 두 겹
const cx = W * 0.5, cy = H * 0.5, R = Math.min(W, H);
for (let i = 0; i < sim.N; i++) {
const r = Math.hypot(sim.X[i] - cx, sim.Y[i] - cy);
const c = r < R * 0.15 ? col.hot : col.glow;
ctx.fillStyle = rgba(c, 0.12);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 3.4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(c, 0.6 + 0.35 * (1 - r / (R * 0.42)));
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 1.3, 0, Math.PI * 2); ctx.fill();
}
// 헤일로 켜짐 — 보이지 않는 질량의 반경 스케일을 옅은 링으로만 암시한다
if (HALORef.current) {
ctx.strokeStyle = rgba(col.tail, 0.30);
ctx.lineWidth = 1;
ctx.setLineDash([3, 5]);
ctx.beginPath(); ctx.arc(cx, cy, R * 0.52, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
}
// 회전 곡선 인셋 — 빈별 평균 접선속도 실측. OFF: 하강 / ON: 플랫
const gx0 = W - 104, gy0 = 12, gw = 88, gh = 62;
ctx.fillStyle = rgba(col.ink, 0.78);
ctx.fillRect(gx0, gy0, gw, gh);
ctx.strokeStyle = rgba(col.glow, 0.35);
ctx.lineWidth = 1;
ctx.strokeRect(gx0 + 0.5, gy0 + 0.5, gw, gh);
const vsum: number[] = [], vcnt: number[] = [];
for (let k = 0; k < K; k++) { vsum[k] = 0; vcnt[k] = 0; }
let vmax = 1;
for (let i = 0; i < sim.N; i++) {
const dx = sim.X[i] - cx, dy = sim.Y[i] - cy;
const vt = Math.abs(-dy * sim.VX[i] + dx * sim.VY[i]) / Math.max(Math.hypot(dx, dy), 1);
const bk = binOf(Math.hypot(dx, dy));
vsum[bk] += vt; vcnt[bk]++;
if (vt > vmax) vmax = vt;
}
ctx.strokeStyle = rgba(HALORef.current ? col.tail : col.glow, 0.95);
ctx.lineWidth = 1.6;
ctx.beginPath();
let started = false;
for (let k = 2; k < K; k++) {
if (!vcnt[k]) continue;
const vx = gx0 + 6 + (k / K) * (gw - 12);
const vy = gy0 + gh - 8 - (vsum[k] / vcnt[k] / vmax) * (gh - 18);
if (!started) { ctx.moveTo(vx, vy); started = true; }
ctx.lineTo(vx, vy);
}
ctx.stroke();
ctx.fillStyle = rgba(col.hot, 0.8);
ctx.font = "9px sans-serif";
ctx.fillText(HALORef.current ? "flat" : "kepler", gx0 + 6, gy0 + 11);
}
function setHalo(on: boolean): void {
HALORef.current = on;
setHaloOn(on);
}
function stillFrame(): void {
// reduce — 헤일로 ON 장면까지 적분한 뒤 한 장을 고정한다
setHalo(true);
for (let s = 0; s < 240; s++) { stepLeap(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 6.0; // 6초 루프 — 3초 꺼짐 / 3초 켜짐 자동 전환
if (autoHaloRef.current) setHalo(loop < 3.0);
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { stepLeap(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const field = fieldRef.current;
if (!root || !field) return;
nstarsRef.current = nstars;
boostRef.current = haloboost;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
autoHaloRef.current = true;
// 토글 — 버튼 클릭 시 자동 전환을 끄고 고정. 필드 클릭도 같은 동작(버튼 누름 제외)
function onFieldClick(e: MouseEvent): void {
const target = e.target as HTMLElement;
if (target.closest("button")) return;
autoHaloRef.current = false;
setHalo(!HALORef.current);
}
field.addEventListener("click", onFieldClick);
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null; aReadyRef.current = false;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
field.removeEventListener("click", onFieldClick);
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, haloboost]);
function onHaloBtn(): void {
autoHaloRef.current = false;
setHalo(!HALORef.current);
}
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>보이지 않는 질량 헤일로를 켜면 바깥 별의 회전 속도가 떨어지지 않고 유지됩니다 — 은하 가장자리가 빠른 이유</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<button
className={styles.haloBtn}
type="button"
aria-pressed={haloOn}
onClick={onHaloBtn}
>
<span hidden={haloOn}>{LABEL_OFF}</span>
<span hidden={!haloOn}>{LABEL_ON}</span>
</button>
</div>
<div className={styles.bar}>
<span className={styles.meta}>별 300개 · 격자 질량 근사</span>
<span className={styles.metaDim}>canvas · grid approximation · requestAnimationFrame</span>
</div>
</div>
</main>
);
}04 · 암흑물질 헤일로 — 보이지 않는 질량을 넣자 궤도가 바뀌는 실험 — 측정 누적면적 37.479% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
보이는 원반 바깥에 그리지 않는 질량 분포를 얹으면 외곽 별들의 회전 속도가 떨어지지 않고 유지되는데, 자동 루프가 3초마다 헤일로를 껐다 켜고 버튼으로 고정할 수도 있습니다. 아홉 판 중 이 판만 전 쌍 계산 대신 반경 격자의 구면 누적 질량으로 등방 근사해 남는 계산을 우측 회전 곡선 그래프에 쓰고, 측정은 누적면적 37.479%로 가장 넓고 강도평균 50.5입니다.
보이지 않는 질량 프로파일을 격자에 미리 구워 두는 준비
function buildHalo() {
// 헤일로 프로파일 — 고정 분포(별과 충돌하지 않는 차가운 암흑물질 가정).
// ρ ∝ 1/(r²+rc²) 를 격자 빈에 적분해 담아 둔다(한 번만 계산)
var rc = Math.min(W, H) * 0.15;
var mstar = 0;
for (var i = 0; i < N; i++) mstar += MASS[i];
var raw = [];
for (var k = 0; k < K; k++) {
var r0 = rEdge(k), r1 = rEdge(k + 1);
raw.push((Math.atan(r1 / rc) - Math.atan(r0 / rc)) * rc); // ∫ r²/(r²+rc²) dr 근사 형태
}
var sum = 0;
for (k = 0; k < K; k++) sum += raw[k];
for (k = 0; k < K; k++) haloMass[k] = raw[k] / sum * mstar * HALOBOOST;
}
05합체 잔해 타원 은하 — 나선이 부서져 뭉툭해지는 결말

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>충돌이 지나도 시간을 계속 흐르게 두면 나선팔이 흩어지고 궤도 방향이 뒤섞여 특징 없는 타원체가 남습니다</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="합체 잔해 실험판" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">두 번째 스침부터 시작 · 별 320개</span>
<span class="gx__meta gx__meta--dim">canvas · virial mix · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 합체 잔해 타원 은하(05). 첫 스침 이후 상태에서 시작해 타원체로 안정되는 장면 —
// 나선 팔 형성(02). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(중심 질량 + 별 상호 중력 O(N²) 레인포드 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 합체 잔해 타원 은하(05) — 첫 스침을 지난 시점부터 시간을 흐르게 두는 장면.
// 시작은 두 은하가 이미 한 번 스쳐 꼬리가 늘어선 상태(두 번째 스침 직전):
// 시드에서 첫 스침을 미리 적분해 만든다(두 원반을 가깝게 놓고 몇백 스텝 = 첫 스침).
// 이후 화면의 시간이 흐르면 궤도 방향이 뒤섞이고(virial 화) 나선팔 구조가 흍어져
// 특징 없는 타원체가 남는다 — 관측되는 타원 은하가 합체 잔해라는 설명의 재현.
// 계산은 01 과 같은 전 쌍 O(N²) 뉴턴 중력 + leapfrog 적분(근사 아님).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 320; // 두 은하 합계 별 수
var PREPASS = 420; // 시작 전 미리 흘려 둘 스텝(첫 스침+꼬리 형성) — 변형 "시작 시점"
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
var N = 0, X = [], Y = [], VX = [], VY = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv100pct();
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
function cv100pct() { cv.style.width = '100%'; cv.style.height = '100%'; }
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 140.0, SOFT2 = 16;
var DT = 1 / 60;
var AX = [], AY = [];
function accel() {
// 전 쌍 O(N²) 뉴턴 중력 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
var i, j, dx, dy, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
var aReady = false;
function stepLeap() {
// leapfrog — 절반 스텝 대칭 킥으로 에너지를 오래 보존(장면이 몇 초 흐러도 부풀지 않는다)
if (!aReady) { accel(); aReady = true; }
var h = DT * 0.5;
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
accel();
for (var j = 0; j < N; j++) {
VX[j] += AX[j] * h; VY[j] += AY[j] * h;
}
}
function placeDisk(cx, cy, R, count, phase, dvx, dvy, dir, rnd, sc) {
// 원반 — r² 균등 면밀도, 원궤도 속도(01 과 동일 결정론 시더)
var M = R * R * 0.90;
for (var i = 0; i < count; i++) {
var r = R * Math.sqrt(rnd());
var a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
var v = Math.sqrt(M / Math.max(r, 6)) * dir;
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r);
VX.push(dvx * sc * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
VY.push(dvy * sc * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
N++;
}
}
function seed() {
// 두 원반을 첫 스침 직전 거리(가깝게)에 놓고 PREPASS 스텝 미리 적분 —
// 화면의 t=0 은 이미 한 번 스치고 꼬리가 늘어선 두 번째 스침 직전 상태다
X = []; Y = []; VX = []; VY = []; N = 0; aReady = false;
var rnd = mulberry32(9);
var half = NSTARS >> 1;
var R = Math.min(W, H) * 0.44;
var sc = R;
placeDisk(W * 0.36, H * 0.34, R, half, 0, 0.62, -0.42, 1.0, rnd, sc);
placeDisk(W * 0.64, H * 0.66, R, NSTARS - half, Math.PI, -0.62, 0.42, 0.85, rnd, sc);
for (var s = 0; s < PREPASS; s++) stepLeap();
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function decay() {
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.012)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
if (prev) {
for (var i = 0; i < N; i++) {
chainCtx.strokeStyle = rgba(i < (N >> 1) ? C_GLOW : C_TAIL, 0.30);
chainCtx.lineWidth = 1.9;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
function draw() {
var ctx = cv.getContext('2d'); // CanvasRenderingContext2D — 이 데모의 모든 그리기 문맥
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
var rnd = mulberry32(10);
ctx.fillStyle = rgba(C_GLOW, 0.35);
for (var k = 0; k < 40; k++) {
var bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 타원체 잔해 중심은 앰버 코어. ellipse 글로우를 겹쳐 은하 전체 윤곽을 암시한다
var cx = 0, cy = 0;
for (i = 0; i < N; i++) { cx += X[i]; cy += Y[i]; }
cx /= N; cy /= N;
var sx = 0, sy = 0;
for (i = 0; i < N; i++) { sx += (X[i] - cx) * (X[i] - cx); sy += (Y[i] - cy) * (Y[i] - cy); }
var rx = Math.sqrt(sx / N) * 1.9, ry = Math.sqrt(sy / N) * 1.9;
ctx.fillStyle = rgba(C_HOT, 0.055);
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(C_HOT, 0.05);
ctx.beginPath(); ctx.ellipse(cx, cy, rx * 0.72, ry * 0.72, 0, 0, Math.PI * 2); ctx.fill();
// 별 점 — 은하 A 페리윙클 · 은하 B 핑크(합쳐지며 섞인다)
for (var i = 0; i < N; i++) {
var col = i < (N >> 1) ? C_GLOW : C_TAIL;
var d = Math.hypot(X[i] - cx, Y[i] - cy);
var near = Math.max(0, 1 - d / (Math.min(W, H) * 0.70));
ctx.fillStyle = rgba(col, 0.10 + near * 0.10);
ctx.beginPath(); ctx.arc(X[i], Y[i], 3.8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col, 0.55 + near * 0.45);
ctx.beginPath(); ctx.arc(X[i], Y[i], 1.7 + near * 1.0, 0, Math.PI * 2); ctx.fill();
}
// 중심부 — 앰버 핵 점(무게중심)
ctx.fillStyle = rgba(C_HOT, 0.95);
ctx.beginPath(); ctx.arc(cx, cy, 2.4, 0, Math.PI * 2); ctx.fill();
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 4.0; // 4초 루프 — 두 번째 스침 → 궤도 뒤섞임 → 타원체
var target = Math.floor(loop / DT);
while (simSteps < target) { stepLeap(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
decay();
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 궤도가 뒤섞여 타원체가 잡히는 장면까지 적분한 뒤 한 장 고정
for (var s = 0; s < 300; s++) { stepLeap(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null; aReady = false;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyMergerRemnant — vanilla 판(run/322/demo/05-galaxy-nbody-merger-remnant/index.html) 의 React 포트.
// 합체 잔해 타원 은하 — 첫 스침을 지난 시점부터 시간을 흐르게 두는 장면.
// 시작은 두 은하가 이미 한 번 스쳐 꼬리가 늘어선 상태(두 번째 스침 직전):
// 시드에서 첫 스침을 미리 적분해 만든다(두 원반을 가깝게 놓고 몇백 스텝 = 첫 스침).
// 이후 화면의 시간이 흐르면 궤도 방향이 뒤섞이고(virial 화) 나선팔 구조가 흍어져
// 특징 없는 타원체가 남는다 — 관측되는 타원 은하가 합체 잔해라는 설명의 재현.
// 계산은 01 과 같은 전 쌍 O(N²) 뉴턴 중력 + leapfrog 적분(근사 아님).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodyMergerRemnant.module.scss";
export type GalaxyNbodyMergerRemnantProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 두 은하 합계 별 수
prepass?: number; // 시작 전 미리 흘려 둘 스텝(첫 스침+꼬리 형성) — 변형 "시작 시점"
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Sim2D = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[] };
const FIELD_LABEL = "합체 잔해 실험판";
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 140.0, SOFT2 = 16;
const DT = 1 / 60;
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodyMergerRemnant({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 320,
prepass = 420
}: GalaxyNbodyMergerRemnantProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const prepassRef = useRef(prepass) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[] }) as Box<Sim2D>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const aReadyRef = useRef(false) as Box<boolean>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
function accel(): void {
// 전 쌍 O(N²) 뉴턴 중력 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
const N = sim.N;
let i: number, j: number, dx: number, dy: number, d2: number, inv: number, f: number;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = sim.X[j] - sim.X[i]; dy = sim.Y[j] - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
function stepLeap(): void {
// leapfrog — 절반 스텝 대칭 킥으로 에너지를 오래 보존(장면이 몇 초 흐러도 부풀지 않는다)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
if (!aReadyRef.current) { accel(); aReadyRef.current = true; }
const h = DT * 0.5;
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * h; sim.VY[i] += AY[i] * h;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
accel();
for (let j = 0; j < sim.N; j++) {
sim.VX[j] += AX[j] * h; sim.VY[j] += AY[j] * h;
}
}
function placeDisk(cx: number, cy: number, R: number, count: number, phase: number, dvx: number, dvy: number, dir: number, rnd: () => number, sc: number): void {
// 원반 — r² 균등 면밀도, 원궤도 속도(01 과 동일 결정론 시더)
const sim = simRef.current;
const M = R * R * 0.90;
for (let i = 0; i < count; i++) {
const r = R * Math.sqrt(rnd());
const a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
const v = Math.sqrt(M / Math.max(r, 6)) * dir;
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r);
sim.VX.push(dvx * sc * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
sim.VY.push(dvy * sc * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
sim.N++;
}
}
function seed(): void {
// 두 원반을 첫 스침 직전 거리(가깝게)에 놓고 PREPASS 스텝 미리 적분 —
// 화면의 t=0 은 이미 한 번 스치고 꼬리가 늘어선 두 번째 스침 직전 상태다
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.N = 0;
aReadyRef.current = false;
const rnd = mulberry32(9);
const half = nstarsRef.current >> 1;
const W = wRef.current, H = hRef.current;
const R = Math.min(W, H) * 0.44;
const sc = R;
placeDisk(W * 0.36, H * 0.34, R, half, 0, 0.62, -0.42, 1.0, rnd, sc);
placeDisk(W * 0.64, H * 0.66, R, nstarsRef.current - half, Math.PI, -0.62, 0.42, 0.85, rnd, sc);
for (let s = 0; s < prepassRef.current; s++) stepLeap();
}
function decay(): void {
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.012)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function stamp(): void {
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
chainCtx.strokeStyle = rgba(i < (sim.N >> 1) ? col.glow : col.tail, 0.30);
chainCtx.lineWidth = 1.9;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(10);
ctx.fillStyle = rgba(col.glow, 0.35);
for (let k = 0; k < 40; k++) {
const bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 타원체 잔해 중심은 앰버 코어. ellipse 글로우를 겹쳐 은하 전체 윤곽을 암시한다
let cx = 0, cy = 0;
for (let i = 0; i < sim.N; i++) { cx += sim.X[i]; cy += sim.Y[i]; }
cx /= sim.N; cy /= sim.N;
let sx = 0, sy = 0;
for (let i = 0; i < sim.N; i++) { sx += (sim.X[i] - cx) * (sim.X[i] - cx); sy += (sim.Y[i] - cy) * (sim.Y[i] - cy); }
const rx = Math.sqrt(sx / sim.N) * 1.9, ry = Math.sqrt(sy / sim.N) * 1.9;
ctx.fillStyle = rgba(col.hot, 0.055);
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col.hot, 0.05);
ctx.beginPath(); ctx.ellipse(cx, cy, rx * 0.72, ry * 0.72, 0, 0, Math.PI * 2); ctx.fill();
// 별 점 — 은하 A 페리윙클 · 은하 B 핑크(합쳐지며 섞인다)
for (let i = 0; i < sim.N; i++) {
const c = i < (sim.N >> 1) ? col.glow : col.tail;
const d = Math.hypot(sim.X[i] - cx, sim.Y[i] - cy);
const near = Math.max(0, 1 - d / (Math.min(W, H) * 0.70));
ctx.fillStyle = rgba(c, 0.10 + near * 0.10);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 3.8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(c, 0.55 + near * 0.45);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 1.7 + near * 1.0, 0, Math.PI * 2); ctx.fill();
}
// 중심부 — 앰버 핵 점(무게중심)
ctx.fillStyle = rgba(col.hot, 0.95);
ctx.beginPath(); ctx.arc(cx, cy, 2.4, 0, Math.PI * 2); ctx.fill();
}
function stillFrame(): void {
// reduce — 궤도가 뒤섞여 타원체가 잡히는 장면까지 적분한 뒤 한 장 고정
for (let s = 0; s < 300; s++) { stepLeap(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 4.0; // 4초 루프 — 두 번째 스침 → 궤도 뒤섞임 → 타원체
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { stepLeap(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
prepassRef.current = prepass;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null; aReadyRef.current = false;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, prepass]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>충돌이 지나도 시간을 계속 흐르게 두면 나선팔이 흩어지고 궤도 방향이 뒤섞여 특징 없는 타원체가 남습니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>두 번째 스침부터 시작 · 별 320개</span>
<span className={styles.metaDim}>canvas · virial mix · requestAnimationFrame</span>
</div>
</div>
</main>
);
}05 · 합체 잔해 타원 은하 — 나선이 부서져 뭉툭해지는 결말 — 측정 누적면적 32.382% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
두 원반을 첫 스침 직전 거리에 놓고 420스텝을 미리 적분해 화면의 시작이 이미 한 번 스치고 꼬리가 늘어선 상태가 되게 하고, 시간이 더 흐르면 궤도 방향이 뒤섞여 나선팔이 흩어지고 특징 없는 타원체가 남는 스토리텔링 씬·우주 콘텐츠 히어로의 결말 장면입니다. 측정은 누적면적 32.382%·강도평균 48.4입니다.
시작 장면을 만들기 위해 미리 흘려 두는 사전 적분
function seed() {
// 두 원반을 첫 스침 직전 거리(가깝게)에 놓고 PREPASS 스텝 미리 적분 —
// 화면의 t=0 은 이미 한 번 스치고 꼬리가 늘어선 두 번째 스침 직전 상태다
X = []; Y = []; VX = []; VY = []; N = 0; aReady = false;
var rnd = mulberry32(9);
var half = NSTARS >> 1;
var R = Math.min(W, H) * 0.44;
var sc = R;
placeDisk(W * 0.36, H * 0.34, R, half, 0, 0.62, -0.42, 1.0, rnd, sc);
placeDisk(W * 0.64, H * 0.66, R, NSTARS - half, Math.PI, -0.62, 0.42, 0.85, rnd, sc);
for (var s = 0; s < PREPASS; s++) stepLeap();
}
06조석 꼬리 클로즈업 — 별 하나의 궤적으로 보는 뿜어짐

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>수천 별 중 한 별만 궤적을 남기면, 꼬리가 만들어지는 순간 그 별이 두 중심 사이를 왕복하다 던져집니다</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="조석 꼬리 클로즈업 실험판" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">별 340개 · 추적 1개 궤적</span>
<span class="gx__meta gx__meta--dim">canvas · one-star trail · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 조석 꼬리 클로즈업(06). 한 별만 굵은 궤적으로 추적하는 장면 —
// 나선 팔 형성(02). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(중심 질량 + 별 상호 중력 O(N²) 레인포드 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 조석 꼬리 클로즈업(06) — 수천 별 중 딱 한 별의 궤적만 굵게 남기는 장면.
// 계산은 01 과 같은 전 쌍 O(N²) 뉴턴 중력 + leapfrog(근사 아님).
// 다른 점은 렌더 뿐이다:
// - 다른 별은 흐린 점 + 짧은 잔상(집단의 흐름만 보인다)
// - 추적 별(TRACK 인덱스, 원반 바깥쪽에 심는다)은 굵은 앰버 궤적을
// 별도 트레일 버퍼(좌표 180개 링)에 쌓아 lineTo 로 이어 그린다 —
// 두 은하 중심 사이를 왕복(Lissajous)하다 조석 킥에 던져지는 과정이 한 줄로 드러난다.
// "꼬리는 별 하나하나의 궤도가 아니라 궤도의 집합"이라는 08 의 전 단계 시연.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 340; // 두 은하 합계 별 수
var TRAILN = 180; // 추적 별 궤적 길이(프레임) — 변형 "궤적 길이"가 180→360으로
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
var N = 0, X = [], Y = [], VX = [], VY = [];
var TRACK = -1; // 추적 별 인덱스 — seed() 에서 원반 가장자리로 정한다
var trailX = [], trailY = []; // 추적 별 궤적 링 버퍼
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 140.0, SOFT2 = 16;
var DT = 1 / 60;
var AX = [], AY = [];
function accel() {
// 전 쌍 O(N²) 뉴턴 중력 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
var i, j, dx, dy, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
var aReady = false;
function stepLeap() {
if (!aReady) { accel(); aReady = true; }
var h = DT * 0.5;
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
accel();
for (var j = 0; j < N; j++) {
VX[j] += AX[j] * h; VY[j] += AY[j] * h;
}
}
function placeDisk(cx, cy, R, count, phase, dvx, dvy, dir, rnd, sc) {
var M = R * R * 0.90;
var start = N;
for (var i = 0; i < count; i++) {
var r = R * Math.sqrt(rnd());
var a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
var v = Math.sqrt(M / Math.max(r, 6)) * dir;
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r);
VX.push(dvx * sc * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
VY.push(dvy * sc * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
N++;
}
// 추적 별 — 첫 원반 바깥쪽 마지막 별(조석 꼬리가 먼저 잡히는 위치)
if (TRACK < 0 && start === 0) TRACK = N - 1;
}
function seed() {
X = []; Y = []; VX = []; VY = []; N = 0; aReady = false;
TRACK = -1; trailX = []; trailY = [];
var rnd = mulberry32(13);
var half = NSTARS >> 1;
var R = Math.min(W, H) * 0.46;
var sc = R;
placeDisk(W * 0.26, H * 0.30, R, half, 0, 0.62, -0.42, 1.0, rnd, sc);
placeDisk(W * 0.74, H * 0.70, R, NSTARS - half, Math.PI, -0.62, 0.42, 0.85, rnd, sc);
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function decay() {
// 집단 잔상은 빨리 지운다(궤적이 아니라 흐름만 남긴다) — 추적 궤적은 trail 버퍼가 기억
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.028)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
if (prev) {
for (var i = 0; i < N; i++) {
if (i === TRACK) continue; // 추적 별은 여기 찍지 않는다(굵은 궤적이 대신한다)
chainCtx.strokeStyle = rgba(i < (N >> 1) ? C_GLOW : C_TAIL, 0.26);
chainCtx.lineWidth = 1.6;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
// 추적 궤적 링 버퍼 — 오래된 좌표는 밀려난다
trailX.push(X[TRACK]); trailY.push(Y[TRACK]);
if (trailX.length > TRAILN) { trailX.shift(); trailY.shift(); }
}
function draw() {
var ctx = cv.getContext('2d'); // CanvasRenderingContext2D — 이 데모의 그리기 문맥
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
var rnd = mulberry32(14);
ctx.fillStyle = rgba(C_GLOW, 0.35);
for (var k = 0; k < 40; k++) {
var bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 추적 궤적 — 굵은 앰버 라인. 오래된 세그먼트일수록 옅게(페이드)
var tn = trailX.length;
if (tn > 1) {
for (var s = 1; s < tn; s++) {
var age = s / tn; // 0 오래됨 → 1 최신
ctx.strokeStyle = rgba(C_HOT, 0.10 + age * 0.75);
ctx.lineWidth = 0.8 + age * 1.9;
ctx.beginPath();
ctx.moveTo(trailX[s - 1], trailY[s - 1]);
ctx.lineTo(trailX[s], trailY[s]);
ctx.stroke();
}
}
// 별 — 집단은 흐린 점, 추적 별은 큰 앰버 글로우 + 코어
for (var i = 0; i < N; i++) {
if (i === TRACK) continue;
var col = i < (N >> 1) ? C_GLOW : C_TAIL;
ctx.fillStyle = rgba(col, 0.11);
ctx.beginPath(); ctx.arc(X[i], Y[i], 3.0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col, 0.58);
ctx.beginPath(); ctx.arc(X[i], Y[i], 1.5, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = rgba(C_HOT, 0.16);
ctx.beginPath(); ctx.arc(X[TRACK], Y[TRACK], 7.5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(C_HOT, 0.55);
ctx.beginPath(); ctx.arc(X[TRACK], Y[TRACK], 3.6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(C_HOT, 1.0);
ctx.beginPath(); ctx.arc(X[TRACK], Y[TRACK], 1.6, 0, Math.PI * 2); ctx.fill();
// 은하 B 코어 표식
ctx.fillStyle = rgba(C_GLOW, 0.85);
ctx.beginPath(); ctx.arc(X[N >> 1], Y[N >> 1], 2.0, 0, Math.PI * 2); ctx.fill();
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 3.0; // 3초 루프 — 스침 → 왕복 → 킥 순간 반복
var target = Math.floor(loop / DT);
while (simSteps < target) { stepLeap(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
decay();
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 킥 직후까지 적분한 뒤 한 장 고정
for (var s = 0; s < 170; s++) { stepLeap(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null; aReady = false;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyTidalTail — vanilla 판(run/322/demo/06-galaxy-nbody-tidal-tail/index.html) 의 React 포트.
// 조석 꼬리 클로즈업 — 수천 별 중 딱 한 별의 궤적만 굵게 남기는 장면.
// 계산은 01 과 같은 전 쌍 O(N²) 뉴턴 중력 + leapfrog(근사 아님).
// 다른 점은 렌더 뿐이다:
// - 다른 별은 흐린 점 + 짧은 잔상(집단의 흐름만 보인다)
// - 추적 별(TRACK 인덱스, 원반 바깥쪽에 심는다)은 굵은 앰버 궤적을
// 별도 트레일 버퍼(좌표 180개 링)에 쌓아 lineTo 로 이어 그린다 —
// 두 은하 중심 사이를 왕복(Lissajous)하다 조석 킥에 던져지는 과정이 한 줄로 드러난다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodyTidalTail.module.scss";
export type GalaxyNbodyTidalTailProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 두 은하 합계 별 수
trailn?: number; // 추적 별 궤적 길이(프레임) — 변형 "궤적 길이"가 180→360으로
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Sim2D = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[] };
const FIELD_LABEL = "조석 꼬리 클로즈업 실험판";
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 140.0, SOFT2 = 16;
const DT = 1 / 60;
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodyTidalTail({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 340,
trailn = 180
}: GalaxyNbodyTidalTailProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const trailnRef = useRef(trailn) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[] }) as Box<Sim2D>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const aReadyRef = useRef(false) as Box<boolean>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
const trackRef = useRef(-1) as Box<number>; // 추적 별 인덱스 — seed() 에서 원반 가장자리로 정한다
const trailXRef = useRef<number[]>([]) as Box<number[]>;
const trailYRef = useRef<number[]>([]) as Box<number[]>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
function accel(): void {
// 전 쌍 O(N²) 뉴턴 중력 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
const N = sim.N;
let i: number, j: number, dx: number, dy: number, d2: number, inv: number, f: number;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = sim.X[j] - sim.X[i]; dy = sim.Y[j] - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
function stepLeap(): void {
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
if (!aReadyRef.current) { accel(); aReadyRef.current = true; }
const h = DT * 0.5;
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * h; sim.VY[i] += AY[i] * h;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
accel();
for (let j = 0; j < sim.N; j++) {
sim.VX[j] += AX[j] * h; sim.VY[j] += AY[j] * h;
}
}
function placeDisk(cx: number, cy: number, R: number, count: number, phase: number, dvx: number, dvy: number, dir: number, rnd: () => number, sc: number): void {
const sim = simRef.current;
const M = R * R * 0.90;
const start = sim.N;
for (let i = 0; i < count; i++) {
const r = R * Math.sqrt(rnd());
const a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
const v = Math.sqrt(M / Math.max(r, 6)) * dir;
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r);
sim.VX.push(dvx * sc * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
sim.VY.push(dvy * sc * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
sim.N++;
}
// 추적 별 — 첫 원반 바깥쪽 마지막 별(조석 꼬리가 먼저 잡히는 위치)
if (trackRef.current < 0 && start === 0) trackRef.current = sim.N - 1;
}
function seed(): void {
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.N = 0;
aReadyRef.current = false;
trackRef.current = -1;
trailXRef.current = []; trailYRef.current = [];
const rnd = mulberry32(13);
const half = nstarsRef.current >> 1;
const W = wRef.current, H = hRef.current;
const R = Math.min(W, H) * 0.46;
const sc = R;
placeDisk(W * 0.26, H * 0.30, R, half, 0, 0.62, -0.42, 1.0, rnd, sc);
placeDisk(W * 0.74, H * 0.70, R, nstarsRef.current - half, Math.PI, -0.62, 0.42, 0.85, rnd, sc);
}
function decay(): void {
// 집단 잔상은 빨리 지운다(궤적이 아니라 흐름만 남긴다) — 추적 궤적은 trail 버퍼가 기억
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.028)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function stamp(): void {
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
if (i === trackRef.current) continue; // 추적 별은 여기 찍지 않는다(굵은 궤적이 대신한다)
chainCtx.strokeStyle = rgba(i < (sim.N >> 1) ? col.glow : col.tail, 0.26);
chainCtx.lineWidth = 1.6;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
// 추적 궤적 링 버퍼 — 오래된 좌표는 밀려난다
trailXRef.current.push(sim.X[trackRef.current]);
trailYRef.current.push(sim.Y[trackRef.current]);
if (trailXRef.current.length > trailnRef.current) { trailXRef.current.shift(); trailYRef.current.shift(); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(14);
ctx.fillStyle = rgba(col.glow, 0.35);
for (let k = 0; k < 40; k++) {
const bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 추적 궤적 — 굵은 앰버 라인. 오래된 세그먼트일수록 옅게(페이드)
const trailX = trailXRef.current, trailY = trailYRef.current;
const tn = trailX.length;
if (tn > 1) {
for (let s = 1; s < tn; s++) {
const age = s / tn; // 0 오래됨 → 1 최신
ctx.strokeStyle = rgba(col.hot, 0.10 + age * 0.75);
ctx.lineWidth = 0.8 + age * 1.9;
ctx.beginPath();
ctx.moveTo(trailX[s - 1], trailY[s - 1]);
ctx.lineTo(trailX[s], trailY[s]);
ctx.stroke();
}
}
// 별 — 집단은 흐린 점, 추적 별은 큰 앰버 글로우 + 코어
const trk = trackRef.current;
for (let i = 0; i < sim.N; i++) {
if (i === trk) continue;
const c = i < (sim.N >> 1) ? col.glow : col.tail;
ctx.fillStyle = rgba(c, 0.11);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 3.0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(c, 0.58);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 1.5, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = rgba(col.hot, 0.16);
ctx.beginPath(); ctx.arc(sim.X[trk], sim.Y[trk], 7.5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col.hot, 0.55);
ctx.beginPath(); ctx.arc(sim.X[trk], sim.Y[trk], 3.6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col.hot, 1.0);
ctx.beginPath(); ctx.arc(sim.X[trk], sim.Y[trk], 1.6, 0, Math.PI * 2); ctx.fill();
// 은하 B 코어 표식
ctx.fillStyle = rgba(col.glow, 0.85);
ctx.beginPath(); ctx.arc(sim.X[sim.N >> 1], sim.Y[sim.N >> 1], 2.0, 0, Math.PI * 2); ctx.fill();
}
function stillFrame(): void {
// reduce — 킥 직후까지 적분한 뒤 한 장 고정
for (let s = 0; s < 170; s++) { stepLeap(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 3.0; // 3초 루프 — 스침 → 왕복 → 킥 순간 반복
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { stepLeap(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
trailnRef.current = trailn;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null; aReadyRef.current = false;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, trailn]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>수천 별 중 한 별만 궤적을 남기면, 꼬리가 만들어지는 순간 그 별이 두 중심 사이를 왕복하다 던져집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>별 340개 · 추적 1개 궤적</span>
<span className={styles.metaDim}>canvas · one-star trail · requestAnimationFrame</span>
</div>
</div>
</main>
);
}06 · 조석 꼬리 클로즈업 — 별 하나의 궤적으로 보는 뿜어짐 — 측정 누적면적 20.762% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
수백 별 중 원반 가장자리의 딱 한 별만 굵은 앰버 궤적을 남기게 하면 그 별이 두 은하 중심 사이를 왕복하다 조석 킥에 바깥으로 던져지는 개별 궤도가 드러나는데, 궤적은 180프레임짜리 링 버퍼에 쌓아 오래된 세그먼트일수록 옅게 지워가는 데이터 스토리텔링·강조 애니메이션용 레이어입니다. 측정은 누적면적 20.762%·강도평균 55.0입니다.
오래된 궤적일수록 옅어지는 세그먼트 페이드
// 추적 궤적 — 굵은 앰버 라인. 오래된 세그먼트일수록 옅게(페이드)
var tn = trailX.length;
if (tn > 1) {
for (var s = 1; s < tn; s++) {
var age = s / tn; // 0 오래됨 → 1 최신
ctx.strokeStyle = rgba(C_HOT, 0.10 + age * 0.75);
ctx.lineWidth = 0.8 + age * 1.9;
ctx.beginPath();
ctx.moveTo(trailX[s - 1], trailY[s - 1]);
ctx.lineTo(trailX[s], trailY[s]);
ctx.stroke();
}
}
07혜성 쏘기 — 드래그 각도·세기로 별을 직접 던져 넣기

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>은하 위를 드래그해 새 별을 던져 넣어 보세요. 각도에 따라 궤도에 잡히기도, 그대로 탈출하기도 합니다</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="혜성 쏘기 실험판" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
<span class="gx__aim" id="aimLbl" hidden>드래그로 발사</span>
</div>
<div class="gx__bar">
<span class="gx__meta">별 180개 · 끌어당기는 중심 질량</span>
<span class="gx__meta gx__meta--dim">canvas · drag to launch · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 혜성 쏘기(07). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(중심 질량 인력 + 발사체 O(N²) 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 혜성 쏘기(07) — 포인터로 발사체를 던져 넣는 조작 장면.
// 무대: 원반 은하(중심 질량 + 별 180개). 드래그(pointerdown → pointermove → pointerup)로
// 새 혜성을 만들어 던진다 — 놓은 지점에서 당긴 반대 방향으로 slingshot 발사.
// 혜성은 같은 O(N²) 중력장에 그대로 들어간다: 각도·속도에 따라
// · 궤도에 잡혀 타원 궤도(중심 질량 지배) — 첫 스침 후 포획
// · 스침이 살짝 어긋나 그대로 탈출(쌍곡선) — 중력 도우거림
// is-demo(렌더)에서는 입력 없이도 3발이 정해진 각도로 자동 발사된다(결정론 렌더).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 180; // 원반 별 수
var LAUNCHV = 0.55; // 발사 속도 배율(캔버스 대각선 비례) — 변형 "발사 속도"
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
var aimLbl = document.getElementById('aimLbl');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
var N = 0, X = [], Y = [], VX = [], VY = [];
// 혜성은 별 배열에 그대로 추가된다(같은 O(N²) 계산 — 특별 대우 없음)
var NC0 = 0; // 원반 별 수 고정(혜성 이전)
var comets = []; // {x, y, vx, vy, born, trail: []}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 60.0, SOFT2 = 40;
var DT = 1 / 60;
var MHALO = 5400; // 중심 질량 — 원반을 지탱하는 핵(그리지 않는다)
var AX = [], AY = [];
function accel() {
// 중심 질량 + 전 쌍 O(N²) 별 상호 중력(혜성 포함 — 배열 뒤에 추가돼 있다)
var i, j, dx, dy, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
var cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < N; i++) {
dx = cx - X[i]; dy = cy - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MHALO * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
}
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
var aReady = false;
function stepLeap() {
if (!aReady) { accel(); aReady = true; }
var h = DT * 0.5;
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
accel();
for (var j = 0; j < N; j++) {
VX[j] += AX[j] * h; VY[j] += AY[j] * h;
}
}
function seed() {
X = []; Y = []; VX = []; VY = []; N = 0; aReady = false; comets = [];
var rnd = mulberry32(17);
var cx = W * 0.5, cy = H * 0.5;
var R = Math.min(W, H) * 0.46;
for (var i = 0; i < NSTARS; i++) {
var far = rnd() < 0.38; // 바깥 별 — 원반 밖까지 오가는 넓은 궤도
var r = far
? R * (0.46 + 1.75 * rnd())
: 0.12 * R + R * 0.80 * Math.sqrt(rnd());
var a = rnd() * Math.PI * 2;
var v = Math.sqrt(G * MHALO / Math.max(r, 8)) * (far ? 0.80 : 1.0);
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r * 0.90);
VX.push(-Math.sin(a) * v + (rnd() - 0.5) * 1.4);
VY.push( Math.cos(a) * v * 0.90 + (rnd() - 0.5) * 1.4);
N++;
}
NC0 = N;
}
function addComet(px, py, vx, vy, born) {
// 혜성 — 별 배열 뒤에 추가(같은 중력 계산). 오래된 혜성은 되감을 때 같이 지운다
X.push(px); Y.push(py); VX.push(vx); VY.push(vy); N++;
aReady = false; // AX/AY 버퍼가 이전 크기(혜성 이전)다 — 재계산 전에 쓰면 undefined*NaN 전파
if (prev) { prev[0].push(px); prev[1].push(py); } // 잔상 이전점 배열도 같은 길이로
comets.push({ idx: N - 1, born: born, trail: [] });
if (comets.length > 4) {
var old = comets.shift();
removeStar(old.idx);
}
}
function removeStar(idx) {
// 배열 압축 — 인덱스가 뒤로 밀리는 혜성 참조를 같이 고친다
X.splice(idx, 1); Y.splice(idx, 1); VX.splice(idx, 1); VY.splice(idx, 1); N--;
aReady = false;
if (prev) { prev[0].splice(idx, 1); prev[1].splice(idx, 1); }
for (var c = 0; c < comets.length; c++) {
if (comets[c].idx > idx) comets[c].idx--;
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function decay() {
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.013)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
if (prev) {
for (var i = 0; i < N; i++) {
var isComet = i >= NC0;
chainCtx.strokeStyle = rgba(isComet ? C_HOT : C_GLOW, isComet ? 0.55 : 0.24);
chainCtx.lineWidth = isComet ? 2.6 : 1.5;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
function draw(aim) {
var ctx = cv.getContext('2d'); // CanvasRenderingContext2D — 이 데모의 그리기 문맥
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
var rnd = mulberry32(18);
ctx.fillStyle = rgba(C_GLOW, 0.35);
for (var k = 0; k < 40; k++) {
var bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 원반 별 — 페리윙클 점
for (var i = 0; i < NC0; i++) {
ctx.fillStyle = rgba(C_GLOW, 0.11);
ctx.beginPath(); ctx.arc(X[i], Y[i], 3.2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(C_GLOW, 0.6);
ctx.beginPath(); ctx.arc(X[i], Y[i], 1.6, 0, Math.PI * 2); ctx.fill();
}
// 중심 질량 자리 — 앰버 점
ctx.fillStyle = rgba(C_HOT, 0.9);
ctx.beginPath(); ctx.arc(W * 0.5, H * 0.5, 2.4, 0, Math.PI * 2); ctx.fill();
// 혜성 — 앰버 큰 글로우 + 코어
for (i = NC0; i < N; i++) {
ctx.fillStyle = rgba(C_HOT, 0.18);
ctx.beginPath(); ctx.arc(X[i], Y[i], 9, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(C_HOT, 0.65);
ctx.beginPath(); ctx.arc(X[i], Y[i], 4.2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(C_HOT, 1);
ctx.beginPath(); ctx.arc(X[i], Y[i], 2.0, 0, Math.PI * 2); ctx.fill();
}
// 조준선 — 드래그 중에만. 당긴 방향 반대로 발사된다(slingshot)
if (aim) {
ctx.strokeStyle = rgba(C_TAIL, 0.9);
ctx.lineWidth = 1.4;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(aim.x0, aim.y0);
ctx.lineTo(aim.x1, aim.y1);
ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle = rgba(C_HOT, 0.95);
ctx.beginPath();
ctx.moveTo(aim.x0, aim.y0);
ctx.lineTo(aim.x0 + (aim.x0 - aim.x1) * 2.2, aim.y0 + (aim.y0 - aim.y1) * 2.2);
ctx.stroke();
ctx.fillStyle = rgba(C_HOT, 0.9);
ctx.beginPath(); ctx.arc(aim.x0, aim.y0, 3, 0, Math.PI * 2); ctx.fill();
}
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
// ── 포인터 발사 — pointerdown → pointermove(조준) → pointerup(발사) ──
var aim = null;
function ptr(e) {
var r = cv.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
field.addEventListener('pointerdown', function (e) {
var p = ptr(e);
aim = { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
aimLbl.hidden = false;
aimLbl.textContent = '드래그로 발사';
e.preventDefault();
});
field.addEventListener('pointermove', function (e) {
if (!aim) return;
var p = ptr(e);
aim.x1 = p.x; aim.y1 = p.y;
});
field.addEventListener('pointerup', function (e) {
if (!aim) return;
var p = ptr(e);
var dx = aim.x0 - p.x, dy = aim.y0 - p.y; // 당긴 반대 = 발사 방향
var len = Math.hypot(dx, dy);
if (len > 6) {
var sp = Math.min(260, len * 2.2) * LAUNCHV;
addComet(aim.x0, aim.y0, dx / len * sp, dy / len * sp, simSteps);
}
aim = null; aimLbl.hidden = true;
});
field.addEventListener('pointerleave', function () { aim = null; aimLbl.hidden = true; });
var simSteps = 0;
// 자동 발사 스케줄 — is-demo 렌더에서도 장면이 차게 찬다(결정론: 시드·각도 고정)
function autoSchedule() {
return [
{ t: 10, x: 0.04, y: 0.14, ang: 0.48, sp: 1.00 },
{ t: 34, x: 0.96, y: 0.20, ang: 2.35, sp: 0.90 },
{ t: 58, x: 0.48, y: 0.03, ang: 1.20, sp: 0.66 },
{ t: 80, x: 0.08, y: 0.86, ang: 0.80, sp: 1.10 },
{ t: 100, x: 0.92, y: 0.90, ang: 3.70, sp: 0.95 },
{ t: 116, x: 0.30, y: 0.06, ang: 1.60, sp: 0.75 }
];
}
var AUTO = autoSchedule();
function fireAuto() {
var diag = Math.hypot(W, H);
while (AUTO.length && AUTO[0].t <= simSteps) {
var a = AUTO.shift();
var sp = diag * 0.42 * a.sp * LAUNCHV;
addComet(W * a.x, H * a.y, Math.cos(a.ang) * sp, Math.sin(a.ang) * sp, simSteps);
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 3.6; // 3.6초 루프 — 4연발 → 궤도/탈출이 갈리는 순간
var target = Math.floor(loop / DT);
while (simSteps < target) { fireAuto(); stepLeap(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; AUTO = autoSchedule(); chainCtx.clearRect(0, 0, W, H); }
decay();
draw(aim);
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 혜성 2발이 궤도에 오른 장면까지 적분한 뒤 한 장 고정
for (var s = 0; s < 100; s++) { fireAuto(); stepLeap(); simSteps++; stamp(); }
draw(null);
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null; aReady = false;
AUTO = autoSchedule(); // 스케줄 되감기
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyLaunchComet — vanilla 판(run/322/demo/07-galaxy-nbody-launch-comet/index.html) 의 React 포트.
// 혜성 쏘기 — 포인터로 발사체를 던져 넣는 조작 장면.
// 무대: 원반 은하(중심 질량 + 별 180개). 드래그(pointerdown → pointermove → pointerup)로
// 새 혜성을 만들어 던진다 — 놓은 지점에서 당긴 반대 방향으로 slingshot 발사.
// 혜성은 같은 O(N²) 중력장에 그대로 들어간다: 각도·속도에 따라
// · 궤도에 잡혀 타원 궤도(중심 질량 지배) — 첫 스침 후 포획
// · 스침이 살짝 어긋나 그대로 탈출(쌍곡선) — 중력 도우거림
// 포인터 입력이 없어도 6발이 정해진 각도로 자동 발사된다(결정론 렌더).
// ※ 데모 소스의 AX/AY 버퍼 동기 수정(혜성 추가·삭제 시 aReady 리셋)이 이미 반영된 판을 그대로 포트한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as RPointerEvent } from "react";
import styles from "./GalaxyNbodyLaunchComet.module.scss";
export type GalaxyNbodyLaunchCometProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 원반 별 수
launchv?: number; // 발사 속도 배율(캔버스 대각선 비례) — 변형 "발사 속도"
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Sim2D = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[] };
type Aim = { x0: number; y0: number; x1: number; y1: number };
type AutoShot = { t: number; x: number; y: number; ang: number; sp: number };
type Comet = { idx: number; born: number };
const FIELD_LABEL = "혜성 쏘기 실험판";
const AIM_LABEL = "드래그로 발사";
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 60.0, SOFT2 = 40;
const DT = 1 / 60;
const MHALO = 5400; // 중심 질량 — 원반을 지탱하는 핵(그리지 않는다)
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자동 발사 스케줄 — 포인터 입력이 없어도 장면이 차게 찬다(결정론: 시드·각도 고정)
function autoSchedule(): AutoShot[] {
return [
{ t: 10, x: 0.04, y: 0.14, ang: 0.48, sp: 1.00 },
{ t: 34, x: 0.96, y: 0.20, ang: 2.35, sp: 0.90 },
{ t: 58, x: 0.48, y: 0.03, ang: 1.20, sp: 0.66 },
{ t: 80, x: 0.08, y: 0.86, ang: 0.80, sp: 1.10 },
{ t: 100, x: 0.92, y: 0.90, ang: 3.70, sp: 0.95 },
{ t: 116, x: 0.30, y: 0.06, ang: 1.60, sp: 0.75 }
];
}
export default function GalaxyNbodyLaunchComet({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 180,
launchv = 0.55
}: GalaxyNbodyLaunchCometProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const launchvRef = useRef(launchv) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [aiming, setAiming] = useState(false); // 조준 라벨 표시
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[] }) as Box<Sim2D>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const aReadyRef = useRef(false) as Box<boolean>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
const nc0Ref = useRef(0) as Box<number>; // 원반 별 수 고정(혜성 이전)
const cometsRef = useRef<Comet[]>([]) as Box<Comet[]>;
const aimRef = useRef<Aim | null>(null) as Box<Aim | null>;
const autoRef = useRef<AutoShot[]>([]) as Box<AutoShot[]>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
function accel(): void {
// 중심 질량 + 전 쌍 O(N²) 별 상호 중력(혜성 포함 — 배열 뒤에 추가돼 있다)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
const W = wRef.current, H = hRef.current;
const N = sim.N;
let i: number, j: number, dx: number, dy: number, d2: number, inv: number, f: number;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
const cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < N; i++) {
dx = cx - sim.X[i]; dy = cy - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MHALO * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
}
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = sim.X[j] - sim.X[i]; dy = sim.Y[j] - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
function stepLeap(): void {
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
if (!aReadyRef.current) { accel(); aReadyRef.current = true; }
const h = DT * 0.5;
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * h; sim.VY[i] += AY[i] * h;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
accel();
for (let j = 0; j < sim.N; j++) {
sim.VX[j] += AX[j] * h; sim.VY[j] += AY[j] * h;
}
}
function seed(): void {
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.N = 0;
aReadyRef.current = false;
cometsRef.current = [];
const rnd = mulberry32(17);
const W = wRef.current, H = hRef.current;
const cx = W * 0.5, cy = H * 0.5;
const R = Math.min(W, H) * 0.46;
for (let i = 0; i < nstarsRef.current; i++) {
const far = rnd() < 0.38; // 바깥 별 — 원반 밖까지 오가는 넓은 궤도
const r = far
? R * (0.46 + 1.75 * rnd())
: 0.12 * R + R * 0.80 * Math.sqrt(rnd());
const a = rnd() * Math.PI * 2;
const v = Math.sqrt(G * MHALO / Math.max(r, 8)) * (far ? 0.80 : 1.0);
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r * 0.90);
sim.VX.push(-Math.sin(a) * v + (rnd() - 0.5) * 1.4);
sim.VY.push(Math.cos(a) * v * 0.90 + (rnd() - 0.5) * 1.4);
sim.N++;
}
nc0Ref.current = sim.N;
}
function removeStar(idx: number): void {
// 배열 압축 — 인덱스가 뒤로 밀리는 혜성 참조를 같이 고친다
const sim = simRef.current;
const prev = prevRef.current;
sim.X.splice(idx, 1); sim.Y.splice(idx, 1); sim.VX.splice(idx, 1); sim.VY.splice(idx, 1); sim.N--;
aReadyRef.current = false;
if (prev) { prev[0].splice(idx, 1); prev[1].splice(idx, 1); }
for (let c = 0; c < cometsRef.current.length; c++) {
if (cometsRef.current[c].idx > idx) cometsRef.current[c].idx--;
}
}
function addComet(px: number, py: number, vx: number, vy: number, born: number): void {
// 혜성 — 별 배열 뒤에 추가(같은 중력 계산). 오래된 혜성은 되감을 때 같이 지운다
const sim = simRef.current;
const prev = prevRef.current;
sim.X.push(px); sim.Y.push(py); sim.VX.push(vx); sim.VY.push(vy); sim.N++;
aReadyRef.current = false; // AX/AY 버퍼가 이전 크기(혜성 이전)다 — 재계산 전에 쓰면 undefined*NaN 전파
if (prev) { prev[0].push(px); prev[1].push(py); } // 잔상 이전점 배열도 같은 길이로
cometsRef.current.push({ idx: sim.N - 1, born: born });
if (cometsRef.current.length > 4) {
const old = cometsRef.current.shift();
if (old) removeStar(old.idx);
}
}
function decay(): void {
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.013)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function stamp(): void {
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
const isComet = i >= nc0Ref.current;
chainCtx.strokeStyle = rgba(isComet ? col.hot : col.glow, isComet ? 0.55 : 0.24);
chainCtx.lineWidth = isComet ? 2.6 : 1.5;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
const aim = aimRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(18);
ctx.fillStyle = rgba(col.glow, 0.35);
for (let k = 0; k < 40; k++) {
const bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.drawImage(chainCv, 0, 0, W, H);
// 원반 별 — 페리윙클 점
const nc0 = nc0Ref.current;
for (let i = 0; i < nc0; i++) {
ctx.fillStyle = rgba(col.glow, 0.11);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 3.2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col.glow, 0.6);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 1.6, 0, Math.PI * 2); ctx.fill();
}
// 중심 질량 자리 — 앰버 점
ctx.fillStyle = rgba(col.hot, 0.9);
ctx.beginPath(); ctx.arc(W * 0.5, H * 0.5, 2.4, 0, Math.PI * 2); ctx.fill();
// 혜성 — 앰버 큰 글로우 + 코어
for (let i = nc0; i < sim.N; i++) {
ctx.fillStyle = rgba(col.hot, 0.18);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 9, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col.hot, 0.65);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 4.2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col.hot, 1);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 2.0, 0, Math.PI * 2); ctx.fill();
}
// 조준선 — 드래그 중에만. 당긴 방향 반대로 발사된다(slingshot)
if (aim) {
ctx.strokeStyle = rgba(col.tail, 0.9);
ctx.lineWidth = 1.4;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(aim.x0, aim.y0);
ctx.lineTo(aim.x1, aim.y1);
ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle = rgba(col.hot, 0.95);
ctx.beginPath();
ctx.moveTo(aim.x0, aim.y0);
ctx.lineTo(aim.x0 + (aim.x0 - aim.x1) * 2.2, aim.y0 + (aim.y0 - aim.y1) * 2.2);
ctx.stroke();
ctx.fillStyle = rgba(col.hot, 0.9);
ctx.beginPath(); ctx.arc(aim.x0, aim.y0, 3, 0, Math.PI * 2); ctx.fill();
}
}
function fireAuto(): void {
// 자동 발사 — 정해진 시각에 스케줄의 혜성을 만들어 던진다
const W = wRef.current, H = hRef.current;
const diag = Math.hypot(W, H);
const AUTO = autoRef.current;
while (AUTO.length && AUTO[0].t <= simStepsRef.current) {
const a = AUTO.shift();
if (!a) break;
const sp = diag * 0.42 * a.sp * launchvRef.current;
addComet(W * a.x, H * a.y, Math.cos(a.ang) * sp, Math.sin(a.ang) * sp, simStepsRef.current);
}
}
// ── 포인터 발사 — pointerdown → pointermove(조준) → pointerup(발사) ──
function ptr(e: RPointerEvent<HTMLDivElement>): { x: number; y: number } {
const cv = cvRef.current;
if (!cv) return { x: 0, y: 0 };
const r = cv.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function onPointerDown(e: RPointerEvent<HTMLDivElement>): void {
const p = ptr(e);
aimRef.current = { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
setAiming(true);
e.preventDefault();
}
function onPointerMove(e: RPointerEvent<HTMLDivElement>): void {
const aim = aimRef.current;
if (!aim) return;
const p = ptr(e);
aim.x1 = p.x; aim.y1 = p.y;
}
function onPointerUp(e: RPointerEvent<HTMLDivElement>): void {
const aim = aimRef.current;
if (!aim) return;
const p = ptr(e);
const dx = aim.x0 - p.x, dy = aim.y0 - p.y; // 당긴 반대 = 발사 방향
const len = Math.hypot(dx, dy);
if (len > 6) {
const sp = Math.min(260, len * 2.2) * launchvRef.current;
addComet(aim.x0, aim.y0, dx / len * sp, dy / len * sp, simStepsRef.current);
}
aimRef.current = null;
setAiming(false);
}
function onPointerLeave(): void {
aimRef.current = null;
setAiming(false);
}
function stillFrame(): void {
// reduce — 혜성 2발이 궤도에 오른 장면까지 적분한 뒤 한 장 고정
for (let s = 0; s < 100; s++) { fireAuto(); stepLeap(); simStepsRef.current++; stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 3.6; // 3.6초 루프 — 4연발 → 궤도/탈출이 갈리는 순간
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { fireAuto(); stepLeap(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
autoRef.current = autoSchedule();
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
launchvRef.current = launchv;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
autoRef.current = autoSchedule();
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null; aReadyRef.current = false;
autoRef.current = autoSchedule(); // 스케줄 되감기
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, launchv]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>은하 위를 드래그해 새 별을 던져 넣어 보세요. 각도에 따라 궤도에 잡히기도, 그대로 탈출하기도 합니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerLeave}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<span className={styles.aim} hidden={!aiming}>{AIM_LABEL}</span>
</div>
<div className={styles.bar}>
<span className={styles.meta}>별 180개 · 끌어당기는 중심 질량</span>
<span className={styles.metaDim}>canvas · drag to launch · requestAnimationFrame</span>
</div>
</div>
</main>
);
}07 · 혜성 쏘기 — 드래그 각도·세기로 별을 직접 던져 넣기 — 측정 누적면적 26.731% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
원반 은하가 있는 캔버스를 드래그해 당긴 방향의 반대로 새 별을 던져 넣으면 각도에 따라 궤도에 잡히기도 그대로 탈출하기도 하는, 중력이 진짜 계산이라는 걸 손으로 확인하는 대표 인터랙션입니다. 조준은 pointerdown·pointermove·pointerup 세 단계로 이루어지고 측정은 누적면적 26.731%·강도평균 55.3입니다.
당긴 반대 방향으로 쏘는 포인터업 처리
field.addEventListener('pointerup', function (e) {
if (!aim) return;
var p = ptr(e);
var dx = aim.x0 - p.x, dy = aim.y0 - p.y; // 당긴 반대 = 발사 방향
var len = Math.hypot(dx, dy);
if (len > 6) {
var sp = Math.min(260, len * 2.2) * LAUNCHV;
addComet(aim.x0, aim.y0, dx / len * sp, dy / len * sp, simSteps);
}
aim = null; aimLbl.hidden = true;
});
08궤적 히스토리 — 잔상으로 쌓이는 별의 과거

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>지난 프레임을 알파로 깔아 두면 궤도가 부드러운 선으로 쌓이고, 나선팔이 한 장이 아니라 궤도의 누적임이 드러납니다</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="궤적 히스토리 실험판" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">별 300개 · 잔상 누적 레이어</span>
<span class="gx__meta gx__meta--dim">canvas · globalAlpha history · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 궤적 히스토리(08). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(별 상호 중력 O(N²) 레인포드 적분 → 잔상 누적)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 궤적 히스토리(08) — 잔상을 "지우는" 게 아니라 "쌓는" 장면.
// 01~07 의 잔상 체인은 destination-out 으로 매 프레임 조금씩 지웠다(흐름만 남긴다).
// 여기선 정반대: 배경 위에 지난 프레임을 globalAlpha 로 깔아 두기만 한다 —
// decay 가 없으므로 궤도가 계속 쌓여 나선팔이 "한 장의 팔"이 아니라
// "수백 개 궤도의 누적"임이 시간이 지날수록 드러난다(장면의 주제 그 자체).
// 계산은 02 와 같은 전 쌍 O(N²) + 중심질량 + leapfrog(근사 아님).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 300; // 원반 별 수
var LAYERS = 3; // 잔상 누적 단수(스텝당 깔리는 알파 겹) — 변형 "누적 깊이"
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
var N = 0, X = [], Y = [], VX = [], VY = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 60.0, SOFT2 = 36;
var DT = 1 / 60;
var MHALO = 6200; // 중심 질량(핵+헤일로) — 그리지 않는다. 차등회전의 원천
var AX = [], AY = [];
function accel() {
// 중심 질량 + 전 쌍 O(N²) 뉴턴 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
var i, j, dx, dy, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
var cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < N; i++) {
dx = cx - X[i]; dy = cy - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MHALO * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
}
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
var aReady = false;
function stepLeap() {
if (!aReady) { accel(); aReady = true; }
var h = DT * 0.5;
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
accel();
for (var j = 0; j < N; j++) {
VX[j] += AX[j] * h; VY[j] += AY[j] * h;
}
}
function seed() {
X = []; Y = []; VX = []; VY = []; N = 0; aReady = false;
var rnd = mulberry32(21);
var cx = W * 0.5, cy = H * 0.5;
var R = Math.min(W, H) * 0.46;
for (var i = 0; i < NSTARS; i++) {
var far = rnd() < 0.44; // 상당수 별은 이심 궤도 — 궤적이 원반 밖까지 뻗는다
var r = far
? R * (0.22 + 2.30 * rnd()) // 먼 별: 최대 ~2.5R 바깥까지 오가는 궤도
: 0.10 * R + R * 0.80 * Math.sqrt(rnd());
var a = rnd() * Math.PI * 2 + 0.14 * Math.sin(r * 0.16); // 초기 밀도 요동 — 쌓이면 나선팔
var v = Math.sqrt(G * MHALO / Math.max(r, 8)) * (far ? 0.80 : 1.06); // 원반 살짝 과속 → 궤적이 살짝 벌어진다
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r * 0.94);
VX.push(-Math.sin(a) * v + (rnd() - 0.5) * 1.5);
VY.push( Math.cos(a) * v * 0.94 + (rnd() - 0.5) * 1.5);
N++;
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
// decay 가 없다 — 잔상 체인은 지워지지 않고 계속 쌓인다(이 데모의 주제).
// 대신 루프 되감을 때만 통째로 지운다.
var prev = null;
function stamp() {
// 누적 — 스텝마다 globalAlpha 를 낮게 해 여러 겹 깔면 궤도가 선으로 굳는다
if (prev) {
for (var L = 0; L < LAYERS; L++) { // LAYERS 겹 — 쌓임 속도 변형
chainCtx.globalAlpha = 0.22 / LAYERS * (L + 1);
for (var i = 0; i < N; i++) {
var r = Math.hypot(X[i] - W * 0.5, Y[i] - H * 0.5);
var col = r < Math.min(W, H) * 0.18 ? C_HOT : C_GLOW;
chainCtx.strokeStyle = rgba(col, 0.9);
chainCtx.lineWidth = r < Math.min(W, H) * 0.18 ? 2.6 : 2.1;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
chainCtx.globalAlpha = 1;
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
function draw() {
var ctx = cv.getContext('2d'); // CanvasRenderingContext2D — 이 데모의 그리기 문맥
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
var rnd = mulberry32(23);
ctx.fillStyle = rgba(C_GLOW, 0.35);
for (var k = 0; k < 40; k++) {
var bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
// 누적 레이어 — 지워지지 않은 궤도 전체가 한 번에 얹힌다
ctx.drawImage(chainCv, 0, 0, W, H);
// 현재 별 위치 — 밝은 코어(쌓인 궤도 위 지나가는 "지금")
var R = Math.min(W, H);
for (var i = 0; i < N; i++) {
var r = Math.hypot(X[i] - W * 0.5, Y[i] - H * 0.5);
var col = r < R * 0.16 ? C_HOT : C_GLOW;
ctx.fillStyle = rgba(col, 0.18);
ctx.beginPath(); ctx.arc(X[i], Y[i], 4.4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col, 0.9);
ctx.beginPath(); ctx.arc(X[i], Y[i], 2.4, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = rgba(C_HOT, 0.9);
ctx.beginPath(); ctx.arc(W * 0.5, H * 0.5, 2.2, 0, Math.PI * 2); ctx.fill();
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 5.0; // 5초 루프 — 길게 흘려 궤도가 충분히 쌓이게
var target = Math.floor(loop / DT);
while (simSteps < target) { stepLeap(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 궤도가 쌓여 팔이 드러난 장면까지 적분한 뒤 한 장 고정
for (var s = 0; s < 260; s++) { stepLeap(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null; aReady = false;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyTrailHistory — vanilla 판(run/322/demo/08-galaxy-nbody-trail-history/index.html) 의 React 포트.
// 궤적 히스토리 — 잔상을 "지우는" 게 아니라 "쌓는" 장면.
// 01~07 의 잔상 체인은 destination-out 으로 매 프레임 조금씩 지웠다(흐름만 남긴다).
// 여기선 정반대: 배경 위에 지난 프레임을 globalAlpha 로 깔아 두기만 한다 —
// decay 가 없으므로 궤도가 계속 쌓여 나선팔이 "한 장의 팔"이 아니라
// "수백 개 궤도의 누적"임이 시간이 지날수록 드러난다(장면의 주제 그 자체).
// 계산은 02 와 같은 전 쌍 O(N²) + 중심질량 + leapfrog(근사 아님).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodyTrailHistory.module.scss";
export type GalaxyNbodyTrailHistoryProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 원반 별 수
layers?: number; // 잔상 누적 단수(스텝당 깔리는 알파 겹) — 변형 "누적 깊이"
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Sim2D = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[] };
const FIELD_LABEL = "궤적 히스토리 실험판";
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 60.0, SOFT2 = 36;
const DT = 1 / 60;
const MHALO = 6200; // 중심 질량(핵+헤일로) — 그리지 않는다. 차등회전의 원천
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodyTrailHistory({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 300,
layers = 3
}: GalaxyNbodyTrailHistoryProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nstarsRef = useRef(nstars) as Box<number>;
const layersRef = useRef(layers) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[] }) as Box<Sim2D>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const aReadyRef = useRef(false) as Box<boolean>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
function accel(): void {
// 중심 질량 + 전 쌍 O(N²) 뉴턴 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
const W = wRef.current, H = hRef.current;
const N = sim.N;
let i: number, j: number, dx: number, dy: number, d2: number, inv: number, f: number;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
const cx = W * 0.5, cy = H * 0.5;
for (i = 0; i < N; i++) {
dx = cx - sim.X[i]; dy = cy - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * MHALO * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
}
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = sim.X[j] - sim.X[i]; dy = sim.Y[j] - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
function stepLeap(): void {
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
if (!aReadyRef.current) { accel(); aReadyRef.current = true; }
const h = DT * 0.5;
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * h; sim.VY[i] += AY[i] * h;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
accel();
for (let j = 0; j < sim.N; j++) {
sim.VX[j] += AX[j] * h; sim.VY[j] += AY[j] * h;
}
}
function seed(): void {
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.N = 0;
aReadyRef.current = false;
const rnd = mulberry32(21);
const W = wRef.current, H = hRef.current;
const cx = W * 0.5, cy = H * 0.5;
const R = Math.min(W, H) * 0.46;
for (let i = 0; i < nstarsRef.current; i++) {
const far = rnd() < 0.44; // 상당수 별은 이심 궤도 — 궤적이 원반 밖까지 뻗는다
const r = far
? R * (0.22 + 2.30 * rnd()) // 먼 별: 최대 ~2.5R 바깥까지 오가는 궤도
: 0.10 * R + R * 0.80 * Math.sqrt(rnd());
const a = rnd() * Math.PI * 2 + 0.14 * Math.sin(r * 0.16); // 초기 밀도 요동 — 쌓이면 나선팔
const v = Math.sqrt(G * MHALO / Math.max(r, 8)) * (far ? 0.80 : 1.06); // 원반 살짝 과속 → 궤적이 살짝 벌어진다
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r * 0.94);
sim.VX.push(-Math.sin(a) * v + (rnd() - 0.5) * 1.5);
sim.VY.push(Math.cos(a) * v * 0.94 + (rnd() - 0.5) * 1.5);
sim.N++;
}
}
// decay 가 없다 — 잔상 체인은 지워지지 않고 계속 쌓인다(이 데모의 주제).
// 대신 루프 되감을 때만 통째로 지운다.
function stamp(): void {
// 누적 — 스텝마다 globalAlpha 를 낮게 해 여러 겹 깔면 궤도가 선으로 굳는다
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let L = 0; L < layersRef.current; L++) { // LAYERS 겹 — 쌓임 속도 변형
chainCtx.globalAlpha = 0.22 / layersRef.current * (L + 1);
for (let i = 0; i < sim.N; i++) {
const r = Math.hypot(sim.X[i] - W * 0.5, sim.Y[i] - H * 0.5);
const c = r < Math.min(W, H) * 0.18 ? col.hot : col.glow;
chainCtx.strokeStyle = rgba(c, 0.9);
chainCtx.lineWidth = r < Math.min(W, H) * 0.18 ? 2.6 : 2.1;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
chainCtx.globalAlpha = 1;
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(23);
ctx.fillStyle = rgba(col.glow, 0.35);
for (let k = 0; k < 40; k++) {
const bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.12 + rnd() * 0.22;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
// 누적 레이어 — 지워지지 않은 궤도 전체가 한 번에 얹힌다
ctx.drawImage(chainCv, 0, 0, W, H);
// 현재 별 위치 — 밝은 코어(쌓인 궤도 위 지나가는 "지금")
const R = Math.min(W, H);
for (let i = 0; i < sim.N; i++) {
const r = Math.hypot(sim.X[i] - W * 0.5, sim.Y[i] - H * 0.5);
const c = r < R * 0.16 ? col.hot : col.glow;
ctx.fillStyle = rgba(c, 0.18);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 4.4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(c, 0.9);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 2.4, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = rgba(col.hot, 0.9);
ctx.beginPath(); ctx.arc(W * 0.5, H * 0.5, 2.2, 0, Math.PI * 2); ctx.fill();
}
function stillFrame(): void {
// reduce — 궤도가 쌓여 팔이 드러난 장면까지 적분한 뒤 한 장 고정
for (let s = 0; s < 260; s++) { stepLeap(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 5.0; // 5초 루프 — 길게 흘려 궤도가 충분히 쌓이게
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { stepLeap(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
layersRef.current = layers;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null; aReadyRef.current = false;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, layers]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>지난 프레임을 알파로 깔아 두면 궤도가 부드러운 선으로 쌓이고, 나선팔이 한 장이 아니라 궤도의 누적임이 드러납니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>별 300개 · 잔상 누적 레이어</span>
<span className={styles.metaDim}>canvas · globalAlpha history · requestAnimationFrame</span>
</div>
</div>
</main>
);
}08 · 궤적 히스토리 — 잔상으로 쌓이는 별의 과거 — 측정 누적면적 32.485% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
01부터 07까지의 잔상이 매 프레임 조금씩 지워진 것과 달리 이 판은 아예 지우지 않고 globalAlpha를 낮게 해 여러 겹씩 깔아 궤도를 선으로 굳히는데, 나설팔이 한 장의 스냅샷이 아니라 궤도의 누적임을 보여 주는 히어로 배경·제너러티브 아트용 레이어입니다. 측정은 누적면적 32.485%·강도평균 62.6으로 강도는 아홉 판 중 가장 높습니다.
지우지 않고 겹겹이 쌓는 잔상 스탬프
// decay 가 없다 — 잔상 체인은 지워지지 않고 계속 쌓인다(이 데모의 주제).
// 대신 루프 되감을 때만 통째로 지운다.
var prev = null;
function stamp() {
// 누적 — 스텝마다 globalAlpha 를 낮게 해 여러 겹 깔면 궤도가 선으로 굳는다
if (prev) {
for (var L = 0; L < LAYERS; L++) { // LAYERS 겹 — 쌓임 속도 변형
chainCtx.globalAlpha = 0.22 / LAYERS * (L + 1);
for (var i = 0; i < N; i++) {
var r = Math.hypot(X[i] - W * 0.5, Y[i] - H * 0.5);
var col = r < Math.min(W, H) * 0.18 ? C_HOT : C_GLOW;
chainCtx.strokeStyle = rgba(col, 0.9);
chainCtx.lineWidth = r < Math.min(W, H) * 0.18 ? 2.6 : 2.1;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
chainCtx.globalAlpha = 1;
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
09심우주 씬 — 몇 억 년을 담은 은하 진화 무대

<main class="stage">
<div class="gx is-demo" id="gxRoot">
<p class="gx__hint">
<span>성운 스프라이트를 깐 심우주 무대에 여러 은하가 차례로 스치며 합쳐지는 진화 시퀀스를 배치했습니다</span>
</p>
<div class="gx__field" id="gxField" role="application" aria-label="심우주 은하 진화 실험판" tabindex="0">
<canvas class="gx__cv" id="gxCv"></canvas>
</div>
<div class="gx__bar">
<span class="gx__meta">은하 3개 · 별 330개</span>
<span class="gx__meta gx__meta--dim">canvas · nebula sprites · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 심우주 씬(09). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(성운 스프라이트 + 은하 3개 O(N²) 적분 → 점 그리기)가 만든다.
// 무대는 편 공통 심우주 팔레트. 파스텔 주색을 --gx-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심우주 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$gx-stage: #12101c; // 심우주 자정 — 저채도 짙은 남보라 (파스텔 검사 무관: 저채도·저명도)
@include stage($gx-stage);
$gx-ink: #0b0a13; // 관측창 바닥 — 무대보다 한 톤 어두운 보라검
$gx-glow: #b7c8ff; // 은하 A 별빛 — 파스텔 페리윙클(고명도라 vivid 아님)
$gx-hot: #ffdf9e; // 은하 B 코어 — 파스텔 앰버
$gx-text: #d8d4e6; // 라벨 글자 — 밝은 회보라
.gx {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --gx-* 를 받아 쓴다
--gx-ink: #{$gx-ink};
--gx-glow: #{$gx-glow};
--gx-hot: #{$gx-hot};
--gx-tail: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gx__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $gx-text;
}
.gx__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $gx-ink;
box-shadow: inset 0 0 0 1px rgba($gx-text, .18);
}
.gx__field:focus-visible { outline: 2px solid $gx-glow; outline-offset: 2px; }
.gx__cv { display: block; width: 100%; height: 100%; }
.gx__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.gx__meta { font-size: 10px; font-weight: 500; color: rgba($gx-text, .72); }
.gx__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.gx { gap: $sp-1; }
.gx__field { height: 100px; }
.gx__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #12101c;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gx {
--gx-ink: #0b0a13;
--gx-glow: #b7c8ff;
--gx-hot: #ffdf9e;
--gx-tail: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gx__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8d4e6;
}
.gx__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a13;
box-shadow: inset 0 0 0 1px rgba(216, 212, 230, 0.18);
}
.gx__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.gx__cv {
display: block;
width: 100%;
height: 100%;
}
.gx__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.gx__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 212, 230, 0.72);
}
.gx__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.gx {
gap: 4px;
}
.gx__field {
height: 100px;
}
.gx__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gx.is-still .gx__field {
cursor: default !important;
}
}// 심우주 씬(09) — 성운을 깐 무대에 은하 3개를 놓는 장면.
// 무대: createRadialGradient 로 구운 성운 스프라이트 3장(청록·장미·앰버)을
// 미리 별도 캔버스에 구워 두고 매 프레임 drawImage 로 얹는다 — 매 프레임 그라디언트를
// 다시 만들지 않아 330개 별 계산과 함께 돌아도 프레임이 나간다.
// 은하 3개(별 110+110+110)는 01 과 같은 전 쌍 O(N²) 뉴턴 중력 + leapfrog 으로 적분한다(근사 아님).
// 궤도는 대각선으로 엇갈리게 심는다 — 스침 → 조석 꼬리 → 합체(타원화) 순서의 진화 시퀀스가
// 한 루프 안에 차례로 나타난다. 별 색은 은하마다 다른 파스텔 3색.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NSTARS = 330; // 세 은하 합계 별 수
var NEBULA = 3; // 성운 스프라이트 수 — 변형 "성운 밀도"
// ────────────────────────────────────────────────────
var root = document.getElementById('gxRoot');
var field = document.getElementById('gxField');
var cv = document.getElementById('gxCv');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--gx-ink');
var C_GLOW = tokRGB('--gx-glow');
var C_HOT = tokRGB('--gx-hot');
var C_TAIL = tokRGB('--gx-tail');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx;
var nebCvs = []; // 구워 둔 성운 스프라이트
var N = 0, X = [], Y = [], VX = [], VY = [], GID = []; // GID — 별이 속한 은하(색·코어 계산용)
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
bakeNebulas();
}
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 성운 스프라이트 — createRadialGradient 로 한 번 구워 둔다(매 프레임 재계산 없음)
function bakeNebulas() {
nebCvs = [];
var rnd = mulberry32(77);
var cols = [C_GLOW, C_TAIL, C_HOT];
for (var k = 0; k < NEBULA; k++) {
var s = document.createElement('canvas');
var sz = Math.max(160, Math.round(Math.min(W, H) * (0.55 + rnd() * 0.5)));
s.width = sz; s.height = sz;
var sc = s.getContext('2d');
var g = sc.createRadialGradient(sz * 0.5, sz * 0.5, 0, sz * 0.5, sz * 0.5, sz * 0.5);
var c = cols[k % 3];
g.addColorStop(0.00, rgba(c, 0.16));
g.addColorStop(0.35, rgba(c, 0.07));
g.addColorStop(0.70, rgba(c, 0.02));
g.addColorStop(1.00, rgba(c, 0));
sc.fillStyle = g;
sc.fillRect(0, 0, sz, sz);
nebCvs.push({ cv: s, x: 0.12 + rnd() * 0.76, y: 0.10 + rnd() * 0.80, sz: sz });
}
}
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
var G = 120.0, SOFT2 = 20;
var DT = 1 / 60;
var AX = [], AY = [];
function accel() {
// 전 쌍 O(N²) 뉴턴 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
var i, j, dx, dy, d2, inv, f;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = X[j] - X[i]; dy = Y[j] - Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
var aReady = false;
function stepLeap() {
if (!aReady) { accel(); aReady = true; }
var h = DT * 0.5;
for (var i = 0; i < N; i++) {
VX[i] += AX[i] * h; VY[i] += AY[i] * h;
X[i] += VX[i] * DT; Y[i] += VY[i] * DT;
}
accel();
for (var j = 0; j < N; j++) {
VX[j] += AX[j] * h; VY[j] += AY[j] * h;
}
}
function placeDisk(cx, cy, R, count, phase, dvx, dvy, dir, gid, rnd) {
var M = R * R * 0.90;
for (var i = 0; i < count; i++) {
var r = R * Math.sqrt(rnd());
var a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
var v = Math.sqrt(M / Math.max(r, 6)) * dir;
X.push(cx + Math.cos(a) * r);
Y.push(cy + Math.sin(a) * r);
VX.push(dvx * R * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
VY.push(dvy * R * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
GID.push(gid);
N++;
}
}
function seed() {
// 세 은하 — 대각선으로 엇갈리는 궤도. 시간이 흐르면 두 쌍이 스치고 세째가 합류한다.
// 산개 별(STRAY)을 섞어 궤적이 캔버스 구석까지 뻗게 한다 — 심우주의 백업 별밭 역할
X = []; Y = []; VX = []; VY = []; GID = []; N = 0; aReady = false;
var rnd = mulberry32(29);
var per = Math.floor(NSTARS / 3);
var R = Math.min(W, H) * 0.28;
placeDisk(W * 0.22, H * 0.26, R, per - 28, 0, 0.62, -0.40, 1.00, 0, rnd);
placeDisk(W * 0.78, H * 0.52, R, per - 28, Math.PI, -0.62, 0.40, 0.85, 1, rnd);
placeDisk(W * 0.38, H * 0.82, R, NSTARS - 2 * per - 28, Math.PI * 0.5, 0.34, -0.62, 0.92, 2, rnd);
// 산개 별 84개 — 세 은하 사이 공간을 채워 스치는 순간 전체가 살아 난다
for (var s = 0; s < 84; s++) {
var sx = W * (0.04 + rnd() * 0.92);
var sy = H * (0.06 + rnd() * 0.88);
var sa = rnd() * Math.PI * 2;
var sv = 38 + rnd() * 34;
X.push(sx); Y.push(sy);
VX.push(Math.cos(sa) * sv); VY.push(Math.sin(sa) * sv);
GID.push(s % 3);
N++;
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function gcol(gid) { return [C_GLOW, C_TAIL, C_HOT][gid % 3]; }
function decay() {
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.013)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prev = null;
function stamp() {
if (prev) {
for (var i = 0; i < N; i++) {
chainCtx.strokeStyle = rgba(gcol(GID[i]), 0.26);
chainCtx.lineWidth = 1.7;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(X[i], Y[i]);
chainCtx.stroke();
}
}
if (!prev) { prev = [X.slice(), Y.slice()]; }
else { prev[0] = X.slice(); prev[1] = Y.slice(); }
}
function draw() {
var ctx = cv.getContext('2d'); // CanvasRenderingContext2D — 이 데모의 그리기 문맥
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// 심우주 배경 — 잉크
ctx.fillStyle = rgb(C_INK);
ctx.fillRect(0, 0, W, H);
// 먼 별밭(정적·시드 고정)
var rnd = mulberry32(31);
ctx.fillStyle = rgba(C_GLOW, 0.35);
for (var k = 0; k < 55; k++) {
var bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.10 + rnd() * 0.24;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
// 성운 스프라이트 — 구워 둔 것을 얹는다. 아주 느린 드리프트로 심우주 배경 자체를 살린다
for (var n = 0; n < nebCvs.length; n++) {
var nb = nebCvs[n];
var ph = n * 2.1;
var dx = Math.sin(simSteps * 0.0031 + ph) * W * 0.035;
var dy = Math.cos(simSteps * 0.0026 + ph * 1.7) * H * 0.045;
var sc = 1 + Math.sin(simSteps * 0.0022 + ph) * 0.10;
var sz2 = nb.sz * sc;
ctx.drawImage(nb.cv, W * nb.x + dx - sz2 * 0.5, H * nb.y + dy - sz2 * 0.5, sz2, sz2);
}
// 궤도 잔상
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 은하별 파스텔 3색
for (var i = 0; i < N; i++) {
var col = gcol(GID[i]);
ctx.fillStyle = rgba(col, 0.10);
ctx.beginPath(); ctx.arc(X[i], Y[i], 3.2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(col, 0.6);
ctx.beginPath(); ctx.arc(X[i], Y[i], 1.6, 0, Math.PI * 2); ctx.fill();
}
// 각 은하의 코어 — 같은 색 밝은 점(무게중심)
for (var g = 0; g < 3; g++) {
var sx = 0, sy = 0, c = 0;
for (var i2 = 0; i2 < N; i2++) {
if (GID[i2] !== g) continue;
sx += X[i2]; sy += Y[i2]; c++;
}
if (!c) continue;
ctx.fillStyle = rgba(gcol(g), 0.9);
ctx.beginPath(); ctx.arc(sx / c, sy / c, 2.2, 0, Math.PI * 2); ctx.fill();
}
}
function rgb(c) { return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; }
var simSteps = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var loop = ((now - t0) / 1000) % 4.5; // 4.5초 루프 — 스침 → 꼬리 → 합류 순서가 다 보이게
var target = Math.floor(loop / DT);
while (simSteps < target) { stepLeap(); simSteps++; stamp(); }
if (target < simSteps - 3) { seed(); simSteps = 0; prev = null; chainCtx.clearRect(0, 0, W, H); }
decay();
draw();
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 첫 스침이 일어난 장면까지 적분한 뒤 한 장 고정
for (var s = 0; s < 200; s++) { stepLeap(); stamp(); }
draw();
}
window.addEventListener('resize', function () {
fit(); seed(); simSteps = 0; prev = null; aReady = false;
if (reduce) stillFrame();
});
fit();
seed();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GalaxyNbodyDeepFieldScene — vanilla 판(run/322/demo/09-galaxy-nbody-deep-field-scene/index.html) 의 React 포트.
// 심우주 씬 — 성운을 깐 무대에 은하 3개를 놓는 장면.
// 무대: createRadialGradient 로 구운 성운 스프라이트 3장(청록·장미·앰버)을
// 미리 별도 캔버스에 구워 두고 매 프레임 drawImage 로 얹는다 — 매 프레임 그라디언트를
// 다시 만들지 않아 330개 별 계산과 함께 돌아도 프레임이 나간다.
// 은하 3개(별 110+110+110)는 01 과 같은 전 쌍 O(N²) 뉴턴 중력 + leapfrog 으로 적분한다(근사 아님).
// 궤도는 대각선으로 엇갈리게 심는다 — 스침 → 조석 꼬리 → 합체(타원화) 순서의 진화 시퀀스가
// 한 루프 안에 차례로 나타난다. 별 색은 은하마다 다른 파스텔 3색.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxyNbodyDeepFieldScene.module.scss";
export type GalaxyNbodyDeepFieldSceneProps = {
duration?: string;
easing?: string;
color?: string;
nstars?: number; // 세 은하 합계 별 수
nebula?: number; // 성운 스프라이트 수 — 변형 "성운 밀도"
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type SimG = { N: number; X: number[]; Y: number[]; VX: number[]; VY: number[]; GID: number[] };
type NebSprite = { cv: HTMLCanvasElement; x: number; y: number; sz: number };
const FIELD_LABEL = "심우주 은하 진화 실험판";
// G·질량은 캔버스 픽셀 단위 시뮬레이션 값(물리 상수 아님 — 글에서 설명)
const G = 120.0, SOFT2 = 20;
const DT = 1 / 60;
function mulberry32(seed: number): () => number {
let s = seed | 0;
return function (): number {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GalaxyNbodyDeepFieldScene({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nstars = 330,
nebula = 3
}: GalaxyNbodyDeepFieldSceneProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB; tail: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nebRef = useRef<NebSprite[]>([]) as Box<NebSprite[]>; // 구워 둔 성운 스프라이트
const nstarsRef = useRef(nstars) as Box<number>;
const nebulaRef = useRef(nebula) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function rgb(c: RGB): string {
return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
}
function gcol(gid: number): RGB {
const col = colRef.current;
if (!col) return [0, 0, 0];
return [col.glow, col.tail, col.hot][gid % 3];
}
const simRef = useRef({ N: 0, X: [] as number[], Y: [] as number[], VX: [] as number[], VY: [] as number[], GID: [] as number[] }) as Box<SimG>;
const AXRef = useRef<number[]>([]) as Box<number[]>;
const AYRef = useRef<number[]>([]) as Box<number[]>;
const aReadyRef = useRef(false) as Box<boolean>;
const prevRef = useRef<[number[], number[]] | null>(null);
const simStepsRef = useRef(0) as Box<number>;
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
bakeNebulas();
}
// 성운 스프라이트 — createRadialGradient 로 한 번 구워 둔다(매 프레임 재계산 없음)
function bakeNebulas(): void {
const col = colRef.current;
if (!col) return;
nebRef.current = [];
const rnd = mulberry32(77);
const W = wRef.current, H = hRef.current;
const cols: RGB[] = [col.glow, col.tail, col.hot];
for (let k = 0; k < nebulaRef.current; k++) {
const s = document.createElement("canvas");
const sz = Math.max(160, Math.round(Math.min(W, H) * (0.55 + rnd() * 0.5)));
s.width = sz; s.height = sz;
const sc = s.getContext("2d");
if (!sc) continue;
const g = sc.createRadialGradient(sz * 0.5, sz * 0.5, 0, sz * 0.5, sz * 0.5, sz * 0.5);
const c = cols[k % 3];
g.addColorStop(0.00, rgba(c, 0.16));
g.addColorStop(0.35, rgba(c, 0.07));
g.addColorStop(0.70, rgba(c, 0.02));
g.addColorStop(1.00, rgba(c, 0));
sc.fillStyle = g;
sc.fillRect(0, 0, sz, sz);
nebRef.current.push({ cv: s, x: 0.12 + rnd() * 0.76, y: 0.10 + rnd() * 0.80, sz: sz });
}
}
function accel(): void {
// 전 쌍 O(N²) 뉴턴 — a_i = Σ G·(x_j - x_i)/(|d|²+ε²)^1.5 (근사 아님)
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
const N = sim.N;
let i: number, j: number, dx: number, dy: number, d2: number, inv: number, f: number;
for (i = 0; i < N; i++) { AX[i] = 0; AY[i] = 0; }
for (i = 0; i < N; i++) {
for (j = i + 1; j < N; j++) {
dx = sim.X[j] - sim.X[i]; dy = sim.Y[j] - sim.Y[i];
d2 = dx * dx + dy * dy + SOFT2;
inv = 1 / Math.sqrt(d2);
f = G * inv * inv * inv;
AX[i] += dx * f; AY[i] += dy * f;
AX[j] -= dx * f; AY[j] -= dy * f;
}
}
}
function stepLeap(): void {
const sim = simRef.current;
const AX = AXRef.current, AY = AYRef.current;
if (!aReadyRef.current) { accel(); aReadyRef.current = true; }
const h = DT * 0.5;
for (let i = 0; i < sim.N; i++) {
sim.VX[i] += AX[i] * h; sim.VY[i] += AY[i] * h;
sim.X[i] += sim.VX[i] * DT; sim.Y[i] += sim.VY[i] * DT;
}
accel();
for (let j = 0; j < sim.N; j++) {
sim.VX[j] += AX[j] * h; sim.VY[j] += AY[j] * h;
}
}
function placeDisk(cx: number, cy: number, R: number, count: number, phase: number, dvx: number, dvy: number, dir: number, gid: number, rnd: () => number): void {
const sim = simRef.current;
const M = R * R * 0.90;
for (let i = 0; i < count; i++) {
const r = R * Math.sqrt(rnd());
const a = phase + (i / count) * Math.PI * 2 + rnd() * 0.5;
const v = Math.sqrt(M / Math.max(r, 6)) * dir;
sim.X.push(cx + Math.cos(a) * r);
sim.Y.push(cy + Math.sin(a) * r);
sim.VX.push(dvx * R * 0.30 + -Math.sin(a) * v + (rnd() - 0.5) * 2);
sim.VY.push(dvy * R * 0.30 + Math.cos(a) * v + (rnd() - 0.5) * 2);
sim.GID.push(gid);
sim.N++;
}
}
function seed(): void {
// 세 은하 — 대각선으로 엇갈리는 궤도. 시간이 흐르면 두 쌍이 스치고 세째가 합류한다.
// 산개 별(STRAY)을 섞어 궤적이 캔버스 구석까지 뻗게 한다 — 심우주의 백업 별밭 역할
const sim = simRef.current;
sim.X = []; sim.Y = []; sim.VX = []; sim.VY = []; sim.GID = []; sim.N = 0;
aReadyRef.current = false;
const rnd = mulberry32(29);
const W = wRef.current, H = hRef.current;
const per = Math.floor(nstarsRef.current / 3);
const R = Math.min(W, H) * 0.28;
placeDisk(W * 0.22, H * 0.26, R, per - 28, 0, 0.62, -0.40, 1.00, 0, rnd);
placeDisk(W * 0.78, H * 0.52, R, per - 28, Math.PI, -0.62, 0.40, 0.85, 1, rnd);
placeDisk(W * 0.38, H * 0.82, R, nstarsRef.current - 2 * per - 28, Math.PI * 0.5, 0.34, -0.62, 0.92, 2, rnd);
// 산개 별 84개 — 세 은하 사이 공간을 채워 스치는 순간 전체가 살아 난다
for (let s = 0; s < 84; s++) {
const sx = W * (0.04 + rnd() * 0.92);
const sy = H * (0.06 + rnd() * 0.88);
const sa = rnd() * Math.PI * 2;
const sv = 38 + rnd() * 34;
sim.X.push(sx); sim.Y.push(sy);
sim.VX.push(Math.cos(sa) * sv); sim.VY.push(Math.sin(sa) * sv);
sim.GID.push(s % 3);
sim.N++;
}
}
function decay(): void {
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.013)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function stamp(): void {
const chainCtx = chainCtxRef.current;
const col = colRef.current;
const sim = simRef.current;
const prev = prevRef.current;
if (!chainCtx || !col) return;
if (prev) {
for (let i = 0; i < sim.N; i++) {
chainCtx.strokeStyle = rgba(gcol(sim.GID[i]), 0.26);
chainCtx.lineWidth = 1.7;
chainCtx.beginPath();
chainCtx.moveTo(prev[0][i], prev[1][i]);
chainCtx.lineTo(sim.X[i], sim.Y[i]);
chainCtx.stroke();
}
}
if (!prev) prevRef.current = [sim.X.slice(), sim.Y.slice()];
else { prev[0] = sim.X.slice(); prev[1] = sim.Y.slice(); }
}
function draw(): void {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const sim = simRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !chainCv || !col) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// 심우주 배경 — 잉크
ctx.fillStyle = rgb(col.ink);
ctx.fillRect(0, 0, W, H);
// 먼 별밭(정적·시드 고정)
const rnd = mulberry32(31);
ctx.fillStyle = rgba(col.glow, 0.35);
for (let k = 0; k < 55; k++) {
const bx = rnd() * W, by = rnd() * H, br = rnd() * 0.8 + 0.3;
ctx.globalAlpha = 0.10 + rnd() * 0.24;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
// 성운 스프라이트 — 구워 둔 것을 얹는다. 아주 느린 드리프트로 심우주 배경 자체를 살린다
const nebCvs = nebRef.current;
for (let n = 0; n < nebCvs.length; n++) {
const nb = nebCvs[n];
const ph = n * 2.1;
const dx = Math.sin(simStepsRef.current * 0.0031 + ph) * W * 0.035;
const dy = Math.cos(simStepsRef.current * 0.0026 + ph * 1.7) * H * 0.045;
const sc = 1 + Math.sin(simStepsRef.current * 0.0022 + ph) * 0.10;
const sz2 = nb.sz * sc;
ctx.drawImage(nb.cv, W * nb.x + dx - sz2 * 0.5, H * nb.y + dy - sz2 * 0.5, sz2, sz2);
}
// 궤도 잔상
ctx.drawImage(chainCv, 0, 0, W, H);
// 별 — 은하별 파스텔 3색
for (let i = 0; i < sim.N; i++) {
const c = gcol(sim.GID[i]);
ctx.fillStyle = rgba(c, 0.10);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 3.2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(c, 0.6);
ctx.beginPath(); ctx.arc(sim.X[i], sim.Y[i], 1.6, 0, Math.PI * 2); ctx.fill();
}
// 각 은하의 코어 — 같은 색 밝은 점(무게중심)
for (let g = 0; g < 3; g++) {
let sx = 0, sy = 0, c = 0;
for (let i2 = 0; i2 < sim.N; i2++) {
if (sim.GID[i2] !== g) continue;
sx += sim.X[i2]; sy += sim.Y[i2]; c++;
}
if (!c) continue;
ctx.fillStyle = rgba(gcol(g), 0.9);
ctx.beginPath(); ctx.arc(sx / c, sy / c, 2.2, 0, Math.PI * 2); ctx.fill();
}
}
function stillFrame(): void {
// reduce — 첫 스침이 일어난 장면까지 적분한 뒤 한 장 고정
for (let s = 0; s < 200; s++) { stepLeap(); stamp(); }
draw();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = ((now - t0) / 1000) % 4.5; // 4.5초 루프 — 스침 → 꼬리 → 합류 순서가 다 보이게
const target = Math.floor(loop / DT);
while (simStepsRef.current < target) { stepLeap(); simStepsRef.current++; stamp(); }
if (target < simStepsRef.current - 3) {
seed(); simStepsRef.current = 0; prevRef.current = null;
const chainCtx = chainCtxRef.current;
if (chainCtx) chainCtx.clearRect(0, 0, wRef.current, hRef.current);
}
decay();
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nstarsRef.current = nstars;
nebulaRef.current = nebula;
colRef.current = {
ink: tokRGB(root, "--gx-ink"),
glow: tokRGB(root, "--gx-glow"),
hot: tokRGB(root, "--gx-hot"),
tail: tokRGB(root, "--gx-tail"),
};
fit();
seed();
simStepsRef.current = 0;
prevRef.current = null;
function onResize(): void {
fit(); seed(); simStepsRef.current = 0; prevRef.current = null; aReadyRef.current = false;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nstars, nebula]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>성운 스프라이트를 깐 심우주 무대에 여러 은하가 차례로 스치며 합쳐지는 진화 시퀀스를 배치했습니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>은하 3개 · 별 330개</span>
<span className={styles.metaDim}>canvas · nebula sprites · requestAnimationFrame</span>
</div>
</div>
</main>
);
}09 · 심우주 씬 — 몇 억 년을 담은 은하 진화 무대 — 측정 누적면적 30.189% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어두운 배경에 방사형 그라디언트로 구운 성운 스프라이트 세 장을 깔고 세 은하와 산개 별 84개를 대각선 궤도로 놓으면 두 쌍이 스치고 셋째 은하가 합류하는 장면이 이어지는, 심우주 사진의 구도를 한 캔버스로 옮긴 대문 히어로이자 스플래시 화면입니다. 측정은 누적면적 30.189%·강도평균 52.2입니다.
성운 한 장을 방사형 그라디언트로 미리 구워 두기
function bakeNebulas() {
nebCvs = [];
var rnd = mulberry32(77);
var cols = [C_GLOW, C_TAIL, C_HOT];
for (var k = 0; k < NEBULA; k++) {
var s = document.createElement('canvas');
var sz = Math.max(160, Math.round(Math.min(W, H) * (0.55 + rnd() * 0.5)));
s.width = sz; s.height = sz;
var sc = s.getContext('2d');
var g = sc.createRadialGradient(sz * 0.5, sz * 0.5, 0, sz * 0.5, sz * 0.5, sz * 0.5);
var c = cols[k % 3];
g.addColorStop(0.00, rgba(c, 0.16));
g.addColorStop(0.35, rgba(c, 0.07));
g.addColorStop(0.70, rgba(c, 0.02));
g.addColorStop(1.00, rgba(c, 0));
sc.fillStyle = g;
sc.fillRect(0, 0, sz, sz);
nebCvs.push({ cv: s, x: 0.12 + rnd() * 0.76, y: 0.10 + rnd() * 0.80, sz: sz });
}
}
대문 히어로와 인터랙티브 랩 페이지 — 이 아홉 개를 어디에 앉히나
우주·과학 테마 사이트의 대문이라면 09 심우주 씬을 그대로 배경에 앉히는 것이 가장 빠릅니다. 몇 억 년의 진화를 이미 한 장면에 눌러 담아 둔 구도라 타이포만 얹으면 히어로 섹션이 완성됩니다. 방문자가 직접 손대 보길 바라는 실험실·튜토리얼 페이지에는 07 혜성 쏘기를 앉히면, 드래그 각도와 세기로 별을 던져 넣는 입력이 그대로 체험형 데모가 됩니다.
물리 교육 콘텐츠에서 궤도의 누적을 보여주고 싶다면 08 궤적 히스토리가 맞습니다. 잔상으로 쌓이는 별의 과거가 팔이 왜 고정된 구조물이 아닌지를 말로 설명하지 않아도 보여 줍니다. 01 두 은하 충돌은 그 자체로 가장 극적인 화면이라 블로그 글이나 발표 자료의 인트로 컷으로도 바로 씁니다.
소프트닝 없이 두 별이 겹치면 가속도가 폭발하는 함정
두 별이 지나치게 가까워지면 가속도가 폭발한다
뉴턴 중력은 거리가 0에 가까워지면 발산하므로, 소프트닝 없이는 두 별이 우연히 겹쳤을 때 그 프레임 하나로 계산 전체가 튕겨 나갑니다. 그래서 모든 판이 거리 제곱에 소프트닝 제곱을 더하는데, 그 값이 판마다 16~90으로 다른 것은 장면의 성격 차이입니다. 격하게 스치는 01·05·06은 16으로 작게 잡아 근접 가속을 살리고, 별이 오래 붙어 도는 03은 60으로 크게 잡아 안정을 먼저 확보합니다.
오일러 적분은 에너지를 흘려 보낸다
01이 쓰는 semi-implicit Euler는 한 프레임 오차가 작아 보여도 매 프레임 조금씩 에너지를 더하거나 빼앗아, 몇 바퀴를 돌면 궤도가 저절로 부풀어 오릅니다. 2초짜리 루프 안에서는 눈에 안 걸리지만 같은 계산을 오래 두고 돌리면 원반이 흩어지므로, 02와 04~09는 절반 스텝을 대칭으로 밀어 에너지를 보존하는 leapfrog으로 적분합니다. 03은 성단 별 각각의 느린 왕복 궤도가 주인공이라 짧은 루프에서 오일러로도 충분해 01과 같은 적분기를 유지했습니다. 두 적분기를 한 줄 상수로 갈아 끼울 수 있게 해 둔 것이 02의 변형 스위치입니다.
혜성을 배열에 넣는 순간 버퍼 크기가 어긋난다
07에서 드래그로 새 별을 던져 넣으면 별 수 N이 늘어나는데, 이때 가속도 버퍼 AX와 AY는 이전 크기 그대로입니다. 재계산 전에 이 버퍼를 참조하면 정의되지 않은 값에서 시작해 NaN이 계산 전체로 퍼지므로, addComet은 배열을 늘리는 즉시 aReady를 끄어 다음 스텝에서 버퍼를 다시 만들게 합니다. 별 하나를 추가하는 일도 상태 하나만 건드는 일이 아니라는 지점입니다.
아홉 판의 index.html 전문과 항목별 소스는 암호 zip 으로 묶여 있고, 여는 비번은 hpcmbqut 입니다. 풀면 본문에 인용한 함수들이 주석까지 그대로 들어 있어 줄 단위로 나란히 대조해 보실 수 있습니다.
왜 별들이 저절로 나선이 되나? — 중력 적분과 진짜 나선의 차이
중력의 세기는 거리 제곱, 방향 성분은 세제곱 나누기
이 중력 시뮬레이션에서 매 프레임 일어나는 계산은 01의 코드 그대로 네 줄입니다. 거리의 역제곱으로 힘의 크기가 정해지지만 화면 좌표로 방향 성분을 더하려면 거리 세제곱으로 나눈 몫에 dx를 곱하는 꼴이 되고, d제곱에 소프트닝 값을 더한 뒤 제곱근을 딱 한 번 나누는 것으로 끝나므로 수학 라이브러리가 필요 없습니다. G와 질량은 물리 상수가 아니라 캔버스 픽셀에 맞춘 시뮬레이션 값이라, G는 판마다 26~140으로, 그리지 않는 중심 질량은 5400~6200으로 다르게 잡았습니다.
차등회전이 잔물결을 감아서 팔을 만든다
02의 원반은 모든 별이 같은 각속도로 도는 게 아니라 안쪽이 더 빨리 도는 궤도 속도로 시드됩니다. 시작 시점의 작은 밀도 요동이 이 속도 차로 감겨 올라가면서 나선이 되는데, 팔의 개수와 감기는 방향은 시드의 요동이 정하지 계산 도중에 임의로 그려지지 않습니다. 시드가 결정론 난수로 고정되어 있어 다시 불러도 같은 팔이 자라는 것도 이 때문입니다.
팔은 고정된 구조물이 아니라 궤도가 겹치는 곳
08처럼 궤도를 계속 쌓아 보면 같은 팔 자리를 지나는 별들이 계속 바뀌는 걸 볼 수 있습니다. 팔은 별들을 묶어 둔 끈이 아니라 궤도가 서로 가까워져 잠깐 밀도가 높아지는 영역이며, 개별 별은 팔을 통과해 자기 궤도를 그대로 돕니다. 02와 08을 나란히 놓으면 계산으로 자라는 팔과 궤도의 누적으로 쌓이는 팔이 같은 현상의 두 얼굴임이 보입니다.
스치기만 해도 꼬리가 나오는 조석력의 차이
조석 꼬리는 두 은하가 부딪히는 순간의 사건이 아니라 스치는 동안 별마다 받는 인력의 차이가 쌓인 결과입니다. 상대 은하에 가까운 쪽 별은 강하게, 먼 쪽 별은 약하게 끌리므로 원반이 양쪽으로 늘어나고, 이 늘어남이 궤도 에너지로 바뀌며 바깥으로 뿜어져 나갑니다. 06의 추적 궤적이 두 중심 사이를 왕복하다 던져지는 모양을 보여 주는 것이 바로 이 차이가 개별 궤도에 남기는 흔적입니다.
별 개수와 적분기를 바꾸는 세 변형과 접근성
값 하나를 바꾼 세 변형
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 별 개수 | 01 NSTARS 340 → 120 | 통계적 덩어리 대신 개별 궤도가 드러난다 |
| 스치는 거리 | 01 SKIP 0.60 → 0.25 | 깊이 스치면 꼬리 두 개 대신 핵이 관통하며 폭발적으로 흩어진다 |
| 적분기 | 02 METHOD 'leapfrog' → 'euler' | 오일러는 에너지가 흘러 몇 바퀴 뒤 원반이 부풀어 오른다 |
세 변형 모두 계산 구조를 건드리지 않고 상수 하나만 바꿉니다. 별 수를 줄이면 06의 추적 궤적처럼 개별 궤도가 눈에 들어오고, 스치는 거리를 좁히면 장면의 성격 자체가 바뀌며, 적분기를 내리면 안정과 정확의 관계를 손으로 확인할 수 있습니다.
동작을 줄이도록 설정한 사용자에게 주는 장면
각 판은 시작할 때 matchMedia('(prefers-reduced-motion: reduce)') 일치 여부를 검사해, 켜져 있으면 장면 중반까지 시뮬레이션을 미리 돌린 뒤 구조가 가장 잘 잡힌 한 장을 고정합니다. 01은 꼬리가 가장 그럴듯한 순간을, 02는 팔이 자란 순간을 고정하므로 동작을 줄인 사용자에게도 각 판의 결론 장면이 그대로 전달됩니다.
손으로 만지는 한 판과 나머지 여덟 판
07만 포인터 입력을 받고 나머지 여덟 판은 손을 대지 않아도 완결됩니다. 07의 발사 영역에는 조준 상태를 알리는 라벨이 붙어 화면 낭독기로 조작 흐름을 따라갈 수 있고, 04의 헤일로 고정 버튼도 키보드로 누를 수 있습니다.
은하 시뮬레이션을 페이지에 얹기 전 자주 묻는 것
이 데모를 제 페이지에 얹으려면 무엇부터 바꿔야 하나요?
zip 을 펼치면 항목 아홉 개의 폴더가 나오니 마음에 드는 판 하나의 index.html 과 style.css 두 파일만 폴더째 페이지에 올리면 그대로 움직입니다. 색은 style.css 의 --gx- 계열 팔레트 토큰을, 장면 크기는 같은 파일의 .gx__field 높이를 바꿔 조정합니다. 어떤 판도 다른 판을 참조하지 않으므로 하나만 가져다 써도 나머지와 무관합니다.
우주 시뮬레이션 사이트처럼 꾸민 페이지에 이 장면을 올려도 되나요?
됩니다. 09는 대문 히어로용으로 구도를 잡은 판이고 01과 08은 배경으로 깔아도 본문 가독을 해치지 않는 어두운 팔레트입니다. 다만 중력 계산이 매 프레임 돌므로 저사양 기기를 위해 별 수를 줄여 두는 편이 안전합니다.
별 수를 크게 늘리면 어느 시점에서 느려지나요?
쌍의 개수는 별 수의 제곱으로 늘어, 01의 별 340개가 매 프레임 57,630쌍을 계산하는 데 별을 두 배로 불리면 쌍은 네 배로 불어납니다. 수천 개를 넘기면 프레임이 떨리기 시작하니, 그 이상을 원하면 04의 격자 누적 질량 방식처럼 계산을 근사로 바꾸는 갈림길입니다.









