Iris is a a real time GPU-accelerated render engine utilizing MacOS's Metal API for visualizing a non-rotating Shwarzschild black hole. It uses a Runge-Kutta numerical method ran on GPU kernels to solve the simplified null geodesic equations for the non-linear light paths, capturing iconic relativistic phenomena such as gravitational lensing and the photon ring.
- ~14ms per frame (~70 fps)
- 1000 x 800 screen resolution (~8ms per frame for 800 x 600)
- 0.05 rad step size, 1000 steps
The code works right out the box as it contains (almost) all the relevant libraries and dependencies needed. However, this is designed to work on Macs, more specifically anything with an Apple Silicon chip (M1 or higher), since it uses Apple GPU kernels for the raytracing. You will need to have Xcode installed to be able to use Metal, and have SDL installed via Homebrew. After building the project with CMake, the first render will be a front view of the black hole. There are two primary ways to interact with the renderer; POV camera view (with WASD and arrow keys) and orbital camera view (AD to rotate around the black hole, WS to change the radius of orbit). You can also adjust the sliders to control the rotation of the accretion disc and the position of the sun (not really a sun, more like a star but whatever).
You can directly change the resolution of the render (and the window) by changing the HEIGHT and WIDTH values in application.cpp. The quality of the raytracing can be adjusted using the step size and step count used for the RK4 light path solver. By default, the step size is 0.05 radians, with 1000 steps, which yields an accurate render of a typical black hole with a photon ring and gravitational lensing. Decreasing the step size will significantly reduce the performance of the renderer, and may no longer work in real time.
At the core of Iris is the simulation of light paths in the curved spacetime around a Schwarzschild Black Hole. Unlike traditional raytracers which simulate linear light transport, Iris computes the null geodesics, which are the curved paths that light follows around the black hole due to its gravity.
We start from the Shwarzschild metric, define the constants of motion, and use symmetries to reduce it to the Binet Equation.
The Schwarzschild metric in spherical coordinates
where
For light rays (photons with speed
Because the metric is independent of
-
Energy (
$e$ ): The metric does not depend on the$t$ -coordinates
-
Angular Momentum (
$L$ ): The metric does not depend on the$\phi$ -coordinates
Substituting the expressions for
Multiplying through by
To find the shape of the orbit
Since
Substitute
Dividing the entire equation by
Differentiating (3) with respect to
We divide both sides by
Substituting
IRIS uses the Runge-Kutta 4th Order (RK4) method to solve the Binet equations for each light path. For every pixel, the GPU integrates the ray's path step-by-step, and returns a value based on where the photon ends up.
To actually compute the light paths, we first define the state vector
Taking
Which we can reduce to first order differential equations in terms of the state vector
$$
\begin{align*} \frac{dy_1}{d \phi} &= y_2 \ \frac{dy_2}{d \phi} &= 3my_1 ^2 - y_1 \end{align*} $$
In vector form,
For the implementation, we will be using geometrized units, where
where
Then by taking
The Binet equation tells us how the light paths curve with a differential equation, so we would have to trace out the curve using numerical methods. The Runge-Kutta 4th Order or RK4 algorithm for short, is an extension of Euler's method to solve for the curve. Since the slope of curved spacetime changes rapidly (especially near the event horizon), using Euler's method alone will be inaccurate, veering way off the actual path for each step.
The RK4 algorithm solves this by sampling the slope at 4 different points within a single step to obtain an average direction for th photon at a particular point. Let our system be
-
$k_1$ : The slope at the start (Euler’s method)
-
$k_2$ : First midpoint slope
-
$k_3$ : Second midpoint slope
-
$k_4$ : End point slope
Then we can combine the 4 increments
while (curr_steps < max_steps) {
if (is_debug_ray && curr_steps % 1000 == 0) {
std::cout << "[Debug Ray] RK4 Step: " << curr_steps << "/" << max_steps
<< " | r = " << (1.0 / u) << std::endl;
}
// RK4 Integration Step
// Sampling 4 different points
double k1_u = v;
double k1_v = 1.5 * u * u - u;
double k2_u = v + 0.5 * d_phi * k1_v;
double k2_v =
1.5 * pow(u + 0.5 * d_phi * k1_u, 2) - (u + 0.5 * d_phi * k1_u);
double k3_u = v + 0.5 * d_phi * k2_v;
double k3_v =
1.5 * pow(u + 0.5 * d_phi * k2_u, 2) - (u + 0.5 * d_phi * k2_u);
double k4_u = v + d_phi * k3_v;
double k4_v = 1.5 * pow(u + d_phi * k3_u, 2) - (u + d_phi * k3_u);
// Update u and v with Weighted Average
u += (d_phi / 6.0) * (k1_u + 2 * k2_u + 2 * k3_u + k4_u);
v += (d_phi / 6.0) * (k1_v + 2 * k2_v + 2 * k3_v + k4_v);
phi += d_phi;
// Check for termination conditions
}To achieve real-time performance, IRIS offloads the heavy RK4 integration to the GPU using Metal kernels. Since the light path comutation is carried out for every pixel on the screen, we can assign each one to a separate thread on the GPU, allowing multiple rays to be calculated simultaneously. The project utilizes the metal-cpp header-only library, which allows the C++ codebase to interact directly with the Metal API without the need for Objective-C or Swift. Data such as the pixel buffer and camera uniforms are stored in shared memory, enabling efficient transfer between the CPU (for SDL/ImGui) and the GPU (for rendering) without expensive copies, unlike traditional GPU/CPU communication via a PCIe bus. This makes the integration of Mac GPUs so much more efficient, offering an enormous performance boost, more than 300% faster! compared to using the CPU alone.
Below is as simplified sample of the raytracing process written in a .metal file that runs on the GPU
kernel void render_black_hole(
device uint* pixels [[buffer(0)]],
constant Uniforms& uniforms [[buffer(1)]],
texture2d<float, access::sample> noise_texture [[texture(0)]],
texture2d<float, access::sample> skybox_texture [[texture(1)]],
uint2 gid [[thread_position_in_grid]]
) {
// 6. RK4 step process
for (int curr_steps = 0; curr_steps < max_steps; curr_steps++) {
// RK4 Integration Step (Solving: v' = 1.5 * u^2 - u)
float k1_u = v;
float k1_v = 1.5f * u * u - u;
float u_k2 = u + 0.5f * d_phi * k1_u;
float k2_u = v + 0.5f * d_phi * k1_v;
float k2_v = 1.5f * u_k2 * u_k2 - u_k2;
float u_k3 = u + 0.5f * d_phi * k2_u;
float k3_u = v + 0.5f * d_phi * k2_v;
float k3_v = 1.5f * u_k3 * u_k3 - u_k3;
float u_k4 = u + d_phi * k3_u;
float k4_u = v + d_phi * k3_v;
float k4_v = 1.5f * u_k4 * u_k4 - u_k4;
// Update u, v, and phi
u += (d_phi / 6.0f) * (k1_u + 2.0f * k2_u + 2.0f * k3_u + k4_u);
v += (d_phi / 6.0f) * (k1_v + 2.0f * k2_v + 2.0f * k3_v + k4_v);
phi += d_phi;
// Map back to 3D to check for collisions
float r = 1.0f / u;
float local_x = r * cos(phi);
float local_y = r * sin(phi);
float3 curr_pos3D = e1 * local_x + e2 * local_y;
// Termination Conditions
// A. Fell into the Event Horizon
if (r <= 1.0f) {
color = float3(0.0f); // Black
break;
}
// B. Escaped to infinity
if (r > 100.0f) {
float3 final_dir = normalize(curr_pos3D - prev_pos3D);
// color = sample_skybox(final_dir);
color = sample_skybox(final_dir, skybox_texture);
break;
}
// C. Intersected the Accretion Disk
// D. Intersected the Sun
}
// Convert float3 (0.0 - 1.0) to ARGB (0 - 255)
uint r_byte = (uint)(clamp(color.x, 0.0f, 1.0f) * 255.0f);
uint g_byte = (uint)(clamp(color.y, 0.0f, 1.0f) * 255.0f);
uint b_byte = (uint)(clamp(color.z, 0.0f, 1.0f) * 255.0f);
uint argb = (255u << 24) | (r_byte << 16) | (g_byte << 8) | b_byte;
// Write directly into the CPU-readable array
pixels[gid.y * uniforms.width + gid.x] = argb;
}Although the computation of the light paths has been simplified to a single Binet equation, it is more than sufficient for demonstrating the key characteristics of a black hole. While most of the effects on the accretion disc aren't physically accurate, the simulation of the foundational behavior of light around the black hole gives rise to the famous gravitational lensing effect on the accretion disc and the space behind the black hole.
The very first iteration of the accretion disc was defined on a 2D plane with a simple lower and upper bound around the origin.
In the following versions, the accretion disc used a generated noise texture to add contrast and imitate the spiralling of dust, gasses and plasma around the black hole. In addition to the texture, doppler beaming and blackbody coloring are also simualted with procedural techniques, since they cannot be achieved with our generalization of the Shwarzschild metric.
-
Generated Noise Texture The organic look of the disc is generated via Domain-Warped Perlin Noise, generated on the CPU using a custom gradient hash. It is then passed to the GPU, where the kernel samples this noise using polar coordinates
$$(r, \phi)$$ to create a realistic, flowing disc structure. - Doppler Beaming: Due to the high orbital velocities of the disc, light from the side moving towards the observer appears brighter and shifted in color, while the side moving away appears dimmer.
- Blackbody Coloring: The disc's color is determined by its temperature (modeled with a radial falloff), shifting from blinding white at the inner edge to deep oranges and reds at the periphery.
Rays that escape the black hole's gravity sample an Equirectangular Skybox, in this case, a high-resolution Milky Way texture. Spherical UV mapping ensures that the stars and nebulae appear correctly distorted by the gravitational lensing for dramatic effect.
<script> window.MathJax = { tex: { inlineMath: [['$', '$'], ['\\(', '\\)']], displayMath: [['$$', '$$'], ['\\[', '\\]']], processEscapes: true } }; </script> <script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>



