Kerr–Newman 블랙홀 — ShaderToy 에디션
ShaderToy 원본을 바탕으로 원반 교차와 카메라 잔상을 수정한 WebGL2 블랙홀. 논문 수식과 실제 코드의 대응을 정리했다.
이 글의 해설과 수식·코드 검토, LAB 등록 작업에는 AI를 활용했다. 핵심 GLSL의 원작자와 파생 코드의 출처는 마지막에 정리했다.
Kerr–Newman 시공간에서 광선의 경로를 계산하고, 강착 원반과 제트의 빛을 다섯 개의 WebGL2 패스로 합성하는 블랙홀 시각화.
1. 개요
이 실험은 카메라의 각 픽셀에서 광선의 경로를 거꾸로 따라가며, 그 경로가 만나는 원반과 배경의 빛을 모아 화면을 만든다. 블랙홀 근처에서는 빛의 경로가 크게 휘므로 원반 뒤쪽도 위아래로 말려 보이고, 배경의 별 역시 원래 방향과 다른 위치에 나타난다.
광선의 궤적과 원반의 생김새는 따로 계산한다. 궤적은 Kerr–Newman 계량과 측지선 방정식으로 구하고, 원반의 두께·밀도·온도와 제트의 방출은 별도의 모델로 정한다. 여기에 시간 누적과 bloom을 더해 밝은 원반과 그 주변의 번짐을 표현한다.
기본 설정은 무차원 회전량 a*=0.997114514, 전하 Q*=0이다. 처음 보이는 장면은 회전하면서 전하가 없는 Kerr 경우이며, 편광·최대 연장·격자 모드는 꺼져 있다. 드래그로 시점을 바꾸면 원반과 배경이 함께 움직이는 모습을 볼 수 있다.
2. 렌더링 파이프라인
Buffer A가 핵심 광선 추적을 맡는다. 나머지 패스는 카메라 상태와 bloom을 처리하고 최종 색을 합성한다. main.js는 이 패스들을 연결하며 시간·해상도·키보드·마우스 값을 셰이더에 전달한다.
패스 | 입력 | 역할 |
|---|---|---|
Buffer A | 이전 B의 카메라 · 이전 A의 색 · 키보드 | 광선 적분, 원반·제트 방출, 배경과 시간 누적 |
Buffer B | 현재 A · 이전 B의 상태 · 키보드 | 카메라 상태 갱신, 8단계 bloom 묶음 |
Buffer C | 현재 B | 가로 Gaussian blur |
Buffer D | 현재 C | 세로 Gaussian blur |
Image | 현재 A/B/C/D | bloom 복원과 최종 색 합성 |
- 패스
Buffer A
- 입력
이전 B의 카메라 · 이전 A의 색 · 키보드
- 역할
광선 적분, 원반·제트 방출, 배경과 시간 누적
- 패스
Buffer B
- 입력
현재 A · 이전 B의 상태 · 키보드
- 역할
카메라 상태 갱신, 8단계 bloom 묶음
- 패스
Buffer C
- 입력
현재 B
- 역할
가로 Gaussian blur
- 패스
Buffer D
- 입력
현재 C
- 역할
세로 Gaussian blur
- 패스
Image
- 입력
현재 A/B/C/D
- 역할
bloom 복원과 최종 색 합성
A와 B는 읽기용·쓰기용 텍스처를 번갈아 쓰는 ping-pong 구조다. A를 그린 뒤 A의 버퍼를 바꾸고, 이어서 B를 그린 뒤 B의 버퍼를 바꾼다. 이 순서 때문에 A가 읽는 카메라는 이전 프레임 B의 상태이고, B가 읽는 장면은 방금 그린 A다.
B는 밝은 신호를 여러 크기로 묶으며, 하단 일부 픽셀에는 카메라 위치와 방향을 저장한다. C와 D가 가로·세로로 흐린 결과를 Image가 다시 합치면서 넓은 빛 번짐이 생긴다. 색 버퍼는 RGBA32F와 선형 필터링을 사용한다.
3. 수학 모델과 구현
3.1 Kerr–Schild 계량
Kerr–Schild 계량은 평평한 시공간의 계량 η에 null 벡터 ℓ로 만든 항을 더해 쓴다. M은 질량, a는 회전 매개변수, Q는 전하이며, r은 아래 좌표 관계를 만족하는 반지름이다. 식은 코드의 회전축 y와 부호 규약 (+,+,+,−)에 맞춰 적었다.1
KerrSchildRadius()가 r을 구하고, ComputeGeometryScalars()가 f와 ℓ을 계산한다. 변수 z2는 이름과 달리 X.y의 제곱이다. 실제 f에는 fade가 곱해지고 분모에는 10⁻²⁰ 하한이 들어간다.
geo.num_f = 2.0 * CONST_M * r3 - PhysicalQ * PhysicalQ * geo.r2;
float den_f = geo.r2 * geo.r2 + geo.a2 * z2;
geo.inv_den_f = 1.0 / max(1e-20, den_f);
geo.f = (geo.num_f * geo.inv_den_f) * fade;ℓ이 null 벡터이므로 역계량도 한 항을 빼는 형태로 쓸 수 있다. RaiseIndex()는 이 역계량을 이용해 공변 운동량을 광선의 접벡터로 올린다.
3.2 광선 적분과 RK4
광선의 위치 x와 공변 운동량 p는 Hamiltonian 형태의 측지선 방정식으로 함께 갱신한다. H=0은 질량이 없는 광선의 조건이고, λ는 경로를 따라가는 매개변수다. ℓ은 계량을 구성하고 p는 개별 광선을 추적한다.2
GetDerivativesAnalytic()가 해석 미분으로 위치와 운동량의 변화율을 구한다. 계량이 시간에 직접 의존하지 않으므로 deriv.P.w는 0이다. StepGeodesicRK4_Optimized()는 중간 상태에서 구한 네 기울기를 다음 식처럼 합친다.
vec4 finalX = s0.X + (dt / 6.0) * (k1.X + 2.0 * k2.X + 2.0 * k3.X + k4.X);
vec4 finalP = s0.P + (dt / 6.0) * (k1.P + 2.0 * k2.P + 2.0 * k3.P + k4.P);보폭은 특이환까지의 거리, 위치·운동량의 변화율과 iQuality에 따라 정한다. 양의 r 쪽에서는 적분 뒤 null 조건에 맞는 운동량 보정도 시도하며, 판별식과 분모를 확인하고 큰 보정은 제한한다.
3.3 지평선과 방출 모델
안쪽·바깥쪽 지평선의 반지름은 Δ=0의 두 근이다. 실수 지평선이 있는 범위는 M²≥a²+Q²이며, 판별식이 음수인 경우에는 코드가 bIsNakedSingularity를 따로 세운다. 길이는 CONST_M=0.5로 정규화하고, a와 Q에는 각각 iSpin*CONST_M, iQ*CONST_M을 사용한다.3
DiskColor()와 제트 샘플링은 광선 구간에 방출과 소광을 쌓는다. 원반의 두께·밀도는 반지름 함수와 노이즈로 조절하고, 움직이는 방출 물질과 광자의 운동량에서 구한 주파수비를 색온도와 밝기에 적용한다. 기본 설정에는 초기 광선 방향을 조정하는 heat haze와 광자환 밝기 증폭도 켜져 있다.
4. 수정한 부분과 원리
4.1 원반 중간면 교차
광선은 구간의 양 끝점이 모두 원반 밖에 있어도 그 사이에서 얇은 원반을 통과할 수 있다. DiskColor()는 하위 구간의 두 점 PosPrev와 PosNext에서 y 부호가 바뀌는지 검사한다. 공개 NPGS 소스에는 이 판정을 계산한 뒤 crossed=false로 덮어쓰는 대입이 있고, 수정판은 그 대입을 제거해 교차 분기를 사용한다.4
분모 하한이 작동하지 않는 경우 C×는 선분과 y=0 평면의 교점이다. 실제 샘플 위치에는 원반 두께에 따른 무작위 오프셋을 더한다. T는 Thin, d̂는 ChordDir, u는 RandomStep()이 만든 0 이상 1 미만의 값이다.
bool crossed = (PosPrev.y * PosNext.y < 0.0);
bool shouldSample = false;
vec3 SamplePos = PosNext;
if (crossed) {
float t_cross = abs(PosPrev.y) / max(1e-9, abs(PosPrev.y) + abs(PosNext.y));
vec3 CPoint = mix(PosPrev, PosNext, t_cross);
SamplePos = CPoint + min(Thin, length(CPoint - PosPrev)) * ChordDir * (-1.0 + 2.0 * RandomStep(10000.0 * (CPoint.zx / OuterRadius), fract(iTime * 1.0 + 0.5)));
shouldSample = true; RayMarchPhase = 1.0; TraveledDist = NextTarget;
}y=1에서 y=−1로 내려가는 구간은 t×=0.5에서 평면을 지난다. Thin=0.1, u=0.75라면 실제 샘플은 교점에서 광선 방향으로 0.05만큼 이동한다. 끝점이 정확히 y=0인 경우는 엄격한 부호 반전 조건에서 빠지고, 극소 구간에서는 분모 하한 때문에 C×가 평면에서 벗어날 수 있다.
4.2 카메라 이동과 시간 누적
이전 프레임을 섞으면 정지한 카메라에서 흔들리는 샘플을 평균화할 수 있다. 시점이 바뀐 뒤에도 같은 픽셀을 그대로 섞으면 예전 화면의 잔상이 남는다. Buffer B는 갱신 전후의 위치·오른쪽·위쪽 벡터를 비교해 카메라 변경 여부를 상태 픽셀에 기록하고, Buffer A는 다음 프레임에 그 표시와 새 카메라를 함께 읽어 누적 여부를 정한다. 처음 누를 때는 이전 마우스 상태가 0이거나 해제 상태이면 회전 차이를 0으로 두어 시점이 튀지 않게 한다.
bool cameraChanged = cameraInitialized
|| any(notEqual(pos, oldPos))
|| any(notEqual(right, oldRight))
|| any(notEqual(up, oldUp));Kₙ은 B가 기록한 위치·오른쪽·위쪽 벡터의 묶음이고, χₙ은 카메라를 초기화했거나 이 묶음이 바뀌었다는 표시다. B는 시작 0~5프레임에서 카메라를 초기화한다. A는 n=0에서 누적하지 않고, 이후에는 직전 B의 변경 표시가 0일 때만 누적한다. hₙ은 GLSL의 historyValid 조건이다.
Fₙ은 이번 프레임의 FinalColor, Cₙ₋₁은 이전 Buffer A의 누적 색이다. 누적은 ApplyToneMapping()의 색 변환을 거친 값에 적용한다. 카메라 초기화가 끝난 뒤 프레임 n에 W 입력이 들어오면 Bₙ이 카메라를 옮기고 변경 표시를 켠다. n+1의 A는 그 표시를 읽어 누적을 끈 채 새 카메라로 그린다. 추가 이동이 없으면 Bₙ₊₁의 표시는 0이 되고 n+2부터 다시 누적한다.
float cameraChangeFlag = texelFetch(iChannel2, ivec2(iBufWidth - 7, 0), 0).x;
bool historyValid = iFrame > 0 && cameraChangeFlag == 0.0;
if(historyValid) {
PrevColor = texelFetch(iHistoryTex, ivec2(FragCoord.xy), 0);
}
FragColor = historyValid
? (iBlendWeight) * FinalColor + (1.0 - iBlendWeight) * PrevColor
: FinalColor;4.3 topology 오버레이
iTopologyMap=0으로 우측 상단의 side/top 진단 좌표계를 숨겼다. mainImage()의 #if iTopologyMap 블록이 RenderTopologyMap() 호출을 제외하며, 계량과 RK4 적분식은 그대로 둔다. 별도의 analytic shadow-culling 경로도 현재 iEnableShadowCulling=0으로 꺼져 있다.
5. 조작과 실행 비용
입력 | 동작 |
|---|---|
드래그 | 시점 회전 |
W/A/S/D | 전후좌우 이동 |
R/F · Q/E | 상승·하강 · 화면 회전 |
Space | 일시 정지 |
H 또는 왼쪽 위 버튼 | 조작 패널과 내부 해상도 설정 |
- 입력
드래그
- 동작
시점 회전
- 입력
W/A/S/D
- 동작
전후좌우 이동
- 입력
R/F · Q/E
- 동작
상승·하강 · 화면 회전
- 입력
Space
- 동작
일시 정지
- 입력
H 또는 왼쪽 위 버튼
- 동작
조작 패널과 내부 해상도 설정
WebGL2와 EXT_color_buffer_float, OES_texture_float_linear가 필요하다. 한 프레임에 다섯 패스를 실행하고 A와 B에 읽기·쓰기 버퍼를 각각 두므로 GPU에 따라 속도가 달라진다.
내부 해상도는 화면 비율을 유지하며 대략 960×540 수준의 픽셀 예산을 목표로 제한한다. 슬라이더는 각 변의 배율을 조절하므로 절반으로 낮추면 픽셀 수는 대략 4분의 1이 된다. 발열이나 끊김이 심하면 해상도를 낮추거나 일시 정지하면 된다.
6. 모델의 범위
중심부의 fade=1인 계량은 위 물리식과 대응하지만, 먼 렌더 경계에서는 GravityFade로 중력항을 감쇠한다. 원반·제트의 방출, 노이즈, heat haze와 bloom도 함께 화면을 만든다. 이 실험은 이러한 모델을 결합한 시각화다.
원작 주석에는 개발 중인 프로젝트이며 원반·제트 내부 샘플링에서 banding이 생길 수 있다고 적혀 있다. 계량·미분의 CPU 수치 대조와 시간 누적 순서는 검토했으며, 전체 광선의 수렴 오차와 모든 GPU 정밀도 조건, 편광·최대 연장 모드는 추가 검증이 필요하다.
7. 출처
핵심 GLSL과 다섯 패스 구성은 baopinsui의 「Kerr Newman Black Hole」에서 이어받았다. Buffer B/C/D와 Image의 bloom 체인은 소스 주석에 따라 sonicether의 「Gargantua With HDR Bloom」에 크레딧을 둔다. 이 수정판은 WebGL2 호스트를 구성하고 원반 교차 분기, 카메라 이동 뒤 누적 처리, 진단 오버레이를 손봤다.5
각주
- Tim Adamo, E. T. Newman, The Kerr-Newman metric: A Review, §3.1, Eq. (3.15) and the following Cartesian Kerr–Schild expressions; §3.3, Eq. (3.18). ↩
- Christopher J. White, Blacklight: A General-Relativistic Ray-Tracing and Analysis Tool, §2.2, Eqs. (20a–c), Table 2. This reference supplies a comparison of the equations, not evidence that the ShaderToy author used Blacklight code. ↩
- Tim Adamo, E. T. Newman, The Kerr-Newman metric: A Review, §3.3, Eq. (3.18), inner and outer horizons. ↩
- baopinshui, NPGS BlackHole_common.glsl, lines 1565–1580, commit
91305ca1661f. The linked file is the author's engine source; it is not a complete archived copy of the ShaderToy revision. ↩ - baopinsui, Kerr Newman Black Hole, ShaderToy. Buffer B/C/D and Image credit sonicether’s “Gargantua With HDR Bloom” in their source comments. ↩