![logo](branding/logo_monochrome_white.jpg) donut is a real-time blackhole renderer that traces light through curved space time. each pixel is represented by a light ray originating from the camera and the ray is integrated through the schwarzschild metric, allowing the renderer to reproduce gravitational lensing and the distortion of the surrounding accretion disk and background. the renderer runs entirely on the gpu allowing the scene to be explored and modified interactively in real time. ## physics the engine assumes the simplest kind of black hole there is, one that doesn't spin and has no charge. the spacetime around it is the schwarzschild solution, and the whole hole comes down to one number, its schwarzschild radius $r_s = \frac{2GM}{c^2}$. the metric is: $$ ds^2 = -\left(1-\frac{r_s}{r}\right)dt^2 +\left(1-\frac{r_s}{r}\right)^{-1}dr^2 +r^2(d\theta^2+\sin^2\theta\,d\phi^2), $$ at $r = r_s$ the first two terms blow up. that's the event horizon, and once a ray gets there the engine stops tracing it. ### following the light light moves along null geodesics, the paths where $ds^2 = 0$. for every pixel the engine shoots a ray out of the camera and runs it backward until something happens to it. it falls into the hole, lands on the accretion disk or an object in the scene, or flies off into the background sky. each ray keeps track of where it is in spherical coordinates $(r, \theta, \phi)$ and how fast each of those is changing, and at every step the geodesic equation nudges those rates. gravity pulls the ray inward, its sideways motion carries it back out, and the balance between the two is what bends the path. since the metric never changes over time, each ray also carries a conserved energy $E = \left(1-\frac{r_s}{r}\right)\frac{dt}{d\lambda}$. that takes care of the time part of the motion, so $t$ never has to be integrated. ### stepping there's no formula for where a general ray ends up, so it gets walked forward one small step at a time. the update itself is basic (first order), and the accuracy mostly comes from picking good step sizes. near the photon sphere, where space is bent the hardest, the steps get tiny. far out, where space is almost flat, a ray covers huge distances in a few strides. close to the disk plane the step is capped at the disk's thickness, otherwise a thin disk seen from the side could be skipped over completely. a ray is done when it crosses the horizon, hits something, leaves the scene, or is heading outward past $50\,r_s$ where nothing is going to bend it much anymore. that last check saves a lot of work, and it means rays near the hole get almost all of the computation. ### three radii that matter the event horizon at $r_s$ is the black shadow. every ray that ends there comes out black. a bit further out, at $1.5\,r_s$, is the photon sphere. light can actually orbit the hole here, though not for long. rays that pass close to it get thrown around at sharp angles or wrap around the hole a few times before escaping. that's where the thin bright ring around the shadow comes from, along with the warped background and the extra copies of the disk. at $3\,r_s$ is the innermost stable circular orbit (isco). gas can't orbit steadily any closer than this, so that's where the disk starts. ### the accretion disk the disk is a thin, flat, glowing surface in the equatorial plane. it's opaque and reaches from the isco out to $12\,r_s$ by default. how bright each part of it is follows the novikov thorne thin disk model. the flux is zero right at the inner edge, peaks just outside it, then fades with distance: $$ F(r) \propto \frac{1}{r^3}\left(1 - \sqrt{\frac{r_\text{in}}{r}}\right) $$ each patch of the disk glows like a blackbody, and since the flux goes as $T^4$ its temperature follows straight from that. the hottest part sits at the disk temperature setting, 4800 k unless you change it. there's also an optional turbulence setting that adds moving noise on top, to make the gas look like it's churning. ### redshift and colour the gas orbits incredibly fast (half the speed of light at the isco) and sits deep in the hole's gravity well, and both of those change its light before it reaches the camera. they fold into one redshift factor $g$, the observed frequency over the emitted one: $$ g = \frac{\sqrt{1 - \frac{3}{2}\frac{r_s}{r}}}{1 - \boldsymbol\beta\cdot\hat n} $$ the top is gravity. light climbing out of the well loses energy, and the gas's own clocks run slow. the bottom is the doppler shift from the gas velocity $\boldsymbol\beta$ along $\hat n$, the direction toward the camera. it's why one side of the disk looks bluer and brighter than the other. that side is coming toward you and the other one is moving away. the colour you see is a blackbody at $g\,T$, and the brightness gets multiplied by $g^4$, which is relativistic beaming. there's one cheat. the real brightness range across the disk is so extreme that it would wash out into a single saturated ring, so it gets squashed for display and you can still see the colour change from the inside out. ### more than a picture the renderer already works out these numbers for every pixel, so it can show them to you instead of the final colour. that's the redshift $g$ and the emission temperature on the disk, and the impact parameter $b$ for every ray, either as raw values for analysis or false coloured to look at. $b$ is how far the ray would have missed the centre of the hole if there were no gravity. rays with $b$ near $\frac{3\sqrt{3}}{2}\,r_s$ are the ones that graze the photon sphere and make up the ring. ## rendering for each pixel, donut generates a ray from the camera using its position, orientation, field of view, and aspect ratio. the ray is then converted into the coordinates used by the geodesic integrator and propagated through the scene. during integration, the renderer checks for intersections with the black hole, accretion disk, and other scene objects. rays that escape are sampled against the background environment. since the disk is viewed through curved spacetime, different parts of it can reach the camera along multiple paths around the black hole. the resulting image includes gravitational lensing, gravitational redshift, and doppler shifting from the rotating disk. the final pixel color is determined from the ray's path, its intersection with the scene, and the relativistic effects accumulated along the way. the entire process runs on the gpu as a full-screen fragment shader, tracing every pixel's ray in parallel. ## building the libraries in `ext/` are git submodules, so clone with `--recursive` (or run `git submodule update --init` in an existing clone). the shaders are prebuilt in `assets/shaders/generated/` and committed, so a plain build doesn't need the slang toolchain. **macos (apple silicon)** ```bash brew install premake molten-vk vulkan-loader vulkan-headers spirv-cross tools/fetch-slang.sh premake5 gmake && make config=debug donut ./bin/debug-macosx/donut ``` on macos `make` also regenerates the shaders from `assets/shaders/*.slang`, which is why it wants slang and spirv-cross. **windows (x64, visual studio 2022)** 1. install the [lunarg vulkan sdk](https://vulkan.lunarg.com/sdk/home). its installer sets `VULKAN_SDK`, which the build reads, so open a fresh terminal afterwards. without the sdk the build still works, it just leaves the vulkan backend out and runs on opengl. 2. run `premake5 vs2022` from the repository root ([premake](https://premake.github.io/) is a single exe). 3. open `donut.sln`, pick `debug` and `x64`, and build. f5 runs the app from the repository root; the exe also finds `assets/` on its own when launched from `bin\debug-windows\`. ## license donut is released under the mit license. the source code may be used, modified, and redistributed freely, including in commercial projects, provided that the original copyright notice and license are retained.