Kerr–Newman Black Hole — ShaderToy Edition
A WebGL2 edition based on the ShaderToy original, with disk-crossing and camera-history changes and a guide to the equations in the code.
AI helped prepare this article’s explanations, review the equations and code, and publish the LAB entry. Credits for the core GLSL and derived code are listed at the end.
A black-hole visualization that traces rays through Kerr–Newman spacetime and combines light from an accretion disk and jets in five WebGL2 passes.
1. Overview
The experiment traces rays backwards from each camera pixel, collecting light from the disk and background along the path. Near the black hole, strong bending makes the far side of the disk appear to wrap above and below it, while background stars appear in shifted positions.
Ray trajectories and the disk’s appearance are calculated separately. The Kerr–Newman metric and geodesic equations determine the trajectories; separate models describe disk thickness, density, temperature and jet emission. Temporal accumulation and bloom add the bright disk’s surrounding glow.
The defaults are dimensionless spin a*=0.997114514 and charge Q*=0. The initial scene is therefore the rotating, uncharged Kerr case, with polarization, maximal-extension and grid modes disabled. Dragging the view moves the disk and background together.
2. Rendering pipeline
Buffer A handles the core ray tracing. The remaining passes process camera state and bloom, then composite the final colour. main.js connects the passes and supplies time, resolution, keyboard and mouse values to the shaders.
Pass | Inputs | Role |
|---|---|---|
Buffer A | Previous B camera · previous A colour · keyboard | Ray integration, disk/jet emission, background and temporal accumulation |
Buffer B | Current A · previous B state · keyboard | Update camera state; pack eight bloom levels |
Buffer C | Current B | Horizontal Gaussian blur |
Buffer D | Current C | Vertical Gaussian blur |
Image | Current A/B/C/D | Reconstruct bloom and composite the final colour |
- Pass
Buffer A
- Inputs
Previous B camera · previous A colour · keyboard
- Role
Ray integration, disk/jet emission, background and temporal accumulation
- Pass
Buffer B
- Inputs
Current A · previous B state · keyboard
- Role
Update camera state; pack eight bloom levels
- Pass
Buffer C
- Inputs
Current B
- Role
Horizontal Gaussian blur
- Pass
Buffer D
- Inputs
Current C
- Role
Vertical Gaussian blur
- Pass
Image
- Inputs
Current A/B/C/D
- Role
Reconstruct bloom and composite the final colour
A and B use ping-pong textures, alternating between read and write buffers. The host draws A and swaps its buffers, then draws B and swaps its buffers. A therefore reads the previous frame’s camera state from B, while B reads the A image just rendered.
B packs the bright signal at several scales and stores camera position and orientation in a small strip of bottom-row pixels. C and D blur along the horizontal and vertical axes; Image combines their results into a broad glow. Colour buffers use RGBA32F with linear filtering.
3. Mathematical model and implementation
3.1 Kerr–Schild metric
The Kerr–Schild metric adds a term constructed from a null vector ℓ to the flat-spacetime metric η. M denotes mass, a the spin parameter, Q charge, and r the radius satisfying the coordinate relation below. The equations use the code’s y rotation axis and (+,+,+,−) signature.1
KerrSchildRadius() computes r, and ComputeGeometryScalars() evaluates f and ℓ. Despite its name, z2 is the square of X.y. The implemented f includes a fade multiplier and a 10⁻²⁰ denominator floor.
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;Because ℓ is null, the inverse metric can also be written with a single correction term, now subtracted. RaiseIndex() uses this inverse to raise covariant momentum into the ray tangent.
3.2 Ray integration and RK4
Position x and covariant momentum p evolve together under the Hamiltonian geodesic equations. H=0 is the null condition, and λ parametrizes the path. ℓ defines the metric decomposition; p tracks an individual ray.2
GetDerivativesAnalytic() computes position and momentum derivatives analytically. Since the metric has no explicit time dependence, deriv.P.w is 0. StepGeodesicRK4_Optimized() combines four slopes evaluated at intermediate states as shown below.
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);Step size depends on distance to the singular ring, position and momentum derivatives, and iQuality. On the positive-r sheet, the code also attempts a post-step momentum correction towards the null condition, checking the discriminant and denominator and limiting large corrections.
3.3 Horizons and emission
The inner and outer horizon radii are the two roots of Δ=0. Real horizons require M²≥a²+Q²; the code sets bIsNakedSingularity separately for a negative discriminant. Lengths are normalized with CONST_M=0.5, and a and Q use iSpin*CONST_M and iQ*CONST_M respectively.3
DiskColor() and jet sampling accumulate emission and extinction along ray segments. Radial profiles and noise control disk thickness and density, while the frequency ratio calculated from emitter motion and photon momentum affects colour temperature and brightness. Heat haze, which adjusts the initial ray direction, and photon-ring brightness boosting are enabled by default.
4. Modifications and how they work
4.1 Disk mid-plane crossing
A ray segment can pass through a thin disk even when both endpoints lie outside it. DiskColor() checks for a sign change in y between the subsegment endpoints PosPrev and PosNext. The public NPGS source calculates this condition and then overwrites it with crossed=false. This edition removes that assignment so the crossing branch runs.4
When the denominator floor is inactive, C× is the straight segment’s intersection with y=0. The actual sample position adds a random offset bounded by disk thickness. T is Thin, d̂ is ChordDir, and u is the value from RandomStep() in [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;
}A segment descending from y=1 to y=−1 crosses the plane at t×=0.5. With Thin=0.1 and u=0.75, the actual sample moves 0.05 along the ray from that point. An endpoint exactly on y=0 fails the strict sign-change condition, and the denominator floor can leave C× off the plane for extremely small segments.
4.2 Camera movement and temporal accumulation
Blending previous frames averages jittered samples for a stationary camera. Continuing to blend the same pixels after a view change leaves a ghost of the old image. Buffer B compares the position, right and up vectors before and after its update and records a camera-change flag in a state pixel. On the next frame, Buffer A reads that flag alongside the new camera to decide whether to accumulate. On an initial press, a previous mouse state of zero or released suppresses the rotation delta, preventing a view jump.
bool cameraChanged = cameraInitialized
|| any(notEqual(pos, oldPos))
|| any(notEqual(right, oldRight))
|| any(notEqual(up, oldUp));Kₙ is the position, right and up vector tuple stored by B. χₙ flags camera initialization or a change to that tuple. B initializes the camera in frames 0–5. A skips accumulation at n=0 and subsequently accumulates only when the previous B change flag is zero. hₙ denotes the GLSL historyValid condition.
Fₙ is the current FinalColor, and Cₙ₋₁ is the accumulated color from the previous Buffer A. Accumulation operates on values after the color conversion in ApplyToneMapping(). After camera initialization, a W input in frame n moves the camera in Bₙ and sets its change flag. A in frame n+1 reads that flag and renders from the new camera with accumulation disabled. With no further movement, the flag in Bₙ₊₁ becomes zero and accumulation resumes at 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 overlay
iTopologyMap=0 hides the upper-right side/top diagnostic inset. The #if iTopologyMap block in mainImage() excludes the RenderTopologyMap() call, leaving the metric and RK4 equations unchanged. The separate analytic shadow-culling path is also disabled with iEnableShadowCulling=0.
5. Controls and runtime cost
Input | Action |
|---|---|
Drag | Rotate the view |
W/A/S/D | Move forward, left, back and right |
R/F · Q/E | Rise/fall · roll the view |
Space | Pause |
H or upper-left button | Controls and internal resolution |
- Input
Drag
- Action
Rotate the view
- Input
W/A/S/D
- Action
Move forward, left, back and right
- Input
R/F · Q/E
- Action
Rise/fall · roll the view
- Input
Space
- Action
Pause
- Input
H or upper-left button
- Action
Controls and internal resolution
WebGL2, EXT_color_buffer_float and OES_texture_float_linear are required. Each frame runs five passes, and A and B each need separate read and write buffers, so performance varies by GPU.
Internal resolution preserves the viewport aspect ratio and targets roughly a 960×540 pixel budget. The slider scales each dimension, so halving it reduces pixel count to about one quarter. Lower the resolution or pause the experiment if it causes excessive heat or stutter.
6. Model scope
In the central region, the metric with fade=1 corresponds to the equations above; farther out, GravityFade attenuates gravity towards the rendering boundary. Disk and jet emission, noise, heat haze and bloom also shape the image. This experiment combines those models into a visualization.
The original comments describe a work in progress and possible banding in disk and jet subsampling. CPU comparisons of the metric and derivatives, along with temporal-history ordering, have been checked. Full-ray convergence error, all GPU precision cases, and polarization and maximal-extension modes still need further validation.
7. Sources and credits
The core GLSL and five-pass pipeline come from baopinsui’s Kerr Newman Black Hole. Source comments credit the bloom chain in Buffer B/C/D and Image to sonicether’s Gargantua With HDR Bloom. This edition provides a WebGL2 host and changes disk crossing, accumulation after camera movement, and the diagnostic overlay.5
Footnotes
- 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. ↩