1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
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.
|