diff options
| author | hachem <im@hachem.wtf> | 2026-08-24 12:58:31 +0200 |
|---|---|---|
| committer | hachem <im@hachem.wtf> | 2026-08-24 12:58:31 +0200 |
| commit | d2f904e9d7ffb3b72ffbd9f70bcdaf72676ae9be (patch) | |
| tree | a7b21c9841b543e4cb2dccfd9d67c042f89569f2 /docs/architecture.md | |
| parent | 59b7c407550d63e997ed1e7bed286be5c332c285 (diff) | |
[docs]: rewrite old documentation
Diffstat (limited to 'docs/architecture.md')
| -rw-r--r-- | docs/architecture.md | 300 |
1 files changed, 300 insertions, 0 deletions
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. |
