← DEVLOG
물리 시뮬레이션2025.10.258 min read

블랙홀 — 중력 렌즈와 생존 게임 구현기

블랙홀의 중력 렌즈 효과를 Canvas로 시각화하고, 사건의 지평선을 피해 생존하는 게임의 제작 과정

canvasphysicsgravitational-lensweb-audio

시작 — 블랙홀을 "그린다"는 것

블랙홀은 빛조차 빠져나오지 못하는 천체입니다. 직접 보이지 않으니, 그 존재는 주변 빛이 휘어지는 현상 — 중력 렌즈(gravitational lensing) — 으로만 알 수 있습니다. 인터스텔라의 가르강튀아처럼, 실제로 관측된 M87* 블랙홀처럼.

Canvas 2D로 이 효과를 어디까지 흉내 낼 수 있을지가 이 프로젝트의 출발점이었습니다. 레이트레이싱은 없고, 셰이더도 없습니다. radial-gradientarc만으로 설득력 있는 블랙홀을 만드는 것이 과제였습니다.


중력 렌즈 시각화

실제 중력 렌즈는 블랙홀 뒤편 별빛이 휘어져 링 형태로 보이는 현상(아인슈타인 링)입니다. Canvas 2D에서 픽셀 단위 광선 추적은 불가능하므로, 여러 겹의 레이어로 그 인상을 재현했습니다.

// 포토스피어 (광구 — 빛이 원형 궤도를 도는 경계)
const photonRing = ctx.createRadialGradient(cx, cy, r * 0.9, cx, cy, r * 1.3);
photonRing.addColorStop(0, 'rgba(255, 200, 80, 0.0)');
photonRing.addColorStop(0.5, 'rgba(255, 200, 80, 0.55)');
photonRing.addColorStop(1, 'rgba(255, 200, 80, 0.0)');

// 사건의 지평선 (event horizon) — 완전한 암흑
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(cx, cy, r * 0.85, 0, Math.PI * 2);
ctx.fill();

중심의 암흑 원, 그 경계의 밝은 광자 링, 바깥으로 퍼지는 강착원반 순서로 레이어를 쌓습니다.


강착원반(Accretion Disk)

블랙홀 주변을 회전하는 뜨거운 가스 원반입니다. 내부는 수백만 도의 고온으로 파란-흰색, 외부로 갈수록 붉은-주황색 온도 구배를 보입니다.

// 강착원반: 타원형 회전 렌더링
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(diskAngle); // 매 프레임 증가
ctx.scale(1, 0.3); // 납작한 타원 (기울기 표현)

const diskGrad = ctx.createRadialGradient(0, 0, r * 1.0, 0, 0, r * 2.8);
diskGrad.addColorStop(0, 'rgba(255, 255, 220, 0.7)');
diskGrad.addColorStop(0.3, 'rgba(255, 140, 40, 0.5)');
diskGrad.addColorStop(0.7, 'rgba(180, 50, 20, 0.25)');
diskGrad.addColorStop(1, 'rgba(100, 20, 10, 0.0)');

ctx.fillStyle = diskGrad;
ctx.beginPath();
ctx.arc(0, 0, r * 2.8, 0, Math.PI * 2);
ctx.fill();
ctx.restore();

ctx.scale(1, 0.3)으로 원을 타원으로 눌러 기울어진 원반처럼 보이게 합니다. 실제 기울기 각도를 3D로 표현하려면 행렬 변환이 필요하지만, 이 정도의 근사로도 시각적으로 충분히 설득력이 있었습니다.


생존 게임 메커니즘

시각화만으로는 인터랙션이 없습니다. 블랙홀의 중력을 실시간으로 체험하는 게임을 추가했습니다.

플레이어 우주선은 방향키나 터치로 조종하며, 블랙홀의 중력이 매 프레임 우주선을 잡아당깁니다.

const dist = Math.hypot(player.x - bh.x, player.y - bh.y);
const gravForce = (BH_GRAVITY * bh.mass) / (dist * dist);
const angle = Math.atan2(bh.y - player.y, bh.x - player.x);

player.vx += Math.cos(angle) * gravForce;
player.vy += Math.sin(angle) * gravForce;

// 사건의 지평선 반경 내 진입 → 게임오버
if (dist < bh.eventHorizonR) {
  st.phase = 'dead';
}

거리가 가까울수록 중력이 제곱에 반비례해 강해집니다. 블랙홀 바로 옆에서는 스러스터 최대 출력으로도 탈출이 불가능한 구간이 자연스럽게 형성됩니다.


최고 생존 시간: useHighScore + bh_best

생존 시간은 초 단위로 측정되며, useHighScore 훅을 통해 bh_best 키로 localStorage에 저장됩니다.

const { highScoreRef, update } = useHighScore('bh_best');

// 게임오버 시
const survived = (Date.now() - startTimeRef.current) / 1000;
update(survived); // 최고 기록보다 높으면 갱신

게임을 시작할 때 기존 최고 기록을 표시하고, 경신 시 팡파레 이펙트를 출력합니다.


localeRef 패턴으로 stale closure 방지

블랙홀 시뮬레이션은 RAF 루프 내에서 한국어/영어 캡션을 표시합니다. 문제는 RAF 클로저 안에서 locale prop이 갱신되지 않는 stale closure 현상입니다.

// ❌ 잘못된 방식: RAF 클로저가 초기 locale 값을 캡처
const loop = () => {
  drawCaption(ctx, locale); // locale 변경 시 반영 안 됨
};

// ✅ 올바른 방식: ref를 통해 항상 최신값 참조
const localeRef = useRef(locale);
localeRef.current = locale; // 매 렌더마다 업데이트

const loop = () => {
  drawCaption(ctx, localeRef.current); // 항상 최신 locale
};

localeRef.current = localeuseEffect 없이 렌더 본문에서 직접 업데이트합니다. 이것이 가능한 이유는 렌더 중 ref 쓰기가 리렌더를 트리거하지 않기 때문입니다.


CanvasSpaceBackground 배경 안정화

BlackholeContainer는 배경으로 CanvasSpaceBackground에 블랙홀 효과 설정을 전달합니다. 이때 매 렌더마다 새 배열 객체를 생성하면 배경 컴포넌트가 불필요하게 재마운트됩니다.

// ❌ 매 렌더마다 새 배열 생성 → 배경 리셋
<CanvasSpaceBackground blackhole={[{ xPct: 6, yPct: 15 }, { xPct: 90, yPct: 80 }]} />

// ✅ 모듈 레벨 상수로 고정
const BLACKHOLE_CONFIG = [
  { xPct: 6, yPct: 15 },
  { xPct: 90, yPct: 80 },
] as const;
<CanvasSpaceBackground blackhole={BLACKHOLE_CONFIG} />

이 패턴은 CanvasSpaceBackground를 사용하는 모든 페이지에 동일하게 적용합니다.


성능 최적화 — hsla 문자열 캐시

블랙홀 시뮬레이션에서 별들은 중력에 이끌려 꼬리를 남깁니다. 이 꼬리의 gradient 색상은 별마다 hue 값이 다르고, 꼬리 길이에 따라 alpha가 변하면서 매 프레임 다수의 hsla(...) 문자열을 생성합니다.

문제는 이 문자열 생성이 프레임당 수백 회 발생한다는 점이었습니다. 해결 방법으로 hue 값을 일정 단위로 양자화(quantize)한 뒤, 동일한 양자화 hue에 대해서는 캐시된 문자열을 재사용하는 방식을 적용했습니다. hue를 5도 단위로 양자화해도 시각적 차이는 구분할 수 없었고, 캐시 히트율은 크게 올라갔습니다.

별이 많아질수록 효과가 두드러지며, 특히 블랙홀 근처에 별이 밀집되어 꼬리가 길어지는 장면에서 프레임 안정성이 개선되었습니다.


마치며

블랙홀을 시각적으로 "설득력 있게" 표현하는 것은 물리적으로 정확하게 표현하는 것보다 어렵습니다. 수식은 명확하지만, 사람이 블랙홀답다고 느끼는 이미지는 수식 밖에 있기 때문입니다.

강착원반의 색온도, 광자 링의 밝기, 사건의 지평선 크기의 비율 — 이것들을 수십 번 조정하면서 '아, 이게 블랙홀처럼 보이는구나' 하는 순간이 왔을 때가 이 프로젝트에서 가장 만족스러운 순간이었습니다.

이 포스트와 연결된 콘텐츠

직접 체험하기