A high-fidelity, real-time black hole visualization and learning tool for Apple Silicon via the Metal API. The engine integrates exact null geodesics per pixel in two different spacetimes — a single Kerr-Newman hole and an exact Majumdar-Papapetrou binary — renders either a Novikov-Thorne thin disk or an optically-thin plasma torus with full covariant radiative transfer, outputs true HDR on XDR displays, and includes a set of toggleable learning lenses — the same alternative visualizations researchers use in papers (photon-ring image orders, redshift maps, checkerboard lensing skies, EHT beam convolution) — validated against a 107-test analytic GR suite.

- Technical Highlights
- Learning Mode
- Architecture
- GPU Rendering Pipeline
- Physics Model
- Project Structure
- Controls
- Building
- Presets
- Validation
- Security Considerations
- Credits
- Exact Kerr-Newman Metric: Boyer-Lindquist coordinates, full mass + spin (
a) + charge (Q). The charge enters every metric coefficient — the geodesic potentials, the camera tetrad, and the disk emitter — not justΔ. - Super-Hamiltonian Geodesic Integrator: Carter-separated potentials with the momenta
(p_r, p_θ)evolved by Hamilton's equations. Unlike the common±√R, ±√Θroot-tracking form, this passes smoothly through radial and polar turning points — the same choice the Interstellar renderer (DNGR) made for exactly this reason. - Past-Directed Backward Rays: Each pixel traces the past-directed continuation of the arriving photon (
E = −1), not its time-reverse — identical in Schwarzschild but essential in Kerr, where the time-reversed congruence mirrors every frame-dragging asymmetry and pairs the shadow's flattened side with the wrong Doppler side. - Adaptive RK4 Stepping: Inverse-sum step controller (RAPTOR-style) with pole-proximity scaling and a stiffness term that resolves near-axis polar turning points. Coarse in the far field, fine where photons whirl near the photon shell.
- ZAMO Camera Tetrad: Zero-angular-momentum-observer frame, regular inside the ergosphere, with the full Kerr-Newman
g_tt,g_tφ,g_φφcoefficients. - Signed Spin / Retrograde Disks:
a < 0renders a counter-rotating disk anchored at the retrograde ISCO (9M at extremal spin, vs 1M prograde), with a validity guard that emits nothing from spacelike orbits. - N-Body Gravity: GPU velocity-Verlet leapfrog (KDK) at a fixed physics timestep (host-substepped), so orbital accuracy is independent of frame rate.
- Dimensionless Units: Length scale
rs = 2M, withM = ½soΔ = r² − r + a² + Q²stays numerically well-conditioned.
- Kerr-Newman (single hole): mass + spin + charge, Carter-separated, past-directed congruence.
- Majumdar-Papapetrou binary: two extremally-charged holes in exact static equilibrium — a genuine solution of Einstein-Maxwell, not a superposition approximation (gravitational attraction is balanced by electrostatic repulsion). Null geodesics reduce to a strikingly simple exact form,
d²x/dλ² = (2/U)[E²∇U − (∇U·ẋ)ẋ]withU = 1 + Σ mᵢ/|x−xᵢ|, and the camera setup is trivial (ẋ = pixel direction,E = 1). There is no Carter constant here — that is the point: the capture basin is a chaotic scatterer with a fractal boundary and self-similar "eyebrow" structures facing each companion. The Image-order lens renders the capture basins directly; the Checkerboard sky shows the lensing.
- Thin disk (optically thick): Novikov-Thorne surface at the equator — the classic Luminet/EHT thin-disk image.
- Volumetric torus (GRMHD-style): an optically-thin plasma torus integrated with the covariant radiative-transfer equation
d(I_ν/ν³)/dλ = j_ν/ν² − (ν α_ν)(I_ν/ν³), with a non-Keplerian rotation law set by a specific-angular-momentum profilel(R)(the EHT code-comparison parameterization, Gold et al. 2020). Emissivity scales asn²(two-body/free-free), and self-absorption gives the flow a real photosphere. The Doppler asymmetry emerges from the transport rather than being applied by hand — at spectral indexα_s = −2it cancels exactly, an identity the test suite verifies to machine precision.
- Extended Dynamic Range: a half-float drawable in extended-linear space with a tonemap that keeps the SDR look bit-for-bit below the knee and re-expands only the crushed highlights, so the Doppler-boosted inner limb genuinely emits ~2× above reference white on a Liquid Retina XDR panel instead of clipping to it.
- Optically-Thick Novikov-Thorne Disk: Page-Thorne flux
F ∝ (r_in/r)³(1 − √(r_in/r))— zero at the ISCO (zero-torque boundary condition), peak at(49/36) r_in. - Physical Doppler Color: A shifted blackbody is exactly another blackbody at
T_obs = g·T_emit. The disk color comes from a baked Planck-spectrum → CIE → sRGB lookup, so the approaching limb genuinely turns blue-white and the receding limb red — no RGB tinting. - Correct Beaming Sign: The g-factor is evaluated with the arriving photon's angular momentum (the backward-traced ray carries the opposite
L_z), so the approaching limb brightens asg⁴— verified end-to-end by the test suite. - Photon Rings from Geodesics: Higher-order images emerge naturally from the integrator; the Photon Ring Boost slider (0 = physical) amplifies
n ≥ 1images for visibility. - Companion Stars with Occlusion: N-body stars are sphere-intersected in world space from the ray's escape point — occluded by the disk and horizon, lensed by the geodesic bending, limb-darkened.
- Temporal Accumulation AA: Per-frame Halton subpixel jitter accumulates into a progressive supersample whenever the camera is static (reference-quality stills in ~64 frames); bounded history while the N-body animation runs.
- Polar Relativistic Jets, Ergosphere Shimmer (decorative, clearly gated).
- Perspective, 360° equirectangular (2:1, for VR / spherical-video players), and Mollweide all-sky — a ray tracer gets these almost free, since only the pixel→direction mapping changes.
- Reference stills: an offline exporter renders at arbitrary resolution and projection with N accumulated jittered samples, independent of the window, and writes a bit-exact scene-linear half-float OpenEXR. The file holds true radiometric values with no tonemapping or UI baked in: a typical capture peaks near 28× reference white with ~19% of components above 1.0.
- The EXR writer is vendored (
include/exr_out.h, ~180 lines, libc + zlib) rather than using macOS ImageIO, which can write OpenEXR but runs pixels through an ICC transform first — measured here, that perturbs 35.5% of pixels and leaks nonzero values into channels whose source was exactly 0.0, so the shadow interior stops being exactly black. Disqualifying for reference data. - Panoramas are levelled: spherical projections lock the pole to the spin axis and put the image centre on the horizontal, because a viewer in a headset cannot roll or pitch to compensate for a baked-in tilt. They are also forced to 2:1.
- Since ImageIO silently drops metadata from EXR, a panorama also gets a tonemapped 16-bit PNG companion carrying the GPano XMP so players recognise it as 360.
- The EXR writer is vendored (
- ACES Filmic Tonemapping with exposure applied before all display-referred effects.
- Auto-Exposure: Mean log-luminance metering that excludes empty sky, targeting middle gray.
- MPS Bloom with a hue-preserving, exposure-consistent luminance threshold.
- Anamorphic Flare, Vignette (toggleable), Film Grain (applied after the tonemap, luminance-scaled, as real grain behaves).
- Motion Blur as an exponential accumulation in linear HDR.
- Triple Buffering with a race-free auto-exposure readback (reads the slot the semaphore guarantees complete).
- Precompiled
.metallib(dependency-tracked in the build; falls back to runtime compilation) with one consistent precision policy on both paths: relaxed math (Inf/NaN preserved), a documented 3× performance trade-off over safe math; full fast math is forbidden. - Adaptive stepping typically converges rays in a few hundred steps; measured ~30 fps (thin disk) and ~21 fps (volumetric torus) at 2400×1600 on an M4 Max.
- Optional closed-form geodesics (Gralla-Lupsasca elliptic integrals): evaluates equatorial crossings directly instead of stepping. Measured 4x faster on the raytrace where the disk fills the frame (16.6 -> 3.9 ms/frame), and break-even on sky-dominated framings, since rays that miss the disk still fall back to RK4 and mixed threadgroups pay both. Agreement with the RK4 path: 0.018% of pixels differ in image order, all of them anti-aliased category edges — that dual-path agreement is itself a validation of both.
- SIMD-Reduced Metering: one atomic per simdgroup.
- ARC enabled on the Objective-C++ sources (no per-frame leaks).
The Learning panel switches the renderer between the visualizations the research community actually uses. Lenses are exclusive full-screen remappings; overlays and physics switches stack on top of any lens.
| Kind | Control | What it teaches | Precedent |
|---|---|---|---|
| Lens | Standard | The photographic image | Luminet 1979, DNGR |
| Lens | Image order | False-color by equatorial crossing count: n=0 direct, n=1 lensed far-side/underside, n=2 photon ring | Gralla-Holz-Wald 2019, EHT photon-ring papers |
| Lens | Redshift map | Diverging blue-white-red map of g = E_obs/E_emit at the first disk hit |
Standard GRRT paper figure |
| Lens | Checkerboard sky | Lat-long checkerboard background exposes pure lensing; repeated patches = image orders | DNGR paint-swatch test, Bohn et al. |
| Lens | EHT view | Image convolved with a telescope restoring beam (FWHM slider in rs) through a radio colormap | EHT Paper IV |
| Switch | Doppler/beaming | Full g⁴ / color-shift-only (the Interstellar convention) / off (Luminet bolometric) |
DNGR Fig. 15 decomposition |
| Overlay | Orbit markers | Coordinate-space horizon, ergosphere, photon orbit, ISCO circles | Outreach "anatomy" diagrams |
| Overlay | Critical curve | Bardeen's analytic shadow boundary drawn over the live image — the rendered shadow edge must land on it | Bardeen 1973, EHT Paper VI |
| Overlay | Geodesic fan | 2D equatorial panel: parallel rays deflecting, orbiting, and being captured | Textbook figures, Müller's teaching tools |
| Overlay | Spacetime grid | Embedding-diagram gravity well (star-system scale) | Standard outreach visual |
Press L to cycle lenses. Every false-color lens draws its own colorbar legend and caption.
graph TD
subgraph Host ["CPU Host (main.mm)"]
GLFW["GLFW Window + Input"]
CAM["Camera (Orbital)"]
IMGUI["ImGui Control Panel<br/>+ Learning overlays"]
UNI["Uniform Upload<br/>(Triple-Buffered)"]
FAN["Geodesic-fan CPU mirror<br/>Bardeen critical curve"]
end
subgraph GPU ["Metal GPU Pipeline"]
PHYS["N-Body Physics<br/>(fixed-dt substeps)"]
RAY["Geodesic Raytracer<br/>(Hamiltonian RK4, adaptive)"]
TA["Temporal Accumulation<br/>(jitter supersampling)"]
BLOOM_EX["Bloom / EHT-beam Extract"]
MPS["MPS Gaussian Blur"]
LUM["Luminance Metering"]
POST["Post-Processing<br/>(exposure → bloom → ACES)"]
GRID["Grid Renderer"]
end
GLFW --> CAM --> UNI
IMGUI --> UNI
FAN --> IMGUI
UNI --> PHYS --> RAY
RAY --> TA --> BLOOM_EX --> MPS --> POST
TA --> LUM --> POST
TA --> POST
POST --> DRAW(("Present"))
GRID --> DRAW
IMGUI --> DRAW
Each frame dispatches, in order:
- N-Body physics — velocity-Verlet substeps at fixed
dt ≈ 9.3e4 s(frame-rate independent). - Geodesic raytrace — full resolution, jittered, adaptive steps, → raw HDR.
- Temporal accumulation — replace / progressive-supersample / motion-blur EMA, selected by camera state.
- Bloom or EHT-beam extraction (half res) + MPS Gaussian blur.
- Luminance metering (1 sample per 4×4 block, simd-summed, sky excluded).
- Post-processing — exposure → bloom → flare → vignette → ACES → grain → sRGB gamma. False-color lenses bypass the photographic chain.
- Grid render + overlays + ImGui.
Per pixel: decompose the jittered camera ray in the ZAMO tetrad → conserved (E=1, L_z, Q_C) and initial momenta (p_r, p_θ) → adaptive RK4 on (r, θ, φ, p_r, p_θ):
dr/dλ = Δ p_r / Σ
dθ/dλ = p_θ / Σ
dφ/dλ = [a(r − Q²) − a²L]/(ΔΣ) + L/(Σ sin²θ)
dp_r/dλ = [(R/Δ)' − Δ' p_r²] / (2Σ)
dp_θ/dλ = [−2a² sinθ cosθ + 2L² cosθ/sin³θ] / (2Σ)
with R(r) = P² − Δ[(L−a)² + Q_C], P = r² + a² − aL, Σ = r² + a²cos²θ, Δ = r² − r + a² + Q². Turning points cost nothing — the momenta pass smoothly through zero. Equatorial crossings are detected by the sign flip of cos θ and interpolated to sub-step accuracy (valid for both crossing directions, so the disk renders correctly from below the plane). Disk hits terminate the ray (optically thick); escaped rays reconstruct a local exit direction for sky and star sampling. A polar-cap bypass handles the Boyer-Lindquist axis singularity exactly (φ jumps by π through the pole).
| Property | Formula | Source |
|---|---|---|
| Inner Edge | r_in = max(r_isco, 1.2 r_+), signed-spin ISCO branch |
Bardeen-Press-Teukolsky (1972) |
| Flux | F ∝ (r_in/r)³ (1 − √(r_in/r)) — zero at ISCO, peak at (49/36) r_in |
Page-Thorne |
| Temperature | T = T_peak · F_norm^(1/4) |
Novikov-Thorne |
| Circular-orbit Ω | Ω = √(Mr − Q²) / (r² + a√(Mr − Q²)), signed a |
Kerr-Newman circular geodesics |
Emitter U^t |
1/√(−g_tt − 2g_tφΩ − g_φφΩ²) (KN coefficients), no emission if spacelike |
Four-velocity normalization |
| g-factor | g = 1/(U^t(1 − Ω L_arr)) with L_arr the arriving photon's L_z |
Cunningham 1975 |
| Observed color | Blackbody at T_obs = g·T via Planck×CIE LUT |
Liouville / DNGR |
| Observed intensity | F_norm · min(g⁴, 15) (bolometric; cap keeps HDR in tonemap range) |
Liouville invariant |
| Static Limit | r_E(θ) = M + √(M² − a²cos²θ) |
Kerr |
| Feature | a=0 | a=0.9 | a=0.998 | a=−0.9 (retro) | Q=0.5 (RN) |
|---|---|---|---|---|---|
| Horizon r₊ | 1.000 | 0.718 | 0.532 | 0.718 | 0.933 |
| ISCO | 3.000 | 1.160 | 0.540 | 4.359 | 3.000* |
| Photon orbit | 1.500 | 0.778 | 0.536 | 1.955 | 1.411 |
| Shadow b_c | 2.598 | 1.42–3.42 | D-shaped | mirrored | 2.484 |
*Kerr ISCO used for charged holes (documented approximation, floored at 1.2 r₊).
metal_blackhole/
├── src/
│ └── main.mm # Metal engine, ImGui panel, overlays, N-body scene
├── shaders/
│ └── geodesic.metal # All GPU kernels (raytrace, accumulate, post, physics, grid)
├── include/
│ ├── ShaderCommon.h # Shared CPU/GPU struct definitions + lens/beaming enums
│ └── Camera.h # Orbital camera controller
├── scripts/
│ └── build_metallib.sh # Offline shader precompilation (fast math off)
├── tests/
│ └── validate_physics.py # 71-test physics validation suite (fp64 shader mirror)
├── libs/imgui/ # Dear ImGui (vendored)
├── RENDERING_INVARIANTS.md # Critical shader invariants & lessons learned
├── CMakeLists.txt
└── README.md
| Input | Action |
|---|---|
| Left Click + Drag | Rotate camera orbit |
| Shift + Left Click + Drag | Pan camera target |
| Scroll Wheel | Zoom in / out |
| L | Cycle learning lenses |
| E | Write the accumulated frame as a scene-linear EXR |
| R | Reference still: 4K, 256 accumulated samples → EXR |
| 3 | 360° panorama: 4096×2048 equirectangular, 128 samples → EXR |
| P | Capture screenshot (PPM) |
| Escape | Quit |
QA/scripting hooks (environment variables): BH_QA=1 renders 100 frames, captures frame 90, and exits; BH_ELEV, BH_AZIM, BH_SPIN, BH_CHARGE, BH_LENS, BH_BEAMING, BH_OVERLAYS=mcfg, BH_MODEL (0 thin / 1 volumetric), BH_ALPHA, BH_ABSORB, BH_JETS, BH_NOEDR, BH_BINARY=<separation>, BH_M2, BH_PROJ (0/1/2), BH_RADIUS, BH_EXR, BH_PANO, BH_STILL, BH_SHOT_DIR override state for reproducible captures. Captures report the peak linear value and the display's EDR headroom.
- macOS with Apple Silicon (M1–M4)
cmake≥ 3.16glfwandglm(via Homebrew or vcpkg)- Xcode (for precompiled
.metallib; optional — falls back to runtime compilation)
brew install cmake glfw glm
mkdir build && cd build
cmake ..
make
./MetalBlackholeShader edits are dependency-tracked: make refreshes the .metallib and the runtime-compile fallback copies.
| Preset | Spin (a) | Charge (Q) | Notes |
|---|---|---|---|
| Schwarzschild | 0.0 | 0.0 | Pure GR baseline |
| Kerr | 0.7 | 0.0 | Frame dragging |
| Extreme Kerr | 0.998 | 0.0 | Near-maximal spin + jets |
| Charged (RN) | 0.0 | 0.5 | Reissner-Nordström (r_ph = 1.411 rs) |
| Kerr-Newman | 0.6 | 0.3 | Full KN metric |
| Cinematic | 0.85 | 0.0 | All visual effects + grid |
| EHT M87* view | 0.9 | 0.0 | 17° inclination, beam-blurred EHT lens |
| MP binary | — | extremal | Two exact-equilibrium holes; fractal capture basins |
| Volumetric torus | 0.9 | 0.0 | GRMHD-style optically-thin plasma flow |
| Luminet 1979 | 0.0 | 0.0 | Near edge-on classic view |
Spin and charge are jointly clamped to a² + Q² ≤ 1 (no silent naked-singularity renders).
python3 tests/validate_physics.py
# Expected: 107 passed, 0 failedThe suite mirrors the shader integrator in double precision (same ZAMO Kerr-Newman tetrad, same Hamiltonian RHS, same adaptive controller) and verifies it against closed-form GR:
| § | Test | Reference |
|---|---|---|
| 1 | Kerr and Kerr-Newman horizons, extremal RN | closed form |
| 2 | BPT ISCO, prograde and retrograde branches (9M limit) | BPT 1972 |
| 3 | Photon-sphere instability — a genuinely dynamical test | null geodesic condition |
| 4 | Capture boundary bisection: ` | L |
| 5 | Exact Darwin deflection at b = 3…10 rs to ~1e-5 (through periapsis) | Darwin 1959 quadrature |
| 6 | RN photon sphere + traced RN capture cross-section | closed form |
| 7 | End-to-end Doppler sign: approaching limb g>1 from first principles | catches inversion bugs |
| 8 | Cunningham ISCO limb extremes: g = √2, √2/3, 1/√2 exactly | Cunningham 1975 |
| 9 | Carter constant (from evolved p_θ) + null-norm drift, on strong-field photon-shell grazes | genuine conservation |
| 10 | KN circular-orbit Ω: closed form vs numerical geodesic condition | metric derivatives |
| 11 | ZAMO frequency, static limit r_E(θ) | exact Kerr |
| 12 | Disk visible from below the plane (ascending-crossing regression) | — |
| 13 | Traced shadow boundary = Bardeen critical curve (a=0.9, both sides) + shadow-flattening/Doppler-side pairing | Bardeen 1973 |
| 14 | Adaptive-integrator self-convergence | step-halving |
| 15 | Page-Thorne flux shape (zero at ISCO, peak at 49/36 r_in) | Page-Thorne |
| 16 | Accuracy at the exact shipped GPU step constants (honest gates) | Darwin quadrature |
| 17 | Through-the-pole continuation (φ jumps by π across the axis) | BL chart continuation |
| 18 | Volumetric Doppler-cancellation identity at α_s = −2 (machine precision, with an α_s = 0 asymmetry control) | Gold et al. 2020 Test 2 |
| 19 | Radiative-transfer limits: absorption dims monotonically; optically-thick intensity saturates at the source function S = J/A |
transfer equation |
| 20 | MP single hole = extremal Reissner-Nordström: critical impact parameter b = 4m to 3×10⁻⁸ — reached through a completely independent formulation from the Carter-separated path that gets the same number in §6 |
exact solution |
| 21 | MP binary: L_x conserved, |ẋ| = E drift < 1e-6 (measured, not re-projected in the mirror), and exact x → −x reflection symmetry for equal masses |
conservation / symmetry |
| 22 | Wide-separation limit: each hole's shadow → the isolated 4m value to 1 part in 10⁴ |
asymptotics |
| 23 | Extremal-charge signature in the deflection: MP-traced bending matches the exact extremal-RN quadrature to 1.1×10⁻⁴, and the second-order coefficient is 3π — not Schwarzschild's 15π/4 |
Darwin-type quadrature |
| 24 | Photon-ring Lyapunov exponent γ = E/(√2 m) to 1.7×10⁻⁴ — the rate that sets how fast successive photon-ring images demagnify |
closed form |
| 25 | Equatorial crossing radii vs independent 40-digit ground truth — 9 fixtures (Schwarzschild, Kerr to a/M=0.998, Kerr-Newman, Reissner-Nordström, a captured ray, a close camera) matched to ≤1.3×10⁻⁶, with no shared code between the reference and the integrator under test | Gralla-Lupsasca closed form + quadrature arbiter |
- Precompiled, signed
.metallibpreferred at startup (no runtime source compilation in release use). - All GPU uniforms clamped at the CPU→GPU write site; spin/charge jointly clamped to the black-hole family.
- Screenshots open with
O_CREAT|O_EXCL|O_NOFOLLOW(no symlink clobbering, no overwrites). - Build enables
-Wall -Wextra -Wformat-security -fstack-protector-strong; the codebase compiles warning-free.
Built by mstits.