aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--assets/shaders/geodesic.slang64
-rw-r--r--src/core/application.cpp2
-rw-r--r--src/core/application.h1
-rw-r--r--src/platform/opengl/opengl_device.cpp34
-rw-r--r--src/platform/vulkan/vulkan_common.h4
-rw-r--r--src/platform/vulkan/vulkan_device.cpp98
-rw-r--r--src/rendering/black_hole_renderer.cpp62
-rw-r--r--src/rendering/black_hole_renderer.h13
-rw-r--r--src/rendering/export_config.h28
-rw-r--r--src/rendering/render_path.cpp94
-rw-r--r--src/rendering/render_path.h5
-rw-r--r--src/rendering/rhi.h14
-rw-r--r--src/ui/ui_layer.cpp39
-rw-r--r--src/ui/workspace.h4
15 files changed, 429 insertions, 35 deletions
diff --git a/.gitignore b/.gitignore
index dc948ec..8610e08 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@ logs/
config/
compile_flags.txt
+.DS_Store
*.log
imgui.ini
@@ -26,3 +27,4 @@ Makefile
tools/slang/
assets/shaders/generated/
+exports/
diff --git a/assets/shaders/geodesic.slang b/assets/shaders/geodesic.slang
index 7acbd78..eee62da 100644
--- a/assets/shaders/geodesic.slang
+++ b/assets/shaders/geodesic.slang
@@ -19,8 +19,19 @@ struct Camera
float tanHalfFov;
float aspect;
bool moving;
- int _pad4;
+ int outputChannel; // 0 = colour; 1 = redshift g; 2 = emission T; 3 = impact parameter
+ int rawOutput; // 0 = display (false-colour / tone-mapped); 1 = raw float value
};
+
+// A jet-ish false-colour ramp (blue -> cyan -> green -> yellow -> red) for the
+// observable export channels. t is expected in [0, 1].
+float3 Falsecolor(float t)
+{
+ t = clamp(t, 0.0, 1.0);
+ return clamp(float3(1.5 - abs(4.0 * t - 3.0),
+ 1.5 - abs(4.0 * t - 2.0),
+ 1.5 - abs(4.0 * t - 1.0)), 0.0, 1.0);
+}
ConstantBuffer<Camera> cam;
struct Disk
@@ -148,8 +159,9 @@ float3 Blackbody(float T)
// g = sqrt(1 - 3M/r) / (1 - beta . nhat) (verified: g -> sqrt(1/2) at ISCO)
// Brightness follows relativistic beaming (I_obs = g^4 I_emit); colour follows
// the redshifted blackbody at T_obs = g * T_emit.
-float3 DiskEmission(float3 P, float3 rayDir)
+float3 DiskEmission(float3 P, float3 rayDir, out float outG, out float outTemit)
{
+ outG = 0.0; outTemit = 0.0;
float rc = length(float2(P.x, P.z)); // cylindrical radius (disk axis = +Y)
float rin = max(disk.disk_r1, R_ISCO);
float rout = disk.disk_r2;
@@ -172,6 +184,7 @@ float3 DiskEmission(float3 P, float3 rayDir)
float3 nhat = -normalize(rayDir); // photon direction toward the observer
float g = sqrt(max(1.0 - 1.5 * SagA_rs / rc, 0.0)) / max(1.0 - dot(beta, nhat), 1e-3);
+ outG = g; outTemit = Temit; // surfaced for the observable export channels
float Tobs = g * Temit;
float3 colour = Blackbody(Tobs);
@@ -317,8 +330,9 @@ float3 ACESFilm(float3 x)
// Trace one primary ray for the given image UV and return its linear,
// pre-tone-map radiance. Called once per sub-sample by fragmentMain.
-float3 TracePixel(float2 texCoord)
+float3 TracePixel(float2 texCoord, out float outG, out float outTemit, out bool outHitDisk)
{
+ outG = 0.0; outTemit = 0.0; outHitDisk = false;
float u = (2.0 * texCoord.x - 1.0) * cam.aspect * cam.tanHalfFov;
float v = (1.0 - 2.0 * texCoord.y) * cam.tanHalfFov;
float3 dir = normalize(u * cam.camRight - v * cam.camUp + cam.camForward);
@@ -367,8 +381,9 @@ float3 TracePixel(float2 texCoord)
float rc = length(float2(hitP.x, hitP.z));
if (rc >= max(disk.disk_r1, R_ISCO) && rc <= disk.disk_r2)
{
- diskColor = DiskEmission(hitP, newPos - prevPos);
- hitDisk = true;
+ diskColor = DiskEmission(hitP, newPos - prevPos, outG, outTemit);
+ hitDisk = true;
+ outHitDisk = true;
break;
}
}
@@ -422,8 +437,43 @@ float4 fragmentMain(VSOutput input) : SV_Target
// on BOTH axes, far better on the near-horizontal lensed edges than an
// ordered grid). Radiance is averaged before tone-mapping; ddx/ddy give the
// resolution-correct per-pixel UV footprint.
+ // Observable export channels: the chosen scalar quantity, either as a raw
+ // float (rawOutput: value in RGB, validity mask in A) or false-coloured.
+ // Channel 0 is the normal colour image.
+ if (cam.outputChannel != 0)
+ {
+ float g, Temit; bool hitDisk;
+ TracePixel(input.texCoord, g, Temit, hitDisk);
+
+ float value; float valid = 1.0;
+ if (cam.outputChannel == 3) // impact parameter: a per-ray geometric quantity (r_s)
+ {
+ float uu = (2.0 * input.texCoord.x - 1.0) * cam.aspect * cam.tanHalfFov;
+ float vv = (1.0 - 2.0 * input.texCoord.y) * cam.tanHalfFov;
+ float3 dir = normalize(uu * cam.camRight - vv * cam.camUp + cam.camForward);
+ value = length(cross(cam.camPos, dir)) / SagA_rs;
+ }
+ else // g / T are disk-only
+ {
+ valid = hitDisk ? 1.0 : 0.0;
+ value = (cam.outputChannel == 1) ? g : Temit;
+ }
+
+ if (cam.rawOutput != 0)
+ return float4(value, value, value, valid); // raw: physical value + validity
+
+ if (valid < 0.5) return float4(0.0, 0.0, 0.0, 1.0);
+ float norm = (cam.outputChannel == 1) ? value / 1.5
+ : (cam.outputChannel == 2) ? value / 15000.0
+ : value / 30.0;
+ return float4(Falsecolor(norm), 1.0);
+ }
+
+ float _g, _t; bool _hd;
+ if (cam.rawOutput != 0) // raw colour: linear pre-tone-map radiance (HDR)
+ return float4(TracePixel(input.texCoord, _g, _t, _hd), 1.0);
if (cam.moving)
- return float4(ACESFilm(TracePixel(input.texCoord)), 1.0);
+ return float4(ACESFilm(TracePixel(input.texCoord, _g, _t, _hd)), 1.0);
float2 dUV = float2(ddx(input.texCoord.x), ddy(input.texCoord.y));
float2 offs[4] = {
@@ -432,7 +482,7 @@ float4 fragmentMain(VSOutput input) : SV_Target
};
float3 sum = float3(0.0);
for (int i = 0; i < 4; ++i)
- sum += TracePixel(input.texCoord + offs[i] * dUV);
+ sum += TracePixel(input.texCoord + offs[i] * dUV, _g, _t, _hd);
return float4(ACESFilm(sum * 0.25), 1.0);
}
diff --git a/src/core/application.cpp b/src/core/application.cpp
index 3a21cac..c24e029 100644
--- a/src/core/application.cpp
+++ b/src/core/application.cpp
@@ -145,6 +145,8 @@ namespace Donut
auto Application::set_vsync(bool on) -> void { if (m_device) m_device->set_vsync(on); }
auto Application::set_ui_scale(float scale) -> void { ImGui::GetIO().FontGlobalScale = scale; }
+ auto Application::export_frame(const ExportConfig& cfg) -> int
+ { return (m_render_path && m_scene) ? m_render_path->export_frame(*m_scene, cfg) : 0; }
auto Application::is_fullscreen() const -> bool
{ return m_window && glfwGetWindowMonitor((GLFWwindow*)m_window->get_native_window()) != nullptr; }
auto Application::get_window_size(int& w, int& h) const -> void
diff --git a/src/core/application.h b/src/core/application.h
index 10a375f..f1e40c8 100644
--- a/src/core/application.h
+++ b/src/core/application.h
@@ -39,6 +39,7 @@ namespace Donut
auto set_ui_scale(float scale) -> void override;
auto get_window_size(int& w, int& h) const -> void override;
auto is_fullscreen() const -> bool override;
+ auto export_frame(const ExportConfig& cfg) -> int override;
private:
auto on_init() -> void;
diff --git a/src/platform/opengl/opengl_device.cpp b/src/platform/opengl/opengl_device.cpp
index 82fdae1..7ef0ba1 100644
--- a/src/platform/opengl/opengl_device.cpp
+++ b/src/platform/opengl/opengl_device.cpp
@@ -7,6 +7,7 @@
#include <glad/glad.h>
#include <GLFW/glfw3.h>
+#include <algorithm>
#include <imgui.h>
#include <imgui_impl_glfw.h>
#include <imgui_impl_opengl3.h>
@@ -18,7 +19,7 @@ namespace Donut::RHI
auto gl_topology(Topology t) -> GLenum { return t == Topology::Lines ? GL_LINES : GL_TRIANGLES; }
auto gl_compare(CompareOp o) -> GLenum { return o == CompareOp::Always ? GL_ALWAYS : o == CompareOp::LessEqual ? GL_LEQUAL : GL_LESS; }
auto gl_filter(Filter f) -> GLint { return f == Filter::Nearest ? GL_NEAREST : GL_LINEAR; }
- auto gl_internal(Format f) -> GLint { return f == Format::RGBA16F ? GL_RGBA16F : f == Format::D32 ? GL_DEPTH_COMPONENT32F : GL_RGBA8; }
+ auto gl_internal(Format f) -> GLint { return f == Format::RGBA16F ? GL_RGBA16F : f == Format::RGBA32F ? GL_RGBA32F : f == Format::D32 ? GL_DEPTH_COMPONENT32F : GL_RGBA8; }
// ---- Buffer -------------------------------------------------------
class GLBuffer : public Buffer
@@ -255,6 +256,37 @@ namespace Donut::RHI
}
auto end_frame() -> void override { glfwSwapBuffers(static_cast<GLFWwindow*>(m_window)); }
+ auto run_offscreen(const std::function<void(CommandList&)>& record) -> void override
+ {
+ glBindVertexArray(m_vao); // GL is immediate: record executes now
+ record(*m_cmds);
+ glFinish();
+ }
+ auto read_render_target(RenderTarget* target, std::vector<uint8_t>& out) -> void override
+ {
+ auto* rt = static_cast<GLRenderTarget*>(target);
+ int w = rt->m_w, h = rt->m_h;
+ out.resize((size_t)w * h * 4);
+ glBindFramebuffer(GL_FRAMEBUFFER, rt->m_fbo);
+ glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, out.data());
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ const size_t row = (size_t)w * 4; // GL is bottom-up -> flip to top-left
+ for (int y = 0; y < h / 2; ++y)
+ std::swap_ranges(out.begin() + y * row, out.begin() + (y + 1) * row, out.begin() + (h - 1 - y) * row);
+ }
+ auto read_render_target_float(RenderTarget* target, std::vector<float>& out) -> void override
+ {
+ auto* rt = static_cast<GLRenderTarget*>(target);
+ int w = rt->m_w, h = rt->m_h;
+ out.resize((size_t)w * h * 4);
+ glBindFramebuffer(GL_FRAMEBUFFER, rt->m_fbo);
+ glReadPixels(0, 0, w, h, GL_RGBA, GL_FLOAT, out.data()); // GL converts to float32
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ const size_t row = (size_t)w * 4; // GL is bottom-up -> flip to top-left
+ for (int y = 0; y < h / 2; ++y)
+ std::swap_ranges(out.begin() + y * row, out.begin() + (y + 1) * row, out.begin() + (h - 1 - y) * row);
+ }
+
auto init_imgui() -> void override
{
IMGUI_CHECKVERSION(); ImGui::CreateContext();
diff --git a/src/platform/vulkan/vulkan_common.h b/src/platform/vulkan/vulkan_common.h
index 5ae257e..8f02f17 100644
--- a/src/platform/vulkan/vulkan_common.h
+++ b/src/platform/vulkan/vulkan_common.h
@@ -41,6 +41,7 @@ namespace Donut::RHI
{
switch (f) {
case Format::RGBA16F: return VK_FORMAT_R16G16B16A16_SFLOAT;
+ case Format::RGBA32F: return VK_FORMAT_R32G32B32A32_SFLOAT;
case Format::D32: return VK_FORMAT_D32_SFLOAT;
default: return VK_FORMAT_R8G8B8A8_UNORM;
}
@@ -277,6 +278,9 @@ namespace Donut::RHI
auto begin_frame(const glm::vec4& clear) -> CommandList* override;
auto end_frame() -> void override;
+ auto run_offscreen(const std::function<void(CommandList&)>& record) -> void override;
+ auto read_render_target(RenderTarget* target, std::vector<uint8_t>& out) -> void override;
+ auto read_render_target_float(RenderTarget* target, std::vector<float>& out) -> void override;
auto init_imgui() -> void override;
auto imgui_new_frame() -> void override;
diff --git a/src/platform/vulkan/vulkan_device.cpp b/src/platform/vulkan/vulkan_device.cpp
index 5d80cff..ff2e296 100644
--- a/src/platform/vulkan/vulkan_device.cpp
+++ b/src/platform/vulkan/vulkan_device.cpp
@@ -117,6 +117,104 @@ namespace Donut::RHI
m_current_frame = (m_current_frame + 1) % MAX_FRAMES_IN_FLIGHT;
}
+ auto VulkanDevice::run_offscreen(const std::function<void(CommandList&)>& record) -> void
+ {
+ vkDeviceWaitIdle(m_device);
+ vkResetDescriptorPool(m_device, m_frame_pools[0], 0); // per-draw sets for this pass
+
+ VkCommandBufferAllocateInfo cbai{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO };
+ cbai.commandPool = m_command_pool; cbai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; cbai.commandBufferCount = 1;
+ VkCommandBuffer cmd; vkAllocateCommandBuffers(m_device, &cbai, &cmd);
+ VkCommandBufferBeginInfo bi{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO }; bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
+ vkBeginCommandBuffer(cmd, &bi);
+
+ m_cmds.m_device = m_device; m_cmds.m_cmd = cmd;
+ m_cmds.m_swapchain_rp = m_swapchain_rp; m_cmds.m_swapchain_fb = VK_NULL_HANDLE; m_cmds.m_extent = m_extent;
+ m_cmds.m_frame_pool = m_frame_pools[0]; m_cmds.m_pipe = nullptr;
+ record(m_cmds); // records its own off-screen render pass(es)
+
+ vkEndCommandBuffer(cmd);
+ VkSubmitInfo si{ VK_STRUCTURE_TYPE_SUBMIT_INFO }; si.commandBufferCount = 1; si.pCommandBuffers = &cmd;
+ vkQueueSubmit(m_graphics_queue, 1, &si, VK_NULL_HANDLE);
+ vkQueueWaitIdle(m_graphics_queue);
+ vkFreeCommandBuffers(m_device, m_command_pool, 1, &cmd);
+ }
+
+ auto VulkanDevice::read_render_target(RenderTarget* target, std::vector<uint8_t>& out) -> void
+ {
+ auto* rt = static_cast<VkRenderTargetR*>(target);
+ uint32_t w = (uint32_t)rt->m_w, h = (uint32_t)rt->m_h;
+ VkDeviceSize sz = (VkDeviceSize)w * h * 4;
+ out.resize(sz);
+
+ VkBuffer buf; VkDeviceMemory mem;
+ create_buffer_raw(sz, VK_BUFFER_USAGE_TRANSFER_DST_BIT,
+ VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, buf, mem);
+
+ VkCommandBufferAllocateInfo cbai{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO };
+ cbai.commandPool = m_command_pool; cbai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; cbai.commandBufferCount = 1;
+ VkCommandBuffer cmd; vkAllocateCommandBuffers(m_device, &cbai, &cmd);
+ VkCommandBufferBeginInfo bi{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO }; bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
+ vkBeginCommandBuffer(cmd, &bi);
+ // The target ended its render pass in SHADER_READ_ONLY; move it to TRANSFER_SRC to copy.
+ VkImageMemoryBarrier b{ VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER };
+ b.oldLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
+ b.image = rt->m_image; b.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };
+ b.srcAccessMask = VK_ACCESS_SHADER_READ_BIT; b.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
+ vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
+ VkBufferImageCopy copy{}; copy.imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 }; copy.imageExtent = { w, h, 1 };
+ vkCmdCopyImageToBuffer(cmd, rt->m_image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, buf, 1, &copy);
+ VkImageMemoryBarrier b2 = b; b2.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; b2.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
+ b2.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; b2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
+ vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b2);
+ vkEndCommandBuffer(cmd);
+ VkSubmitInfo si{ VK_STRUCTURE_TYPE_SUBMIT_INFO }; si.commandBufferCount = 1; si.pCommandBuffers = &cmd;
+ vkQueueSubmit(m_graphics_queue, 1, &si, VK_NULL_HANDLE); vkQueueWaitIdle(m_graphics_queue);
+
+ void* mp = nullptr; vkMapMemory(m_device, mem, 0, sz, 0, &mp);
+ std::memcpy(out.data(), mp, sz); // RGBA8_UNORM, top-down (top-left origin)
+ vkUnmapMemory(m_device, mem);
+ vkFreeCommandBuffers(m_device, m_command_pool, 1, &cmd);
+ vkDestroyBuffer(m_device, buf, nullptr); vkFreeMemory(m_device, mem, nullptr);
+ }
+
+ auto VulkanDevice::read_render_target_float(RenderTarget* target, std::vector<float>& out) -> void
+ {
+ auto* rt = static_cast<VkRenderTargetR*>(target);
+ uint32_t w = (uint32_t)rt->m_w, h = (uint32_t)rt->m_h;
+ out.resize((size_t)w * h * 4);
+ VkDeviceSize sz = (VkDeviceSize)out.size() * sizeof(float);
+
+ VkBuffer buf; VkDeviceMemory mem;
+ create_buffer_raw(sz, VK_BUFFER_USAGE_TRANSFER_DST_BIT,
+ VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, buf, mem);
+
+ VkCommandBufferAllocateInfo cbai{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO };
+ cbai.commandPool = m_command_pool; cbai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; cbai.commandBufferCount = 1;
+ VkCommandBuffer cmd; vkAllocateCommandBuffers(m_device, &cbai, &cmd);
+ VkCommandBufferBeginInfo bi{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO }; bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
+ vkBeginCommandBuffer(cmd, &bi);
+ VkImageMemoryBarrier b{ VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER };
+ b.oldLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
+ b.image = rt->m_image; b.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };
+ b.srcAccessMask = VK_ACCESS_SHADER_READ_BIT; b.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
+ vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
+ VkBufferImageCopy copy{}; copy.imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 }; copy.imageExtent = { w, h, 1 };
+ vkCmdCopyImageToBuffer(cmd, rt->m_image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, buf, 1, &copy);
+ VkImageMemoryBarrier b2 = b; b2.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; b2.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
+ b2.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; b2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
+ vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b2);
+ vkEndCommandBuffer(cmd);
+ VkSubmitInfo si{ VK_STRUCTURE_TYPE_SUBMIT_INFO }; si.commandBufferCount = 1; si.pCommandBuffers = &cmd;
+ vkQueueSubmit(m_graphics_queue, 1, &si, VK_NULL_HANDLE); vkQueueWaitIdle(m_graphics_queue);
+
+ void* mp = nullptr; vkMapMemory(m_device, mem, 0, sz, 0, &mp);
+ std::memcpy(out.data(), mp, sz); // R32G32B32A32_SFLOAT, top-down
+ vkUnmapMemory(m_device, mem);
+ vkFreeCommandBuffers(m_device, m_command_pool, 1, &cmd);
+ vkDestroyBuffer(m_device, buf, nullptr); vkFreeMemory(m_device, mem, nullptr);
+ }
+
auto VulkanDevice::init_imgui() -> void
{
VkDescriptorPoolSize pool_size{ VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1000 };
diff --git a/src/rendering/black_hole_renderer.cpp b/src/rendering/black_hole_renderer.cpp
index 5916171..283435e 100644
--- a/src/rendering/black_hole_renderer.cpp
+++ b/src/rendering/black_hole_renderer.cpp
@@ -18,7 +18,7 @@ namespace Donut
struct CamUBO {
glm::vec3 pos; float p0; glm::vec3 right; float p1;
glm::vec3 up; float p2; glm::vec3 fwd; float p3;
- float tan_half_fov; float aspect; uint32_t moving; int p4;
+ float tan_half_fov; float aspect; uint32_t moving; int channel; int raw;
};
struct SimUBO { int steps_moving; int steps_static; float early_exit; float time; };
}
@@ -61,6 +61,8 @@ namespace Donut
d.topology = Topology::Triangles;
d.target = { Format::RGBA8, Format::None }; // colour-only off-screen target
m_geo_pipeline = device.create_pipeline(d);
+ d.target = { Format::RGBA32F, Format::None }; // raw-float export variant
+ m_geo_pipeline_hdr = device.create_pipeline(d);
}
{
PipelineDesc d;
@@ -81,11 +83,35 @@ namespace Donut
const BlackHoleParams& params,
const std::vector<SceneObject>& objects, RHI::Texture* cubemap) -> void
{
+ fill_uniforms(view, params, objects, 0, false); // channel 0 = colour, display
+
+ // Progressive resolution: small target while moving, large once settled.
+ RenderTarget* target = view.moving ? m_geo_lo.get() : m_geo_hi.get();
+ m_last_target = target;
+ draw_geodesic(cmd, target, cubemap, m_geo_pipeline.get());
+ }
+
+ // Renders one channel (0 colour, 1 redshift, 2 temperature, 3 impact parameter)
+ // into an arbitrary RGBA8 target — used by the exporter for high-res output.
+ auto BlackHoleRenderer::render_export(RHI::CommandList& cmd, RHI::RenderTarget* target,
+ const GeodesicView& view, const BlackHoleParams& params,
+ const std::vector<SceneObject>& objects,
+ RHI::Texture* cubemap, int channel, bool raw) -> void
+ {
+ fill_uniforms(view, params, objects, channel, raw);
+ draw_geodesic(cmd, target, cubemap, raw ? m_geo_pipeline_hdr.get() : m_geo_pipeline.get());
+ }
+
+ auto BlackHoleRenderer::fill_uniforms(const GeodesicView& view, const BlackHoleParams& params,
+ const std::vector<SceneObject>& objects, int channel, bool raw) -> void
+ {
CamUBO cam{};
cam.pos = view.position; cam.right = view.right; cam.up = view.up; cam.fwd = view.forward;
cam.tan_half_fov = view.tan_half_fov;
cam.aspect = view.aspect;
cam.moving = view.moving ? 1u : 0u;
+ cam.channel = channel;
+ cam.raw = raw ? 1 : 0;
m_cam_ubo->update(&cam, sizeof(cam));
// Same integration budget whether moving or settled (the disk vanishes at
@@ -115,29 +141,27 @@ namespace Donut
// Scene objects → the geodesic's Objects UBO (std140: numObjects@0,
// objPosRadius[i]@16+16i, objColor[i]@272+16i). They render as spheres the
// curved rays intersect, so the hole lenses them. 1 scene unit = 1 r_s.
+ const float k = SagA_rs / SCENE_UNITS_PER_RS; // scene units -> metres
+ std::vector<uint8_t> objbuf(800, 0);
+ int n = std::min((int)objects.size(), 16);
+ std::memcpy(objbuf.data(), &n, 4);
+ for (int i = 0; i < n; ++i)
{
- const float k = SagA_rs / SCENE_UNITS_PER_RS; // scene units -> metres
- std::vector<uint8_t> objbuf(800, 0);
- int n = std::min((int)objects.size(), 16);
- std::memcpy(objbuf.data(), &n, 4);
- for (int i = 0; i < n; ++i)
- {
- const SceneObject& o = objects[i];
- float pr[4] = { o.position.x * k, o.position.y * k, o.position.z * k, o.radius * k };
- float col[4] = { o.color.r, o.color.g, o.color.b, 1.0f };
- std::memcpy(objbuf.data() + 16 + 16 * i, pr, 16);
- std::memcpy(objbuf.data() + 272 + 16 * i, col, 16);
- }
- m_obj_ubo->update(objbuf.data(), objbuf.size());
+ const SceneObject& o = objects[i];
+ float pr[4] = { o.position.x * k, o.position.y * k, o.position.z * k, o.radius * k };
+ float col[4] = { o.color.r, o.color.g, o.color.b, 1.0f };
+ std::memcpy(objbuf.data() + 16 + 16 * i, pr, 16);
+ std::memcpy(objbuf.data() + 272 + 16 * i, col, 16);
}
+ m_obj_ubo->update(objbuf.data(), objbuf.size());
+ }
- // Progressive resolution: small target while moving, large once settled.
- RenderTarget* target = view.moving ? m_geo_lo.get() : m_geo_hi.get();
- m_last_target = target;
-
+ auto BlackHoleRenderer::draw_geodesic(RHI::CommandList& cmd, RHI::RenderTarget* target,
+ RHI::Texture* cubemap, RHI::Pipeline* pipeline) -> void
+ {
cmd.begin_render_pass(target, glm::vec4(0, 0, 0, 1));
cmd.set_viewport(0, 0, target->width(), target->height(), false);
- cmd.bind_pipeline(m_geo_pipeline.get());
+ cmd.bind_pipeline(pipeline);
cmd.bind_uniform(0, m_cam_ubo.get());
cmd.bind_uniform(1, m_disk_ubo.get());
cmd.bind_uniform(2, m_obj_ubo.get());
diff --git a/src/rendering/black_hole_renderer.h b/src/rendering/black_hole_renderer.h
index 2ae03dd..c2cb2af 100644
--- a/src/rendering/black_hole_renderer.h
+++ b/src/rendering/black_hole_renderer.h
@@ -41,10 +41,23 @@ namespace Donut
// swapchain render pass, before ImGui).
auto blit(RHI::CommandList& cmd, int fb_width, int fb_height) -> void;
+ // Renders one channel (0 colour, 1 redshift g, 2 emission T, 3 impact
+ // parameter) into an arbitrary RGBA8 target — for high-res export.
+ auto render_export(RHI::CommandList& cmd, RHI::RenderTarget* target,
+ const GeodesicView& view, const BlackHoleParams& params,
+ const std::vector<SceneObject>& objects, RHI::Texture* cubemap,
+ int channel, bool raw) -> void;
+
private:
+ auto fill_uniforms(const GeodesicView& view, const BlackHoleParams& params,
+ const std::vector<SceneObject>& objects, int channel, bool raw) -> void;
+ auto draw_geodesic(RHI::CommandList& cmd, RHI::RenderTarget* target,
+ RHI::Texture* cubemap, RHI::Pipeline* pipeline) -> void;
+
RHI::Device* m_device = nullptr;
Ref<RHI::Pipeline> m_geo_pipeline;
+ Ref<RHI::Pipeline> m_geo_pipeline_hdr; // RGBA32F variant for raw export
Ref<RHI::RenderTarget> m_geo_lo;
Ref<RHI::RenderTarget> m_geo_hi;
Ref<RHI::Buffer> m_quad_vb;
diff --git a/src/rendering/export_config.h b/src/rendering/export_config.h
new file mode 100644
index 0000000..e490db6
--- /dev/null
+++ b/src/rendering/export_config.h
@@ -0,0 +1,28 @@
+#pragma once
+
+#include <string>
+
+namespace Donut
+{
+ // How each channel is written:
+ // Png = display image (tone-mapped colour / false-coloured observable).
+ // Pfm = raw float image (actual physical values; RGB, Portable Float Map).
+ // Csv = raw scalar grid (the observable's value per pixel, one row per line).
+ enum class ExportFormat { Png, Pfm, Csv };
+
+ // What the Export workspace asks the RenderPath to write to disk: which
+ // observable channels to render, at what resolution, in what format, and where.
+ // Each enabled channel becomes one file.
+ struct ExportConfig
+ {
+ bool color = true; // colour image (tone-mapped, or linear HDR when raw)
+ bool redshift = false; // redshift factor g (disk only)
+ bool temperature = false; // emission temperature in K (disk only)
+ bool impact = false; // impact parameter in r_s (whole frame)
+
+ int width = 1920;
+ int height = 1080;
+ ExportFormat format = ExportFormat::Png;
+ std::string directory = "exports";
+ };
+}
diff --git a/src/rendering/render_path.cpp b/src/rendering/render_path.cpp
index 115d182..8cb0328 100644
--- a/src/rendering/render_path.cpp
+++ b/src/rendering/render_path.cpp
@@ -1,10 +1,18 @@
#include "render_path.h"
#include "scene/scene.h"
+#include "core/log.h"
+
+#define STB_IMAGE_WRITE_IMPLEMENTATION
+#include "stb_image_write.h"
#include <glm/gtc/matrix_transform.hpp>
#include <algorithm>
#include <cmath>
+#include <filesystem>
+#include <ctime>
+#include <cstdio>
+#include <vector>
namespace Donut
{
@@ -90,4 +98,90 @@ namespace Donut
// Backend-specific HDRI/GPU resource cleanup is the device's job (see e.g.
// the OpenGL device clearing the HDRIManager texture cache on shutdown).
}
+
+ // Portable Float Map: a raw RGB float image (the actual physical values). Its
+ // raster is bottom-up; -1.0 scale flags little-endian.
+ static auto write_pfm(const std::string& path, int w, int h, const std::vector<float>& rgba) -> bool
+ {
+ std::FILE* f = std::fopen(path.c_str(), "wb");
+ if (!f) return false;
+ std::fprintf(f, "PF\n%d %d\n-1.0\n", w, h);
+ std::vector<float> rgb((size_t)w * 3);
+ for (int y = h - 1; y >= 0; --y)
+ {
+ const float* src = &rgba[(size_t)y * w * 4];
+ for (int x = 0; x < w; ++x) { rgb[x*3+0] = src[x*4+0]; rgb[x*3+1] = src[x*4+1]; rgb[x*3+2] = src[x*4+2]; }
+ std::fwrite(rgb.data(), sizeof(float), (size_t)w * 3, f);
+ }
+ std::fclose(f);
+ return true;
+ }
+
+ // CSV grid of the scalar value (the R channel), one image row per text line.
+ static auto write_csv(const std::string& path, int w, int h, const std::vector<float>& rgba) -> bool
+ {
+ std::FILE* f = std::fopen(path.c_str(), "wb");
+ if (!f) return false;
+ for (int y = 0; y < h; ++y)
+ {
+ for (int x = 0; x < w; ++x) std::fprintf(f, x ? ",%g" : "%g", rgba[((size_t)y * w + x) * 4]);
+ std::fputc('\n', f);
+ }
+ std::fclose(f);
+ return true;
+ }
+
+ auto RenderPath::export_frame(Scene& scene, const ExportConfig& cfg) -> int
+ {
+ if (!m_device) return 0;
+ const int w = std::max(cfg.width, 1), h = std::max(cfg.height, 1);
+ const bool raw = cfg.format != ExportFormat::Png; // Pfm / Csv want physical values
+ auto target = m_device->create_render_target(
+ w, h, raw ? RHI::Format::RGBA32F : RHI::Format::RGBA8, RHI::Format::None, RHI::Filter::Nearest);
+
+ // Full-quality geodesic view from the sim camera (moving = false = settled).
+ GeodesicView gv;
+ glm::vec3 pos, fwd;
+ if (scene.sim_camera.get_camera_mode() == CameraMode::FPS)
+ { pos = scene.sim_camera.get_position(); fwd = scene.sim_camera.get_forward_direction(); }
+ else
+ { pos = scene.sim_camera.get_orbital_position(); fwd = glm::normalize(scene.sim_camera.get_orbital_target() - pos); }
+ glm::vec3 right = glm::normalize(glm::cross(fwd, glm::vec3(0, 1, 0)));
+ gv.position = pos; gv.right = right; gv.up = glm::cross(right, fwd); gv.forward = fwd;
+ gv.tan_half_fov = (float)tan(glm::radians(scene.black_hole.fov_degrees * 0.5f));
+ gv.aspect = (float)w / (float)h;
+ gv.moving = false;
+ gv.time = 0.0f;
+
+ std::error_code ec; std::filesystem::create_directories(cfg.directory, ec);
+ char stamp[32]; std::time_t t = std::time(nullptr);
+ std::strftime(stamp, sizeof(stamp), "%Y%m%d_%H%M%S", std::localtime(&t));
+ const char* ext = cfg.format == ExportFormat::Pfm ? "pfm" : cfg.format == ExportFormat::Csv ? "csv" : "png";
+
+ int written = 0;
+ std::vector<uint8_t> px8; std::vector<float> pxf;
+ auto do_channel = [&](bool enabled, int channel, const char* name)
+ {
+ if (!enabled) return;
+ m_device->run_offscreen([&](RHI::CommandList& cmd) {
+ m_black_hole_renderer->render_export(cmd, target.get(), gv, scene.black_hole,
+ scene.objects, m_cubemap.get(), channel, raw);
+ });
+ std::string path = cfg.directory + "/donut_" + name + "_" + stamp + "." + ext;
+ bool ok;
+ if (cfg.format == ExportFormat::Png)
+ { m_device->read_render_target(target.get(), px8); ok = stbi_write_png(path.c_str(), w, h, 4, px8.data(), w * 4) != 0; }
+ else
+ {
+ m_device->read_render_target_float(target.get(), pxf);
+ ok = (cfg.format == ExportFormat::Pfm) ? write_pfm(path, w, h, pxf) : write_csv(path, w, h, pxf);
+ }
+ if (ok) { DONUT_INFO("Exported {}", path); ++written; } else DONUT_ERROR("Export failed: {}", path);
+ };
+ do_channel(cfg.color, 0, "color");
+ do_channel(cfg.redshift, 1, "redshift");
+ do_channel(cfg.temperature, 2, "temperature");
+ do_channel(cfg.impact, 3, "impact");
+ return written;
+ }
}
diff --git a/src/rendering/render_path.h b/src/rendering/render_path.h
index 0d97395..c029132 100644
--- a/src/rendering/render_path.h
+++ b/src/rendering/render_path.h
@@ -4,6 +4,7 @@
#include "view.h"
#include "scene_renderer.h"
#include "black_hole_renderer.h"
+#include "export_config.h"
#include "core/memory.h"
#include <string>
@@ -30,6 +31,10 @@ namespace Donut
auto render(RHI::CommandList& cmd, Scene& scene, View view,
int fb_width, int fb_height, bool moving, float time) -> void;
+ // Renders the requested export channels off-screen at cfg.width x cfg.height
+ // and writes one PNG per channel. Returns the number of files written.
+ auto export_frame(Scene& scene, const ExportConfig& cfg) -> int;
+
private:
RHI::Device* m_device = nullptr;
Scope<SceneRenderer> m_scene_renderer;
diff --git a/src/rendering/rhi.h b/src/rendering/rhi.h
index e4e7e62..6a2042a 100644
--- a/src/rendering/rhi.h
+++ b/src/rendering/rhi.h
@@ -22,6 +22,7 @@ namespace Donut::RHI
Swapchain, // a pipeline's colour target = the presented image (resolved per backend)
RGBA8, // 8-bit unorm colour (LDR / off-screen)
RGBA16F, // half-float colour (HDR / cubemap)
+ RGBA32F, // full-float colour (raw observable export)
D32, // 32-bit depth
};
@@ -146,6 +147,19 @@ namespace Donut::RHI
virtual auto begin_frame(const glm::vec4& clear) -> CommandList* = 0;
virtual auto end_frame() -> void = 0;
+ // One-shot off-screen work outside the frame loop (used by the exporter):
+ // records a self-contained pass into a transient command list and blocks
+ // until the GPU finishes, so the target can be read back immediately.
+ virtual auto run_offscreen(const std::function<void(CommandList&)>& record) -> void = 0;
+
+ // Reads a render target's colour back to the CPU as tightly-packed RGBA8,
+ // top-left origin (out is resized to width*height*4). For export/analysis.
+ virtual auto read_render_target(RenderTarget* target, std::vector<uint8_t>& out) -> void = 0;
+
+ // Same, but as full-float RGBA (out resized to width*height*4 floats). Read
+ // an RGBA32F target back for raw observable export.
+ virtual auto read_render_target_float(RenderTarget* target, std::vector<float>& out) -> void = 0;
+
// ImGui lives above the RHI but its platform/render backend is per-device.
virtual auto init_imgui() -> void = 0;
virtual auto imgui_new_frame() -> void = 0;
diff --git a/src/ui/ui_layer.cpp b/src/ui/ui_layer.cpp
index 4aa470b..f1ec4d2 100644
--- a/src/ui/ui_layer.cpp
+++ b/src/ui/ui_layer.cpp
@@ -280,31 +280,54 @@ namespace Donut
public:
auto name() const -> const char* override { return "Export"; }
auto view() const -> View override { return View::None; }
- auto on_ui(const UIContext&) -> void override
+ auto on_ui(const UIContext& ctx) -> void override
{
- ImGui::TextDisabled("Render observables to disk for analysis.");
+ ImGui::TextDisabled("Render the black hole + observables to disk for analysis.");
ImGui::Spacing();
if (ImGui::CollapsingHeader("Channels", ImGuiTreeNodeFlags_DefaultOpen))
{
- ImGui::Checkbox("Colour (tonemapped)", &m_color);
+ ImGui::Checkbox("Colour (tone-mapped)", &m_color);
ImGui::Checkbox("Redshift (g)", &m_redshift);
ImGui::Checkbox("Emission temperature", &m_temperature);
ImGui::Checkbox("Impact parameter", &m_impact);
+ ImGui::TextDisabled("Redshift & temperature are disk-only; impact is whole-frame (false-colour).");
}
if (ImGui::CollapsingHeader("Output", ImGuiTreeNodeFlags_DefaultOpen))
{
- ImGui::Combo("Resolution", &m_resolution, "960 x 540\0" "1920 x 1080\0" "3840 x 2160\0");
- ImGui::Combo("Format", &m_format, "PNG\0" "EXR (HDR)\0" "CSV (data)\0");
+ ImGui::Combo("Resolution", &m_resolution, "1280 x 720\0" "1920 x 1080\0" "2560 x 1440\0" "3840 x 2160\0");
+ ImGui::Combo("Format", &m_format, "Display image (PNG)\0" "Raw float image (PFM)\0" "Raw values (CSV)\0");
+ if (m_format == 0) ImGui::TextDisabled("Tone-mapped colour / false-coloured observables.");
+ else ImGui::TextDisabled("Actual physical values: g, temperature (K), impact (r_s).");
}
+
ImGui::Spacing();
- ImGui::BeginDisabled();
- ImGui::Button("Export frame", ImVec2(-1.0f, 0.0f));
+ const bool any = m_color || m_redshift || m_temperature || m_impact;
+ ImGui::BeginDisabled(!any);
+ if (ImGui::Button("Export frame", ImVec2(-1.0f, 0.0f)))
+ {
+ static const int dims[][2] = { {1280,720}, {1920,1080}, {2560,1440}, {3840,2160} };
+ ExportConfig cfg;
+ cfg.color = m_color; cfg.redshift = m_redshift;
+ cfg.temperature = m_temperature; cfg.impact = m_impact;
+ cfg.width = dims[m_resolution][0]; cfg.height = dims[m_resolution][1];
+ cfg.format = static_cast<ExportFormat>(m_format);
+ m_written = ctx.actions.export_frame(cfg);
+ m_reported = true;
+ }
ImGui::EndDisabled();
- ImGui::TextDisabled("Not wired up yet: needs the RHI readback path + G-buffer shader outputs.");
+
+ if (m_reported)
+ {
+ if (m_written > 0)
+ ImGui::TextColored(ImVec4(0.55f, 0.85f, 0.45f, 1.0f), "Wrote %d PNG(s) to exports/", m_written);
+ else
+ ImGui::TextColored(ImVec4(0.95f, 0.45f, 0.35f, 1.0f), "Export failed (see log)");
+ }
}
private:
bool m_color = true, m_redshift = false, m_temperature = false, m_impact = false;
int m_resolution = 1, m_format = 0;
+ int m_written = 0; bool m_reported = false;
};
}
diff --git a/src/ui/workspace.h b/src/ui/workspace.h
index 804d5a8..d86614a 100644
--- a/src/ui/workspace.h
+++ b/src/ui/workspace.h
@@ -1,6 +1,7 @@
#pragma once
#include "rendering/view.h"
+#include "rendering/export_config.h"
#include <string>
namespace Donut
@@ -18,6 +19,9 @@ namespace Donut
virtual auto set_ui_scale(float scale) -> void = 0;
virtual auto get_window_size(int& w, int& h) const -> void = 0;
virtual auto is_fullscreen() const -> bool = 0;
+ // Renders the requested channels off-screen and writes PNGs; returns the
+ // number of files written (0 on failure).
+ virtual auto export_frame(const ExportConfig& cfg) -> int = 0;
};
// What a workspace gets each frame to build its panels. `scene` is mutable —