Null Geodesics
It has always been an aim of mine to write a physically-based black hole path tracer. However, before doing that, I thought I would take on an easier challenge - plotting the orbits of photons around Kerr black holes, but with code generalizable to any black hole spacetime.
It should be noted that we are using units of $G = c = 1$ here.
First, we need to write out the Kerr metric. The metric is given by:
$$ g_{\mu\nu} = \begin{pmatrix} -(1 - \frac{2 M r}{\Sigma}) & 0 & 0 & -\frac{2 M r a \sin^2 \theta}{\Sigma} \\ 0 & \frac{\Sigma}{\Delta} & 0 & 0 \\ 0 & 0 & \Sigma & 0 \\ -\frac{2 M r a \sin^2 \theta}{\Sigma} & 0 & 0 & (r^2 + a^2 + \frac{2 M r a^2}{\Sigma} \sin^2 \theta) \sin^2 \theta \end{pmatrix} $$
Where:
$$ a = \frac{J}{M} $$
$$ \Sigma = r^2 + a^2 \cos^2 \theta $$
$$ \Delta = r^2 - 2Mr + a^2 $$
And the inverse metric is given by:
$$ g^{\mu\nu} = \begin{pmatrix} -\frac{1}{\Delta} (r^2 + a^2 + \frac{2 M r a^2}{\Sigma} \sin^2 \theta) & 0 & 0 & -\frac{2 M r a}{\Delta \Sigma} \\ 0 & \frac{\Delta}{\Sigma} & 0 & 0 \\ 0 & 0 & \frac{1}{\Sigma} & 0 \\ -\frac{2 M r a}{\Delta \Sigma} & 0 & 0 & \frac{1}{\Delta \sin^2 \theta} (1 - \frac{2 M r}{\Sigma}) \end{pmatrix} $$
So, in code, we have:
def kerr_metric(coords, M=2e30, a=0.97):
t = coords[0]
r = coords[1]
theta = coords[2]
phi = coords[3]
sigma = r ** 2 + a ** 2 * torch.cos(theta) ** 2
delta = r ** 2 - 2 * M * r + a ** 2
return torch.tensor([
[-(1 - (2 * M * r) /sigma), 0., 0., -((2 * M * r * a * torch.sin(theta) ** 2) / sigma)],
[0., sigma / delta, 0., 0.],
[0., 0., sigma, 0.],
[-((2 * M * r * a * torch.sin(theta) ** 2) / sigma), 0., 0., (r ** 2 + a ** 2 + (2 * M * r * a ** 2)/sigma * torch.sin(theta) ** 2) * torch.sin(theta) ** 2]
])
def kerr_inverse_metric(coords, M=2e30, a=0.97):
# Based off https://www.roma1.infn.it/teongrav/onde19_20/kerr.pdf
t = coords[0]
r = coords[1]
theta = coords[2]
phi = coords[3]
sigma = r ** 2 + a ** 2 * torch.cos(theta) ** 2
delta = r ** 2 - 2 * M * r + a ** 2
return torch.tensor([
[-1 / delta * (r ** 2 + a ** 2 + (2 * M * r * a ** 2) / sigma * torch.sin(theta) ** 2), 0., 0., -(2 * M * r * a) / (sigma * delta)],
[0., delta / sigma, 0., 0.],
[0., 0., 1 / sigma, 0.],
[-(2 * M * r * a) / (sigma * delta), 0., 0., (delta - a ** 2 * torch.sin(theta) ** 2) / (sigma * delta * torch.sin(theta) ** 2)]
])
Next, we will calculate Christoffel symbols by automatic differentiation of the metric tensor. To do this, we compute the Jacobian matrix of the metric tensor. Recall that the Jacobian matrix of a vector valued function, in tensor index notation, is given by:
$$ J_{mn} = \frac{\partial F_m}{\partial x^n} $$
The metric tensor, however, is a matrix-valued function, so we need to add an additional index to the Jacobian, giving:
$$ J_{\alpha \mu \nu} = \frac{\partial g_{\mu \nu}}{\partial x^\alpha} $$
We can then write the Christoffel symbols in terms of the Jacobian:
$$ \Gamma^\mu_{\gamma \delta} = \frac{1}{2} g^{\mu \eta} \left( \frac{\partial g_{\gamma \eta}}{\partial x^\delta} + \frac{\partial g_{\eta \delta}}{\partial x^\gamma} - \frac{\partial g_{\gamma \delta}}{\partial x^\eta}\right) = \frac{1}{2} g^{\mu \eta} (J_{\gamma \eta \delta} + J_{\eta \delta \gamma} - J_{\gamma \delta \eta}) $$
Or with a change of indices:
$$ \Gamma^\beta_{\mu \nu} = \frac{1}{2} g^{\beta \alpha} (J_{\mu \alpha \nu} + J_{\eta \alpha \mu} - J_{\mu \nu \alpha}) $$
This is some code I took from here and modified that calculates the Christoffel symbols in the way outlined:
def calculate_christoffel(jacob, g_inv, dims):
# based on https://github.com/AndreaAntoniali/Riemann-tensor-calculator/blob/main/Riemann_Calculations.ipynb
gamma = np.zeros((dims, dims, dims))
for beta in range(dims):
for mu in range(dims):
for nu in range(dims):
for alpha in range(dims):
gamma[beta,mu,nu] = 1/2 * g_inv[alpha][beta] * (jacob[alpha][mu][nu] + jacob[alpha][nu][mu] - jacob[mu][nu][alpha])
return gamma
def christoffel_at_point_4d(metric, inverse_metric, t, r, theta, phi, dims):
coord = torch.tensor([t, r, theta, phi], requires_grad=True)
g_inv = inverse_metric(coord)
jacobian = torch.autograd.functional.jacobian(metric, coord, create_graph=True)
return calculate_christoffel(jacobian, g_inv, dims)
Now, we can finally solve the geodesic equations. However, firstly, we want to rewrite the typical geodesic equations in a slightly differing form:
$$ \frac{d^2 x^\mu}{ds^2} = -\Gamma^\mu_{\alpha \beta} \frac{dx^\alpha}{ds} \frac{dx^\beta}{ds} $$
Here, note the use of the Einstein summation convention; $i$ and $j$ are summation (dummy) indices, so we have to fully expand out the summations in our code. The geodesic equation describes a system of equations, one equation each for $(t, r, \theta, \phi)$. Therefore, to simplify our code, we group the equations together as a vector. Additionally, because it is a second-order equation, we also need to keep track of the 4 components of velocity, one each for $(v_t, v_r, v_\theta, v_\phi)$. So we have a vector of 8 components that stores all the position and velocity information of our solution to the differential equation:
def kerr_d_ds(X, s, metric=kerr_metric, inverse_metric=kerr_inverse_metric):
"""
The value of the first and second
derivatives with respect to an affine
parameter s
"""
# Create a new vector to hold the positions and velocities
u = np.zeros(X.shape)
# X is a vector with 4 components of position
# and 4 components of velocity
x = X[:4]
velocities = X[4:]
# Find christoffel symbols given the position and the metric
# here t is coordinate time, not the affine parameter s, and
# also not the proper time
x0, x1, x2, x3 = x
Gamma = christoffel_at_point_4d(metric, inverse_metric, x0, x1, x2, x3, 4)
# Given the christoffel symbols, calculate the next position
for mu in range(4):
for i in range(4):
for j in range(4):
# Solve for x components
# we sum due to the Einstein summation convention
u[mu] += -Gamma[mu][i][j] * velocities[i] * velocities[j]
# Solve for v components
u[4:] = velocities
return u
We then write a basic RK4 solver to finally solve:
def rk4(f, u0, t0, tf, n):
t = np.linspace(t0, tf, n+1)
u = np.zeros((n+1, len(u0)))
u[0] = u0
h = t[1]-t[0]
for i in tqdm(range(n)):
k1 = h * f(u[i], t[i])
k2 = h * f(u[i] + 0.5 * k1, t[i] + 0.5*h)
k3 = h * f(u[i] + 0.5 * k2, t[i] + 0.5*h)
k4 = h * f(u[i] + k3, t[i] + h)
u[i+1] = u[i] + (k1 + 2*(k2 + k3 ) + k4) / 6
return u, t
Now it's time to set up the initial conditions. Our initial radius will be a radius of $6M$, with a polar angle $\theta_0 = \frac{\pi}{4}$ and an azimuthal angle $\phi_0 = 0$. These are arbitrary; so long as the photon doesn't start close to the event horizon, it doesn't matter where it starts. Then, we set our velocities $v_r = 1$ (by definition, as $c = 1$), and $v_\theta = v_\phi = 0$ (also by definition, as the speed of light must be $c$). We then integrate to find the geodesics.
Back to home