Black holes, rendered

Three spacetimes traced by hand, and the light a black hole makes for itself.
numpy and one CPU; nothing in the pictures is painted on. Where a feature appears, the equations put it there.

1. A Schwarzschild black hole with a disc

The control: the simplest black hole, seen the way the famous pictures see it.

Geometric units, G = c = 1, the mass M = 1: the horizon at r = 2, the photon sphere at 3, the innermost stable circular orbit at 6. A thin, opaque, glowing dust disc from 6 M to 18 M; a camera at 40 M, 78 degrees off the disc's axis, so the far side of the disc is lensed over and under the shadow. Every pixel's light ray is integrated once, backwards from the camera, in its own orbital plane with the Binet equation u'' = −u + 3u² (u = 1/r), fourth-order Runge-Kutta, 921,600 rays, until it falls through the horizon (black), escapes to a faint star field looked up by its exit direction, or crosses the disc's plane between the two radii. A crossing keeps the disc's radius and azimuth there and the redshift factor g = √(1 − 3/r) / (1 + Ω bz) of a Keplerian emitter, which carries the gravitational and the Doppler shift together. Only the first crossing counts: the disc is opaque.

Each frame is then a lookup: emission g⁴ (1 − √(6/r)) r−3 times a multi-octave dust pattern in (log r, φ) that winds up under differential rotation, coloured by the observed temperature on a warm ramp. The picture has what the famous ones have because the physics puts it there: the shadow, the disc in front, its far side lensed into an arch above and a lobe below, the approaching side beamed bright, and the thin photon ring inside the shadow.

blackhole-disc-10s.mp4 · 1280×720, 240 frames at 24 fps, 20 MB. The Schwarzschild disc. The dust winds up under differential rotation, one turn in four seconds at the inner edge.
Frame 120 of the film.
blackhole-disc-frame120.png. Frame 120 of the film.

The same film through a motion-field reader

This work sits beside a video-interpolation project whose shaders estimate a velocity per texel in real time and can paint that estimate over the picture: hue for direction, opacity for speed. The black hole's disc is a warped, differentially rotating surface, and this is its field as the shader reads it: the picture on the left, the velocity reading on the right.

blackhole-disc-picture-velocityreading.mp4 · 2560×720, 240 frames at 24 fps, 26 MB. Picture and velocity reading side by side.

2. Black holes that are not Schwarzschild

Every exact solution sets something to zero. Render one that is not the Schwarzschild metric.

There is no general black hole to render: the general case exists only in numerical relativity, and even there as two holes merging on a supercomputer. What can be done is two steps out from Schwarzschild, the second of them beyond any exact solution. The second tracer, geodesic_disc.py, does not care which spacetime it is given. It integrates Hamilton's equations for a null geodesic in the full four dimensions, H = ½ gμν pμ pν = 0, with the metric entering only as its five covariant components gtt, gtφ, grr, gθθ, gφφ as functions of (r, θ). The inverse is taken numerically and the derivatives by central differences, so any stationary axisymmetric metric can be dropped in without deriving anything. The step shrinks toward the horizon and toward the coordinate poles (without the latter a seam appears at the top of the shadow, where rays pass over the pole). The disc's innermost stable orbit, orbital frequency and ut are found numerically from the metric, so a new metric brings its own disc; the redshift is Cunningham's g = 1 / (ut (1 − Ω pφ)). The same camera as before: a zero-angular-momentum observer at 40 M, 78 degrees off the axis.

metricwhat it ishorizoninnermost stable orbitrays at 960×540: hit / fell / escaped
schwarzschilda = 0, the control; the general tracer reproduces the first one2.0006.00186,972 / 27,470 / 303,958
kerrspin a = 0.9: frame dragging, the shadow flattened into a D on its prograde side, the disc reaching in to a third of the radius1.4362.32213,379 / 16,055 / 288,966
jpJohannsen & Psaltis (2011): that Kerr metric with the deformation h = ε3 M³ r / Σ² in gtt, gtφ, grr, gφφ, ε3 = 3. It solves no vacuum field equation; it is the kind of parametrised non-Kerr black hole astronomers test the no-hair theorem against1.4361.46218,648 / 9,415 / 290,337
blackhole-triptych-schwarzschild-kerr09-johannsenpsaltis-5s.mp4 · 2880×540, 120 frames at 24 fps, 13 MB. The three metrics from one camera: Schwarzschild, Kerr at a = 0.9, and the Johannsen-Psaltis deformation. The JP disc is smaller and dimmer and reaches almost to the horizon: its image is whatever integrating its light paths gives, nothing else.
Frame 60 of the triptych.
blackhole-triptych-frame60.png. Frame 60 of the triptych.
blackhole-schwarzschild-5s.mp4 · 960×540, 120 frames at 24 fps, 6 MB. Schwarzschild alone.
blackhole-kerr-5s.mp4 · 960×540, 120 frames at 24 fps, 5 MB. Kerr, a = 0.9, alone.
blackhole-jp-5s.mp4 · 960×540, 120 frames at 24 fps, 3 MB. Johannsen-Psaltis, a = 0.9, ε3 = 3, alone.

The Johannsen-Psaltis hole at 4K

The same tracer at 3840×2160: 8,294,400 rays in 6,238 seconds on one CPU, traced in four horizontal bands with a checkpoint per band (a run that is interrupted resumes where it stopped). 3,498,121 rays hit the disc, 150,703 fell in, 4,645,576 escaped.

blackhole-johannsenpsaltis-4k-10s.mp4 · 3840×2160, 240 frames at 24 fps, 59 MB. The Johannsen-Psaltis black hole at 4K. A large file.
Frame 120 at full resolution (3 MB).
blackhole-johannsenpsaltis-4k-frame120.png. Frame 120 at full resolution (3 MB).

The triptych through the motion-field reader

blackhole-triptych-velocityreading-5s.mp4 · 2880×540, 120 frames at 24 fps, 12 MB. The three discs as the interpolation shader reads them.

3. Hawking radiation, rendered as far as the mathematics allows

Hawking radiation is undetectable in nature because it is fainter than the cosmic background. A synthetic space has no background. Can a black hole be rendered close enough to see the never-seen glow?

The answer has a negative half, computed rather than quoted, and two honest pictures.

The negative half

greybody.py integrates the electromagnetic Regge-Wheeler equation for the photon modes born at the horizon, at 260 frequencies and eight multipoles: fourth-order Runge-Kutta in the tortoise coordinate from r = 3000 M in to r − 2M = 10−7, with the outgoing and ingoing parts separated at the horizon end to give the transmission. The checks: the transmission tends to 1 at high frequency; the dipole's goes as ω4.1 at low frequency (theory: 4); the multipole sum tends to the capture cross-section 27ω² above ω ~ 1/M; and the total photon power comes out 3.364×10−5 ℏc⁶/G²M² against Page's 1976 value of 3.36×10−5.

The greybody factors by multipole (top) and the photon power spectrum (bottom) against the blackbody it is usually drawn as.
hawking-spectrum-chart.png. The greybody factors by multipole (top) and the photon power spectrum (bottom) against the blackbody it is usually drawn as.

The potential barrier at r = 3M throws the long wavelengths back in, so the photon spectrum peaks at ωM = 0.243, six times the Hawking temperature where a blackbody peaks at 2.8, and that is a wavelength of 25.8 M, which is 12.9 horizon radii for a hole of any mass. 98% of the power is in the ℓ = 1 dipole (transmission 0.415 at the peak; the quadrupole's is 0.0004). A black hole radiating at its own peak is a pure dipole. Its light carries no image of it, at any distance, in any instrument: an emitter thirteen times smaller than its light is a point, and going closer never changes that. The wished-for render of a hole lit by its own Hawking glow does not exist, not for lack of signal but for lack of wavelength.

quantityvalue
photon power, this solver / Page 19763.364×10−5 / 3.36×10−5 ℏc⁶/G²M² (24% of the blackbody over the capture area)
spectrum peakωM = 0.243 = 6.1 TH; wavelength 25.8 M = 12.9 horizon radii
share of the power by multipoleℓ = 1: 98.0%, ℓ = 2: 2.0%, the rest nothing
mean photon energy5.7 TH

The mass ladder, photons only, the peak wavelength 25.8 GM/c²:

massTHhorizonpeak wavelengthpowernote
1018 kg123,000 K1.5 nm19 nm0.58 mWultraviolet
2.0×1019 kg6,000 K30 nm392 nm1.4 µWthe Sun's colour; visible at arm's length as a bright star
6.1×1019 kg2,000 K91 nm1.2 µm150 nWthe film's hole: a 35-km asteroid's mass; a faint orange star at arm's length
1021 kg123 K1.5 µm19 µm0.58 nWinfrared
4.5×1022 kg2.73 K67 µm0.86 mm0.28 pWTH equals the cosmic background: heavier holes absorb more than they emit, which is the observation problem in one line
the Moon1.7 K0.11 mm1.4 mm0.11 pW
the Earth0.02 K8.9 mm11 cm1.6×10−17 W
the Sun6.2×10−8 K2.95 km38 km1.5×10−28 W

Picture one: the approach

Ray optics, honest for the short-wavelength tail of the spectrum and stated as such. A static, hovering observer descends from 40 M to 2.02 M looking straight down and straight up. Every direction, traced backwards, either came from the horizon, carrying the Hawking glow (uniform: a blackbody at TH / √(1 − 2M/r), the same in every direction for a static observer), or from infinity, carrying nothing but starlight, lensed and blueshifted. That is the Unruh state, an evaporating hole in empty space: exactly the no-background case of the question. The border is the escape cone, sin ψe = (3√3 M / r) √(1 − 2M/r), analytic and confirmed by the tracer on every frame.

So the glow is the shadow: the disc that is black under external light is precisely the set of directions that carry Hawking flux, seven degrees across at 40 M, half the sky at 3 M, and at 2.02 M everything but a fifteen-degree cone straight up, into which the whole universe is compressed, Einstein rings at its rim. The colour runs orange-red (2000 K) through white (4900 K at 2.4 M) to blue-white (20,100 K at 2.02 M) while the camera's exposure drops 17 stops; the caption counts them. The thrust needed to hover ends at 2.46 c⁴/GM, five hundred thousand billion billion g, and there the glow's temperature TH / √(1 − 2M/r) tends to a / 2π: the hovering observer's thermometer reads the Unruh temperature of its own acceleration. At the horizon, Hawking's radiation and Unruh's are one thing.

hawking-approach-hover-40M-to-2.02M-10s.mp4 · 1920×1040, 240 frames at 24 fps, 14 MB. The descent. Left: looking down at the hole. Right: looking up, away from it. The glow eats the sky.
r = 40.00 M
hawking-approach-r40.00M.png. r = 40.00 M
r = 10.00 M
hawking-approach-r10.00M.png. r = 10.00 M
r = 4.00 M
hawking-approach-r4.00M.png. r = 4.00 M
r = 2.50 M
hawking-approach-r2.50M.png. r = 2.50 M
r = 2.10 M
hawking-approach-r2.10M.png. r = 2.10 M
r = 2.02 M
hawking-approach-r2.02M.png. r = 2.02 M

Stated limits. The glow is drawn as a blackbody, not as the greybody-filtered spectrum above, because the filter is the barrier at 3 M (a hoverer inside it sees the unfiltered flux) and because the filter is the same wave effect that forbids this picture's sharp edge: the blackbody is the one spectrum consistent with ray optics. The stars are drawn at fixed brightness with their true colour shift; under the auto-exposure that holds the glow they would vanish within a few M. The free-falling observer is not rendered: what a falling detector registers is a literature of its own, and the naive Doppler bookkeeping is not the whole answer.

Picture two: the mode itself

The thing ray optics cannot draw. The ℓ = m = 1 photon mode at the peak frequency in the equatorial plane, Re[ψ(r*) ei(φ − ωt)], the flux-normalised amplitude: born at the horizon with unit amplitude, 41% transmitted through the barrier and 59% reflected (a near-standing wave inside 3 M), a spiral wave outside with a wavelength thirteen times the horizon. Beside it the ℓ = m = 2 mode at the same frequency, trapped. Near the horizon the crests pile up (the tortoise coordinate runs to minus infinity) and peel off at the coordinate speed 1 − 2M/r: the trans-Planckian side of Hawking's derivation, to scale.

hawking-mode-dipole-quadrupole-10s.mp4 · 1920×1080, 240 frames at 24 fps, 11 MB. The dipole gets out; the quadrupole is trapped. Black disc: the horizon. Dashed ring: the barrier's peak at r = 3 M. Warm is positive, cool is negative.
Frame 120 of the mode film.
hawking-mode-frame120.png. Frame 120 of the mode film.

4. How would Einstein have felt to fly through his 3-Sphere with these miraculous machines we have made

A separate page. Not a black hole: the shape of the universe Einstein first proposed, flown through from the inside.

The same evening's question, one step further out. Einstein's first cosmology, in 1917, was a 3-sphere, chosen so that space could be finite without having an edge. The natural extension of the point, the circle and the sphere is generally held to be impossible to picture. The page argues that seeing it all at once is impossible for a reason that has nothing to do with the fourth dimension, that knowing it is already finished, and that flying through it is an ordinary three-dimensional render with one line changed; then it does the flight. Read it and fly →

Standing inside a 3-sphere. The grey grid behind everything is the back of the observer's own head, seen by light that has gone all the way round.
hypersphere-inside-frame0.png. Standing inside a 3-sphere. The grey grid behind everything is the back of the observer's own head, seen by light that has gone all the way round.

The scripts

numpy only; ffmpeg for the encodes. Each is a single file with its method in the docstring.

blackhole.py (8 kB) · The first tracer: Schwarzschild only, the Binet equation in each ray's own orbital plane, the thin disc, the dust. download
"""A Schwarzschild black hole with a thin, opaque, glowing dust disc, seen from a camera near the disc's plane
so the far side of the disc is lensed over and under the shadow (the owner's ask, 2026-09-08).

    blackhole.py <outdir> [frames] [width] [height]

Geometric units, M = 1 (the horizon at r = 2, the photon sphere at 3, the innermost stable orbit at 6). The
camera sits at distance D = 40 at inclination 78 degrees from the disc's axis. For every pixel the null
geodesic is integrated once in its own orbital plane with the Binet equation u'' = -u + 3 u^2 (u = 1 / r),
fourth-order Runge-Kutta, until it falls through the horizon (black), escapes (a faint lensed star field
looked up by its exit direction) or crosses the disc's plane between r_in = 6 and r_out = 18 (a hit: the
disc's radius and azimuth there, and the redshift factor g = sqrt(1 - 3/r) / (1 + Omega b_z) for a Keplerian
emitter with Omega = r^-1.5 and b_z the photon's angular momentum about the disc's axis, which carries the
gravitational and Doppler shifts together). Only the first crossing counts: the disc is opaque.

Then every frame is a lookup: emission = g^4 * (1 - sqrt(6 / r)) r^-3 * dust(r, phi - omega(r) t), the dust a
multi-octave noise in (log r, phi) that winds up under differential rotation, and a warm colour ramp by the
observed temperature. Output: rgb24 raw frames for ffmpeg (<outdir>/frames.rgb), plus one PNG-ready preview.
The tracing takes a few minutes at 1280x720; the frames are fast.
"""
import math
import pathlib
import sys
import time

import numpy as np

out = pathlib.Path(sys.argv[1]); out.mkdir(parents=True, exist_ok=True)
NF = int(sys.argv[2]) if len(sys.argv) > 2 else 240
W = int(sys.argv[3]) if len(sys.argv) > 3 else 1280
H = int(sys.argv[4]) if len(sys.argv) > 4 else 720
FPS = 24.0

D = 40.0                      # camera distance
INC = math.radians(78.0)      # inclination from the disc's axis
FOV = math.radians(56.0)      # horizontal field of view
R_IN, R_OUT = 6.0, 18.0
DPHI = 0.004                  # integration step in the orbital angle
PHI_MAX = 3.5 * math.pi       # rays that loop more than this are lost (they are the photon ring's tail)

t0 = time.time()
# ---- camera rays -------------------------------------------------------------------------------------------
c_hat = np.array([math.sin(INC), 0.0, math.cos(INC)])            # the camera's position direction
fwd = -c_hat
right = np.cross(fwd, np.array([0.0, 0.0, 1.0])); right /= np.linalg.norm(right)
up = np.cross(right, fwd)
tanx = math.tan(FOV / 2); tany = tanx * H / W
px = (np.arange(W) + 0.5) / W * 2 - 1
py = 1 - (np.arange(H) + 0.5) / H * 2
PX, PY = np.meshgrid(px, py)
d = fwd[None, None, :] + tanx * PX[..., None] * right[None, None, :] + tany * PY[..., None] * up[None, None, :]
d = d.reshape(-1, 3); d /= np.linalg.norm(d, axis=1, keepdims=True)
N = d.shape[0]
e1 = np.repeat(c_hat[None, :], N, axis=0)                        # the orbital plane: e1 toward the camera
d_r = d @ c_hat
e2 = d - d_r[:, None] * e1[:, :]
d_t = np.linalg.norm(e2, axis=1)
e2 /= np.maximum(d_t, 1e-9)[:, None]
b_z = D * np.cross(c_hat, d)[:, 2]                               # the photon's L_z / E (sign chooses the disc's spin)

u = np.full(N, 1.0 / D, np.float64)
du = -u * d_r / np.maximum(d_t, 1e-9)                            # du/dphi at the camera (inward: positive)
phi = np.zeros(N)
z_prev = D * c_hat[2] * np.ones(N)
state = np.zeros(N, np.int8)                                     # 0 flying, 1 hit the disc, 2 fell in, 3 escaped
hit_r = np.zeros(N); hit_az = np.zeros(N); esc_dir = np.zeros((N, 3))
e1z, e2z = c_hat[2], e2[:, 2]

def f(u, du):
    return du, -u + 3.0 * u * u

active = np.arange(N)
step = 0
while active.size and step * DPHI < PHI_MAX:
    ua, dua = u[active], du[active]
    k1u, k1d = f(ua, dua)
    k2u, k2d = f(ua + 0.5 * DPHI * k1u, dua + 0.5 * DPHI * k1d)
    k3u, k3d = f(ua + 0.5 * DPHI * k2u, dua + 0.5 * DPHI * k2d)
    k4u, k4d = f(ua + DPHI * k3u, dua + DPHI * k3d)
    un = ua + DPHI / 6 * (k1u + 2 * k2u + 2 * k3u + k4u)
    dn = dua + DPHI / 6 * (k1d + 2 * k2d + 2 * k3d + k4d)
    phin = phi[active] + DPHI
    r = 1.0 / np.maximum(un, 1e-9)
    z = r * (math.cos(0) * 0 + np.cos(phin) * e1z + np.sin(phin) * e2z[active])
    fell = un >= 0.5
    escaped = (un < 1.0 / (2.5 * D)) & (dn < 0)
    crossed = (np.sign(z) != np.sign(z_prev[active])) & (z_prev[active] != 0) & ~fell
    # the crossing point, interpolated in phi
    frac = np.where(crossed, np.abs(z_prev[active]) / np.maximum(np.abs(z_prev[active]) + np.abs(z), 1e-9), 0.0)
    phic = phi[active] + frac * DPHI
    uc = u[active] + frac * (un - u[active])
    rc = 1.0 / np.maximum(uc, 1e-9)
    hit = crossed & (rc >= R_IN) & (rc <= R_OUT)
    # azimuth in the disc plane at the crossing
    pos = rc[:, None] * (np.cos(phic)[:, None] * e1[active] + np.sin(phic)[:, None] * e2[active])
    az = np.arctan2(pos[:, 1], pos[:, 0])
    idx = active
    state[idx[hit]] = 1; hit_r[idx[hit]] = rc[hit]; hit_az[idx[hit]] = az[hit]
    state[idx[fell & ~hit]] = 2
    esc = escaped & ~hit & ~fell
    state[idx[esc]] = 3
    esc_dir[idx[esc]] = (np.cos(phin)[:, None] * e1[active] + np.sin(phin)[:, None] * e2[active])[esc]
    u[idx] = un; du[idx] = dn; phi[idx] = phin; z_prev[idx] = z
    active = idx[~(hit | fell | esc)]
    step += 1
    if step % 400 == 0:
        print(f"  step {step}: {active.size} rays flying, {int((state == 1).sum())} hits, {int((state == 2).sum())} fell in, {int((state == 3).sum())} escaped ({time.time() - t0:.0f} s)")
state[active] = 2                                                # what is still circling is the photon ring's tail: black
print(f"traced {N} rays in {time.time() - t0:.0f} s: {int((state == 1).sum())} hit the disc, {int((state == 2).sum())} fell in, {int((state == 3).sum())} escaped")

# ---- the redshift, the geometry maps -----------------------------------------------------------------------
hitm = state == 1
omega = np.where(hitm, hit_r ** -1.5, 0.0)
g = np.where(hitm, np.sqrt(np.clip(1.0 - 3.0 / np.maximum(hit_r, 3.01), 0, 1)) / (1.0 + omega * b_z), 0.0)
emis = np.where(hitm, (1.0 - np.sqrt(R_IN / np.maximum(hit_r, R_IN))) * np.maximum(hit_r, R_IN) ** -3.0, 0.0)
emis /= emis[hitm].max() if hitm.any() else 1.0

# ---- the star field for escaped rays: sparse points by a hash of the exit direction -------------------------
def stars(dirs):
    v = np.floor((dirs + 1.0) * 400.0).astype(np.int64)
    h = (v[:, 0] * 73856093 ^ v[:, 1] * 19349663 ^ v[:, 2] * 83492791) & 0xFFFFFF
    s = (h < 0xFFFFFF * 0.004).astype(np.float64) * (0.3 + 0.7 * ((h >> 8) & 0xFF) / 255.0)
    return s
sky = np.zeros(N); escm = state == 3
sky[escm] = stars(esc_dir[escm]) * 0.9

# ---- the dust: multi-octave noise in (log r, phi), periodic in phi ------------------------------------------
rng = np.random.default_rng(3)
NP, NR = 512, 128
def band_noise(nr, nph, k_lo, k_hi):
    fr = np.fft.fftfreq(nr)[:, None]; fp = np.fft.fftfreq(nph)[None, :]
    k = np.hypot(fr * nr / 8.0, fp * nph / 40.0)
    band = (k >= k_lo) & (k <= k_hi)
    spec = np.where(band, np.exp(1j * rng.uniform(0, 2 * np.pi, (nr, nph))), 0.0)
    x = np.fft.ifft2(spec).real
    return (x - x.mean()) / (x.std() + 1e-9)
dust = 0.6 * band_noise(NR, NP, 0.5, 2.0) + 0.3 * band_noise(NR, NP, 2.0, 6.0) + 0.15 * band_noise(NR, NP, 6.0, 16.0)
dust = np.clip(0.5 + 0.45 * dust, 0.03, 1.4)

def ramp(x):
    """a warm ramp: black -> deep red -> orange -> yellow -> white, x in [0, ~1.5]"""
    x = np.clip(x, 0, 1.6)
    r = np.clip(x * 1.6, 0, 1)
    gch = np.clip(x * 1.15 - 0.25, 0, 1) ** 1.3
    b = np.clip(x * 0.9 - 0.55, 0, 1) ** 1.6
    return np.stack([r, gch, b], -1)

lr = np.where(hitm, (np.log(np.maximum(hit_r, R_IN)) - math.log(R_IN)) / (math.log(R_OUT) - math.log(R_IN)), 0.0)
ri = np.clip((lr * (NR - 1)).astype(int), 0, NR - 1)
omega_vis = np.where(hitm, 2 * np.pi / 4.0 * (R_IN / np.maximum(hit_r, R_IN)) ** 1.5, 0.0)   # one turn in 4 s at r_in
sky_rgb = np.stack([sky, sky, sky * 1.1], -1)

frames = np.zeros((NF, H * W, 3), np.uint8)
for n in range(NF):
    t = n / FPS
    ph = np.mod(hit_az - omega_vis * t, 2 * np.pi)
    pi_ = np.clip((ph / (2 * np.pi) * NP).astype(int), 0, NP - 1)
    tex = dust[ri, pi_]
    I = emis * g ** 4 * tex * 6.0
    temp = np.where(hitm, g * (R_IN / np.maximum(hit_r, R_IN)) ** 0.75, 0.0)
    col = ramp(temp * 1.15) * (I / (1.0 + I))[:, None] * 1.5 + sky_rgb
    frames[n] = (np.clip(col, 0, 1) ** (1 / 2.2) * 255).astype(np.uint8)
    if n % 60 == 0:
        print(f"  frame {n} ({time.time() - t0:.0f} s)")
frames.tofile(out / "frames.rgb")
print(f"{NF} frames of {W}x{H} -> {out / 'frames.rgb'} ({time.time() - t0:.0f} s)")
geodesic_disc.py (15 kB) · The general tracer: Hamilton's equations, the metric entering as five covariant components; schwarzschild, kerr and jp built in; per-band checkpoints for 4K. download
"""A black hole that is not Schwarzschild: a thin glowing dust disc around a Kerr black hole, or around a
Johannsen-Psaltis deformation of one that solves no vacuum field equation, rendered by one metric-agnostic
null-geodesic tracer (2026-09-08).

    geodesic_disc.py <metric> <outdir> [frames] [width] [height] [bands] [spin] [eps3]
        metric  schwarzschild | kerr | jp
        frames  default 120 at 24 fps
        width, height  default 960 x 540 (3840 x 2160 takes about two hours of CPU with 4 bands)
        bands   horizontal bands the frame is traced in, default 1 (use 4 for 4K: memory); each band is
                checkpointed in <outdir>, so an interrupted run resumes where it stopped
        spin    a / M for kerr and jp, default 0.9
        eps3    the deformation for jp, default 3.0 (0 is Kerr again)

THE TRACER. Every pixel's photon is integrated backwards from the camera with Hamilton's equations for a null
geodesic, H = 1/2 g^{mu nu} p_mu p_nu = 0, in Boyer-Lindquist-like coordinates (t, r, theta, phi):
    dx^mu / dlambda = g^{mu nu} p_nu,      dp_r / dlambda = -dH/dr,      dp_theta / dlambda = -dH/dtheta,
with p_t = -1 (energy 1) and p_phi conserved. The metric enters ONLY as its covariant components g_tt, g_tphi,
g_rr, g_thetatheta, g_phiphi as functions of (r, theta): the inverse is taken numerically (a 2x2 block and two
reciprocals) and dH/dr, dH/dtheta by central differences, so any stationary axisymmetric metric can be dropped
into metric_cov() without deriving anything. Fourth-order Runge-Kutta, the step shrinking toward the horizon
and toward the coordinate poles (without the latter a seam appears at the top of the shadow, where rays pass
over the pole). A ray ends when it falls through the horizon (black), escapes beyond 55 M (a star field by its
exit direction), or crosses the disc's plane theta = pi/2 between the innermost stable circular orbit and
18 M (a hit). The camera is a zero-angular-momentum observer at 40 M, 78 degrees off the disc's axis.

THE METRICS (geometric units, M = 1).
    schwarzschild   a = 0: the control (horizon 2, innermost stable orbit 6).
    kerr            frame dragging, the D-shaped shadow flattened on the prograde side, the disc reaching in
                    (a = 0.9: horizon 1.436, innermost stable orbit 2.321).
    jp              Johannsen & Psaltis (2011): Kerr with the deformation h = eps3 M^3 r / Sigma^2 in
                    g_tt, g_tphi, g_rr and g_phiphi. NOT a solution of the vacuum Einstein equations: a
                    parametrised deviation of the kind used to test whether real black holes are Kerr. The
                    image is what integrating this metric's geodesics gives, nothing else (a = 0.9, eps3 = 3:
                    innermost stable orbit 1.465, just outside the horizon).

THE DISC. Thin, opaque, on prograde circular orbits. The orbital frequency Omega(r), u^t and the innermost
stable orbit are found numerically from the metric in the equatorial plane (Omega from the radial derivatives
of g_tt, g_tphi, g_phiphi; the innermost stable orbit at the minimum of the orbit's energy), so a new metric
brings its own disc. The redshift factor of a hit is Cunningham's g = 1 / (u^t (1 - Omega p_phi)) with p_phi
the photon's conserved angular momentum: gravitational and Doppler shifts together. The emission is
g^4 (1 - sqrt(r_in / r)) r^-3 times a multi-octave dust pattern in (log r, phi) that winds up under the
metric's own Omega(r) (one turn in 4 s at the inner edge), coloured by the observed temperature on a warm
ramp. Output: <outdir>/frames.rgb, raw rgb24 frames for ffmpeg, e.g.
    ffmpeg -f rawvideo -pix_fmt rgb24 -s 960x540 -r 24 -i frames.rgb -vf format=yuv420p -c:v libx264 out.mp4
and <outdir>/summary.txt with the horizon, the innermost stable orbit and the ray counts.

This is not part of the interpolation tests. It was written for the owner's curiosity and stays because the
tracer is general and small.
"""
import math
import pathlib
import sys
import time

import numpy as np

metric_name = sys.argv[1]
out = pathlib.Path(sys.argv[2]); out.mkdir(parents=True, exist_ok=True)
NF = int(sys.argv[3]) if len(sys.argv) > 3 else 120
W = int(sys.argv[4]) if len(sys.argv) > 4 else 960
H = int(sys.argv[5]) if len(sys.argv) > 5 else 540
BANDS = int(sys.argv[6]) if len(sys.argv) > 6 else 1
SPIN = float(sys.argv[7]) if len(sys.argv) > 7 else 0.9
EPS3_ARG = float(sys.argv[8]) if len(sys.argv) > 8 else 3.0
FPS = 24.0
if metric_name not in ("schwarzschild", "kerr", "jp"):
    sys.exit("metric: schwarzschild | kerr | jp")
A = 0.0 if metric_name == "schwarzschild" else SPIN
EPS3 = EPS3_ARG if metric_name == "jp" else 0.0
R_OBS, TH_OBS = 40.0, math.radians(78.0)
FOV = math.radians(56.0)
R_OUT = 18.0
R_ESC = 55.0
t0 = time.time()


def metric_cov(r, th):
    """Covariant components g_tt, g_tp, g_rr, g_hh, g_pp of the Kerr metric with the Johannsen-Psaltis
    deformation h = EPS3 r / Sigma^2 (EPS3 = 0 is Kerr; A = 0 too is Schwarzschild). M = 1."""
    s2 = np.maximum(np.sin(th) ** 2, 1e-12)
    c2 = 1.0 - s2
    Sig = r * r + A * A * c2
    Del = r * r - 2.0 * r + A * A
    h = EPS3 * r / (Sig * Sig)
    g_tt = -(1.0 + h) * (1.0 - 2.0 * r / Sig)
    g_tp = -2.0 * A * r * s2 * (1.0 + h) / Sig
    g_pp = s2 * (r * r + A * A + 2.0 * A * A * r * s2 / Sig) + h * A * A * s2 * s2 * (Sig + 2.0 * r) / Sig
    g_rr = Sig * (1.0 + h) / np.maximum(Del + A * A * h * s2, 1e-9)
    g_hh = Sig
    return g_tt, g_tp, g_rr, g_hh, g_pp


def metric_inv(r, th):
    g_tt, g_tp, g_rr, g_hh, g_pp = metric_cov(r, th)
    det = g_tt * g_pp - g_tp * g_tp
    det = np.where(np.abs(det) < 1e-12, -1e-12, det)
    return g_pp / det, -g_tp / det, 1.0 / g_rr, 1.0 / g_hh, g_tt / det      # g^tt, g^tp, g^rr, g^hh, g^pp


def hamiltonian(r, th, p_t, p_r, p_h, p_p):
    gtt, gtp, grr, ghh, gpp = metric_inv(r, th)
    return 0.5 * (gtt * p_t * p_t + 2.0 * gtp * p_t * p_p + gpp * p_p * p_p + grr * p_r * p_r + ghh * p_h * p_h)


def rhs(r, th, p_r, p_h, p_t, p_p):
    gtt, gtp, grr, ghh, gpp = metric_inv(r, th)
    dr = grr * p_r
    dth = ghh * p_h
    dph = gtp * p_t + gpp * p_p
    hr = 1e-4 * np.maximum(r, 1.0)
    dHdr = (hamiltonian(r + hr, th, p_t, p_r, p_h, p_p) - hamiltonian(r - hr, th, p_t, p_r, p_h, p_p)) / (2.0 * hr)
    hh = 1e-4
    dHdh = (hamiltonian(r, th + hh, p_t, p_r, p_h, p_p) - hamiltonian(r, th - hh, p_t, p_r, p_h, p_p)) / (2.0 * hh)
    return dr, dth, dph, -dHdr, -dHdh


# ---- the horizon and the disc's orbits from the metric ------------------------------------------------------
r_h = 1.0 + math.sqrt(max(1.0 - A * A, 0.0))
rr = np.linspace(r_h * 1.02, 30.0, 6000)
th_eq = np.full_like(rr, math.pi / 2)
dr_ = 1e-4
def eq(r):
    return metric_cov(r, np.full_like(r, math.pi / 2))
gtt0, gtp0, _, _, gpp0 = eq(rr)
gtt1, gtp1, _, _, gpp1 = eq(rr + dr_); gtt2, gtp2, _, _, gpp2 = eq(rr - dr_)
dgtt = (gtt1 - gtt2) / (2 * dr_); dgtp = (gtp1 - gtp2) / (2 * dr_); dgpp = (gpp1 - gpp2) / (2 * dr_)
disc_ = dgtp * dgtp - dgtt * dgpp
Om = (-dgtp + np.sqrt(np.maximum(disc_, 0.0))) / dgpp                       # prograde
norm = -(gtt0 + 2.0 * gtp0 * Om + gpp0 * Om * Om)
ok = (norm > 0) & (disc_ > 0)
E_orb = np.where(ok, -(gtt0 + gtp0 * Om) / np.sqrt(np.maximum(norm, 1e-12)), np.inf)
i_isco = int(np.argmin(E_orb))
R_IN = float(rr[i_isco])
def orbit(r):
    """Omega and u^t of the prograde circular orbit at equatorial r, by interpolation on the grid."""
    Omg = np.interp(r, rr, Om)
    ut = np.interp(r, rr, 1.0 / np.sqrt(np.maximum(norm, 1e-12)))
    return Omg, ut
print(f"{metric_name}: a = {A}, eps3 = {EPS3}, horizon r = {r_h:.3f}, ISCO r = {R_IN:.3f} (Omega there {float(Om[i_isco]):.4f})")

# ---- the camera: a zero-angular-momentum observer's tetrad at (R_OBS, TH_OBS, phi = 0) ---------------------
g_tt, g_tp, g_rr, g_hh, g_pp = [float(x[0]) for x in metric_cov(np.array([R_OBS]), np.array([TH_OBS]))]
omega_z = -g_tp / g_pp
alpha = math.sqrt(-(g_tt - g_tp * g_tp / g_pp))
tanx = math.tan(FOV / 2); tany = tanx * H / W
px = (np.arange(W) + 0.5) / W * 2 - 1
py = 1 - (np.arange(H) + 0.5) / H * 2


def trace_band(y0, y1):
    """Trace the rays of rows y0..y1-1; return state, hit_r, hit_az, hit_pp, esc_dir for them."""
    PX, PY = np.meshgrid(px, py[y0:y1])
    PX = PX.ravel(); PY = PY.ravel()
    N = PX.size
    n_r = -np.ones(N); n_h = -tany * PY; n_p = -tanx * PX
    k = 1.0 / np.sqrt(n_r * n_r + n_h * n_h + n_p * n_p)
    n_r *= k; n_h *= k; n_p *= k
    pu_t = 1.0 / alpha * np.ones(N)
    pu_r = n_r / math.sqrt(g_rr)
    pu_h = n_h / math.sqrt(g_hh)
    pu_p = omega_z / alpha + n_p / math.sqrt(g_pp)
    p_t = g_tt * pu_t + g_tp * pu_p
    p_p = g_tp * pu_t + g_pp * pu_p
    p_r = g_rr * pu_r
    p_h = g_hh * pu_h
    E = -p_t
    p_t = p_t / E; p_p = p_p / E; p_r = p_r / E; p_h = p_h / E
    r = np.full(N, R_OBS); th = np.full(N, TH_OBS); ph = np.zeros(N)
    state = np.zeros(N, np.int8)
    hit_r = np.zeros(N); hit_az = np.zeros(N); hit_pp = np.zeros(N); esc_dir = np.zeros((N, 3))
    active = np.arange(N)
    step = 0
    while active.size and step < MAX_STEPS:
        ra, ta, pa, pra, pha = r[active], th[active], ph[active], p_r[active], p_h[active]
        pt, pp = p_t[active], p_p[active]
        dl = np.clip(0.04 * (ra - r_h), 0.004, 0.5) * np.clip(np.sin(ta) / 0.25, 0.04, 1.0)
        k1 = rhs(ra, ta, pra, pha, pt, pp)
        k2 = rhs(ra + 0.5 * dl * k1[0], ta + 0.5 * dl * k1[1], pra + 0.5 * dl * k1[3], pha + 0.5 * dl * k1[4], pt, pp)
        k3 = rhs(ra + 0.5 * dl * k2[0], ta + 0.5 * dl * k2[1], pra + 0.5 * dl * k2[3], pha + 0.5 * dl * k2[4], pt, pp)
        k4 = rhs(ra + dl * k3[0], ta + dl * k3[1], pra + dl * k3[3], pha + dl * k3[4], pt, pp)
        rn = ra + dl / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0])
        tn = ta + dl / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1])
        pn = pa + dl / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
        prn = pra + dl / 6 * (k1[3] + 2 * k2[3] + 2 * k3[3] + k4[3])
        phn = pha + dl / 6 * (k1[4] + 2 * k2[4] + 2 * k3[4] + k4[4])
        fell = rn <= r_h * 1.02
        escaped = (rn > R_ESC) & (rn > ra)
        zc = (np.cos(ta) * np.cos(tn) < 0)
        frac = np.where(zc, np.abs(np.cos(ta)) / np.maximum(np.abs(np.cos(ta)) + np.abs(np.cos(tn)), 1e-12), 0.0)
        rc = ra + frac * (rn - ra); pc = pa + frac * (pn - pa)
        hit = zc & (rc >= R_IN) & (rc <= R_OUT) & ~fell
        idx = active
        state[idx[hit]] = 1; hit_r[idx[hit]] = rc[hit]; hit_az[idx[hit]] = pc[hit]; hit_pp[idx[hit]] = pp[hit]
        state[idx[fell & ~hit]] = 2
        esc = escaped & ~hit & ~fell
        state[idx[esc]] = 3
        esc_dir[idx[esc]] = np.stack([np.sin(tn) * np.cos(pn), np.sin(tn) * np.sin(pn), np.cos(tn)], -1)[esc]
        r[idx] = rn; th[idx] = np.clip(tn, 1e-4, math.pi - 1e-4); ph[idx] = pn; p_r[idx] = prn; p_h[idx] = phn
        active = idx[~(hit | fell | esc)]
        step += 1
    state[active] = 2
    return state, hit_r, hit_az, hit_pp, esc_dir


MAX_STEPS = 40000
parts = []
for b in range(BANDS):
    y0, y1 = b * H // BANDS, (b + 1) * H // BANDS
    ck = out / f"band_{b}_of_{BANDS}_{W}x{H}.npz"          # each band is checkpointed: a restart skips the bands done
    if ck.exists():
        z = np.load(ck)
        parts.append((z["state"], z["hit_r"], z["hit_az"], z["hit_pp"], z["esc_dir"]))
        print(f"  band {b + 1}/{BANDS} (rows {y0}-{y1 - 1}): loaded from {ck.name}", flush=True)
        continue
    parts.append(trace_band(y0, y1))
    st, hr_, ha_, hp_, ed_ = parts[-1]
    np.savez(ck, state=st, hit_r=hr_, hit_az=ha_, hit_pp=hp_, esc_dir=ed_)
    print(f"  band {b + 1}/{BANDS} (rows {y0}-{y1 - 1}): {int((st == 1).sum())} hit, {int((st == 2).sum())} fell, {int((st == 3).sum())} escaped ({time.time() - t0:.0f} s)", flush=True)
state = np.concatenate([p[0] for p in parts]); hit_r = np.concatenate([p[1] for p in parts]); hit_az = np.concatenate([p[2] for p in parts])
hit_pp = np.concatenate([p[3] for p in parts]); esc_dir = np.concatenate([p[4] for p in parts]); del parts
N = state.size
print(f"traced {N} rays in {time.time() - t0:.0f} s: {int((state == 1).sum())} hit, {int((state == 2).sum())} fell, {int((state == 3).sum())} escaped", flush=True)

# ---- the redshift and the frames ---------------------------------------------------------------------------
hitm = state == 1
Omg, ut = orbit(np.where(hitm, hit_r, R_IN))
g = np.where(hitm, 1.0 / (ut * (1.0 - Omg * hit_pp)), 0.0)
g = np.clip(g, 0.0, 3.0)
emis = np.where(hitm, (1.0 - np.sqrt(R_IN / np.maximum(hit_r, R_IN))) * np.maximum(hit_r, R_IN) ** -3.0, 0.0)
emis /= emis[hitm].max() if hitm.any() else 1.0

def stars(dirs):
    v = np.floor((dirs + 1.0) * (400.0 * W / 960.0)).astype(np.int64)
    hsh = (v[:, 0] * 73856093 ^ v[:, 1] * 19349663 ^ v[:, 2] * 83492791) & 0xFFFFFF
    return (hsh < 0xFFFFFF * 0.004 * (960.0 / W) ** 0.5).astype(np.float64) * (0.3 + 0.7 * ((hsh >> 8) & 0xFF) / 255.0)
sky = np.zeros(N); escm = state == 3
sky[escm] = stars(esc_dir[escm]) * 0.9

rng = np.random.default_rng(3)
NP = int(512 * W / 960); NR = int(128 * W / 960)
def band_noise(nr, nph, k_lo, k_hi):
    fr = np.fft.fftfreq(nr)[:, None]; fp = np.fft.fftfreq(nph)[None, :]
    kk = np.hypot(fr * nr / (8.0 * W / 960.0), fp * nph / (40.0 * W / 960.0))
    band = (kk >= k_lo) & (kk <= k_hi)
    spec = np.where(band, np.exp(1j * rng.uniform(0, 2 * np.pi, (nr, nph))), 0.0)
    x = np.fft.ifft2(spec).real
    return (x - x.mean()) / (x.std() + 1e-9)
dust = 0.6 * band_noise(NR, NP, 0.5, 2.0) + 0.3 * band_noise(NR, NP, 2.0, 6.0) + 0.15 * band_noise(NR, NP, 6.0, 16.0)
dust = np.clip(0.5 + 0.45 * dust, 0.03, 1.4)

def ramp(x):
    x = np.clip(x, 0, 1.6)
    return np.stack([np.clip(x * 1.6, 0, 1), np.clip(x * 1.15 - 0.25, 0, 1) ** 1.3, np.clip(x * 0.9 - 0.55, 0, 1) ** 1.6], -1)

lr = np.where(hitm, (np.log(np.maximum(hit_r, R_IN)) - math.log(R_IN)) / (math.log(R_OUT) - math.log(R_IN)), 0.0)
ri = np.clip((lr * (NR - 1)).astype(int), 0, NR - 1)
Om_in = float(np.interp(R_IN, rr, Om))
omega_vis = np.where(hitm, 2 * np.pi / 4.0 * Omg / Om_in, 0.0)        # the metric's own Omega(r), one turn in 4 s at the ISCO
temp_scale = np.where(hitm, g * (R_IN / np.maximum(hit_r, R_IN)) ** 0.75, 0.0)
sky_rgb = np.stack([sky, sky, sky * 1.1], -1)
base_col = ramp(temp_scale * 1.15) * 1.5
with open(out / "frames.rgb", "wb") as fh:
    for n in range(NF):
        t = n / FPS
        phn_ = np.mod(hit_az - omega_vis * t, 2 * np.pi)
        pi_ = np.clip((phn_ / (2 * np.pi) * NP).astype(int), 0, NP - 1)
        I = emis * g ** 4 * dust[ri, pi_] * 6.0
        col = base_col * (I / (1.0 + I))[:, None] + sky_rgb
        fh.write((np.clip(col, 0, 1) ** (1 / 2.2) * 255).astype(np.uint8).tobytes())
        if n % 24 == 0:
            print(f"  frame {n} ({time.time() - t0:.0f} s)", flush=True)
(out / "summary.txt").write_text(f"{metric_name} a={A} eps3={EPS3} horizon={r_h:.4f} isco={R_IN:.4f} hits={int(hitm.sum())} fell={int((state == 2).sum())} escaped={int(escm.sum())} g_max={float(g[hitm].max()) if hitm.any() else 0:.3f}\n")
print(f"{NF} frames of {W}x{H} -> {out / 'frames.rgb'} ({time.time() - t0:.0f} s)", flush=True)
README.md (2 kB) · The general tracer's readme. download
# A black hole that is not Schwarzschild

One script, `geodesic_disc.py`, numpy only, with ffmpeg for the encode. It renders a
thin glowing dust disc around a black hole by tracing every pixel's light ray
backwards through the spacetime, and it does not care which spacetime: the metric
enters as five functions of radius and angle, and the tracer inverts and
differentiates them numerically. Three are built in.

- `schwarzschild`: the control. Horizon at 2 M, innermost stable orbit at 6 M.
- `kerr`: the spinning hole (default spin 0.9). Frame dragging, the shadow
  flattened into a D on its prograde side, the disc reaching in to 2.32 M.
- `jp`: the Johannsen-Psaltis deformation of that Kerr metric (default deviation
  3). It is not a solution of the vacuum Einstein equations at all. It is the
  kind of parametrised non-Kerr black hole astronomers test the no-hair theorem
  against, and its image is whatever integrating its light paths gives.

    ./geodesic_disc.py jp out 120 960 540
    ffmpeg -f rawvideo -pix_fmt rgb24 -s 960x540 -r 24 -i out/frames.rgb -vf format=yuv420p -c:v libx264 jp.mp4

A 960x540 frame traces in about six minutes on one CPU; 3840x2160 takes about two
hours and should be run with four bands (the sixth argument) so it fits in memory.
The disc's inner edge, its rotation and its redshift are all found from the metric,
so dropping a new metric into `metric_cov()` brings its own disc with it.

This lives beside the interpolation project rather than inside it. It was written in
an evening for the owner's curiosity, after the question "can you render a black
hole that is not the Schwarzschild metric", and it stays because the tracer is
general and small. There is no general solution to render: that is numerical
relativity on a supercomputer. This is two steps beyond Schwarzschild, the second of
them beyond any exact solution.
greybody.py (7 kB) · Hawking radiation in photons: the Regge-Wheeler equation integrated for the greybody factors, the spectrum, the power (Page 1976 reproduced), and the mode functions at the peak. download
"""Hawking radiation of a Schwarzschild black hole in photons: the greybody factors, the spectrum, the power, and
the mode functions at the spectrum's peak (2026-09-08, the owner's question "can Hawking radiation be rendered").

Geometric units G = c = hbar = k_B = 1, M = 1: T_H = 1 / (8 pi) = 0.0398. For each multipole l the electromagnetic
(s = 1) Regge-Wheeler equation  d2psi/dr*2 + (w^2 - V) psi = 0,  V = (1 - 2/r) l (l + 1) / r^2,  r* = r + 2 ln(r/2 - 1)
is integrated inward from r = R_far with the pure outgoing wave psi = exp(i w r*) (this is the "up" mode: born at the
horizon, partly transmitted to infinity, partly reflected back in) down to r - 2 = 1e-7, where it is decomposed into
A exp(i w r*) + B exp(-i w r*). The transmission probability Gamma_l(w) = 1 / |A|^2 is the greybody factor (by
reciprocity the same as for a wave sent in from infinity), and psi / A is the mode itself, unit outgoing amplitude at the
horizon. The photon emission is then

    dN/dt dw = 2 * sum_l (2l + 1) Gamma_l(w) / (2 pi (exp(8 pi w) - 1)),   dE/dt dw = w dN/dt dw,

the 2 for the polarisations. Checks printed: Gamma -> 1 at high w for low l, the w^4 law at low w, and the total photon
power against Page 1976 (3.36e-5 hbar c^6 / G^2 M^2, 16.7 % of his 2.011e-4 total). Writes greybody.npz: the w grid,
Gamma[l, w], the spectra, and the l = 1, 2 up-modes on an r* grid at the spectrum's peak frequency.
"""
import math
import pathlib
import sys
import time

import numpy as np

here = pathlib.Path(__file__).resolve().parent
OUT = here / "greybody.npz"
LMAX = 8
R_FAR = 3000.0
X_HOR = 1e-7                  # r - 2 where the horizon decomposition is done
H = 0.05                      # step in r*
w = np.exp(np.linspace(math.log(0.01), math.log(1.5), 260))   # the frequency grid, w M
NW = w.size
T_H = 1.0 / (8 * math.pi)


def rstar_of_x(x):
    return 2.0 + x + 2.0 * np.log(x / 2.0)


def x_of_rstar(rs):
    """invert r* = 2 + x + 2 ln(x/2) by Newton (rs may be an array)"""
    rs = np.asarray(rs, dtype=np.float64)
    x = np.where(rs > 4, rs - 2.0, 2.0 * np.exp((rs - 2.0) / 2.0))
    for _ in range(60):
        f = 2.0 + x + 2.0 * np.log(x / 2.0) - rs
        x = np.maximum(x - f / (1.0 + 2.0 / x), 1e-300)
    return x


def integrate_up(l, ws, keep_below=None):
    """the up-mode for multipole l at frequencies ws (vector): inward RK4 from R_FAR. Returns Gamma (per w), and if
    keep_below is given (an r* value) the arrays (rstar, x, psi[w, i]) for r* < keep_below."""
    ll = l * (l + 1)
    x0 = R_FAR - 2.0
    rs0 = rstar_of_x(x0)
    rs_end = rstar_of_x(X_HOR)
    n = int(math.ceil((rs0 - rs_end) / H))
    h = -(rs0 - rs_end) / n
    rs = rs0
    x = np.float64(x0)
    psi = np.exp(1j * ws * rs0)
    dpsi = 1j * ws * psi
    keep_rs, keep_x, keep_psi = [], [], []

    def V_of_x(xx):
        return (xx / (xx + 2.0)) * ll / (xx + 2.0) ** 2

    def dx(xx):
        return xx / (xx + 2.0)

    for i in range(n):
        # x at the stage points (scalar, shared by all w)
        k1x = dx(x)
        x2 = x + 0.5 * h * k1x
        k2x = dx(x2)
        x3 = x + 0.5 * h * k2x
        k3x = dx(x3)
        x4 = x + h * k3x
        k4x = dx(x4)
        V1, V2, V4 = V_of_x(x), V_of_x(x2), V_of_x(x4)       # V at stage 2 and 3 are the same point in r*
        w2 = ws * ws
        k1p = dpsi;                     k1d = (V1 - w2) * psi
        p2 = psi + 0.5 * h * k1p;       d2 = dpsi + 0.5 * h * k1d
        k2p = d2;                       k2d = (V2 - w2) * p2
        p3 = psi + 0.5 * h * k2p;       d3 = dpsi + 0.5 * h * k2d
        k3p = d3;                       k3d = (V2 - w2) * p3
        p4 = psi + h * k3p;             d4 = dpsi + h * k3d
        k4p = d4;                       k4d = (V4 - w2) * p4
        psi = psi + h / 6 * (k1p + 2 * k2p + 2 * k3p + k4p)
        dpsi = dpsi + h / 6 * (k1d + 2 * k2d + 2 * k3d + k4d)
        x = x + h / 6 * (k1x + 2 * k2x + 2 * k3x + k4x)
        rs = rs + h
        if keep_below is not None and rs < keep_below:
            keep_rs.append(rs); keep_x.append(float(x)); keep_psi.append(psi.copy())
    # decompose at the horizon end: psi = A e^{i w r*} + B e^{-i w r*}
    A = 0.5 * (psi + dpsi / (1j * ws)) * np.exp(-1j * ws * rs)
    B = 0.5 * (psi - dpsi / (1j * ws)) * np.exp(1j * ws * rs)
    gamma = 1.0 / np.abs(A) ** 2
    flux_check = np.abs(A) ** 2 - np.abs(B) ** 2      # must be 1 (unit transmitted flux at infinity)
    if keep_below is None:
        return gamma, flux_check, A, B
    keep = (np.array(keep_rs[::-1]), np.array(keep_x[::-1]), np.array(keep_psi[::-1]) / A[None, :])
    return gamma, flux_check, A, B, keep


t0 = time.time()
Gamma = np.zeros((LMAX + 1, NW))
for l in range(1, LMAX + 1):
    g, fc, A, B = integrate_up(l, w)
    Gamma[l] = g
    print(f"l = {l}: Gamma at w = {w[0]:.3f}: {g[0]:.3e}, at w = {w[NW // 2]:.3f}: {g[NW // 2]:.4f}, at w = {w[-1]:.2f}: {g[-1]:.6f}; "
          f"flux conservation max |1 - (|A|^2 - |B|^2)| = {np.abs(1 - fc).max():.1e} ({time.time() - t0:.0f} s)", flush=True)

# checks: the w^4 law at low w for l = 1 (Gamma ~ c w^4), and the geometric-optics limit sum (2l+1) Gamma -> 27 w^2
lo = (w < 0.03)
slope = np.polyfit(np.log(w[lo]), np.log(Gamma[1][lo]), 1)[0]
S = np.sum((2 * np.arange(LMAX + 1) + 1)[:, None] * Gamma, axis=0)
print(f"low-w slope of Gamma_1: {slope:.3f} (theory 4)")
for wi in (0.3, 0.6, 1.0, 1.5):
    i = np.argmin(np.abs(w - wi))
    print(f"  sum (2l+1) Gamma at w = {w[i]:.2f}: {S[i]:.3f}  vs geometric 27 w^2 = {27 * w[i] ** 2:.3f}  (ratio {S[i] / (27 * w[i] ** 2):.3f})")

# the spectrum and the power
planck = 1.0 / np.expm1(8 * math.pi * w)
dNdw = 2.0 * S * planck / (2 * math.pi)
dEdw = w * dNdw
dEdw_geo = 2.0 * 27 * w ** 2 * planck / (2 * math.pi) * w
P = np.trapezoid(dEdw, w); P_geo = np.trapezoid(dEdw_geo, w); Ndot = np.trapezoid(dNdw, w)
P_geo_exact = 27 * math.pi ** 3 * T_H ** 4 / 15
ipk = int(np.argmax(dEdw)); ipk_n = int(np.argmax(dNdw))
print(f"photon power P M^2 = {P:.3e}  (Page 1976: 3.36e-5; geometric-optics blackbody {P_geo:.3e}, exact {P_geo_exact:.3e}; ratio P/P_geo = {P / P_geo_exact:.3f})")
print(f"photon rate  N M   = {Ndot:.3e} per unit time M;  mean photon energy {P / Ndot:.4f} = {P / Ndot / T_H:.2f} T_H")
print(f"energy spectrum peak at w M = {w[ipk]:.3f} = {w[ipk] / T_H:.2f} T_H  (blackbody peak 2.82 T_H = {2.82 * T_H:.3f});  wavelength 2 pi / w = {2 * math.pi / w[ipk]:.1f} M = {math.pi / w[ipk]:.1f} r_s")
print(f"number spectrum peak at w M = {w[ipk_n]:.3f}")
frac = {}
for l in range(1, 5):
    frac[l] = np.trapezoid(w * 2 * (2 * l + 1) * Gamma[l] * planck / (2 * math.pi), w) / P
print("share of the power by multipole: " + ", ".join(f"l={l}: {frac[l] * 100:.1f}%" for l in frac))

# the modes at the peak, l = 1 and 2, kept for r* < 90 (r < ~ 80)
w_pk = float(w[ipk])
modes = {}
for l in (1, 2):
    g, fc, A, B, (krs, kx, kpsi) = integrate_up(l, np.array([w_pk]), keep_below=90.0)
    modes[l] = (krs, kx, kpsi[:, 0], float(g[0]), complex(B[0] / A[0]))
    print(f"mode l = {l} at w = {w_pk:.3f}: Gamma = {g[0]:.4f}, |R|^2 = {abs(B[0] / A[0]) ** 2:.4f}, {krs.size} points kept, r* in [{krs[0]:.1f}, {krs[-1]:.1f}] ({time.time() - t0:.0f} s)")

np.savez(OUT, w=w, Gamma=Gamma, S=S, dNdw=dNdw, dEdw=dEdw, dEdw_geo=dEdw_geo, P=P, Ndot=Ndot, w_peak=w_pk,
         rs1=modes[1][0], x1=modes[1][1], psi1=modes[1][2], gamma1=modes[1][3],
         rs2=modes[2][0], x2=modes[2][1], psi2=modes[2][2], gamma2=modes[2][3])
print(f"wrote {OUT} ({time.time() - t0:.0f} s)")
hawkcolor.py (11 kB) · Shared pieces: CIE colour from a spectrum, the Hawking spectrum, a tone map, a 5x7 bitmap font. download
"""Shared pieces for the Hawking renders (2026-09-08): colour from a spectrum, the Hawking spectrum from greybody.npz,
a 5x7 bitmap font for captions, and a few constants. numpy only.

Radiance bookkeeping (geometric units, M = 1, per unit frequency w per steradian, both polarisations):
    blackbody          B_w(T) = w^3 / (4 pi^3 (exp(w / T) - 1))
    Hawking, at infinity, along a ray from the horizon (the shadow's uniform radiance in ray optics):
                       I_w = [S(w) / (27 w^2)] * B_w(T_H),   S = sum_l (2l + 1) Gamma_l(w),   T_H = 1 / (8 pi)
    seen by a static observer at r (blueshift beta = 1 / sqrt(1 - 2/r), the same in every direction):
                       I_loc(w) = beta^3 I_inf(w / beta)      (I_w / w^3 is invariant along a ray)
A temperature in kelvin converts to geometric units through the hole's mass: T_geo = (T_K / T_H,K) / (8 pi), and a
frequency w (geometric) is a wavelength lambda = 2 pi c / (w c^3 / (G M)). The CIE 1931 colour-matching functions are
the multi-lobe Gaussian fits of Wyman, Sloan & Shirley (2013), then XYZ -> linear sRGB (D65).
"""
import math
import pathlib

import numpy as np

HBAR, C, G, KB = 1.0546e-34, 2.99792458e8, 6.674e-11, 1.3807e-23
T_H_GEO = 1.0 / (8 * math.pi)
MK_PER_TH = HBAR * C ** 3 / (8 * math.pi * G * KB)        # M * T_H in kg K: 1.227e23


def mass_for_TH(T_K):
    return MK_PER_TH / T_K


def rs_metres(M_kg):
    return 2 * G * M_kg / C ** 2


LAM = np.linspace(380e-9, 780e-9, 401)                     # the colour integral's wavelength grid


def _lobe(lam_nm, mu, s1, s2):
    s = np.where(lam_nm < mu, s1, s2)
    return np.exp(-0.5 * ((lam_nm - mu) / s) ** 2)


_ln = LAM * 1e9
XBAR = 1.056 * _lobe(_ln, 599.8, 37.9, 31.0) + 0.362 * _lobe(_ln, 442.0, 16.0, 26.7) - 0.065 * _lobe(_ln, 501.1, 20.4, 26.2)
YBAR = 0.821 * _lobe(_ln, 568.8, 46.9, 40.5) + 0.286 * _lobe(_ln, 530.9, 16.3, 31.1)
ZBAR = 1.217 * _lobe(_ln, 437.0, 11.8, 36.0) + 0.681 * _lobe(_ln, 459.0, 26.0, 13.8)
CMF = np.stack([XBAR, YBAR, ZBAR], -1)                       # [lam, 3]
XYZ2RGB = np.array([[3.2406, -1.5372, -0.4986], [-0.9689, 1.8758, 0.0415], [0.0557, -0.2040, 1.0570]])


def omega_geo_of_lambda(M_kg):
    """the geometric frequency w M of each wavelength on LAM, for a hole of mass M_kg"""
    unit = C ** 3 / (G * M_kg)                              # rad/s per unit geometric frequency
    return 2 * math.pi * C / LAM / unit


def xyz_of_radiance(I_of_w, M_kg):
    """XYZ of a radiance given per unit geometric frequency, I_of_w(w) with w an array, for a hole of mass M_kg"""
    w = omega_geo_of_lambda(M_kg)
    I_w = I_of_w(w)
    I_lam = I_w * w / LAM                                    # I_lam dlam = I_w dw, |dw/dlam| = w / lam
    dl = LAM[1] - LAM[0]
    return (I_lam[:, None] * CMF).sum(0) * dl


def rgb_of_xyz(xyz):
    return np.clip(XYZ2RGB @ xyz, 0, None)


def planck_w(w, T_geo):
    x = np.clip(w / T_geo, 1e-9, 700)
    return w ** 3 / (4 * math.pi ** 3 * np.expm1(x))


def blackbody_rgb(T_K, M_kg):
    """linear sRGB (with its absolute luminance in the common radiance units) of a blackbody at T_K"""
    T_geo = T_K / (MK_PER_TH / M_kg) * T_H_GEO              # T_K / T_H,K * T_H,geo
    return rgb_of_xyz(xyz_of_radiance(lambda w: planck_w(w, T_geo), M_kg))


class Hawking:
    def __init__(self, npz=None):
        npz = pathlib.Path(npz) if npz else pathlib.Path(__file__).resolve().parent / "greybody.npz"
        d = np.load(npz)
        self.w, self.S = d["w"], d["S"]
        self.w_peak = float(d["w_peak"])
        self.P, self.Ndot = float(d["P"]), float(d["Ndot"])
        self.d = d

    def greybody_ratio(self, w):
        """S(w) / (27 w^2): 1 in geometric optics, -> 0 at low frequency"""
        lo, hi = self.w[0], self.w[-1]
        ratio = self.S / (27 * self.w ** 2)
        out = np.interp(np.log(np.clip(w, lo, hi)), np.log(self.w), ratio)
        out = np.where(w < lo, ratio[0] * (w / lo) ** 2, out)      # Gamma_1 ~ w^4 below the grid: ratio ~ w^2
        out = np.where(w > hi, 1.0, out)
        return out

    def radiance_inf(self, w):
        return self.greybody_ratio(w) * planck_w(w, T_H_GEO)

    def rgb(self, M_kg, beta=1.0):
        """linear sRGB of the glow seen by a static observer with blueshift beta"""
        return rgb_of_xyz(xyz_of_radiance(lambda w: beta ** 3 * self.radiance_inf(w / beta), M_kg))

    def power_watts(self, M_kg):
        return self.P * HBAR * C ** 6 / (G ** 2 * M_kg ** 2)


def tonemap(rgb_lin):
    """luminance-preserving Reinhard, then the display gamma; rgb_lin [..., 3] linear, exposure already applied"""
    L = 0.2126 * rgb_lin[..., 0] + 0.7152 * rgb_lin[..., 1] + 0.0722 * rgb_lin[..., 2]
    Lt = L / (1.0 + L)
    scale = np.where(L > 1e-12, Lt / np.maximum(L, 1e-12), 0.0)
    out = np.clip(rgb_lin * scale[..., None], 0, 1)
    over = np.clip(L - 4.0, 0, None) / 8.0                      # the very bright bleach toward white, as eyes do
    out = out + (1.0 - out) * np.clip(over, 0, 1)[..., None]
    return np.clip(out, 0, 1) ** (1 / 2.2)


# ---- a 5x7 font ---------------------------------------------------------------------------------------------------
_F = {}
_F["A"] = ".###. #...# #...# ##### #...# #...# #...#"
_F["B"] = "####. #...# #...# ####. #...# #...# ####."
_F["C"] = ".###. #...# #.... #.... #.... #...# .###."
_F["D"] = "####. #...# #...# #...# #...# #...# ####."
_F["E"] = "##### #.... #.... ####. #.... #.... #####"
_F["F"] = "##### #.... #.... ####. #.... #.... #...."
_F["G"] = ".###. #...# #.... #.### #...# #...# .###."
_F["H"] = "#...# #...# #...# ##### #...# #...# #...#"
_F["I"] = ".###. ..#.. ..#.. ..#.. ..#.. ..#.. .###."
_F["J"] = "..### ...#. ...#. ...#. ...#. #..#. .##.."
_F["K"] = "#...# #..#. #.#.. ##... #.#.. #..#. #...#"
_F["L"] = "#.... #.... #.... #.... #.... #.... #####"
_F["M"] = "#...# ##.## #.#.# #.#.# #...# #...# #...#"
_F["N"] = "#...# ##..# #.#.# #..## #...# #...# #...#"
_F["O"] = ".###. #...# #...# #...# #...# #...# .###."
_F["P"] = "####. #...# #...# ####. #.... #.... #...."
_F["Q"] = ".###. #...# #...# #...# #.#.# #..#. .##.#"
_F["R"] = "####. #...# #...# ####. #.#.. #..#. #...#"
_F["S"] = ".#### #.... #.... .###. ....# ....# ####."
_F["T"] = "##### ..#.. ..#.. ..#.. ..#.. ..#.. ..#.."
_F["U"] = "#...# #...# #...# #...# #...# #...# .###."
_F["V"] = "#...# #...# #...# #...# #...# .#.#. ..#.."
_F["W"] = "#...# #...# #...# #.#.# #.#.# ##.## #...#"
_F["X"] = "#...# #...# .#.#. ..#.. .#.#. #...# #...#"
_F["Y"] = "#...# #...# .#.#. ..#.. ..#.. ..#.. ..#.."
_F["Z"] = "##### ....# ...#. ..#.. .#... #.... #####"
_F["a"] = "..... ..... .###. ....# .#### #...# .####"
_F["b"] = "#.... #.... ####. #...# #...# #...# ####."
_F["c"] = "..... ..... .###. #.... #.... #...# .###."
_F["d"] = "....# ....# .#### #...# #...# #...# .####"
_F["e"] = "..... ..... .###. #...# ##### #.... .###."
_F["f"] = "..##. .#..# .#... ###.. .#... .#... .#..."
_F["g"] = "..... .#### #...# #...# .#### ....# .###."
_F["h"] = "#.... #.... ####. #...# #...# #...# #...#"
_F["i"] = "..#.. ..... .##.. ..#.. ..#.. ..#.. .###."
_F["j"] = "...#. ..... ..##. ...#. ...#. #..#. .##.."
_F["k"] = "#.... #.... #..#. #.#.. ##... #.#.. #..#."
_F["l"] = ".##.. ..#.. ..#.. ..#.. ..#.. ..#.. .###."
_F["m"] = "..... ..... ##.#. #.#.# #.#.# #.#.# #...#"
_F["n"] = "..... ..... ####. #...# #...# #...# #...#"
_F["o"] = "..... ..... .###. #...# #...# #...# .###."
_F["p"] = "..... ####. #...# #...# ####. #.... #...."
_F["q"] = "..... .#### #...# #...# .#### ....# ....#"
_F["r"] = "..... ..... #.##. ##..# #.... #.... #...."
_F["s"] = "..... ..... .#### #.... .###. ....# ####."
_F["t"] = ".#... .#... ###.. .#... .#... .#..# ..##."
_F["u"] = "..... ..... #...# #...# #...# #..## .##.#"
_F["v"] = "..... ..... #...# #...# #...# .#.#. ..#.."
_F["w"] = "..... ..... #...# #...# #.#.# #.#.# .#.#."
_F["x"] = "..... ..... #...# .#.#. ..#.. .#.#. #...#"
_F["y"] = "..... #...# #...# #...# .#### ....# .###."
_F["z"] = "..... ..... ##### ...#. ..#.. .#... #####"
_F["0"] = ".###. #...# #..## #.#.# ##..# #...# .###."
_F["1"] = "..#.. .##.. ..#.. ..#.. ..#.. ..#.. .###."
_F["2"] = ".###. #...# ....# ...#. ..#.. .#... #####"
_F["3"] = "##### ...#. ..#.. ...#. ....# #...# .###."
_F["4"] = "...#. ..##. .#.#. #..#. ##### ...#. ...#."
_F["5"] = "##### #.... ####. ....# ....# #...# .###."
_F["6"] = "..##. .#... #.... ####. #...# #...# .###."
_F["7"] = "##### ....# ...#. ..#.. .#... .#... .#..."
_F["8"] = ".###. #...# #...# .###. #...# #...# .###."
_F["9"] = ".###. #...# #...# .#### ....# ...#. .##.."
_F[" "] = "..... ..... ..... ..... ..... ..... ....."
_F["."] = "..... ..... ..... ..... ..... .##.. .##.."
_F[","] = "..... ..... ..... ..... .##.. ..#.. .#..."
_F["="] = "..... ..... ##### ..... ##### ..... ....."
_F["-"] = "..... ..... ..... ##### ..... ..... ....."
_F["+"] = "..... ..#.. ..#.. ##### ..#.. ..#.. ....."
_F["/"] = "....# ....# ...#. ..#.. .#... #.... #...."
_F["("] = "..#.. .#... #.... #.... #.... .#... ..#.."
_F[")"] = "..#.. ...#. ....# ....# ....# ...#. ..#.."
_F[":"] = "..... .##.. .##.. ..... .##.. .##.. ....."
_F["^"] = "..#.. .#.#. #...# ..... ..... ..... ....."
_F["_"] = "..... ..... ..... ..... ..... ..... #####"
_F["%"] = "##... ##..# ...#. ..#.. .#... #..## ...##"
_F["|"] = "..#.. ..#.. ..#.. ..#.. ..#.. ..#.. ..#.."
_F["~"] = "..... ..... .#... #.#.# ...#. ..... ....."
_F["'"] = "..#.. ..#.. ..... ..... ..... ..... ....."
_F["<"] = "...#. ..#.. .#... #.... .#... ..#.. ...#."
_F[">"] = ".#... ..#.. ...#. ....# ...#. ..#.. .#..."
_F["*"] = "..... #.#.# .###. ##### .###. #.#.# ....."
_F["["] = ".###. .#... .#... .#... .#... .#... .###."
_F["]"] = ".###. ...#. ...#. ...#. ...#. ...#. .###."
GLYPH = {}
for ch, rows in _F.items():
    r = rows.split(" ")
    assert len(r) == 7 and all(len(x) == 5 for x in r), ch
    GLYPH[ch] = np.array([[c == "#" for c in row] for row in r])


def text_width(text, scale=2):
    return len(text) * 6 * scale


def draw_text(img, x, y, text, scale=2, color=(220, 220, 220)):
    """draw text into img (H, W, 3) uint8 at top-left (x, y); returns the x after the text"""
    H, W = img.shape[:2]
    col = np.array(color, np.uint8)
    for ch in text:
        g = GLYPH.get(ch, GLYPH["?"] if "?" in GLYPH else GLYPH[" "])
        big = np.kron(g, np.ones((scale, scale), bool))
        h, w = big.shape
        y0, x0 = y, x
        y1, x1 = min(H, y0 + h), min(W, x0 + w)
        if y1 > y0 and x1 > x0 and y0 >= 0 and x0 >= 0:
            sub = img[y0:y1, x0:x1]
            m = big[: y1 - y0, : x1 - x0]
            sub[m] = col
        x += 6 * scale
    return x


def write_ppm(path, img):
    path = pathlib.Path(path)
    with open(path, "wb") as f:
        f.write(f"P6 {img.shape[1]} {img.shape[0]} 255".encode() + bytes([10]))
        f.write(np.ascontiguousarray(img, dtype=np.uint8).tobytes())
hawkapproach.py (11 kB) · The hovering approach film: escape cone, lensing table, the glow as a blackbody at the local temperature. download
"""The approach: a static (hovering) observer descends toward a Schwarzschild black hole that is radiating Hawking
photons into empty space (the Unruh state: outgoing modes thermal at T_H, nothing coming in but starlight), and
looks straight down and straight up (2026-09-08, the owner's question: can the never-seen Hawking glow be rendered
by going closer than anyone has looked).

    hawkapproach.py <outdir> [frames] [panel px] [T_H in kelvin] [r_start / M] [r_end / M]

Ray optics (honest for the short-wavelength tail of the spectrum, wrong at its peak, where the wavelength is 13 horizon
radii and the glow is a dipole blur; see hawkmode.py): every direction the observer looks is traced backwards, and it
either came from the horizon or from infinity. From the horizon it carries the Hawking glow: a uniform radiance,
thermal at T_H at infinity, blueshifted for the static observer at r by beta = 1 / sqrt(1 - 2M/r), the same in every
direction, so the glow is a uniform disc (or, inside r = 3M, a uniform sky with a hole in it) at the local temperature
T_H beta. From infinity it carries a faint star field, lensed, and blueshifted by the same beta. The border between
the two is the escape cone: from the outward direction, sky for angles psi < psi_e with sin(psi_e) = (3 sqrt3 M / r)
sqrt(1 - 2M/r) inside r = 3M, and psi_e = pi - asin(...) outside; every ray is also integrated numerically (the affine
null geodesic r'' = b^2 (1 - 3M/r) / r^3) for the lensing of the stars, and the two agree. The observer hovers: the
thrust needed is a = M / (r^2 sqrt(1 - 2M/r)), and as r -> 2M the glow's temperature T_H beta -> a / 2 pi, the Unruh
temperature of that thrust: at the horizon Hawking's radiation and Unruh's are one thing.

The glow's colour is the blackbody at T_H beta, integrated against the CIE colour-matching functions for a hole of
the mass that gives the chosen T_H (2000 K: 6.1e19 kg, a 91-nanometre horizon). Not the greybody-filtered spectrum
of greybody.npz: the filtering happens at the barrier at r = 3M, so a hoverer inside it sees the unfiltered thermal
flux, and the filter is the same wave effect that forbids this picture's sharp edge; the blackbody is the one spectrum
consistent with ray optics, and the real spectrum is in the chart. A camera's auto-exposure holds the glow at a fixed
display luminance (the caption counts the stops, which climb by 17 over the descent) so the colour is what changes:
orange-red at T_H, white near 5000 K, blue-white at 20,000 K. The stars are drawn at a fixed brightness with their
true colour shift (under that exposure they would fade out within a few M). Output: rgb24 frames
(<outdir>/frames.rgb), 1920 x 1040 (two 960 panels and a caption strip), and PPM stills at a few radii.
"""
import math
import pathlib
import sys
import time

import numpy as np

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import hawkcolor as hc

out = pathlib.Path(sys.argv[1]); out.mkdir(parents=True, exist_ok=True)
NF = int(sys.argv[2]) if len(sys.argv) > 2 else 240
P = int(sys.argv[3]) if len(sys.argv) > 3 else 960
TH_K = float(sys.argv[4]) if len(sys.argv) > 4 else 2000.0
R0 = float(sys.argv[5]) if len(sys.argv) > 5 else 40.0
R1 = float(sys.argv[6]) if len(sys.argv) > 6 else 2.02
FOV = math.radians(100.0)
HUD = 80
W, H = 2 * P, P + HUD
R_FAR = 800.0
SQ27 = 3 * math.sqrt(3)

t0 = time.time()
M_kg = hc.mass_for_TH(TH_K)
hk = hc.Hawking()
rs_m = hc.rs_metres(M_kg)
P_W = hk.power_watts(M_kg)
print(f"T_H = {TH_K:.0f} K -> M = {M_kg:.3e} kg, r_s = {rs_m * 1e9:.1f} nm, photon power {P_W:.3e} W; spectrum peak at w M = {hk.w_peak:.3f}")


def lum(rgb):
    return 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2]


glow1 = hc.blackbody_rgb(TH_K, M_kg)
TARGET_L = 0.8                       # the glow's linear luminance before the tone map (0.44 after it, 0.69 displayed)
EXPO0 = TARGET_L / lum(glow1)        # the exposure at the start; then a camera's auto-exposure holds the glow there
STAR_T = 3000.0 * (10000.0 / 3000.0) ** (np.arange(8) / 7.0)   # and the caption counts the stops
print(f"exposure: glow at T_H -> linear luminance {TARGET_L} (linear rgb of the glow at beta = 1: {np.round(glow1 / glow1.max(), 3)})")
# the stars are drawn at a fixed display brightness with their true colour shift: under the auto-exposure that holds
# the glow they would fade to nothing within a few M (the glow, in the Wien tail, brightens thousands of times faster)

# ---- the two panels' look directions (fixed) ------------------------------------------------------------------
tanx = math.tan(FOV / 2)
px = (np.arange(P) + 0.5) / P * 2 - 1
py = 1 - (np.arange(P) + 0.5) / P * 2
PX, PY = np.meshgrid(px, py)
panels = {}
for name, fz in (("down", -1.0), ("up", 1.0)):
    # the observer's frame: z is up (away from the hole), x right, y up-in-the-picture; looking down flips z and x
    nx = tanx * PX * (1.0 if fz > 0 else -1.0)
    ny = tanx * PY
    nz = np.full_like(nx, fz)
    nrm = np.sqrt(nx * nx + ny * ny + nz * nz)
    nx, ny, nz = nx / nrm, ny / nrm, nz / nrm
    psi = np.arccos(np.clip(nz, -1, 1))                              # angle from up
    s = np.maximum(np.sqrt(nx * nx + ny * ny), 1e-12)
    panels[name] = (psi.ravel(), (nx / s).ravel(), (ny / s).ravel())  # psi and the unit transverse direction e


def cone(r):
    B = (SQ27 / r) * math.sqrt(1 - 2 / r)
    return math.asin(min(B, 1.0)) if r < 3 else math.pi - math.asin(min(B, 1.0))


def trace(r_obs, psis):
    """null geodesics from a static observer at r_obs looking at angles psis from the outward radial direction.
    Returns (escaped mask, phi_infinity) with phi the position angle from the outward direction in the ray's plane."""
    n = psis.size
    b = r_obs * np.sin(psis) / math.sqrt(1 - 2 / r_obs)
    r = np.full(n, r_obs); pr = np.cos(psis); ph = np.zeros(n)
    state = np.zeros(n, np.int8)                                     # 0 flying, 1 escaped, 2 fell / lost
    phi_inf = np.zeros(n)
    active = np.arange(n)
    b2 = b * b
    steps = 0
    while active.size and steps < 60000:
        ra, pa, pha, bb = r[active], pr[active], ph[active], b2[active]
        h = np.clip(0.01 * ra, 0.004, 5.0)

        def f(rr, pp):
            return pp, bb * (1.0 / rr ** 3 - 3.0 / rr ** 4), np.sqrt(bb) / (rr * rr)

        k1r, k1p, k1f = f(ra, pa)
        k2r, k2p, k2f = f(ra + 0.5 * h * k1r, pa + 0.5 * h * k1p)
        k3r, k3p, k3f = f(ra + 0.5 * h * k2r, pa + 0.5 * h * k2p)
        k4r, k4p, k4f = f(ra + h * k3r, pa + h * k3p)
        rn = ra + h / 6 * (k1r + 2 * k2r + 2 * k3r + k4r)
        pn = pa + h / 6 * (k1p + 2 * k2p + 2 * k3p + k4p)
        phn = pha + h / 6 * (k1f + 2 * k2f + 2 * k3f + k4f)
        esc = rn > R_FAR
        fell = rn < 2.0005
        idx = active
        r[idx] = rn; pr[idx] = pn; ph[idx] = phn
        state[idx[esc]] = 1
        phi_inf[idx[esc]] = phn[esc] + np.arcsin(np.clip(np.sqrt(bb[esc]) / rn[esc], -1, 1))
        state[idx[fell & ~esc]] = 2
        active = idx[~(esc | fell)]
        steps += 1
    state[active] = 2
    return state == 1, phi_inf


def deflection_table(r_obs, psi_e):
    qa = np.linspace(0, 1, 1400, endpoint=False) * psi_e
    qb = psi_e - np.exp(np.linspace(math.log(psi_e / 1400), math.log(1e-8), 1400))
    psis = np.unique(np.concatenate([qa, qb]))
    psis = psis[(psis > 1e-5) & (psis < psi_e)]
    escm, phi_inf = trace(r_obs, psis)
    # the nearly radial rays are straight
    psis = np.concatenate([[0.0], psis[escm]]); phi_inf = np.concatenate([[0.0], phi_inf[escm]])
    return psis, phi_inf, int((~escm).sum())


def stars(ex, ey, ez):
    v0 = np.floor((ex + 1.0) * 400.0).astype(np.int64)
    v1 = np.floor((ey + 1.0) * 400.0).astype(np.int64)
    v2 = np.floor((ez + 1.0) * 400.0).astype(np.int64)
    h = (v0 * 73856093 ^ v1 * 19349663 ^ v2 * 83492791) & 0xFFFFFF
    h2 = (v0 * 83492791 ^ v1 * 73856093 ^ v2 * 19349663) & 0xFFFF
    on = h < int(0xFFFFFF * 0.004)
    bright = 0.25 + 0.75 * ((h2 >> 3) & 0xFF) / 255.0
    cls = h2 & 7
    return on, bright, cls


# ---- the frames ------------------------------------------------------------------------------------------------
rr = 2.0 + (R0 - 2.0) * ((R1 - 2.0) / (R0 - 2.0)) ** (np.arange(NF) / max(NF - 1, 1))
stills = {int(np.argmin(np.abs(rr - v))): v for v in (R0, 10.0, 4.0, 2.5, 2.1, R1)}
fh = open(out / "frames.rgb", "wb")
checked = False
for n in range(NF):
    r = float(rr[n])
    beta = 1.0 / math.sqrt(1 - 2 / r)
    T_loc = TH_K * beta
    psi_e = cone(r)
    accel = 1.0 / (r * r * math.sqrt(1 - 2 / r))                   # in units of c^4 / (G M)
    accel_g = accel * hc.C ** 4 / (hc.G * M_kg) / 9.80665
    psis, phis, lost = deflection_table(r, psi_e)
    if not checked:
        # the analytic cone against the tracer, once: a ray just inside the cone escapes, just outside falls
        e_in, _ = trace(r, np.array([psi_e - 0.02]))
        e_out, _ = trace(r, np.array([min(psi_e + 0.02, math.pi - 1e-4)]))
        print(f"cone check at r = {r:.2f}: psi_e = {math.degrees(psi_e):.2f} deg; inside escapes: {bool(e_in[0])}, outside escapes: {bool(e_out[0])}")
        checked = True
    glow_lin = hc.blackbody_rgb(T_loc, M_kg)
    EXPO = TARGET_L / lum(glow_lin)
    stops = math.log2(EXPO0 / EXPO)
    glow = glow_lin * EXPO
    star_rgb = []
    for T in STAR_T:
        c = hc.blackbody_rgb(float(T) * beta, M_kg)
        star_rgb.append(c / max(lum(c), 1e-30) * 0.9)
    star_rgb = np.stack(star_rgb)
    frame = np.zeros((H, W, 3), np.uint8)
    for k, name in enumerate(("down", "up")):
        psi, ex, ey = panels[name]
        sky = psi < psi_e
        rgb = np.tile(glow[None, :], (psi.size, 1))
        if sky.any():
            phi_inf = np.interp(psi[sky], psis, phis)
            c, s = np.cos(phi_inf), np.sin(phi_inf)
            on, bright, cls = stars(s * ex[sky], s * ey[sky], c)
            col = star_rgb[cls] * (bright * on)[:, None]
            rgb[sky] = col
        img = hc.tonemap(rgb.reshape(P, P, 3))
        frame[:P, k * P:(k + 1) * P] = (img * 255 + 0.5).astype(np.uint8)
    frame[P - 1:P + 1, :] = 40
    frame[:P, P - 1:P + 1] = 40
    hc.draw_text(frame, 16, 14, "looking down at the hole", 2, (200, 200, 200))
    hc.draw_text(frame, P + 16, 14, "looking up, away from it", 2, (200, 200, 200))
    line1 = f"hovering at r = {r:6.3f} M    glow T = {beta:5.2f} T_H = {T_loc:6.0f} K    sky cone {math.degrees(psi_e):5.1f} deg    exposure {-stops:+5.1f} stops"
    line2 = f"thrust to hover a = {accel:8.4f} c^4/GM = {accel_g:8.2e} g    T_H = {TH_K:.0f} K, M = {M_kg:.2e} kg, horizon {rs_m * 1e9:.0f} nm, {P_W * 1e9:.0f} nW    stars: fixed brightness, true colour shift"
    hc.draw_text(frame, 16, P + 14, line1, 2, (230, 230, 230))
    hc.draw_text(frame, 16, P + 46, line2, 2, (150, 150, 150))
    fh.write(frame.tobytes())
    if n in stills:
        hc.write_ppm(out / f"still_r{stills[n]:.2f}.ppm", frame)
    if n % 24 == 0 or n == NF - 1:
        print(f"  frame {n:3d}: r = {r:6.3f}, beta = {beta:5.2f}, T = {T_loc:6.0f} K, cone {math.degrees(psi_e):5.1f} deg, table {psis.size} rays ({lost} lost) ({time.time() - t0:.0f} s)", flush=True)
fh.close()
print(f"{NF} frames of {W}x{H} -> {out / 'frames.rgb'} ({time.time() - t0:.0f} s)")
hawkmode.py (5 kB) · The mode film: the l = 1 and l = 2 photon modes at the spectrum's peak, in the equatorial plane. download
"""The Hawking mode itself, at the wavelength it is actually emitted at (2026-09-08).

    hawkmode.py <outdir> [frames] [panel width] [panel height] [pixels per M]

Ray optics cannot picture Hawking radiation at its peak: the photon spectrum peaks at w M = 0.243 (greybody.npz), a
wavelength of 25.8 M = 12.9 horizon radii, so the emitter is far smaller than its own light and only the l = 1 partial
wave gets out (98 % of the power). What CAN be drawn exactly is the mode function: the electromagnetic "up" mode of
the Regge-Wheeler equation at that frequency, born at the horizon with unit outgoing amplitude, partly transmitted
through the potential barrier that peaks at r = 3M (Gamma_1 = 0.415), partly reflected back in. Left panel l = m = 1,
right panel l = m = 2 (Gamma_2 = 0.0004: trapped), both in the equatorial plane, the field Re[psi(r*) exp(i (m phi -
w t))] as a signed colour (warm positive, cool negative), the flux-normalised amplitude psi rather than the 1/r field
so the outgoing wave keeps its brightness. Near the horizon the wave's crests pile up (r* -> -infinity: the same
outgoing wave, infinitely compressed in r, the trans-Planckian side of Hawking's derivation) and peel off it at the
coordinate speed 1 - 2M/r. The horizon is the black disc, the barrier's peak the dashed ring. Output rgb24 frames.
"""
import math
import pathlib
import sys
import time

import numpy as np

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import hawkcolor as hc

out = pathlib.Path(sys.argv[1]); out.mkdir(parents=True, exist_ok=True)
NF = int(sys.argv[2]) if len(sys.argv) > 2 else 240
PW = int(sys.argv[3]) if len(sys.argv) > 3 else 960
PH = int(sys.argv[4]) if len(sys.argv) > 4 else 1080
PPM = float(sys.argv[5]) if len(sys.argv) > 5 else 16.0
PERIODS = 3.0

t0 = time.time()
d = np.load(pathlib.Path(__file__).resolve().parent / "greybody.npz")
w = float(d["w_peak"])
modes = {1: (d["rs1"], d["psi1"], float(d["gamma1"])), 2: (d["rs2"], d["psi2"], float(d["gamma2"]))}
lam = 2 * math.pi / w
print(f"w = {w:.4f} / M, wavelength {lam:.1f} M = {lam / 2:.1f} r_s; Gamma_1 = {modes[1][2]:.4f}, Gamma_2 = {modes[2][2]:.5f}")

xs = (np.arange(PW) + 0.5 - PW / 2) / PPM
ys = -(np.arange(PH) + 0.5 - PH / 2) / PPM
X, Y = np.meshgrid(xs, ys)
R = np.hypot(X, Y)
PHI = np.arctan2(Y, X)
outside = R > 2.0
xh = np.maximum(R - 2.0, 1e-12)
RS = 2.0 + xh + 2.0 * np.log(xh / 2.0)
fields = {}
for l, (rs, psi, g) in modes.items():
    A = np.interp(RS, rs, psi.real)
    B = np.interp(RS, rs, psi.imag)
    A[~outside] = 0; B[~outside] = 0
    fields[l] = (A, B)
vmax = max(np.sqrt(A * A + B * B).max() for A, B in fields.values())
print(f"amplitude range: max |psi| = {vmax:.3f}; l = 1 outside the barrier |psi| = {np.sqrt(fields[1][0] ** 2 + fields[1][1] ** 2)[(R > 6) & (R < 8)].mean():.3f}, l = 2 there {np.sqrt(fields[2][0] ** 2 + fields[2][1] ** 2)[(R > 6) & (R < 8)].mean():.4f}")

WARM = np.array([1.0, 0.62, 0.22])
COOL = np.array([0.25, 0.55, 1.0])
horizon_ring = np.abs(R - 2.0) < 0.75 / PPM
barrier = (np.abs(R - 3.0) < 0.6 / PPM) & ((np.floor(PHI / (math.pi / 18)).astype(int) % 2) == 0)
period = 2 * math.pi / w
fh = open(out / "frames.rgb", "wb")
for n in range(NF):
    t = n / NF * PERIODS * period
    frame = np.zeros((PH, 2 * PW, 3), np.uint8)
    for k, l in enumerate((1, 2)):
        A, B = fields[l]
        ph = l * PHI - w * t
        v = (A * np.cos(ph) - B * np.sin(ph)) / vmax
        a = np.abs(v) ** 0.75
        rgb = np.where((v > 0)[..., None], WARM, COOL) * a[..., None]
        rgb[~outside] = 0
        rgb[horizon_ring] = 0.85
        rgb[barrier] = np.maximum(rgb[barrier], 0.45)
        img = (np.clip(rgb, 0, 1) ** (1 / 2.2) * 255 + 0.5).astype(np.uint8)
        frame[:, k * PW:(k + 1) * PW] = img
    frame[:, PW - 1:PW + 1] = 40
    # the wavelength bar and captions
    x0, y0 = 24, 110
    frame[y0 - 1:y0 + 2, x0:x0 + int(lam * PPM)] = 220
    frame[y0 - 8:y0 + 9, x0:x0 + 2] = 220; frame[y0 - 8:y0 + 9, x0 + int(lam * PPM) - 2:x0 + int(lam * PPM)] = 220
    hc.draw_text(frame, x0, y0 - 34, f"one wavelength: {lam:.1f} M = {lam / 2:.1f} horizon radii", 2, (220, 220, 220))
    hc.draw_text(frame, 24, 14, "the Hawking photon mode at its spectrum's peak, w = 0.243 / M", 2, (200, 200, 200))
    hc.draw_text(frame, 24, 40, "flux-normalised amplitude psi, equatorial plane, time e^(-i w t)", 2, (150, 150, 150))
    hc.draw_text(frame, PW + 24, 14, "the next multipole at the same frequency", 2, (200, 200, 200))
    hc.draw_text(frame, 24, PH - 60, f"l = 1, m = 1    Gamma = {modes[1][2]:.3f}: the dipole gets out (98% of the power)", 2, (230, 230, 230))
    hc.draw_text(frame, 24, PH - 32, "black disc: horizon r = 2 M    dashed: barrier peak r = 3 M", 2, (150, 150, 150))
    hc.draw_text(frame, PW + 24, PH - 60, f"l = 2, m = 2    Gamma = {modes[2][2]:.4f}: the quadrupole is trapped", 2, (230, 230, 230))
    hc.draw_text(frame, PW + 24, PH - 32, "a near-standing wave inside the barrier; a few percent leak out", 2, (150, 150, 150))
    fh.write(frame.tobytes())
    if n == NF // 2:
        hc.write_ppm(out / "still_mid.ppm", frame)
    if n % 60 == 0:
        print(f"  frame {n} ({time.time() - t0:.0f} s)", flush=True)
fh.close()
print(f"{NF} frames of {2 * PW}x{PH} -> {out / 'frames.rgb'} ({time.time() - t0:.0f} s)")
hawkchart.py (7 kB) · The spectrum chart and the mass ladder. download
"""The chart: photon greybody factors and the Hawking photon spectrum against the blackbody it is usually drawn as,
plus the mass ladder printed for the record (2026-09-08). numpy only, drawn by hand; writes <out.ppm>.

    hawkchart.py <out.ppm>
"""
import math
import pathlib
import sys

import numpy as np

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import hawkcolor as hc

outp = pathlib.Path(sys.argv[1])
d = np.load(pathlib.Path(__file__).resolve().parent / "greybody.npz")
w, Gamma, dEdw, dEdw_geo = d["w"], d["Gamma"], d["dEdw"], d["dEdw_geo"]
P, Ndot, w_pk = float(d["P"]), float(d["Ndot"]), float(d["w_peak"])
T_H = 1 / (8 * math.pi)

W, H = 1600, 1100
img = np.zeros((H, W, 3), np.uint8)
img[:] = 12


class Axes:
    def __init__(self, box, xr, yr, logx=False, logy=False):
        self.x0, self.y0, self.x1, self.y1 = box
        self.xr, self.yr, self.logx, self.logy = xr, yr, logx, logy

    def _tx(self, x):
        a, b = self.xr
        if self.logx:
            x, a, b = np.log(x), math.log(a), math.log(b)
        return self.x0 + (x - a) / (b - a) * (self.x1 - self.x0)

    def _ty(self, y):
        a, b = self.yr
        if self.logy:
            y, a, b = np.log(np.maximum(y, 1e-300)), math.log(a), math.log(b)
        return self.y1 - (y - a) / (b - a) * (self.y1 - self.y0)

    def frame(self):
        img[self.y0:self.y1 + 1, self.x0:self.x0 + 2] = 120
        img[self.y0:self.y1 + 1, self.x1 - 1:self.x1 + 1] = 120
        img[self.y0:self.y0 + 2, self.x0:self.x1 + 1] = 120
        img[self.y1 - 1:self.y1 + 1, self.x0:self.x1 + 1] = 120

    def line(self, xs, ys, color, width=2):
        px, py = self._tx(np.asarray(xs, float)), self._ty(np.asarray(ys, float))
        ok = np.isfinite(px) & np.isfinite(py)
        px, py = px[ok], py[ok]
        for i in range(len(px) - 1):
            n = int(max(2, math.hypot(px[i + 1] - px[i], py[i + 1] - py[i]) * 1.5))
            xx = np.linspace(px[i], px[i + 1], n); yy = np.linspace(py[i], py[i + 1], n)
            for dx in range(width):
                for dy in range(width):
                    xi = (xx + dx - width // 2).astype(int); yi = (yy + dy - width // 2).astype(int)
                    m = (xi >= self.x0) & (xi <= self.x1) & (yi >= self.y0) & (yi <= self.y1)
                    img[yi[m], xi[m]] = color

    def vband(self, xa, xb, color):
        a, b = int(self._tx(xa)), int(self._tx(xb))
        img[self.y0 + 2:self.y1 - 1, a:b] = color

    def xtick(self, x, label):
        px = int(self._tx(x))
        img[self.y1 - 8:self.y1, px:px + 2] = 160
        hc.draw_text(img, px - hc.text_width(label, 2) // 2, self.y1 + 8, label, 2, (190, 190, 190))

    def ytick(self, y, label):
        py = int(self._ty(y))
        img[py:py + 2, self.x0:self.x0 + 8] = 160
        hc.draw_text(img, self.x0 - hc.text_width(label, 2) - 8, py - 7, label, 2, (190, 190, 190))

    def text(self, x, y, s, color=(220, 220, 220), scale=2):
        hc.draw_text(img, int(self._tx(x)), int(self._ty(y)), s, scale, color)


hc.draw_text(img, 40, 24, "Hawking radiation of a Schwarzschild black hole in photons  (computed this session; Page 1976 reproduced)", 2, (240, 240, 240))

# top: greybody factors
ax = Axes((160, 90, 1540, 470), (0.01, 1.5), (1e-8, 1.5), logx=True, logy=True)
ax.frame()
cols = [(255, 170, 60), (90, 200, 255), (150, 255, 150), (255, 120, 200), (200, 200, 120)]
for l in range(1, 6):
    ax.line(w, Gamma[l], cols[l - 1], 3)
    i = int(np.argmin(np.abs(np.log(Gamma[l]) - math.log(1e-3))))
    ax.text(w[i] * 1.12, 1e-3, f"l = {l}", cols[l - 1])
for v in (0.01, 0.03, 0.1, 0.3, 1.0):
    ax.xtick(v, f"{v:g}")
for v in (1e-8, 1e-6, 1e-4, 1e-2, 1.0):
    ax.ytick(v, f"{v:.0e}" if v < 0.5 else "1")
hc.draw_text(img, 170, 100, "greybody factor Gamma_l(w): the fraction of a horizon-born wave that reaches infinity", 2, (220, 220, 220))
hc.draw_text(img, 700, 480 + 30, "frequency w in units of 1/M   (T_H = 1/8 pi M = 0.040/M)", 2, (190, 190, 190))

# bottom: the spectrum
ymax = float(dEdw_geo.max()) * 1.08
ax2 = Axes((160, 600, 1540, 1000), (0.0, 1.2), (0.0, ymax))
ax2.frame()
M_film = hc.mass_for_TH(2000.0)
unit = hc.C ** 3 / (hc.G * M_film)
wa, wb = 2 * math.pi * hc.C / 780e-9 / unit, 2 * math.pi * hc.C / 380e-9 / unit
ax2.vband(wa, wb, (40, 40, 52))
ax2.line(w, dEdw_geo, (110, 110, 110), 3)
ax2.line(w, dEdw, (255, 170, 60), 4)
ipk = int(np.argmax(dEdw))
ax2.line([w_pk, w_pk], [0, dEdw[ipk]], (255, 170, 60), 1)
for v in (0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.2):
    ax2.xtick(v, f"{v:g}")
ax2.ytick(0.0, "0"); ax2.ytick(ymax / 1.08, f"{ymax / 1.08:.1e}")
hc.draw_text(img, 170, 610, "power spectrum dE/dt dw, in units of hbar c^6 / G^2 M^2 per unit w M", 2, (220, 220, 220))
hc.draw_text(img, 700, 1010 + 30, "frequency w in units of 1/M", 2, (190, 190, 190))
ax2.text(0.42, ymax * 0.92, "grey: blackbody at T_H over the capture area 27 pi M^2  (P = 1.40e-4)", (170, 170, 170))
ax2.text(0.42, ymax * 0.82, f"orange: with the greybody factors  (P = {P:.3e}; Page 1976: 3.36e-5)", (255, 170, 60))
ax2.text(0.42, ymax * 0.72, f"peak w = {w_pk:.3f}/M = {w_pk / T_H:.1f} T_H (blackbody 2.8 T_H); wavelength {2 * math.pi / w_pk:.1f} M = {math.pi / w_pk:.1f} r_s", (255, 170, 60))
ax2.text(0.42, ymax * 0.62, "98% of the power in l = 1: at its peak the hole is a pure dipole", (255, 170, 60))
ax2.text(wa + 0.005, ymax * 0.30, "visible band (380-780 nm)", (140, 140, 170))
ax2.text(wa + 0.005, ymax * 0.24, "for the film's hole: T_H = 2000 K", (140, 140, 170))
ax2.text(wa + 0.005, ymax * 0.18, "M = 6.1e19 kg, horizon 91 nm", (140, 140, 170))
hc.write_ppm(outp, img)
print(f"wrote {outp}")

# the mass ladder, for the record
print()
print("mass ladder (Schwarzschild, photons only; P from this session's 3.364e-5 hbar c^6 / G^2 M^2; peak wavelength 25.8 GM/c^2):")
print(f"{'M (kg)':>10} {'T_H (K)':>10} {'horizon':>10} {'peak lambda':>12} {'P (W)':>10} {'at 1 m (W/m^2)':>15}  note")
M_sun, M_earth, M_moon = 1.989e30, 5.972e24, 7.35e22
rows = [(1e17, ""), (1e18, ""), (1e19, ""), (hc.mass_for_TH(6000.0), "T_H = 6000 K: the Sun's colour"),
        (hc.mass_for_TH(2000.0), "the film's hole"), (1e21, ""), (1e22, ""), (hc.mass_for_TH(2.725), "T_H = the CMB: heavier holes absorb more than they emit"),
        (M_moon, "the Moon"), (M_earth, "the Earth"), (M_sun, "the Sun"), (4e6 * M_sun, "Sgr A*")]
hk = hc.Hawking()
for M, note in rows:
    T = hc.MK_PER_TH / M
    rs = hc.rs_metres(M)
    lam = 25.8 * hc.G * M / hc.C ** 2
    Pw = hk.power_watts(M)
    def fm(x, u=("m", "mm", "um", "nm", "pm", "fm", "am", "zm")):
        if x >= 1e3:
            return f"{x / 1e3:.3g} km"
        for i, s in enumerate(u):
            if x >= 10 ** (-3 * i) or i == len(u) - 1:
                return f"{x / 10 ** (-3 * i):.3g} {s}"
    print(f"{M:10.2e} {T:10.3g} {fm(rs):>10} {fm(lam):>12} {Pw:10.2e} {Pw / (4 * math.pi):15.2e}  {note}")
hawkrender.sh (2 kB) · The driver that ran the three Hawking scripts and encoded the films. download
#!/bin/bash
# Hawking radiation, rendered as far as the mathematics honestly allows (2026-09-08): the hovering approach film,
# the mode film, the chart; encode and copy to hot-drops. CPU only; run alone.
set -u
FFDIR="${FFDIR:-/path/to/ffmpeg}"          # the directory holding ffmpeg.exe / ffprobe.exe
export PATH="$FFDIR:$PATH"
G=$(pwd); HD=$G/out; mkdir -p "$HD"
cd "$G"
echo "########## HAWKRENDER start $(date +%T)"
python hawkapproach.py hawk_approach 240 960 2000 40 2.02 2>&1 | tee hawk_approach.log
python hawkmode.py hawk_mode 240 960 1080 16 2>&1 | tee hawk_mode.log
python hawkchart.py hawk_chart.ppm 2>&1 | tee hawk_chart.log
echo "########## encode $(date +%T)"
ffmpeg.exe -y -hide_banner -loglevel error -f rawvideo -pix_fmt rgb24 -s 1920x1040 -r 24 -i hawk_approach/frames.rgb -vf format=yuv420p -c:v h264_mf -b:v 20M -movflags +faststart "$HD/hawking-approach-hover-40M-to-2.02M-10s.mp4" && echo "  approach: $(stat -c %s "$HD/hawking-approach-hover-40M-to-2.02M-10s.mp4") bytes"
ffmpeg.exe -y -hide_banner -loglevel error -f rawvideo -pix_fmt rgb24 -s 1920x1080 -r 24 -i hawk_mode/frames.rgb -vf format=yuv420p -c:v h264_mf -b:v 20M -movflags +faststart "$HD/hawking-mode-dipole-quadrupole-10s.mp4" && echo "  mode: $(stat -c %s "$HD/hawking-mode-dipole-quadrupole-10s.mp4") bytes"
for f in hawk_approach/still_r*.ppm; do
  b=$(basename "$f" .ppm); r=${b#still_r}
  ffmpeg.exe -y -hide_banner -loglevel error -i "$f" "$HD/hawking-approach-r${r}M.png" && echo "  still r = $r"
done
ffmpeg.exe -y -hide_banner -loglevel error -i hawk_mode/still_mid.ppm "$HD/hawking-mode-frame120.png" && echo "  mode still"
ffmpeg.exe -y -hide_banner -loglevel error -i hawk_chart.ppm "$HD/hawking-spectrum-chart.png" && echo "  chart"
ls -la "$HD" | grep hawking
echo "HAWKRENDER DONE $(date +%T)"
greybody.log (2 kB) · The greybody solver's printed checks, verbatim. download
l = 1: Gamma at w = 0.010: 7.622e-08, at w = 0.124: 0.0064, at w = 1.50: 1.000150; flux conservation max |1 - (|A|^2 - |B|^2)| = 1.5e-04 (3 s)
l = 2: Gamma at w = 0.010: 7.585e-14, at w = 0.124: 0.0000, at w = 1.50: 1.000150; flux conservation max |1 - (|A|^2 - |B|^2)| = 5.9e-03 (5 s)
l = 3: Gamma at w = 0.010: 4.434e-20, at w = 0.124: 0.0000, at w = 1.50: 1.000150; flux conservation max |1 - (|A|^2 - |B|^2)| = 8.2e+03 (8 s)
l = 4: Gamma at w = 0.010: 1.554e-26, at w = 0.124: 0.0000, at w = 1.50: 1.000150; flux conservation max |1 - (|A|^2 - |B|^2)| = 2.1e+10 (11 s)
l = 5: Gamma at w = 0.010: 3.688e-33, at w = 0.124: 0.0000, at w = 1.50: 1.000149; flux conservation max |1 - (|A|^2 - |B|^2)| = 1.8e+17 (14 s)
l = 6: Gamma at w = 0.010: 6.139e-40, at w = 0.124: 0.0000, at w = 1.50: 0.999874; flux conservation max |1 - (|A|^2 - |B|^2)| = 1.5e+24 (16 s)
l = 7: Gamma at w = 0.010: 7.414e-47, at w = 0.124: 0.0000, at w = 1.50: 0.887382; flux conservation max |1 - (|A|^2 - |B|^2)| = 6.3e+30 (19 s)
l = 8: Gamma at w = 0.010: 7.492e-54, at w = 0.124: 0.0000, at w = 1.50: 0.013485; flux conservation max |1 - (|A|^2 - |B|^2)| = 8.5e+37 (22 s)
low-w slope of Gamma_1: 4.143 (theory 4)
  sum (2l+1) Gamma at w = 0.30: 2.511  vs geometric 27 w^2 = 2.448  (ratio 1.026)
  sum (2l+1) Gamma at w = 0.60: 8.928  vs geometric 27 w^2 = 9.858  (ratio 0.906)
  sum (2l+1) Gamma at w = 1.00: 25.695  vs geometric 27 w^2 = 26.957  (ratio 0.953)
  sum (2l+1) Gamma at w = 1.50: 61.544  vs geometric 27 w^2 = 60.750  (ratio 1.013)
photon power P M^2 = 3.364e-05  (Page 1976: 3.36e-5; geometric-optics blackbody 1.398e-04, exact 1.399e-04; ratio P/P_geo = 0.240)
photon rate  N M   = 1.480e-04 per unit time M;  mean photon energy 0.2273 = 5.71 T_H
energy spectrum peak at w M = 0.243 = 6.12 T_H  (blackbody peak 2.82 T_H = 0.112);  wavelength 2 pi / w = 25.8 M = 12.9 r_s
number spectrum peak at w M = 0.230
share of the power by multipole: l=1: 98.0%, l=2: 2.0%, l=3: 0.0%, l=4: 0.0%
mode l = 1 at w = 0.243: Gamma = 0.4148, |R|^2 = 0.5852, 2433 points kept, r* in [-31.6, 90.0] (24 s)
mode l = 2 at w = 0.243: Gamma = 0.0004, |R|^2 = 0.9996, 2433 points kept, r* in [-31.6, 90.0] (26 s)
wrote greybody.npz (26 s)
jp-4k-summary.txt (0 kB) · The 4K run's summary line. download
jp a=0.9 eps3=3.0 horizon=1.4359 isco=1.4646 hits=3498121 fell=150703 escaped=4645576 g_max=1.320
traced 8294400 rays in 6238 s: 3498121 hit the disc, 150703 fell in, 4645576 escaped (3840x2160, 240 frames, 4 bands)

Running them

python blackhole.py out 240 1280 720                # Schwarzschild disc, a few minutes
python geodesic_disc.py jp out 120 960 540           # jp | kerr | schwarzschild; about six minutes
python geodesic_disc.py jp out4k 240 3840 2160 4     # the 4K film, about two hours, resumable
python greybody.py                                   # writes greybody.npz; half a minute
bash hawkrender.sh                                   # the approach film, the mode film, the chart
ffmpeg -f rawvideo -pix_fmt rgb24 -s 960x540 -r 24 -i out/frames.rgb -vf format=yuv420p -c:v libx264 out.mp4

Colophon

Geometric units throughout, G = c = 1 and, for the Hawking work, ℏ = kB = 1 as well, with the mass M = 1; a mass in kilograms enters only when a temperature or a wavelength is quoted in ordinary units. What was checked: the general tracer reproduces the Schwarzschild control (horizon 2.000, innermost stable orbit 6.00); the greybody solver reproduces Page's 1976 photon power, the ω⁴ law and the capture cross-section; the escape cone's analytic form agrees with the traced rays on every frame of the descent; flux is conserved through the barrier to one part in ten thousand for the dipole. What is not claimed: any picture of Hawking radiation itself. Such a picture does not exist, and the reason is the wavelength, not the noise.

Computed, rendered and written over an evening and the following morning in September 2026, in a working session between the project's author and Claude, beside a video-interpolation project the black holes have nothing to do with. The dust is random; the rest is the equations.