hachem.wtf
aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorhachem <im@hachem.wtf>2026-10-16 19:31:01 +0200
committerhachem <im@hachem.wtf>2026-10-16 19:31:01 +0200
commit9a725ac5c498e637ed7146e6f2aaaf56df49886b (patch)
tree8679f57eb3d296167c45a4869795c49c760ad29c /docs
parent4afbb2ed9cc055ad925abd678d0fc0c63ab90fbc (diff)
chore: docsHEADmain
Diffstat (limited to 'docs')
-rw-r--r--docs/README.md27
-rw-r--r--docs/architecture.md348
-rw-r--r--docs/physics.md369
3 files changed, 0 insertions, 744 deletions
diff --git a/docs/README.md b/docs/README.md
deleted file mode 100644
index a10d307..0000000
--- a/docs/README.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# 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 workspace tabs and docking ui, 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
deleted file mode 100644
index 1e6560b..0000000
--- a/docs/architecture.md
+++ /dev/null
@@ -1,348 +0,0 @@
-# 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<br/>the docking UI shell"]
- 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` | [`src/ui/ui_layer.cpp`](../src/ui/ui_layer.cpp) | the docking shell: menu bar, workspace tabs, dockable panels with a layout per workspace; 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 in the scene view.
-
-`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 when the centre view is
-simulation, 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 $r_s/3$ (the code's `SagA_rs / 3`) to reach physical metres, and uploads
-them into the shader's `Objects` uniform (up to 16 spheres).
-
-so a sphere placed in the scene view shows up in the same spot in the simulation
-view, 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.
-
-## workspaces: tabs, docking and panels
-
-the interface is a docking shell with three workspaces along the top —
-**simulation**, **scene editor** and **export** — each a tab with its own
-independent layout, the way blender's workspace tabs work. a main menu bar sits
-above the tab strip; below it is a full-viewport dock space with a pass-through
-centre, so the live 3d render shows through the middle while the workspace's tool
-panels attach to the edges — drag, tab, hide, or restore them like a normal
-desktop app. rearranging one workspace never touches the others, and all three
-layouts, the open workspace and each one's panel set persist in `imgui.ini`
-between runs.
-
-each workspace fixes what the centre viewport shows — the `View` that
-`UILayer::draw` returns to the `RenderPath` — and has its own set of panels. the
-sphere panels are available in the black-hole workspaces too, since the lensed
-render draws the spheres, though the gizmo itself only works in the scene editor:
-
-| workspace | view | panels (open by default in bold) |
-| --- | --- | --- |
-| simulation | `BlackHole` — the lensed black hole | **black hole**, **diagnostics**, settings, outliner, properties |
-| scene editor | `Scene` — the world editor | **outliner**, **properties**, **diagnostics**, settings |
-| export | `BlackHole` | **export**, **black hole**, **diagnostics**, settings, outliner, properties |
-
-the panels themselves:
-
-| panel | for |
-| --- | --- |
-| outliner | the sphere list: add, delete, select |
-| properties | the selected sphere's transform and colour, plus the viewport gizmo |
-| black hole | camera mode/fov, the accretion disk, and integration quality |
-| settings | display & renderer: vsync, frame cap, resolution, fullscreen, ui scale, api, overlay, hdri |
-| export | choose which observable channels, at what resolution and format, then render to disk |
-| diagnostics | device, backend, fps and a frame-time graph |
-
-the **workspace** menu (or cmd/ctrl + 1/2/3) switches tabs, the **window** menu
-toggles the open workspace's panels, and **layout → reset** rebuilds the open
-workspace's default arrangement, or all three.
-
-underneath, each workspace is its own imgui dock space with a fixed id. only the
-open one is submitted for real each frame; the other two get a keep-alive ping,
-which freezes their trees in place instead of tearing them down — nothing is laid
-out or undocked while a workspace is off screen. an imgui window can only live in
-one dock tree, so every panel is instanced once per workspace under a stable
-`###workspace.panel` id (the same code draws all of them), which is also what lets
-imgui save and restore each tree on its own. a workspace with no saved tree is
-seeded on the first frame with the `DockBuilder` api. imgui doesn't remember which
-workspace was open or which panels were showing, so a small `[Donut][Workspaces]`
-section in `imgui.ini` does. only panels are docked windows — the centre stays a
-pass-through hole onto the full-frame 3d render, so `Application::update_input`
-drives the camera whenever the cursor is over that centre (imgui reports it
-doesn't want the mouse) and yields to the panels otherwise.
-
-## 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 the build: gnu makefiles on macos
-(`premake5 gmake`) and a visual studio 2022 solution on windows
-(`premake5 vs2022`). 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". the one exception is windows without the vulkan sdk:
-premake then leaves the vulkan backend out (`DONUT_NO_VULKAN`) and the binary is
-opengl only. on windows `vulkan-1.dll` is delay-loaded, so a vulkan build still
-starts on a machine with no vulkan driver, and any vulkan failure at startup falls
-back to opengl. step-by-step instructions for both platforms are in the
-[readme](../README.md#building).
-
-```bash
-premake5 gmake && make config=debug donut # macos
-premake5 vs2022 # windows, then build donut.sln
-```
-
-the shaders are authored once in slang (`assets/shaders/*.slang`) and compiled by
-`tools/compile-shaders.sh` into `assets/shaders/generated/`: glsl 4.10 for opengl
-and spir-v for vulkan. the generated files are committed, so windows builds use
-them as-is; on macos the script runs as a prebuild step and keeps them current.
-
-`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
deleted file mode 100644
index d4599a8..0000000
--- a/docs/physics.md
+++ /dev/null
@@ -1,369 +0,0 @@
-# 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 docking
-ui 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 $r_s/3$ (the code's `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 = \mathrm{clamp}\!\left(0.02\,\max(r - r_\text{photon},\,0),\; \Delta_\text{min},\; \Delta_\text{max}\right),
-$$
-
-with $\Delta_\text{min} = 10^6$ and $\Delta_\text{max} = 2\times10^{10}$ metres.
-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 panels and the export pipeline.