← DEVLOG
시뮬레이터2026.02.159 min read

SpaceX Starship Mission을 웹으로 재현하며

Canvas 2D만으로 SpaceX 스타쉽 발사부터 Mechazilla 포획까지 — 물리 시뮬레이션과 오디오 합성의 기록

canvasweb-audiophysicsanimation

시작 — "이걸 웹으로 만들 수 있을까?"

2024년 IFT-3에서 Super Heavy 부스터가 발사대의 기계 팔에 포획되는 장면을 보고 나서 이 프로젝트를 시작했습니다. Canvas 2D API와 Web Audio API만으로 그 장면을 재현할 수 있는지 확인하고 싶었습니다.

결론적으로 가능했습니다. 하지만 생각보다 훨씬 많은 세부 사항들이 필요했습니다.


물리 시뮬레이션 설계

좌표계 선택

로켓의 상태를 표현하는 핵심 좌표계는 scrollY 기반으로 설계했습니다.

// 발사→분리 구간: scrollY 누적값으로 고도 환산
const altKm = scrollY * SCROLL_TO_KM;
// 예: SEP_TRIGGER_SCROLL(16000px) × 0.00437 ≈ 70km

scrollY가 증가할수록 카메라가 위로 올라가며, 로켓은 화면 중앙에 고정된 채 배경(별, 구름, 지구 곡선)이 아래로 내려가는 시차 효과를 만듭니다.

1단 분리 순간

분리 순간은 게임의 가장 중요한 물리적 이벤트입니다. 실제 IFT 기준 분리 고도는 67~70km입니다.

if (altKm >= SEP_ALT_KM && st.phase === 'ascent') {
  st.phase = 'separation';
  st.boosterVy = SEP_BOOSTER_VY; // 역추진 시작
  st.starshipVy = SEP_STARSHIP_VY; // 상단 계속 가속
}

분리 직후 30프레임 동안 thrustLevel을 0으로 점감시켜 엔진이 서서히 꺼지는 효과를 표현했습니다.


Mechazilla 포획 시퀀스

이 부분이 가장 까다로웠습니다. 포획 암(arm)의 Y좌표 계산에는 두 가지 좌표 기준이 혼재합니다.

  • padY: 발사대 기저부 Y (지면 기준)
  • padYAnim: 부스터가 실제로 착지하는 Y (92px 차이)
// 격자핀 위치 = padY - (92 + s1H - 5)
const gripperY = padY - (92 + BOOSTER_S1H - 5);
// armTopY 비율 = (towerH - 208) / towerH ≈ 0.27

처음에는 이 92px 오프셋을 무시하고 구현했다가 팔이 로켓 몸통 밖으로 튀어나오는 버그가 생겼습니다. 좌표계 불일치가 원인이었습니다.


오디오 — Web Audio API 순수 합성

외부 오디오 파일을 전혀 사용하지 않습니다. 모든 사운드는 Web Audio API의 OscillatorNode, BiquadFilterNode, GainNode를 조합해 실시간 합성합니다.

엔진 럼블 (Engine Rumble)

저주파 노이즈 기반으로 Raptor 엔진 특유의 고음 성분을 추가했습니다.

// 노이즈 소스 → HPF 필터 → 웨이블릿 변조
const noise = createNoiseSource(ctx);
const hpf = ctx.createBiquadFilter();
hpf.type = 'highpass';
hpf.frequency.value = 80; // 저주파 컷

재진입 열방패 오디오

고도 52km 피크, 12~78km 구간에서 smoothstep 함수로 강도를 부드럽게 제어합니다.

const t = smoothstep(12, 52, altKm) - smoothstep(52, 78, altKm);
setReentryHeatIntensity(t); // 0 ~ 1

성능 최적화

DPR 캡

4K 디스플레이에서 Canvas가 8K 해상도로 렌더링되면 GPU가 감당하지 못합니다.

const MAX_AREA = 1920 * 1080;
const area = w * h;
const dpr = Math.min(window.devicePixelRatio, Math.sqrt(MAX_AREA / area));

uiTick 패턴

매 프레임 setState를 호출하면 React 렌더링 비용이 폭발합니다. 6프레임마다 한 번만 동기화합니다.

uiTick++;
if (uiTick >= 6) {
  uiTick = 0;
  setUiSnapshot({ phase: st.phase, altKm: st.altKm });
}

엔진 클러스터 OffscreenCanvas 캐시

Super Heavy 부스터는 33개의 Raptor 엔진으로 구성됩니다. 각 엔진을 radialGradient로 그리면 프레임당 31회 이상의 gradient 생성이 발생합니다. 그런데 엔진 클러스터의 시각적 결과는 추력 레벨과 연소 모드에만 의존하고, 매 프레임 크게 변하지 않습니다.

이 점을 이용해 OffscreenCanvas에 엔진 클러스터를 캐시하고, 상태가 변경될 때만 재생성하는 패턴을 적용했습니다. 캐시 키는 ${mode}:${seed}:${thrustBucket} 형식으로, 추력 레벨을 3~5프레임 단위로 양자화해 캐시 히트율을 극대화했습니다. 결과적으로 약 97%의 프레임에서 drawImage 한 번으로 엔진 렌더링을 완료합니다.

구름 시스템 최적화

Starship 발사·착륙 장면의 구름은 수백 개의 radialGradient 원으로 구성됩니다. 세 가지 최적화를 적용했습니다.

첫째, 구름 형태를 white(기본)/shadow(하단 음영)/warm(상단 햇빛 반사) 3모드로 분리해 그라디언트 생성을 체계화했습니다. 둘째, 화면 밖 구름은 viewport culling으로 건너뛰어 실제 렌더링 대상을 절반 이하로 줄였습니다. 셋째, 모바일에서는 구름 수 자체를 절반으로 줄여 저사양 기기에서도 안정적인 프레임을 유지합니다.


마치며

처음 아이디어에서 완성까지 약 3주가 걸렸습니다. Canvas 2D의 한계라고 생각했던 것들이 의외로 가능했고, 불가능하다고 생각하지도 않았던 것들이 실제 장벽이 됐습니다.

특히 오디오 합성만으로 엔진 럼블을 표현하는 과정이 예상보다 훨씬 즐거웠습니다. 파형을 조합해가며 "이게 좀 로켓처럼 들리는데?" 하는 순간이 제일 재밌었습니다.

이 포스트와 연결된 콘텐츠

직접 체험하기