aboutsummaryrefslogtreecommitdiff
path: root/src/ui/ui_layer.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'src/ui/ui_layer.cpp')
-rw-r--r--src/ui/ui_layer.cpp685
1 files changed, 420 insertions, 265 deletions
diff --git a/src/ui/ui_layer.cpp b/src/ui/ui_layer.cpp
index f1ec4d2..88e1b1d 100644
--- a/src/ui/ui_layer.cpp
+++ b/src/ui/ui_layer.cpp
@@ -6,16 +6,25 @@
#include "rendering/render_api.h"
#include <imgui.h>
+#include <imgui_internal.h> // DockBuilder*
#include <ImGuizmo.h>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <algorithm>
-#include <cstdlib>
#include <cstdio>
#include <string>
namespace Donut
{
+ // Panel window titles. These are the identity DockBuilder docks against, so
+ // they must match the ImGui::Begin() titles exactly.
+ static const char* kOutliner = "Outliner";
+ static const char* kProperties = "Properties";
+ static const char* kBlackHole = "Black Hole";
+ static const char* kSettings = "Settings";
+ static const char* kExport = "Export";
+ static const char* kStats = "Stats";
+
// One-time ImGui theme: rounded, roomy, dark with a warm accretion-disk accent.
static auto apply_donut_style() -> void
{
@@ -32,6 +41,7 @@ namespace Donut
c[ImGuiCol_WindowBg] = ImVec4(0.07f, 0.08f, 0.10f, 0.97f);
c[ImGuiCol_ChildBg] = ImVec4(0.10f, 0.11f, 0.13f, 0.55f);
c[ImGuiCol_PopupBg] = ImVec4(0.09f, 0.10f, 0.12f, 0.98f);
+ c[ImGuiCol_MenuBarBg] = ImVec4(0.09f, 0.10f, 0.12f, 1.00f);
c[ImGuiCol_TitleBg] = ImVec4(0.09f, 0.10f, 0.12f, 1.00f);
c[ImGuiCol_TitleBgActive] = ImVec4(0.13f, 0.14f, 0.17f, 1.00f);
c[ImGuiCol_Text] = ImVec4(0.90f, 0.91f, 0.93f, 1.00f);
@@ -53,163 +63,260 @@ namespace Donut
c[ImGuiCol_TabActive] = ImVec4(0.22f, 0.24f, 0.28f, 1.00f);
c[ImGuiCol_Separator] = ImVec4(0.20f, 0.22f, 0.26f, 1.00f);
c[ImGuiCol_ScrollbarGrab] = ImVec4(0.24f, 0.26f, 0.30f, 1.00f);
+ c[ImGuiCol_DockingPreview] = ImVec4(0.98f, 0.62f, 0.20f, 0.55f);
+ c[ImGuiCol_DockingEmptyBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); // pass-through
}
- namespace
+ auto UILayer::draw(const UIContext& ctx) -> View
{
- // Setup: display / renderer / overlay / environment settings
- class SetupWorkspace : public Workspace
+ static bool styled = false;
+ if (!styled) { apply_donut_style(); styled = true; }
+
+ ImGuizmo::BeginFrame(); // the Properties panel draws the scene gizmo
+
+ draw_menu_bar(ctx);
+
+ // Full-viewport dock space with a pass-through centre: panels dock to the
+ // edges while the render shows through the middle. Sits under the menu bar.
+ // ImGui owns the id (stable per viewport) and persists the layout in imgui.ini.
+ const ImGuiID dockspace_id = ImGui::DockSpaceOverViewport(
+ 0, ImGui::GetMainViewport(), ImGuiDockNodeFlags_PassthruCentralNode);
+
+ // On the first frame, seed a default layout unless imgui.ini restored one
+ // (a restored dock tree has splits/windows, so the root node isn't empty).
+ if (m_first_frame)
{
- public:
- auto name() const -> const char* override { return "Setup"; }
- auto view() const -> View override { return View::None; }
- auto on_ui(const UIContext& ctx) -> void override
- {
- auto& s = SettingsManager::get_settings();
+ m_first_frame = false;
+ ImGuiDockNode* node = ImGui::DockBuilderGetNode(dockspace_id);
+ if (node == nullptr || node->IsEmpty())
+ m_rebuild_layout = true;
+ }
+ if (m_rebuild_layout)
+ {
+ build_layout(dockspace_id);
+ m_rebuild_layout = false;
+ }
- if (ImGui::CollapsingHeader("Display", ImGuiTreeNodeFlags_DefaultOpen))
- {
- const bool fs = ctx.actions.is_fullscreen();
- int cw = 0, ch = 0; ctx.actions.get_window_size(cw, ch);
+ if (m_show_outliner) panel_outliner(ctx);
+ if (m_show_properties) panel_properties(ctx);
+ if (m_show_black_hole) panel_black_hole(ctx);
+ if (m_show_settings) panel_settings(ctx);
+ if (m_show_export) panel_export(ctx);
+ if (m_show_stats) panel_stats(ctx);
- // Resolution presets (windowed only).
- struct Res { const char* label; int w, h; };
- static const Res kRes[] = {
- { "1280 x 720", 1280, 720 }, { "1600 x 900", 1600, 900 },
- { "1920 x 1080", 1920, 1080 }, { "2560 x 1440", 2560, 1440 },
- };
- char cur_label[32]; std::snprintf(cur_label, sizeof(cur_label), "%d x %d", cw, ch);
- ImGui::BeginDisabled(fs);
- if (ImGui::BeginCombo("Resolution", cur_label))
- {
- for (const auto& r : kRes)
- if (ImGui::Selectable(r.label, cw == r.w && ch == r.h))
- ctx.actions.set_resolution(r.w, r.h);
- ImGui::EndCombo();
- }
- ImGui::EndDisabled();
+ return m_mode == Mode::Scene ? View::Scene : View::BlackHole;
+ }
- bool fs_v = fs;
- if (ImGui::Checkbox("Fullscreen", &fs_v)) ctx.actions.set_fullscreen(fs_v);
+ auto UILayer::draw_menu_bar(const UIContext& ctx) -> void
+ {
+ if (!ImGui::BeginMainMenuBar()) return;
- if (ImGui::Checkbox("V-Sync", &s.graphics.v_sync_enabled))
- {
- ctx.actions.set_vsync(s.graphics.v_sync_enabled);
- SettingsManager::save_settings();
- }
+ if (ImGui::BeginMenu("View"))
+ {
+ ImGui::TextDisabled("Centre viewport");
+ ImGui::Separator();
+ if (ImGui::MenuItem("Scene", nullptr, m_mode == Mode::Scene)) m_mode = Mode::Scene;
+ if (ImGui::MenuItem("Simulation", nullptr, m_mode == Mode::Simulation)) m_mode = Mode::Simulation;
+ ImGui::EndMenu();
+ }
- ImGui::BeginDisabled(s.graphics.v_sync_enabled);
- if (ImGui::SliderInt("Frame cap", &s.simulation.target_fps, 30, 240, "%d FPS"))
- SettingsManager::save_settings();
- ImGui::EndDisabled();
- if (s.graphics.v_sync_enabled)
- ImGui::TextDisabled("Frame cap applies only with V-Sync off.");
+ if (ImGui::BeginMenu("Window"))
+ {
+ ImGui::MenuItem(kOutliner, nullptr, &m_show_outliner);
+ ImGui::MenuItem(kProperties, nullptr, &m_show_properties);
+ ImGui::MenuItem(kBlackHole, nullptr, &m_show_black_hole);
+ ImGui::MenuItem(kSettings, nullptr, &m_show_settings);
+ ImGui::MenuItem(kExport, nullptr, &m_show_export);
+ ImGui::MenuItem(kStats, nullptr, &m_show_stats);
+ ImGui::EndMenu();
+ }
- if (ImGui::SliderFloat("UI scale", &m_ui_scale, 0.8f, 1.6f, "%.2fx"))
- ctx.actions.set_ui_scale(m_ui_scale);
- }
+ if (ImGui::BeginMenu("Layout"))
+ {
+ ImGui::TextDisabled("Default layouts");
+ ImGui::Separator();
+ if (ImGui::MenuItem("Simulation", nullptr, m_layout == Layout::Simulation)) apply_layout(Layout::Simulation);
+ if (ImGui::MenuItem("Scene editing", nullptr, m_layout == Layout::SceneEditing)) apply_layout(Layout::SceneEditing);
+ if (ImGui::MenuItem("Export", nullptr, m_layout == Layout::Export)) apply_layout(Layout::Export);
+ ImGui::Separator();
+ if (ImGui::MenuItem("Reset current layout")) m_rebuild_layout = true;
+ ImGui::EndMenu();
+ }
- if (ImGui::CollapsingHeader("Renderer", ImGuiTreeNodeFlags_DefaultOpen))
- {
- const char* apis[] = { "OpenGL", "Vulkan" };
- int cur = (s.graphics.render_api == "Vulkan") ? 1 : 0;
- if (ImGui::Combo("Graphics API", &cur, apis, 2))
- {
- s.graphics.render_api = apis[cur];
- SettingsManager::save_settings();
- }
- const char* running = (RendererAPI::get_api() == RendererAPI::API::Vulkan) ? "Vulkan" : "OpenGL";
- if (s.graphics.render_api != running)
- ImGui::TextColored(ImVec4(0.98f, 0.62f, 0.20f, 1.0f), "Restart to apply (%s running)", running);
- else
- ImGui::TextDisabled("Active backend: %s (%s)", running, ctx.device_name.c_str());
- }
+ // Right-aligned mode + FPS readout.
+ char status[64];
+ std::snprintf(status, sizeof(status), "%s %.0f FPS",
+ m_mode == Mode::Scene ? "Scene" : "Simulation", ImGui::GetIO().Framerate);
+ float tw = ImGui::CalcTextSize(status).x;
+ ImGui::SameLine(ImGui::GetWindowWidth() - tw - 16.0f);
+ ImGui::TextDisabled("%s", status);
- if (ImGui::CollapsingHeader("Overlay", ImGuiTreeNodeFlags_DefaultOpen))
- {
- bool dirty = false;
- dirty |= ImGui::Checkbox("Show FPS", &s.graphics.show_fps);
- dirty |= ImGui::Checkbox("Show frame time", &s.graphics.show_performance_metrics);
- if (dirty) SettingsManager::save_settings();
- }
+ ImGui::EndMainMenuBar();
+ }
- if (ImGui::CollapsingHeader("Environment", ImGuiTreeNodeFlags_DefaultOpen))
- {
- auto& hdri = HDRIManager::get();
- std::string preview = hdri.get_hdri_name(ctx.scene.hdri_path);
- if (ImGui::BeginCombo("Starfield", preview.c_str()))
- {
- for (const auto& path : hdri.get_available_hdri())
- {
- bool selected = (path == ctx.scene.hdri_path);
- if (ImGui::Selectable(hdri.get_hdri_name(path).c_str(), selected))
- ctx.scene.hdri_path = path; // RenderPath rebuilds the cubemap next frame
- if (selected) ImGui::SetItemDefaultFocus();
- }
- ImGui::EndCombo();
- }
- ImGui::TextDisabled("Switching rebuilds the cubemap (brief pause).");
- }
- }
- private:
- float m_ui_scale = 1.0f;
- };
+ // Rebuild the dock tree for the current layout. Windows named but not currently
+ // visible still get a dock home here, so toggling them on lands them sensibly.
+ auto UILayer::build_layout(unsigned int dockspace_id) -> void
+ {
+ ImGui::DockBuilderRemoveNode(dockspace_id);
+ // DockSpace is a private flag, PassthruCentralNode a public one; combine as
+ // int to avoid the C++20 mixed-enum bitwise-or warning.
+ ImGui::DockBuilderAddNode(dockspace_id,
+ (int)ImGuiDockNodeFlags_DockSpace | (int)ImGuiDockNodeFlags_PassthruCentralNode);
+ ImGui::DockBuilderSetNodeSize(dockspace_id, ImGui::GetMainViewport()->WorkSize);
+
+ ImGuiID centre = dockspace_id;
- // Scene: place and edit objects
- class SceneWorkspace : public Workspace
+ switch (m_layout)
+ {
+ case Layout::SceneEditing:
+ {
+ ImGuiID left = 0, left_bottom = 0, right = 0;
+ ImGui::DockBuilderSplitNode(centre, ImGuiDir_Left, 0.24f, &left, &centre);
+ ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.50f, &left_bottom, &left);
+ ImGui::DockBuilderSplitNode(centre, ImGuiDir_Right, 0.24f, &right, &centre);
+ ImGui::DockBuilderDockWindow(kOutliner, left);
+ ImGui::DockBuilderDockWindow(kProperties, left_bottom);
+ ImGui::DockBuilderDockWindow(kBlackHole, right);
+ ImGui::DockBuilderDockWindow(kSettings, right);
+ ImGui::DockBuilderDockWindow(kExport, right);
+ ImGui::DockBuilderDockWindow(kStats, right);
+ break;
+ }
+ case Layout::Export:
+ {
+ ImGuiID right = 0, right_bottom = 0;
+ ImGui::DockBuilderSplitNode(centre, ImGuiDir_Right, 0.28f, &right, &centre);
+ ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.50f, &right_bottom, &right);
+ ImGui::DockBuilderDockWindow(kExport, right);
+ ImGui::DockBuilderDockWindow(kOutliner, right);
+ ImGui::DockBuilderDockWindow(kProperties, right);
+ ImGui::DockBuilderDockWindow(kBlackHole, right_bottom);
+ ImGui::DockBuilderDockWindow(kSettings, right_bottom);
+ ImGui::DockBuilderDockWindow(kStats, right_bottom);
+ break;
+ }
+ case Layout::Simulation:
+ default:
+ {
+ ImGuiID right = 0, right_bottom = 0;
+ ImGui::DockBuilderSplitNode(centre, ImGuiDir_Right, 0.24f, &right, &centre);
+ ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.30f, &right_bottom, &right);
+ ImGui::DockBuilderDockWindow(kBlackHole, right);
+ ImGui::DockBuilderDockWindow(kSettings, right);
+ ImGui::DockBuilderDockWindow(kExport, right);
+ ImGui::DockBuilderDockWindow(kOutliner, right);
+ ImGui::DockBuilderDockWindow(kProperties, right);
+ ImGui::DockBuilderDockWindow(kStats, right_bottom);
+ break;
+ }
+ }
+
+ ImGui::DockBuilderFinish(dockspace_id);
+ }
+
+ // Apply a preset: set the centre view, choose which panels are visible, and
+ // request a dock-tree rebuild next frame.
+ auto UILayer::apply_layout(Layout layout) -> void
+ {
+ m_layout = layout;
+ m_rebuild_layout = true;
+ switch (layout)
+ {
+ case Layout::SceneEditing:
+ m_mode = Mode::Scene;
+ m_show_outliner = true; m_show_properties = true;
+ m_show_black_hole = false; m_show_settings = false; m_show_export = false;
+ m_show_stats = true;
+ break;
+ case Layout::Export:
+ m_mode = Mode::Simulation;
+ m_show_export = true; m_show_black_hole = true;
+ m_show_outliner = false; m_show_properties = false; m_show_settings = false;
+ m_show_stats = true;
+ break;
+ case Layout::Simulation:
+ default:
+ m_mode = Mode::Simulation;
+ m_show_black_hole = true;
+ m_show_outliner = false; m_show_properties = false;
+ m_show_settings = false; m_show_export = false;
+ m_show_stats = true;
+ break;
+ }
+ }
+
+ auto UILayer::panel_outliner(const UIContext& ctx) -> void
+ {
+ if (ImGui::Begin(kOutliner, &m_show_outliner))
{
- public:
- auto name() const -> const char* override { return "Scene"; }
- auto view() const -> View override { return View::Scene; }
- auto on_ui(const UIContext& ctx) -> void override
+ Scene& sc = ctx.scene;
+ auto& objs = sc.objects;
+ if (sc.selected_object >= (int)objs.size()) sc.selected_object = (int)objs.size() - 1;
+
+ if (ImGui::Button("Add sphere") && objs.size() < 64)
+ {
+ SceneObject o;
+ o.position = glm::vec3(((int)objs.size() % 5) * 5.0f - 10.0f, 2.0f, ((int)objs.size() / 5) * 5.0f);
+ o.radius = 1.5f;
+ o.color = glm::vec3(0.35f + 0.12f * (objs.size() % 5), 0.55f, 0.9f - 0.12f * (objs.size() % 4));
+ objs.push_back(o);
+ sc.selected_object = (int)objs.size() - 1;
+ }
+ ImGui::SameLine();
+ ImGui::BeginDisabled(objs.empty());
+ if (ImGui::Button("Delete") && !objs.empty())
{
- Scene& sc = ctx.scene;
- ImGui::TextDisabled("Drag to orbit, scroll to zoom. HDRI skybox behind.");
- auto& objs = sc.objects;
+ objs.erase(objs.begin() + sc.selected_object);
if (sc.selected_object >= (int)objs.size()) sc.selected_object = (int)objs.size() - 1;
+ }
+ ImGui::EndDisabled();
- ImGui::Text("Spheres (%d)", (int)objs.size());
- if (ImGui::Button("Add") && objs.size() < 64)
- {
- SceneObject o;
- o.position = glm::vec3(((int)objs.size() % 5) * 5.0f - 10.0f, 2.0f, ((int)objs.size() / 5) * 5.0f);
- o.radius = 1.5f;
- o.color = glm::vec3(0.35f + 0.12f * (objs.size() % 5), 0.55f, 0.9f - 0.12f * (objs.size() % 4));
- objs.push_back(o);
- sc.selected_object = (int)objs.size() - 1;
- }
- ImGui::SameLine();
- if (ImGui::Button("Delete") && !objs.empty())
- {
- objs.erase(objs.begin() + sc.selected_object);
- if (sc.selected_object >= (int)objs.size()) sc.selected_object = (int)objs.size() - 1;
- }
+ ImGui::SameLine();
+ ImGui::TextDisabled("%d / 64", (int)objs.size());
+ ImGui::Separator();
- ImGui::BeginChild("obj_list", ImVec2(0, 120), true);
- for (int i = 0; i < (int)objs.size(); ++i)
- {
- std::string label = "Sphere " + std::to_string(i);
- if (ImGui::Selectable(label.c_str(), sc.selected_object == i)) sc.selected_object = i;
- }
- ImGui::EndChild();
+ ImGui::BeginChild("obj_list");
+ for (int i = 0; i < (int)objs.size(); ++i)
+ {
+ std::string label = "Sphere " + std::to_string(i);
+ if (ImGui::Selectable(label.c_str(), sc.selected_object == i)) sc.selected_object = i;
+ }
+ if (objs.empty()) ImGui::TextDisabled("No spheres. Add one above.");
+ ImGui::EndChild();
+ }
+ ImGui::End();
+ }
- if (sc.selected_object >= 0 && sc.selected_object < (int)objs.size())
- {
- SceneObject& o = objs[sc.selected_object];
- ImGui::DragFloat3("Position", &o.position.x, 0.1f);
- ImGui::DragFloat("Radius", &o.radius, 0.05f, 0.1f, 20.0f);
- ImGui::ColorEdit3("Color", &o.color.x);
+ auto UILayer::panel_properties(const UIContext& ctx) -> void
+ {
+ if (ImGui::Begin(kProperties, &m_show_properties))
+ {
+ Scene& sc = ctx.scene;
+ auto& objs = sc.objects;
+ if (objs.empty() || sc.selected_object < 0 || sc.selected_object >= (int)objs.size())
+ {
+ ImGui::TextDisabled("Select a sphere in the Outliner.");
+ }
+ else
+ {
+ SceneObject& o = objs[sc.selected_object];
+ ImGui::Text("Sphere %d", sc.selected_object);
+ ImGui::Separator();
+ ImGui::DragFloat3("Position", &o.position.x, 0.1f);
+ ImGui::DragFloat("Radius", &o.radius, 0.05f, 0.1f, 20.0f);
+ ImGui::ColorEdit3("Color", &o.color.x);
- ImGui::Spacing();
- if (ImGui::RadioButton("Move", m_op == ImGuizmo::TRANSLATE)) m_op = ImGuizmo::TRANSLATE;
- ImGui::SameLine();
- if (ImGui::RadioButton("Scale", m_op == ImGuizmo::SCALE)) m_op = ImGuizmo::SCALE;
- ImGui::SameLine(); ImGui::TextDisabled("(drag the handles in the viewport)");
+ ImGui::Spacing();
+ if (ImGui::RadioButton("Move", m_gizmo_op == 0)) m_gizmo_op = 0;
+ ImGui::SameLine();
+ if (ImGui::RadioButton("Scale", m_gizmo_op == 1)) m_gizmo_op = 1;
- // Viewport gizmo for the selected sphere. Uses the scene camera
- // (its projection was set for this frame by the Application) and
- // draws into the background draw list so it sits over the render
- // but under the panels.
+ if (m_mode == Mode::Scene)
+ {
+ // Viewport gizmo for the selected sphere, using the scene camera
+ // (its projection was set for this frame by the Application). Drawn
+ // into the background list so it sits over the render, under panels.
ImGuizmo::SetOrthographic(false);
ImGuizmo::SetDrawlist(ImGui::GetBackgroundDrawList());
ImGuiIO& io = ImGui::GetIO();
@@ -218,175 +325,223 @@ namespace Donut
glm::mat4 proj = sc.scene_camera.get_projection_matrix();
glm::mat4 xform = glm::translate(glm::mat4(1.0f), o.position)
* glm::scale(glm::mat4(1.0f), glm::vec3(o.radius));
- if (ImGuizmo::Manipulate(&view[0][0], &proj[0][0], m_op, ImGuizmo::WORLD, &xform[0][0]))
+ ImGuizmo::OPERATION op = (m_gizmo_op == 1) ? ImGuizmo::SCALE : ImGuizmo::TRANSLATE;
+ if (ImGuizmo::Manipulate(&view[0][0], &proj[0][0], op, ImGuizmo::WORLD, &xform[0][0]))
{
float t[3], r[3], s[3];
ImGuizmo::DecomposeMatrixToComponents(&xform[0][0], t, r, s);
o.position = glm::vec3(t[0], t[1], t[2]);
o.radius = std::max(0.05f, (s[0] + s[1] + s[2]) / 3.0f);
}
+ ImGui::TextDisabled("Drag the handles in the viewport.");
+ }
+ else
+ {
+ ImGui::TextDisabled("Switch View to Scene to drag the gizmo.");
}
}
- private:
- ImGuizmo::OPERATION m_op = ImGuizmo::TRANSLATE;
- };
+ }
+ ImGui::End();
+ }
- // Simulation: camera + accretion disk + quality
- class SimulationWorkspace : public Workspace
+ auto UILayer::panel_black_hole(const UIContext& ctx) -> void
+ {
+ if (ImGui::Begin(kBlackHole, &m_show_black_hole))
{
- public:
- auto name() const -> const char* override { return "Simulation"; }
- auto view() const -> View override { return View::BlackHole; }
- auto on_ui(const UIContext& ctx) -> void override
- {
- Scene& sc = ctx.scene;
- BlackHoleParams& bh = sc.black_hole;
+ Scene& sc = ctx.scene;
+ BlackHoleParams& bh = sc.black_hole;
- if (ImGui::CollapsingHeader("Camera", ImGuiTreeNodeFlags_DefaultOpen))
- {
- bool ff = sc.is_free_fly();
- if (ImGui::RadioButton("Orbital", !ff)) sc.set_free_fly(false);
- ImGui::SameLine();
- if (ImGui::RadioButton("Free-fly", ff)) sc.set_free_fly(true);
- ImGui::SameLine();
- if (ImGui::Button("Reset view")) sc.reset_sim_camera();
- ImGui::SliderFloat("FOV", &bh.fov_degrees, 20.0f, 90.0f, "%.0f\xc2\xb0");
- ImGui::TextDisabled(ff ? "WASD move | Q/E down-up | Shift boost | drag look"
- : "Drag to orbit | scroll to zoom");
- }
+ if (ImGui::CollapsingHeader("Camera", ImGuiTreeNodeFlags_DefaultOpen))
+ {
+ bool ff = sc.is_free_fly();
+ if (ImGui::RadioButton("Orbital", !ff)) sc.set_free_fly(false);
+ ImGui::SameLine();
+ if (ImGui::RadioButton("Free-fly", ff)) sc.set_free_fly(true);
+ ImGui::SameLine();
+ if (ImGui::Button("Reset view")) sc.reset_sim_camera();
+ ImGui::SliderFloat("FOV", &bh.fov_degrees, 20.0f, 90.0f, "%.0f\xc2\xb0");
+ ImGui::TextDisabled(ff ? "WASD move | Q/E down-up | Shift boost | drag look"
+ : "Drag to orbit | scroll to zoom");
+ }
- if (ImGui::CollapsingHeader("Accretion disk", ImGuiTreeNodeFlags_DefaultOpen))
- {
- ImGui::SliderFloat("Temperature", &bh.temperature, 2000.0f, 15000.0f, "%.0f K");
- ImGui::SliderFloat("Brightness", &bh.brightness, 0.0f, 3.0f, "%.2f");
- ImGui::SliderFloat("Inner radius", &bh.disk_inner_rs, 3.0f, 12.0f, "%.1f r_s");
- if (bh.disk_outer_rs < bh.disk_inner_rs + 0.5f) bh.disk_outer_rs = bh.disk_inner_rs + 0.5f;
- ImGui::SliderFloat("Outer radius", &bh.disk_outer_rs, bh.disk_inner_rs + 0.5f, 25.0f, "%.1f r_s");
- ImGui::SliderFloat("Turbulence", &bh.turbulence, 0.0f, 2.0f, "%.2f");
- ImGui::TextDisabled("Inner edge is the ISCO (3 r_s). Novikov-Thorne profile.");
- }
+ if (ImGui::CollapsingHeader("Accretion disk", ImGuiTreeNodeFlags_DefaultOpen))
+ {
+ ImGui::SliderFloat("Temperature", &bh.temperature, 2000.0f, 15000.0f, "%.0f K");
+ ImGui::SliderFloat("Brightness", &bh.brightness, 0.0f, 3.0f, "%.2f");
+ ImGui::SliderFloat("Inner radius", &bh.disk_inner_rs, 3.0f, 12.0f, "%.1f r_s");
+ if (bh.disk_outer_rs < bh.disk_inner_rs + 0.5f) bh.disk_outer_rs = bh.disk_inner_rs + 0.5f;
+ ImGui::SliderFloat("Outer radius", &bh.disk_outer_rs, bh.disk_inner_rs + 0.5f, 25.0f, "%.1f r_s");
+ ImGui::SliderFloat("Turbulence", &bh.turbulence, 0.0f, 2.0f, "%.2f");
+ ImGui::TextDisabled("Inner edge is the ISCO (3 r_s). Novikov-Thorne profile.");
+ }
- if (ImGui::CollapsingHeader("Quality"))
- {
- ImGui::SliderInt("Integration steps", &bh.quality_steps, 2000, 15000);
- ImGui::TextDisabled("Deeper lensing at higher cost. Settled frame is 4x supersampled.");
- }
+ if (ImGui::CollapsingHeader("Quality"))
+ {
+ ImGui::SliderInt("Integration steps", &bh.quality_steps, 2000, 15000);
+ ImGui::TextDisabled("Deeper lensing at higher cost. Settled frame is 4x supersampled.");
}
- };
+ }
+ ImGui::End();
+ }
- // Export: pick what to render out (not wired up yet)
- class ExportWorkspace : public Workspace
+ auto UILayer::panel_settings(const UIContext& ctx) -> void
+ {
+ if (ImGui::Begin(kSettings, &m_show_settings))
{
- public:
- auto name() const -> const char* override { return "Export"; }
- auto view() const -> View override { return View::None; }
- auto on_ui(const UIContext& ctx) -> void override
+ auto& s = SettingsManager::get_settings();
+
+ if (ImGui::CollapsingHeader("Display", ImGuiTreeNodeFlags_DefaultOpen))
{
- ImGui::TextDisabled("Render the black hole + observables to disk for analysis.");
- ImGui::Spacing();
- if (ImGui::CollapsingHeader("Channels", ImGuiTreeNodeFlags_DefaultOpen))
- {
- 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))
+ const bool fs = ctx.actions.is_fullscreen();
+ int cw = 0, ch = 0; ctx.actions.get_window_size(cw, ch);
+
+ struct Res { const char* label; int w, h; };
+ static const Res kRes[] = {
+ { "1280 x 720", 1280, 720 }, { "1600 x 900", 1600, 900 },
+ { "1920 x 1080", 1920, 1080 }, { "2560 x 1440", 2560, 1440 },
+ };
+ char cur_label[32]; std::snprintf(cur_label, sizeof(cur_label), "%d x %d", cw, ch);
+ ImGui::BeginDisabled(fs);
+ if (ImGui::BeginCombo("Resolution", cur_label))
{
- 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).");
+ for (const auto& r : kRes)
+ if (ImGui::Selectable(r.label, cw == r.w && ch == r.h))
+ ctx.actions.set_resolution(r.w, r.h);
+ ImGui::EndCombo();
}
+ ImGui::EndDisabled();
- ImGui::Spacing();
- const bool any = m_color || m_redshift || m_temperature || m_impact;
- ImGui::BeginDisabled(!any);
- if (ImGui::Button("Export frame", ImVec2(-1.0f, 0.0f)))
+ bool fs_v = fs;
+ if (ImGui::Checkbox("Fullscreen", &fs_v)) ctx.actions.set_fullscreen(fs_v);
+
+ if (ImGui::Checkbox("V-Sync", &s.graphics.v_sync_enabled))
{
- 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;
+ ctx.actions.set_vsync(s.graphics.v_sync_enabled);
+ SettingsManager::save_settings();
}
+
+ ImGui::BeginDisabled(s.graphics.v_sync_enabled);
+ if (ImGui::SliderInt("Frame cap", &s.simulation.target_fps, 30, 240, "%d FPS"))
+ SettingsManager::save_settings();
ImGui::EndDisabled();
+ if (s.graphics.v_sync_enabled)
+ ImGui::TextDisabled("Frame cap applies only with V-Sync off.");
+
+ if (ImGui::SliderFloat("UI scale", &m_ui_scale, 0.8f, 1.6f, "%.2fx"))
+ ctx.actions.set_ui_scale(m_ui_scale);
+ }
- if (m_reported)
+ if (ImGui::CollapsingHeader("Renderer", ImGuiTreeNodeFlags_DefaultOpen))
+ {
+ const char* apis[] = { "OpenGL", "Vulkan" };
+ int cur = (s.graphics.render_api == "Vulkan") ? 1 : 0;
+ if (ImGui::Combo("Graphics API", &cur, apis, 2))
{
- 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)");
+ s.graphics.render_api = apis[cur];
+ SettingsManager::save_settings();
}
+ const char* running = (RendererAPI::get_api() == RendererAPI::API::Vulkan) ? "Vulkan" : "OpenGL";
+ if (s.graphics.render_api != running)
+ ImGui::TextColored(ImVec4(0.98f, 0.62f, 0.20f, 1.0f), "Restart to apply (%s running)", running);
+ else
+ ImGui::TextDisabled("Active backend: %s (%s)", running, ctx.device_name.c_str());
}
- 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;
- };
- }
- UILayer::UILayer()
- {
- m_workspaces.push_back(create_scope<SetupWorkspace>());
- m_workspaces.push_back(create_scope<SceneWorkspace>());
- m_workspaces.push_back(create_scope<SimulationWorkspace>());
- m_workspaces.push_back(create_scope<ExportWorkspace>());
+ if (ImGui::CollapsingHeader("Overlay", ImGuiTreeNodeFlags_DefaultOpen))
+ {
+ bool dirty = false;
+ dirty |= ImGui::Checkbox("Show FPS", &s.graphics.show_fps);
+ dirty |= ImGui::Checkbox("Show frame time", &s.graphics.show_performance_metrics);
+ if (dirty) SettingsManager::save_settings();
+ }
- // Open on Simulation so the black hole greets the user on launch.
- for (int i = 0; i < (int)m_workspaces.size(); ++i)
- if (std::string(m_workspaces[i]->name()) == "Simulation") { m_landing = i; m_active = i; }
+ if (ImGui::CollapsingHeader("Environment", ImGuiTreeNodeFlags_DefaultOpen))
+ {
+ auto& hdri = HDRIManager::get();
+ std::string preview = hdri.get_hdri_name(ctx.scene.hdri_path);
+ if (ImGui::BeginCombo("Starfield", preview.c_str()))
+ {
+ for (const auto& path : hdri.get_available_hdri())
+ {
+ bool selected = (path == ctx.scene.hdri_path);
+ if (ImGui::Selectable(hdri.get_hdri_name(path).c_str(), selected))
+ ctx.scene.hdri_path = path; // RenderPath rebuilds the cubemap next frame
+ if (selected) ImGui::SetItemDefaultFocus();
+ }
+ ImGui::EndCombo();
+ }
+ ImGui::TextDisabled("Switching rebuilds the cubemap (brief pause).");
+ }
+ }
+ ImGui::End();
}
- auto UILayer::draw(const UIContext& ctx) -> View
+ auto UILayer::panel_export(const UIContext& ctx) -> void
{
- static bool styled = false;
- if (!styled) { apply_donut_style(); styled = true; }
-
- ImGuizmo::BeginFrame(); // gizmos are drawn by the Scene workspace
-
- // Full-viewport dock space with a pass-through centre, so the panel docks to
- // any edge while the render shows through the middle.
- ImGui::DockSpaceOverViewport(0, ImGui::GetMainViewport(), ImGuiDockNodeFlags_PassthruCentralNode);
+ if (ImGui::Begin(kExport, &m_show_export))
+ {
+ ImGui::TextDisabled("Render the black hole + observables to disk.");
+ ImGui::Spacing();
+ if (ImGui::CollapsingHeader("Channels", ImGuiTreeNodeFlags_DefaultOpen))
+ {
+ ImGui::Checkbox("Colour (tone-mapped)", &m_exp_color);
+ ImGui::Checkbox("Redshift (g)", &m_exp_redshift);
+ ImGui::Checkbox("Emission temperature", &m_exp_temperature);
+ ImGui::Checkbox("Impact parameter", &m_exp_impact);
+ ImGui::TextDisabled("Redshift & temperature are disk-only; impact is whole-frame.");
+ }
+ if (ImGui::CollapsingHeader("Output", ImGuiTreeNodeFlags_DefaultOpen))
+ {
+ ImGui::Combo("Resolution", &m_exp_resolution, "1280 x 720\0" "1920 x 1080\0" "2560 x 1440\0" "3840 x 2160\0");
+ ImGui::Combo("Format", &m_exp_format, "Display image (PNG)\0" "Raw float image (PFM)\0" "Raw values (CSV)\0");
+ if (m_exp_format == 0) ImGui::TextDisabled("Tone-mapped colour / false-coloured observables.");
+ else ImGui::TextDisabled("Actual physical values: g, temperature (K), impact (r_s).");
+ }
- ImGui::SetNextWindowSize(ImVec2(360, 620), ImGuiCond_FirstUseEver);
- ImGui::Begin("Donut");
+ ImGui::Spacing();
+ const bool any = m_exp_color || m_exp_redshift || m_exp_temperature || m_exp_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_exp_color; cfg.redshift = m_exp_redshift;
+ cfg.temperature = m_exp_temperature; cfg.impact = m_exp_impact;
+ cfg.width = dims[m_exp_resolution][0]; cfg.height = dims[m_exp_resolution][1];
+ cfg.format = static_cast<ExportFormat>(m_exp_format);
+ m_exp_written = ctx.actions.export_frame(cfg);
+ m_exp_reported = true;
+ }
+ ImGui::EndDisabled();
- ImGuiIO& io = ImGui::GetIO();
- ImGui::TextDisabled("%s", ctx.device_name.empty() ? "GPU" : ctx.device_name.c_str());
- const bool show_fps = SettingsManager::get_show_fps();
- const bool show_ms = SettingsManager::get_show_performance_metrics();
- if (show_fps) ImGui::Text("%.0f FPS", io.Framerate);
- if (show_ms)
- {
- if (show_fps) ImGui::SameLine();
- ImGui::TextDisabled("%.1f ms/frame", io.Framerate > 0 ? 1000.0f / io.Framerate : 0.0f);
+ if (m_exp_reported)
+ {
+ if (m_exp_written > 0)
+ ImGui::TextColored(ImVec4(0.55f, 0.85f, 0.45f, 1.0f), "Wrote %d file(s) to exports/", m_exp_written);
+ else
+ ImGui::TextColored(ImVec4(0.95f, 0.45f, 0.35f, 1.0f), "Export failed (see log)");
+ }
}
- ImGui::Spacing();
+ ImGui::End();
+ }
- if (ImGui::BeginTabBar("workspaces"))
+ auto UILayer::panel_stats(const UIContext& ctx) -> void
+ {
+ if (ImGui::Begin(kStats, &m_show_stats))
{
- for (int i = 0; i < (int)m_workspaces.size(); ++i)
+ ImGuiIO& io = ImGui::GetIO();
+ ImGui::TextDisabled("%s", ctx.device_name.empty() ? "GPU" : ctx.device_name.c_str());
+ const bool show_fps = SettingsManager::get_show_fps();
+ const bool show_ms = SettingsManager::get_show_performance_metrics();
+ if (show_fps) ImGui::Text("%.0f FPS", io.Framerate);
+ if (show_ms)
{
- ImGuiTabItemFlags flags = (!m_landed && i == m_landing) ? ImGuiTabItemFlags_SetSelected : 0;
- if (ImGui::BeginTabItem(m_workspaces[i]->name(), nullptr, flags))
- {
- m_active = i;
- ImGui::Spacing();
- m_workspaces[i]->on_ui(ctx);
- ImGui::EndTabItem();
- }
+ if (show_fps) ImGui::SameLine();
+ ImGui::TextDisabled("%.1f ms/frame", io.Framerate > 0 ? 1000.0f / io.Framerate : 0.0f);
}
- ImGui::EndTabBar();
- m_landed = true;
+ if (!show_fps && !show_ms)
+ ImGui::TextDisabled("Enable FPS / frame time in Settings > Overlay.");
}
ImGui::End();
-
- return m_workspaces[m_active]->view();
}
}