diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/core/application.cpp | 2 | ||||
| -rw-r--r-- | src/core/application.h | 1 | ||||
| -rw-r--r-- | src/platform/opengl/opengl_device.cpp | 34 | ||||
| -rw-r--r-- | src/platform/vulkan/vulkan_common.h | 4 | ||||
| -rw-r--r-- | src/platform/vulkan/vulkan_device.cpp | 98 | ||||
| -rw-r--r-- | src/rendering/black_hole_renderer.cpp | 62 | ||||
| -rw-r--r-- | src/rendering/black_hole_renderer.h | 13 | ||||
| -rw-r--r-- | src/rendering/export_config.h | 28 | ||||
| -rw-r--r-- | src/rendering/render_path.cpp | 94 | ||||
| -rw-r--r-- | src/rendering/render_path.h | 5 | ||||
| -rw-r--r-- | src/rendering/rhi.h | 14 | ||||
| -rw-r--r-- | src/ui/ui_layer.cpp | 39 | ||||
| -rw-r--r-- | src/ui/workspace.h | 4 |
13 files changed, 370 insertions, 28 deletions
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, ©); + 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, ©); + 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 — |
