diff options
| -rw-r--r-- | README.md | 57 | ||||
| -rw-r--r-- | docs/README.md | 27 | ||||
| -rw-r--r-- | docs/architecture.md | 348 | ||||
| -rw-r--r-- | docs/physics.md | 369 | ||||
| -rw-r--r-- | premake5.lua | 15 |
5 files changed, 46 insertions, 770 deletions
@@ -1,9 +1,9 @@  -donut is a real-time renderer for sagittarius a* (sgr a*) that traces light through the curved spacetime surrounding the black hole. each pixel is represented by a light ray originating from the camera. the ray is integrated through the schwarzschild metric, allowing the renderer to reproduce gravitational lensing and the distortion of the surrounding accretion disk and background. the renderer runs entirely on the gpu — one ray per pixel, traced in a full-screen fragment shader — allowing the scene to be explored interactively in real time. +donut is a real-time blackhole renderer that traces light through curved space time. each pixel is represented by a light ray originating from the camera and the ray is integrated through the schwarzschild metric, allowing the renderer to reproduce gravitational lensing and the distortion of the surrounding accretion disk and background. the renderer runs entirely on the gpu allowing the scene to be explored and modified interactively in real time. ## physics -donut uses the schwarzschild solution to describe the spacetime around sgr a*. the metric is (with $r_s = \frac{2GM}{c^2}$): +the engine assumes the simplest kind of black hole there is, one that doesn't spin and has no charge. the spacetime around it is the schwarzschild solution, and the whole hole comes down to one number, its schwarzschild radius $r_s = \frac{2GM}{c^2}$. the metric is: $$ ds^2 = @@ -12,14 +12,50 @@ ds^2 = +r^2(d\theta^2+\sin^2\theta\,d\phi^2), $$ -light rays are propagated along null geodesics of this metric. the geodesic equations are integrated numerically, the ray represented in spherical coordinates together with its derivatives. the schwarzschild metric provides a conserved energy along each geodesic, which supplies the time component of the motion so that the $t$ coordinate never has to be integrated explicitly. +at $r = r_s$ the first two terms blow up. that's the event horizon, and once a ray gets there the engine stops tracing it. -each ray is integrated step by step until it reaches the event horizon, intersects an object or the accretion disk, or escapes the region being rendered. -the integration uses adaptive step sizes, and this is where most of the accuracy comes from. rays passing through regions of strong curvature near the photon sphere take very small steps, while rays far from the black hole are advanced across near-flat space in a few large strides. rays that are clearly escaping are terminated early to avoid unnecessary computation, so rays near the black hole receive far more computation than rays already leaving the gravitational field. -the curvature of spacetime changes the direction of each ray as it passes around the black hole. rays passing close to the photon sphere can undergo large deflections or orbit the black hole several times before escaping. this produces the distorted background, multiple images of the accretion disk, and the bright lensing structures surrounding the shadow. +### following the light +light moves along null geodesics, the paths where $ds^2 = 0$. for every pixel the engine shoots a ray out of the camera and runs it backward until something happens to it. it falls into the hole, lands on the accretion disk or an object in the scene, or flies off into the background sky. + +each ray keeps track of where it is in spherical coordinates $(r, \theta, \phi)$ and how fast each of those is changing, and at every step the geodesic equation nudges those rates. gravity pulls the ray inward, its sideways motion carries it back out, and the balance between the two is what bends the path. since the metric never changes over time, each ray also carries a conserved energy $E = \left(1-\frac{r_s}{r}\right)\frac{dt}{d\lambda}$. that takes care of the time part of the motion, so $t$ never has to be integrated. + +### stepping +there's no formula for where a general ray ends up, so it gets walked forward one small step at a time. the update itself is basic (first order), and the accuracy mostly comes from picking good step sizes. near the photon sphere, where space is bent the hardest, the steps get tiny. far out, where space is almost flat, a ray covers huge distances in a few strides. close to the disk plane the step is capped at the disk's thickness, otherwise a thin disk seen from the side could be skipped over completely. + +a ray is done when it crosses the horizon, hits something, leaves the scene, or is heading outward past $50\,r_s$ where nothing is going to bend it much anymore. that last check saves a lot of work, and it means rays near the hole get almost all of the computation. + +### three radii that matter +the event horizon at $r_s$ is the black shadow. every ray that ends there comes out black. + +a bit further out, at $1.5\,r_s$, is the photon sphere. light can actually orbit the hole here, though not for long. rays that pass close to it get thrown around at sharp angles or wrap around the hole a few times before escaping. that's where the thin bright ring around the shadow comes from, along with the warped background and the extra copies of the disk. + +at $3\,r_s$ is the innermost stable circular orbit (isco). gas can't orbit steadily any closer than this, so that's where the disk starts. + +### the accretion disk +the disk is a thin, flat, glowing surface in the equatorial plane. it's opaque and reaches from the isco out to $12\,r_s$ by default. how bright each part of it is follows the novikov thorne thin disk model. the flux is zero right at the inner edge, peaks just outside it, then fades with distance: + +$$ +F(r) \propto \frac{1}{r^3}\left(1 - \sqrt{\frac{r_\text{in}}{r}}\right) +$$ + +each patch of the disk glows like a blackbody, and since the flux goes as $T^4$ its temperature follows straight from that. the hottest part sits at the disk temperature setting, 4800 k unless you change it. there's also an optional turbulence setting that adds moving noise on top, to make the gas look like it's churning. + +### redshift and colour +the gas orbits incredibly fast (half the speed of light at the isco) and sits deep in the hole's gravity well, and both of those change its light before it reaches the camera. they fold into one redshift factor $g$, the observed frequency over the emitted one: + +$$ +g = \frac{\sqrt{1 - \frac{3}{2}\frac{r_s}{r}}}{1 - \boldsymbol\beta\cdot\hat n} +$$ + +the top is gravity. light climbing out of the well loses energy, and the gas's own clocks run slow. the bottom is the doppler shift from the gas velocity $\boldsymbol\beta$ along $\hat n$, the direction toward the camera. it's why one side of the disk looks bluer and brighter than the other. that side is coming toward you and the other one is moving away. + +the colour you see is a blackbody at $g\,T$, and the brightness gets multiplied by $g^4$, which is relativistic beaming. there's one cheat. the real brightness range across the disk is so extreme that it would wash out into a single saturated ring, so it gets squashed for display and you can still see the colour change from the inside out. + +### more than a picture +the renderer already works out these numbers for every pixel, so it can show them to you instead of the final colour. that's the redshift $g$ and the emission temperature on the disk, and the impact parameter $b$ for every ray, either as raw values for analysis or false coloured to look at. $b$ is how far the ray would have missed the centre of the hole if there were no gravity. rays with $b$ near $\frac{3\sqrt{3}}{2}\,r_s$ are the ones that graze the photon sphere and make up the ring. ## rendering -for each pixel, donut generates a ray from the camera using its position, orientation, field of view, and aspect ratio. the ray is then converted into the coordinates used by the geodesic integrator and propagated through the scene. during integration, the renderer checks for intersections with the black hole, accretion disk, and other scene objects. rays that escape are sampled against the background environment. the accretion disk is a thin, opaque, self-luminous surface in the equatorial plane, with a novikov–thorne temperature profile that is hottest just outside the inner edge and cools outward, coloured as a redshifted blackbody; an optional turbulence overlay modulates its brightness. its gas orbits at relativistic keplerian speed (reaching half the speed of light at the innermost stable orbit), so the disk is doppler-brightened on the side rotating toward the camera. since the disk is viewed through curved spacetime, different parts of it can reach the camera along multiple paths around the black hole. the resulting image includes gravitational lensing, gravitational redshift, and doppler shifting from the rotating disk. the final pixel color is determined from the ray's path, its intersection with the scene, and the relativistic effects accumulated along the way. the entire process runs on the gpu as a full-screen fragment shader, tracing every pixel's ray in parallel. +for each pixel, donut generates a ray from the camera using its position, orientation, field of view, and aspect ratio. the ray is then converted into the coordinates used by the geodesic integrator and propagated through the scene. during integration, the renderer checks for intersections with the black hole, accretion disk, and other scene objects. rays that escape are sampled against the background environment. since the disk is viewed through curved spacetime, different parts of it can reach the camera along multiple paths around the black hole. the resulting image includes gravitational lensing, gravitational redshift, and doppler shifting from the rotating disk. the final pixel color is determined from the ray's path, its intersection with the scene, and the relativistic effects accumulated along the way. the entire process runs on the gpu as a full-screen fragment shader, tracing every pixel's ray in parallel. ## building the libraries in `ext/` are git submodules, so clone with `--recursive` (or run `git submodule update --init` in an existing clone). the shaders are prebuilt in `assets/shaders/generated/` and committed, so a plain build doesn't need the slang toolchain. @@ -41,13 +77,6 @@ on macos `make` also regenerates the shaders from `assets/shaders/*.slang`, whic 2. run `premake5 vs2022` from the repository root ([premake](https://premake.github.io/) is a single exe). 3. open `donut.sln`, pick `debug` and `x64`, and build. f5 runs the app from the repository root; the exe also finds `assets/` on its own when launched from `bin\debug-windows\`. -**picking a backend.** `config/settings.toml` is created on first run, and `render_api = "OpenGL"` or `"Vulkan"` picks the backend (also under settings → renderer, applied on restart). if vulkan can't start on a machine, donut falls back to opengl and says why in `logs/donut.log`. - -## documentation -for a deeper explanation of how donut works, see the [`docs/`](docs/) folder: -- [docs/physics.md](docs/physics.md) — the physics and mathematics: null geodesics and the equations of motion, the numerical integrator, the event horizon / photon sphere / isco, the novikov–thorne accretion disk, gravitational + doppler redshift and relativistic beaming, and the observable quantities the renderer can measure. -- [docs/architecture.md](docs/architecture.md) — how the program is built: the portable opengl/vulkan rhi, the two renderers, the unified scene-and-simulation world, the rendering pipeline, the workspace tabs and docking ui, and the physical-value export pipeline. - ## license donut is released under the mit license. the source code may be used, modified, and redistributed freely, including in commercial projects, provided that the original copyright notice and license are retained. 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. diff --git a/premake5.lua b/premake5.lua index 88cf22a..c12de36 100644 --- a/premake5.lua +++ b/premake5.lua @@ -1,7 +1,5 @@ output_dir = "%{cfg.buildcfg}-%{cfg.system}" --- the LunarG installer sets this on windows. without it the vulkan backend is --- left out of the build (DONUT_NO_VULKAN) and the app runs opengl only. vulkan_sdk = os.getenv("VULKAN_SDK") if os.target() == "windows" and not vulkan_sdk then print("VULKAN_SDK is not set: building without the Vulkan backend (OpenGL only)") @@ -45,8 +43,6 @@ function setup_c_target() runtime "Debug" symbols "On" - -- premake filters don't nest: this one has to name debug again, or the - -- sanitizers end up in every config filter { "configurations:debug", "system:macosx or system:linux" } buildoptions { "-g", @@ -87,8 +83,6 @@ function setup_cpp_target() runtime "Debug" symbols "On" - -- premake filters don't nest: this one has to name debug again, or the - -- sanitizers end up in every config filter { "configurations:debug", "system:macosx or system:linux" } buildoptions { "-g", @@ -402,7 +396,7 @@ project "donut" } includedirs "src" - debugdir "%{wks.location}" -- every asset path is relative to the repo root + debugdir "%{wks.location}" externalincludedirs { "%{include_dir.glm}", @@ -429,16 +423,14 @@ project "donut" filter "system:windows" systemversion "latest" - defines "NOMINMAX" -- glad drags in windows.h, whose min/max macros eat std::min/std::max - usestandardpreprocessor "On" -- msvc only knows __VA_OPT__ (log.h) with its conforming preprocessor + defines "NOMINMAX" + usestandardpreprocessor "On" conformancemode "On" buildoptions "/utf-8" links "opengl32" if vulkan_sdk then externalincludedirs (vulkan_sdk .. "/Include") libdirs (vulkan_sdk .. "/Lib") - -- delay-loaded, so a box without a Vulkan driver still starts (on OpenGL) - -- instead of the loader refusing to run the exe at all links { "vulkan-1", "delayimp" } linkoptions "/DELAYLOAD:vulkan-1.dll" else @@ -471,4 +463,3 @@ project "donut" filter "configurations:dist" defines "DONUT_DIST" filter {} - |
