aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorhachem <im@hachem.wtf>2026-08-24 12:58:31 +0200
committerhachem <im@hachem.wtf>2026-08-24 12:58:31 +0200
commitd2f904e9d7ffb3b72ffbd9f70bcdaf72676ae9be (patch)
treea7b21c9841b543e4cb2dccfd9d67c042f89569f2 /docs
parent59b7c407550d63e997ed1e7bed286be5c332c285 (diff)
[docs]: rewrite old documentation
Diffstat (limited to 'docs')
-rw-r--r--docs/README.md27
-rw-r--r--docs/architecture.md300
-rw-r--r--docs/physics.md373
3 files changed, 700 insertions, 0 deletions
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..fa5d6ed
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,27 @@
+# Donut Documentation
+
+Deeper documentation for Donut, the real-time Schwarzschild black-hole renderer.
+The top-level [`README.md`](../README.md) is the overview; the two documents here
+go into how it actually works.
+
+- **[physics.md](physics.md)** — the physics and maths. Tracing light through
+ curved spacetime: the Schwarzschild metric, null geodesics and the equations of
+ motion, the numerical integrator and its adaptive step, the event horizon /
+ photon sphere / ISCO, the Novikov–Thorne accretion disk, gravitational and
+ Doppler redshift with relativistic beaming, and the observable quantities the
+ renderer can measure. Tied throughout to
+ [`assets/shaders/geodesic.slang`](../assets/shaders/geodesic.slang).
+
+- **[architecture.md](architecture.md)** — how the program is built. The code's
+ layering (Application / Scene / RenderPath / UILayer), the portable RHI that lets
+ the same rendering run on OpenGL and Vulkan, the frame loop, the unified
+ scene-and-simulation world, the rendering pipeline (progressive resolution,
+ supersampling, environment lighting, tone-mapping), the tabs, the export
+ pipeline, and the build system.
+
+For each pixel, Donut casts a ray from the camera and follows it backward through
+the curved spacetime around Sagittarius A\*. Rays that fall past the horizon are
+the shadow; rays that graze the photon sphere wind around it into the bright ring;
+rays that strike the hot orbiting disk pick up its shifted glow; rays that escape
+read the background sky. That trace is one GPU shader, fed by a small portable
+graphics layer so it runs identically on both backends.
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..4eb4153
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,300 @@
+# The Architecture of Donut
+
+How Donut is put together as a program: how the code is layered, how the same
+rendering runs on two graphics APIs, how a frame is drawn, and how the editor, the
+live simulation, and the data export fit together.
+
+For the physics behind the image itself, see [`physics.md`](physics.md).
+
+## Contents
+
+- [Overview](#overview)
+- [The RHI: one interface, two backends](#the-rhi-one-interface-two-backends)
+- [A frame, end to end](#a-frame-end-to-end)
+- [The two renderers](#the-two-renderers)
+- [Scene and Simulation: one world](#scene-and-simulation-one-world)
+- [The rendering pipeline](#the-rendering-pipeline)
+ - [Progressive resolution and supersampling](#progressive-resolution-and-supersampling)
+- [The workspace: tabs](#the-workspace-tabs)
+- [The export pipeline](#the-export-pipeline)
+- [The build system](#the-build-system)
+
+## Overview
+
+Donut is split into a few pieces with distinct jobs, so the physics, the platform,
+and the interface can change independently.
+
+```mermaid
+flowchart TD
+ App["Application<br/>thin shell + main loop"]
+ App --> Scene["Scene<br/>the document/world:<br/>objects, black hole, cameras"]
+ App --> UI["UILayer / Workspace<br/>the tabbed ImGui interface"]
+ App --> RP["RenderPath<br/>device-side rendering"]
+ App --> Dev["RHI::Device<br/>the GPU, abstracted"]
+
+ RP --> SR["SceneRenderer<br/>raster world editor"]
+ RP --> BHR["BlackHoleRenderer<br/>geodesic ray tracer"]
+ RP --> Dev
+
+ Dev -.implemented by.-> GL["OpenGLDevice"]
+ Dev -.implemented by.-> VK["VulkanDevice<br/>MoltenVK"]
+
+ UI -.reads/writes.-> Scene
+ SR -.reads.-> Scene
+ BHR -.reads.-> Scene
+```
+
+| Component | File | Responsibility |
+| --- | --- | --- |
+| `Application` | [`src/core/application.cpp`](../src/core/application.cpp) | Owns everything; runs the main loop; handles input, resize, vsync, fullscreen; exposes actions to the UI |
+| `Scene` | [`src/scene/scene.h`](../src/scene/scene.h) | The world as plain data — placed objects, black-hole/disk parameters, the editor and simulation cameras, the HDRI path |
+| `UILayer` / `Workspace` | [`src/ui/ui_layer.cpp`](../src/ui/ui_layer.cpp) | The tabbed interface; returns which view is live and drives the scene through `AppActions` |
+| `RenderPath` | [`src/rendering/render_path.cpp`](../src/rendering/render_path.cpp) | Turns the scene into pixels on whatever device is active; owns the two renderers and the environment cubemap |
+| `RHI::Device` | [`src/rendering/rhi.h`](../src/rendering/rhi.h) | The portable GPU interface every backend implements |
+
+Two things carry most of the weight here. `Scene` is plain data with no knowledge
+of the backend or the UI, and everything that touches the GPU goes through one
+narrow interface (the RHI). The `Application` stays thin: it hands the document to
+`Scene`, the pixels to `RenderPath`, and the controls to `UILayer`.
+
+## The RHI: one interface, two backends
+
+Donut runs on both OpenGL and Vulkan (through MoltenVK on macOS) from one codebase.
+All rendering is written once against an abstract Render Hardware Interface in the
+`Donut::RHI` namespace, and each API supplies an implementation.
+
+```mermaid
+flowchart LR
+ Renderers["SceneRenderer<br/>BlackHoleRenderer<br/>written once"] --> RHI["RHI::Device / CommandList<br/>Buffer · Texture · Pipeline · RenderTarget"]
+ RHI --> GL["platform/opengl/<br/>OpenGLDevice"]
+ RHI --> VK["platform/vulkan/<br/>VulkanDevice"]
+ GL --> GLAPI[("OpenGL")]
+ VK --> VKAPI[("Vulkan / MoltenVK")]
+```
+
+The interface (in [`rhi.h`](../src/rendering/rhi.h)) is small and shaped for modern
+GPUs:
+
+- `Device` is the factory and frame driver: `create_buffer`, `create_texture`,
+ `create_cubemap_from_hdri`, `create_render_target`, `create_pipeline`;
+ `begin_frame` / `end_frame`; `set_vsync`, `resize`, `wait_idle`; the ImGui hooks;
+ and the export helpers `run_offscreen`, `read_render_target`,
+ `read_render_target_float`.
+- `CommandList` records work: `begin_render_pass` / `end_render_pass` (a `nullptr`
+ target means the swapchain), `bind_pipeline`, `set_viewport`, `bind_uniform`,
+ `bind_texture`, `bind_vertex_buffer`, `draw`.
+- `Buffer`, `Texture`, `Pipeline` and `RenderTarget` are opaque GPU resources.
+- `Format` is `{ None, Swapchain, RGBA8, RGBA16F, RGBA32F, D32 }`. `Swapchain`
+ means whatever the presented image is, resolved per backend; `RGBA32F` is what
+ makes raw floating-point export possible.
+
+The backend is chosen once at startup, before the window exists, since the two APIs
+want the window created differently:
+
+```mermaid
+sequenceDiagram
+ participant A as Application ctor
+ participant S as SettingsManager
+ participant W as Window (GLFW)
+ participant D as RHI::Device
+ A->>S: read graphics.render_api
+ alt Vulkan
+ A->>A: vulkan_prepare_glfw (GLFW_NO_API)
+ end
+ A->>W: create window
+ A->>D: create_vulkan_device or create_opengl_device
+ A->>D: init(native window)
+```
+
+Because the renderers only ever see the RHI, the same draw code gives
+pixel-identical output on both backends. That parity is checked by rendering to an
+off-screen target and comparing the read-back pixels.
+
+## A frame, end to end
+
+The main loop is `Application::run`: poll events, render, then let vsync pace the
+frame or sleep to hit the target FPS. Each frame is assembled in
+`Application::render_frame`:
+
+```mermaid
+sequenceDiagram
+ participant Dev as Device
+ participant UI as UILayer
+ participant In as Input
+ participant RP as RenderPath
+ Dev->>Dev: begin_frame(clear) → CommandList
+ Dev->>Dev: imgui_new_frame
+ Note over RP: update the active camera's projection
+ UI->>UI: draw(ctx) → returns active View
+ In->>In: update_input(view) — orbit / FPS camera
+ RP->>RP: sync_hdri — reload cubemap if changed
+ RP->>RP: render(cmd, scene, view, w, h, moving, time)
+ Dev->>Dev: end_frame — submit + present
+```
+
+The `View` the UI returns — `None`, `Scene` or `BlackHole` — decides which
+viewport is live and therefore what `RenderPath` draws. The `moving` flag, true
+while the user is dragging or flying the camera, triggers the progressive-resolution
+path described below.
+
+## The two renderers
+
+`RenderPath` owns two independent renderers, both written purely against the RHI.
+
+`SceneRenderer` ([`scene_renderer.cpp`](../src/rendering/scene_renderer.cpp)) is the
+world editor: a conventional rasteriser that draws the placed spheres, the ground
+grid, the selection outline and gizmo, and a near-black black-hole marker at the
+origin, sized to the horizon and ringed with an amber accretion-glow outline so it
+reads against the dark background. This is what you manipulate on the Scene tab.
+
+`BlackHoleRenderer` ([`black_hole_renderer.cpp`](../src/rendering/black_hole_renderer.cpp))
+is the geodesic ray tracer. It runs the physics shader from [`physics.md`](physics.md)
+as a full-screen fragment pass into an off-screen target, then presents that target
+to the screen. This is the expensive work, and it runs only on the Simulation tab
+and during export.
+
+`RenderPath::render` routes to the right one based on the `View`:
+
+```mermaid
+flowchart TD
+ R{View?}
+ R -->|Scene| SP["Swapchain pass:<br/>SceneRenderer.render + ImGui"]
+ R -->|None| EP["Swapchain pass:<br/>empty viewport + ImGui"]
+ R -->|BlackHole| GP["Off-screen geodesic pass →<br/>blit to swapchain + ImGui"]
+```
+
+## Scene and Simulation: one world
+
+The editor and the simulation are the same world, not two separate scenes. The
+constant `SCENE_UNITS_PER_RS = 3.0` connects them: three editor grid units equal
+one Schwarzschild radius. `SceneRenderer` draws the black-hole marker's horizon at
+that radius, and `BlackHoleRenderer` takes every placed `SceneObject`, multiplies
+its position and radius by $\text{SagA\_rs}/3$ to reach physical metres, and uploads
+them into the shader's `Objects` uniform (up to 16 spheres).
+
+So a sphere placed on the Scene tab shows up in the same spot on the Simulation
+tab, except now the curved rays bend around the hole and lens it, and it can appear
+stretched, doubled, or smeared into an arc. The spheres are passive lit objects,
+planets and the like; they don't exert their own gravity, only the black hole bends
+light.
+
+## The rendering pipeline
+
+When the Simulation view is active, `BlackHoleRenderer::render_geodesic` does three
+things each frame:
+
+1. Fills the uniforms (`fill_uniforms`): the camera basis and FOV; the
+ black-hole/disk parameters (radii converted to metres, temperature, brightness,
+ turbulence); the integration budget (`quality_steps`, clamped 1000–15000); and
+ the scene objects.
+2. Picks the off-screen target by the `moving` flag (below) and runs the geodesic
+ fragment shader over a full-screen quad into it.
+3. Blits that target to the swapchain (`blit`) with a present pipeline, flipping
+ vertically where needed so OpenGL and Vulkan agree on orientation, then draws the
+ ImGui overlay on top.
+
+Inside the shader, each pixel builds a ray from the camera basis, FOV and aspect,
+marches the geodesic (see [physics](physics.md#the-equations-of-motion)), and shades
+from whatever it hit: the opaque disk's redshifted blackbody, the black shadow, a
+lit object, or the background. The background is the HDRI loaded as a cubemap
+(`create_cubemap_from_hdri`); escaped rays sample it at a mip level chosen from how
+fast neighbouring rays diverge (`ddx`/`ddy`), so the strongly lensed background
+blurs rather than aliasing into a shimmering fan. The colour channel is then
+tone-mapped with an ACES filmic curve.
+
+### Progressive resolution and supersampling
+
+Interactivity trades against quality through resolution and sample count, not by
+touching the physics:
+
+| State | Off-screen target | Samples per pixel |
+| --- | --- | --- |
+| Camera moving | `GEO_LO` = 480 × 270 | 1 |
+| Camera settled | `GEO_HI` = 960 × 540 | 4× rotated-grid supersampling |
+
+The integration budget is the same in both states, because the disk needs a high
+step count to resolve at steep poses and lowering it during motion makes it
+flicker. When the camera stops, the renderer switches to the larger target and the
+fragment shader averages four sub-pixel samples in a rotated-grid ("4-rook") pattern
+before tone-mapping, which cleans up the near-horizontal lensed edges and the thin
+photon ring.
+
+## The workspace: tabs
+
+The interface follows Dorico's mode tabs: separate workspaces, one active at a
+time, each returning a `View` so the renderer knows what to draw.
+
+| Tab | View | For |
+| --- | --- | --- |
+| Setup | `None` | Display & quality: vsync, target FPS, resolution, fullscreen, UI scale, HDRI selection, integration quality, early-exit distance |
+| Scene | `Scene` | The world builder: place, select and transform objects with a gizmo; orbit the editor camera around the black-hole marker |
+| Simulation | `BlackHole` | The live lensed view — the only place the geodesic tracer runs; tune the black hole and disk; orbit or fly (FPS) the camera |
+| Export | `None` | Choose which observable channels, at what resolution and format, then render them to disk |
+
+Tabs whose view is `None` have no live 3-D viewport, so `Application::update_input`
+skips camera handling for them.
+
+## The export pipeline
+
+Export writes out the physical quantities Donut computes, not just a screenshot. It
+is driven by `RenderPath::export_frame`
+([`render_path.cpp`](../src/rendering/render_path.cpp)) and the RHI's off-screen
+helpers.
+
+```mermaid
+flowchart LR
+ Cfg["ExportConfig<br/>channels · resolution · format"] --> Loop
+ subgraph Loop["for each enabled channel"]
+ direction TB
+ RT["create_render_target<br/>RGBA8 (PNG) or RGBA32F (raw)"] --> OS["run_offscreen:<br/>render_export(channel, raw)"]
+ OS --> RB["read_render_target(_float)"]
+ RB --> WR["write PNG / PFM / CSV"]
+ end
+ WR --> Files["exports/donut_[channel]_[timestamp].[ext]"]
+```
+
+A few points worth knowing:
+
+- The export always renders the settled view (`moving = false`) from the simulation
+ camera, at the requested resolution, whatever the live window is doing.
+- Any combination of colour, redshift $g$, emission temperature and impact parameter
+ (the observables from [physics](physics.md#observable-channels)) can be exported in
+ one pass.
+- PNG requests use an `RGBA8` target and the standard shader pipeline
+ (`m_geo_pipeline`), giving a viewable tone-mapped or false-coloured image. PFM and
+ CSV requests use an `RGBA32F` target and an HDR pipeline variant
+ (`m_geo_pipeline_hdr`), so the file holds the actual floating-point values: $g$ as
+ a ratio, temperature in Kelvin, impact parameter in $r_s$, colour as linear HDR
+ radiance. For the disk-only channels, the alpha channel carries a validity mask (1
+ where a ray hit the disk, 0 elsewhere).
+- Formats are PNG via `stb_image_write`, PFM (Portable Float Map — raw RGB float,
+ the usual choice for HDR data) via a small writer, and CSV for the scalar channels,
+ one grid value per cell.
+- `run_offscreen` records a transient command buffer, submits it and waits, with no
+ swapchain and no frame pacing. The target is then read back on the CPU and written
+ to a timestamped file under `exports/`.
+
+## The build system
+
+Donut uses premake5 to generate GNU Makefiles. Both backends compile into one
+binary; the choice between them is made at runtime from the saved settings, so
+there is no separate "OpenGL build" and "Vulkan build".
+
+Generate and build (arm64 macOS):
+
+```bash
+premake5 gmake && make config=debug-macosx
+```
+
+`premake5 clean` is wired up as a custom action that removes the generated build
+output (`bin/`, `bin-int/`, the Makefiles) along with the transient runtime files
+(`logs/`, `config/`, `imgui.ini`), for a genuine from-scratch reset.
+
+Vendored third-party code lives under `ext/`. The portable renderers and the RHI
+are under `src/rendering/`, with the two backends under `src/platform/opengl/` and
+`src/platform/vulkan/`.
+
+---
+
+See [`physics.md`](physics.md) for the maths behind the image, and the top-level
+[`README.md`](../README.md) for a project overview.
diff --git a/docs/physics.md b/docs/physics.md
new file mode 100644
index 0000000..4877f64
--- /dev/null
+++ b/docs/physics.md
@@ -0,0 +1,373 @@
+# The Physics of Donut
+
+Everything Donut draws comes from tracing light backward through the curved
+spacetime around a black hole. This document works through the physics and maths
+of that trace, in roughly the order the shader applies it, with references to the
+code in [`assets/shaders/geodesic.slang`](../assets/shaders/geodesic.slang) —
+symbol names below (`InitRay`, `GeodesicRHS`, `DiskEmission`) all live in that file.
+
+For the software side — how the shader gets fed, the render backends, the tabs
+and the export path — see [`architecture.md`](architecture.md).
+
+## Contents
+
+- [Overview](#overview)
+- [Units and scale](#units-and-scale)
+- [The Schwarzschild metric](#the-schwarzschild-metric)
+- [Null geodesics and conserved quantities](#null-geodesics-and-conserved-quantities)
+- [The equations of motion](#the-equations-of-motion)
+- [Numerical integration](#numerical-integration)
+- [The three critical radii](#the-three-critical-radii)
+- [The accretion disk](#the-accretion-disk)
+- [Redshift, Doppler beaming and colour](#redshift-doppler-beaming-and-colour)
+- [The impact parameter](#the-impact-parameter)
+- [Observable channels](#observable-channels)
+
+## Overview
+
+A black hole isn't drawn like ordinary geometry. For each pixel Donut casts a ray
+from the camera and follows it *backward* until one of four things happens: it
+crosses the event horizon, it strikes the accretion disk, it hits a placed object,
+or it escapes to the background sky. Mass bends the path of light, so the rays
+curve, and that one effect produces the whole picture: the dark shadow, the bright
+ring wrapped around it, the far side of the disk folded up over the top of the
+hole, and the Doppler-brightened leading edge.
+
+```mermaid
+flowchart LR
+ A[Camera pixel] --> B[Build ray direction]
+ B --> C{March the geodesic<br/>through curved spacetime}
+ C -->|falls in| D[Event horizon<br/>black shadow]
+ C -->|hits disk| E[Accretion disk<br/>redshifted blackbody]
+ C -->|hits object| F[Placed sphere<br/>shaded]
+ C -->|escapes| G[Background sky<br/>HDRI environment]
+ D --> H[Pixel colour]
+ E --> H
+ F --> H
+ G --> H
+```
+
+## Units and scale
+
+Donut works in geometric units, $G = c = 1$. Mass then carries units of length,
+and the Schwarzschild radius reduces to
+
+$$
+r_s = \frac{2GM}{c^2} = 2M, \qquad\text{so}\qquad M = \frac{r_s}{2}.
+$$
+
+One number describes the hole. For Sagittarius A* the shader fixes it as
+
+```
+static const float SagA_rs = 1.269e10; // metres (M ≈ 4.3×10⁶ M☉)
+```
+
+Every distance the integrator handles is a physical length in metres, written as a
+multiple of `SagA_rs`, so the critical radii come out as constants:
+
+```
+R_PHOTON = 1.5 * SagA_rs // photon sphere (3M)
+R_ISCO = 3.0 * SagA_rs // ISCO (6M)
+```
+
+The scene editor uses a friendlier grid. The constant `SCENE_UNITS_PER_RS = 3.0`
+(in [`src/scene/scene_types.h`](../src/scene/scene_types.h)) sets three grid units
+to one Schwarzschild radius. When the renderer hands a placed object to the shader
+it scales the position by $\text{SagA\_rs}/3$ to get metres, so the editor and the
+simulation always agree on where things sit.
+
+## The Schwarzschild metric
+
+Sgr A* is treated as a non-rotating, uncharged black hole, whose spacetime is the
+exact Schwarzschild solution of Einstein's equations. In spherical coordinates
+$(t, r, \theta, \phi)$ the line element is
+
+$$
+ds^2 = -\left(1-\frac{r_s}{r}\right)dt^2
+ + \left(1-\frac{r_s}{r}\right)^{-1}dr^2
+ + r^2\left(d\theta^2 + \sin^2\theta\, d\phi^2\right).
+$$
+
+The factor that keeps recurring is abbreviated
+
+$$f(r) = 1 - \frac{r_s}{r},$$
+
+which is `float f = 1.0 - SagA_rs / r;` in the code. As $r \to r_s$, $f \to 0$ and
+the metric coefficients diverge. That divergence is a coordinate artifact rather
+than a real singularity, but it is why the integrator stops a ray once it reaches
+$r \le r_s$ instead of pushing through.
+
+## Null geodesics and conserved quantities
+
+Light follows null geodesics, the curves with $ds^2 = 0$. The metric has no
+explicit dependence on $t$ or $\phi$ (a time-translation symmetry and an axial
+rotation symmetry), so two quantities stay constant along every ray:
+
+$$
+E = f(r)\,\frac{dt}{d\lambda}
+\qquad\text{(energy)}, \qquad\qquad
+L_z = r^2\sin^2\theta\,\frac{d\phi}{d\lambda}
+\qquad\text{(axial angular momentum)},
+$$
+
+with $\lambda$ an affine parameter along the ray. `InitRay` sets both at the
+camera: it turns the camera-space ray direction into the spherical components
+$(\dot r, \dot\theta, \dot\phi)$, then computes
+
+```
+ray.L = r*r * sin(theta) * dphi; // angular momentum
+dt_dL = sqrt(dr*dr/f + r*r*(dtheta² + sin²θ·dphi²));
+ray.E = f * dt_dL; // energy
+```
+
+`E` is put to work during integration: `GeodesicRHS` reads the time component
+$\dot t = E/f$ from it, so the $t$ coordinate never has to be integrated on its
+own — one fewer equation per step. `L` is computed at initialisation as the ray's
+angular momentum but isn't fed back into the equations of motion; the azimuthal
+motion is carried directly by $\dot\phi$.
+
+For a null geodesic the affine parameter has an arbitrary overall scale, and the
+ray's shape — which is all the image depends on — doesn't change with it, so the
+exact normalisation of `E` is only a convention.
+
+## The equations of motion
+
+Marching a ray means solving the geodesic equation
+$\ddot x^\mu + \Gamma^\mu_{\alpha\beta}\dot x^\alpha \dot x^\beta = 0$ for the
+Schwarzschild metric. `GeodesicRHS` writes it as a first-order system in the six
+ray variables $(r,\theta,\phi,\dot r,\dot\theta,\dot\phi)$. The three positions
+advance by their velocities,
+
+$$\dot r,\qquad \dot\theta,\qquad \dot\phi,$$
+
+and the three velocities accelerate with the curvature:
+
+$$
+\ddot r = -\frac{r_s}{2r^2}\,f\,\dot t^2
+ + \frac{r_s}{2r^2 f}\,\dot r^2
+ + r\left(\dot\theta^2 + \sin^2\theta\,\dot\phi^2\right),
+\qquad \dot t = \frac{E}{f},
+$$
+
+$$
+\ddot\theta = -\frac{2}{r}\,\dot r\,\dot\theta
+ + \sin\theta\cos\theta\,\dot\phi^2,
+$$
+
+$$
+\ddot\phi = -\frac{2}{r}\,\dot r\,\dot\phi
+ - 2\cot\theta\,\dot\theta\,\dot\phi.
+$$
+
+The terms are the Christoffel symbols of the metric. In $\ddot r$ the first term
+is the inward pull of gravity (it carries $\dot t^2$, hence the energy); the rest
+are the centrifugal contributions from angular motion. The $\theta$ and $\phi$
+equations are the angular-momentum couplings that hold the ray to its orbital
+plane and sweep it around the hole. The code is a direct transcription:
+
+```
+d2.x = -(SagA_rs/(2r²))·f·dt_dL² + (SagA_rs/(2r²f))·dr² + r·(dtheta² + sin²θ·dphi²);
+d2.y = -2·dr·dtheta/r + sin(theta)·cos(theta)·dphi²;
+d2.z = -2·dr·dphi/r - 2·(cos/sin)(theta)·dtheta·dphi;
+```
+
+## Numerical integration
+
+There is no closed form for a general ray, so the integrator advances it in steps.
+
+`RK4Step` takes one step. It evaluates `GeodesicRHS` once, advances the six
+variables by `dL` times their rates, and recomputes the Cartesian position from
+the new spherical coordinates. That is a single forward-Euler stage, despite the
+name: only the first slope `k1` is evaluated, where a genuine fourth-order step
+would also compute `k2`, `k3` and `k4` at intermediate points. Moving to real RK4
+is the obvious accuracy upgrade; as it stands, almost all of the accuracy comes
+from the step-size control instead.
+
+`CalculateAdaptiveStepSize` chooses the step length. A fixed step would waste time
+far from the hole and lose the trajectory near it, so the step scales with distance
+from the photon sphere:
+
+$$
+\Delta\lambda = \operatorname{clamp}\!\left(0.02\,\max(r - r_\text{photon},\,0),\; \Delta_\text{min},\; \Delta_\text{max}\right),
+\qquad
+\begin{aligned}
+\Delta_\text{min} &= 10^6\\
+\Delta_\text{max} &= 2\times10^{10}
+\end{aligned}
+$$
+
+Far out, the ray is in near-flat space and crosses it in a handful of long
+strides. Near the photon sphere, where the path bends hardest and mistakes show
+the most, the step shrinks to follow the curve. A second clamp forces the step
+down to the disk's half-thickness whenever the ray is near the disk plane, so a
+thin, nearly edge-on disk is never stepped straight over.
+
+A ray's march ends on the first of these:
+
+| Condition | Meaning |
+| --- | --- |
+| $r \le r_s$ (`Intercept`) | Fell through the horizon → shadow (black) |
+| Crossed / entered the disk slab | Hit the opaque disk → emit its colour |
+| `InterceptObject` (every 5 steps) | Hit a placed sphere → shade it |
+| $r > $ `earlyExitDistance` ($2\times10^{12}$) | Left the rendered region → sample the sky |
+| $\dot r > 0$ and $r > 50\,r_s$ | Outbound in flat space, direction frozen → sample the sky early |
+| step count exceeds the budget | Up to `quality_steps` (default 15000, clamped 1000–15000) |
+
+The step budget is the same whether the camera is moving or settled. At steep,
+strongly-lensed poses the disk only resolves with a high step count, so cutting it
+during motion would make the disk flicker. Responsiveness during a drag comes from
+the rendering resolution and sample count instead — a smaller target and one sample
+per pixel while moving, sharpening to full resolution and 4× supersampling once the
+camera settles (see
+[`architecture.md`](architecture.md#progressive-resolution-and-supersampling)).
+
+## The three critical radii
+
+Three radii set up everything you see:
+
+```mermaid
+flowchart LR
+ subgraph one[" "]
+ direction LR
+ H["Event horizon<br/>r = rₛ = 2M"] --- P["Photon sphere<br/>r = 1.5 rₛ = 3M"] --- I["ISCO<br/>r = 3 rₛ = 6M"]
+ end
+```
+
+The **event horizon** at $r_s = 2M$ is the point of no return; the set of
+directions whose rays end there is the black shadow. The **photon sphere** at
+$\tfrac{3}{2}r_s = 3M$ is where light can circle the hole on unstable orbits, so
+rays passing near it loop around once or more before escaping — this makes the thin
+photon ring against the shadow and the folded multiple images of the disk. The
+**ISCO** at $3r_s = 6M$ is the innermost stable circular orbit, inside which matter
+can't hold a steady orbit; it is the disk's inner edge, and `DiskEmission` clamps
+the inner radius with `max(disk.disk_r1, R_ISCO)`.
+
+## The accretion disk
+
+Donut models the disk as a thin, opaque, self-luminous slab in the equatorial
+plane ($y = 0$), not a volumetric cloud. A ray hits it the first time it crosses
+the midplane (or grazes into the slab of half-thickness `disk.thickness`) inside
+the radial band $[r_\text{in}, r_\text{out}]$, and that surface's emission is the
+pixel colour. The default band runs from $3\,r_s$ to $12\,r_s$.
+
+A steady thin accretion disk radiates with a flux that rises from zero at the
+inner edge, peaks just outside it, and tails off with radius:
+
+$$
+F(r) \;\propto\; \frac{1}{r^3}\left(1 - \sqrt{\frac{r_\text{in}}{r}}\right).
+$$
+
+This is the Novikov–Thorne / Shakura–Sunyaev thin-disk profile. Its peak sits at
+$r/r_\text{in} \approx 1.36$ with value `FLUX_PEAK = 0.0569`, which normalises it.
+A blackbody's flux goes as $T^4$ (Stefan–Boltzmann), so the local temperature is
+
+$$
+T(r) = T_\text{peak}\left(\frac{F(r)}{F_\text{peak}}\right)^{1/4},
+$$
+
+where $T_\text{peak}$ is the tunable `disk.temperature`, 4800 K by default:
+
+```
+flux = max((1 - sqrt(1/xr)) / (xr*xr*xr), 0); xr = rc / r_in
+Tn = pow(flux / FLUX_PEAK, 0.25); // normalised temperature, peak ≈ 1
+Temit = disk.temperature * Tn;
+```
+
+An optional turbulence overlay (the `turbulence` parameter, `disk.disk_num`)
+modulates the brightness with animated fractal noise to suggest churning gas. It
+never changes the fact that the disk is an opaque surface.
+
+## Redshift, Doppler beaming and colour
+
+The disk is hot gas on relativistic orbits, deep in the gravity well. Two effects
+shift its light on the way to the camera, and both collapse into a single redshift
+factor $g$ (observed frequency over emitted).
+
+The gas moves on prograde circular geodesics. For Schwarzschild, the locally
+measured orbital speed is
+
+$$
+v = \sqrt{\frac{M}{r - 2M}} = \sqrt{\frac{r_s/2}{r - r_s}},
+$$
+
+which is exactly $0.5\,c$ at the ISCO. The velocity vector is
+$\boldsymbol\beta = v\,\hat\phi$, tangent to the orbit. Combining the gravitational
+and time-dilation shift of a circular orbit with the relativistic Doppler shift
+from that motion gives
+
+$$
+g = \frac{\sqrt{\,1 - \tfrac{3}{2}\,\dfrac{r_s}{r_c}\,}}{1 - \boldsymbol\beta\cdot\hat n},
+$$
+
+where $\hat n$ points along the photon toward the observer and $r_c$ is the
+cylindrical radius of the emission point. The numerator is the gravitational part
+(it vanishes at the photon sphere $r_c = \tfrac{3}{2}r_s$, where even orbiting
+light is infinitely redshifted); the denominator is the Doppler part, which
+brightens and blueshifts the side turning toward the camera and dims and redshifts
+the receding side. As a check, $g \to \sqrt{1/2}$ at the ISCO, matching the code.
+
+Two things follow from $g$, both physical:
+
+$$
+T_\text{obs} = g\,T_\text{emit}
+\qquad\text{(colour: a redshifted blackbody)},
+\qquad\qquad
+I_\text{obs} = g^4\,I_\text{emit}
+\qquad\text{(relativistic beaming)}.
+$$
+
+The colour is the Planckian blackbody colour at the observed temperature,
+`Blackbody(g · Temit)`, using a Tanner-Helland fit to the Planckian locus. The
+brightness keeps the physical $g^4$ beaming — the real approaching/receding
+asymmetry — while the enormous $T^4$ radial range is compressed to $T_n^2$ for
+display, so the colour gradient across the disk stays visible instead of collapsing
+to a single saturated ring:
+
+```
+bright = pow(Tn, 2.0) * pow(g, 4.0) * edge; // edge = soft inner/outer falloff
+colour = Blackbody(g * Temit) * bright;
+```
+
+That $T_n^2$ in place of the physical $T_n^4 = F$ is the one intentional
+concession to legibility; the rest of the disk model is the genuine relativistic
+result.
+
+## The impact parameter
+
+A ray's impact parameter $b$ is the perpendicular distance from the hole's centre
+to the straight line the ray would have followed with no gravity — the quantity
+that sets how strongly it deflects. Donut reads it straight off the camera geometry
+(in units of $r_s$):
+
+$$
+b = \frac{\lVert \mathbf{r}_\text{cam} \times \hat d\,\rVert}{r_s},
+$$
+
+with $\mathbf{r}_\text{cam}$ the camera position relative to the hole and $\hat d$
+the pixel's ray direction. Rays whose $b$ is near the critical value (about
+$\tfrac{3\sqrt3}{2}r_s$) are the ones that skim the photon sphere and build the
+ring.
+
+## Observable channels
+
+The renderer already computes these physical quantities while tracing, so it can
+output them directly instead of only the final colour. The shader's `outputChannel`
+picks which quantity each pixel reports, and `rawOutput` picks whether to write the
+raw floating-point value (for analysis) or a false-coloured / tone-mapped version
+(for viewing):
+
+| Channel | Quantity | Notes |
+| --- | --- | --- |
+| 0 | Colour | The final tone-mapped HDR radiance — the normal image |
+| 1 | Redshift $g$ | Disk pixels only; validity flagged in alpha |
+| 2 | Emission temperature $T_\text{emit}$ (K) | Disk pixels only; validity in alpha |
+| 3 | Impact parameter $b$ ($r_s$) | A per-ray geometric quantity, defined everywhere |
+
+The raw channels are what make the export usable as data rather than just imagery;
+[`architecture.md`](architecture.md#the-export-pipeline) covers how they are
+rendered off-screen and written to PFM or CSV.
+
+---
+
+See also [`architecture.md`](architecture.md) for how the renderer is built, from
+the portable GPU layer up through the tabs and the export pipeline.