03구상 성단 공전 — 수백 개 별이 무게중심을 도는 궤도
구형으로 흩뿌린 별들에 랜덤 궤도 속도를 주면 은하와 달리 중심을 향해 뭉치지 않고 무게중심 주위를 제각각 돌며 전체가 느리게 뒤집히는 구상 성단의 성질이 그대로 드러납니다.
쓰임우주 테마 배경, 별자리 인터랙티브
CanvasRenderingContext2DarcrequestAnimationFrame

<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 포함 · 은하 충돌 시뮬레이션 9종 — 서로 잡아당기는 별들로 나선팔·조석 꼬리를 만드는 캔버스
이 데모가 속한 글
은하 충돌 시뮬레이션 9종 — 서로 잡아당기는 별들로 나선팔·조석 꼬리를 만드는 캔버스은하 충돌 시뮬레이션은 라이브러리·WebGL 없이 별 하나하나에 뉴턴 중력을 매 프레임 적분하면 두 은하가 스치며 조석 꼬리를 남기고 합쳐지는데, 새로고침 없이 눈앞에서 우주 규모의 인력이 이루어지는 장면을 그대로 이 글의 데모 전부 보기

