diff options
| author | hachem <im@hachem.wtf> | 2026-08-23 22:39:47 +0200 |
|---|---|---|
| committer | hachem <im@hachem.wtf> | 2026-08-23 22:39:47 +0200 |
| commit | 90ff03c2fdffc09ddb26bdc7e32c3968a6d274fb (patch) | |
| tree | 2fdb1b6f54c4e380af43c75f1051935587a87faa | |
| parent | 5ea6c14c0e14bacee766ce1280ab2c71564e05ec (diff) | |
[feat]: wire in scene into the simulation engine
| -rw-r--r-- | src/core/application.cpp | 62 | ||||
| -rw-r--r-- | src/core/application.h | 13 | ||||
| -rw-r--r-- | src/platform/opengl/opengl_device.cpp | 1 | ||||
| -rw-r--r-- | src/platform/vulkan/vulkan_common.h | 2 | ||||
| -rw-r--r-- | src/platform/vulkan/vulkan_swapchain.cpp | 14 | ||||
| -rw-r--r-- | src/rendering/black_hole_renderer.cpp | 30 | ||||
| -rw-r--r-- | src/rendering/black_hole_renderer.h | 10 | ||||
| -rw-r--r-- | src/rendering/render_path.cpp | 5 | ||||
| -rw-r--r-- | src/rendering/rhi.h | 4 | ||||
| -rw-r--r-- | src/rendering/scene_renderer.cpp | 25 | ||||
| -rw-r--r-- | src/rendering/scene_renderer.h | 1 | ||||
| -rw-r--r-- | src/scene/scene.cpp | 4 | ||||
| -rw-r--r-- | src/scene/scene.h | 5 | ||||
| -rw-r--r-- | src/scene/scene_types.h | 6 | ||||
| -rw-r--r-- | src/ui/ui_layer.cpp | 104 | ||||
| -rw-r--r-- | src/ui/workspace.h | 16 |
16 files changed, 275 insertions, 27 deletions
diff --git a/src/core/application.cpp b/src/core/application.cpp index 1541e34..3a21cac 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -10,6 +10,8 @@ #include <imgui.h> #include <glm/glm.hpp> #include <algorithm> +#include <thread> +#include <chrono> namespace Donut { @@ -85,6 +87,9 @@ namespace Donut g_prev_scroll = glfwSetScrollCallback((GLFWwindow*)m_window->get_native_window(), donut_scroll_callback); + m_device->set_vsync(SettingsManager::get_v_sync_enabled()); // apply the persisted preference + glfwGetWindowSize((GLFWwindow*)m_window->get_native_window(), &m_windowed_w, &m_windowed_h); + m_start_time = glfwGetTime(); DONUT_INFO("Application ready on {} backend", m_device->device_name()); } @@ -93,24 +98,75 @@ namespace Donut { while (m_running) { + double frame_start = glfwGetTime(); glfwPollEvents(); if (!m_minimized && m_device) render_frame(); + + // With vsync off, cap to the target FPS (vsync itself paces otherwise). + if (!SettingsManager::get_v_sync_enabled()) + { + int fps = SettingsManager::get_target_fps(); + if (fps > 0) + { + double budget = 1.0 / (double)fps; + double elapsed = glfwGetTime() - frame_start; + if (elapsed < budget) + std::this_thread::sleep_for(std::chrono::duration<double>(budget - elapsed)); + } + } + } + } + + auto Application::set_resolution(int w, int h) -> void + { + if (m_window && !is_fullscreen()) + glfwSetWindowSize((GLFWwindow*)m_window->get_native_window(), w, h); + } + + auto Application::set_fullscreen(bool on) -> void + { + if (!m_window) return; + GLFWwindow* win = (GLFWwindow*)m_window->get_native_window(); + if (on == is_fullscreen()) return; + if (on) + { + glfwGetWindowPos(win, &m_windowed_x, &m_windowed_y); + glfwGetWindowSize(win, &m_windowed_w, &m_windowed_h); + GLFWmonitor* mon = glfwGetPrimaryMonitor(); + const GLFWvidmode* mode = glfwGetVideoMode(mon); + glfwSetWindowMonitor(win, mon, 0, 0, mode->width, mode->height, mode->refreshRate); + } + else + { + glfwSetWindowMonitor(win, nullptr, m_windowed_x, m_windowed_y, m_windowed_w, m_windowed_h, 0); } } + 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::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 + { w = 0; h = 0; if (m_window) glfwGetWindowSize((GLFWwindow*)m_window->get_native_window(), &w, &h); } + auto Application::render_frame() -> void { RHI::CommandList* cmd = m_device->begin_frame(glm::vec4(0.05f, 0.06f, 0.10f, 1.0f)); if (!cmd) return; // frame skipped (e.g. swapchain recreation) m_device->imgui_new_frame(); - View view = m_ui->draw(UIContext{ *m_scene, m_device->device_name() }); - update_input(view); - m_render_path->sync_hdri(m_scene->hdri_path); + // Keep the scene camera's projection current before the UI builds, so the + // scene-tab gizmo and the render agree on the same matrices this frame. int w = 0, h = 0; glfwGetFramebufferSize((GLFWwindow*)m_window->get_native_window(), &w, &h); + m_scene->scene_camera.set_projection(45.0f, (float)w / (float)std::max(h, 1), 0.1f, 1000.0f); + + View view = m_ui->draw(UIContext{ *m_scene, m_device->device_name(), *this }); + update_input(view); + m_render_path->sync_hdri(m_scene->hdri_path); + float time = (float)(glfwGetTime() - m_start_time); m_render_path->render(*cmd, *m_scene, view, w, h, m_user_moving, time); diff --git a/src/core/application.h b/src/core/application.h index cbd741d..10a375f 100644 --- a/src/core/application.h +++ b/src/core/application.h @@ -19,7 +19,7 @@ namespace Donut // RenderPath (device-side rendering) and the UILayer (tabbed workspaces), and // runs the frame loop that wires them together. All the rendering lives in the // RHI + shared renderers; all the editing lives in the Scene + workspaces. - class Application + class Application : public AppActions { public: Application(const std::string& name = "Donut", @@ -32,6 +32,14 @@ namespace Donut auto get_window() -> Window& { return *m_window; } static auto get() -> Application& { return *s_instance; } + // AppActions — window/device changes requested by the Setup workspace. + auto set_resolution(int w, int h) -> void override; + auto set_fullscreen(bool on) -> void override; + auto set_vsync(bool on) -> void override; + 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; + private: auto on_init() -> void; auto on_shutdown() -> void; @@ -54,6 +62,9 @@ namespace Donut double m_start_time = 0.0; bool m_user_moving = false; + // windowed-mode geometry saved across a fullscreen toggle + int m_windowed_x = 100, m_windowed_y = 100, m_windowed_w = 1280, m_windowed_h = 720; + bool m_running = true; bool m_minimized = false; diff --git a/src/platform/opengl/opengl_device.cpp b/src/platform/opengl/opengl_device.cpp index 8516a3c..82fdae1 100644 --- a/src/platform/opengl/opengl_device.cpp +++ b/src/platform/opengl/opengl_device.cpp @@ -230,6 +230,7 @@ namespace Donut::RHI } auto resize(int w, int h) -> void override { m_width = w; m_height = h; } auto wait_idle() -> void override { glFinish(); } + auto set_vsync(bool enabled) -> void override { glfwSwapInterval(enabled ? 1 : 0); } auto create_buffer(BufferType t, size_t size, const void* data) -> Ref<Buffer> override { return create_ref<GLBuffer>(t, size, data); } diff --git a/src/platform/vulkan/vulkan_common.h b/src/platform/vulkan/vulkan_common.h index 0faa107..5ae257e 100644 --- a/src/platform/vulkan/vulkan_common.h +++ b/src/platform/vulkan/vulkan_common.h @@ -267,6 +267,7 @@ namespace Donut::RHI auto shutdown() -> void override; auto resize(int width, int height) -> void override { m_framebuffer_resized = true; m_width = width; m_height = height; } auto wait_idle() -> void override { if (m_device) vkDeviceWaitIdle(m_device); } + auto set_vsync(bool enabled) -> void override { m_vsync = enabled; m_framebuffer_resized = true; } auto create_buffer(BufferType type, size_t size, const void* data) -> Ref<Buffer> override; auto create_texture(int w, int h, Format format, Filter filter, const void* data) -> Ref<Texture> override; @@ -305,6 +306,7 @@ namespace Donut::RHI GLFWwindow* m_window = nullptr; int m_width = 0, m_height = 0; bool m_framebuffer_resized = false; + bool m_vsync = true; std::string m_gpu_name; VkInstance m_instance = VK_NULL_HANDLE; diff --git a/src/platform/vulkan/vulkan_swapchain.cpp b/src/platform/vulkan/vulkan_swapchain.cpp index fe5bd38..5bf2f60 100644 --- a/src/platform/vulkan/vulkan_swapchain.cpp +++ b/src/platform/vulkan/vulkan_swapchain.cpp @@ -117,7 +117,19 @@ namespace Donut::RHI sci.imageExtent = m_extent; sci.imageArrayLayers = 1; sci.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; sci.preTransform = caps.currentTransform; sci.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; - sci.presentMode = VK_PRESENT_MODE_FIFO_KHR; sci.clipped = VK_TRUE; + // FIFO is always available (vsync). With vsync off, prefer IMMEDIATE + // (uncapped, may tear) then MAILBOX, if the surface supports them. + VkPresentModeKHR present_mode = VK_PRESENT_MODE_FIFO_KHR; + if (!m_vsync) + { + uint32_t pmc = 0; vkGetPhysicalDeviceSurfacePresentModesKHR(m_physical, m_surface, &pmc, nullptr); + std::vector<VkPresentModeKHR> modes(pmc); + vkGetPhysicalDeviceSurfacePresentModesKHR(m_physical, m_surface, &pmc, modes.data()); + auto has = [&](VkPresentModeKHR m){ return std::find(modes.begin(), modes.end(), m) != modes.end(); }; + if (has(VK_PRESENT_MODE_IMMEDIATE_KHR)) present_mode = VK_PRESENT_MODE_IMMEDIATE_KHR; + else if (has(VK_PRESENT_MODE_MAILBOX_KHR)) present_mode = VK_PRESENT_MODE_MAILBOX_KHR; + } + sci.presentMode = present_mode; sci.clipped = VK_TRUE; uint32_t fam[2] = { m_graphics_family, m_present_family }; if (m_graphics_family != m_present_family) { sci.imageSharingMode = VK_SHARING_MODE_CONCURRENT; sci.queueFamilyIndexCount = 2; sci.pQueueFamilyIndices = fam; } diff --git a/src/rendering/black_hole_renderer.cpp b/src/rendering/black_hole_renderer.cpp index e4bc2a4..5916171 100644 --- a/src/rendering/black_hole_renderer.cpp +++ b/src/rendering/black_hole_renderer.cpp @@ -42,11 +42,9 @@ namespace Donut m_obj_ubo = device.create_buffer(BufferType::Uniform, 800); m_sim_ubo = device.create_buffer(BufferType::Uniform, 16); - // The single "object" is the black hole itself (position 0, radius = r_s). - std::vector<uint8_t> obj(800, 0); - int num_objects = 1; std::memcpy(obj.data() + 0, &num_objects, 4); - float pos_radius[4] = { 0, 0, 0, SagA_rs }; std::memcpy(obj.data() + 16, pos_radius, 16); - float color[4] = { 0, 0, 0, 1 }; std::memcpy(obj.data() + 272, color, 16); + // The hole itself is the hardcoded singularity in the shader; the Objects + // UBO carries the Scene's spheres, refilled each frame in render_geodesic. + std::vector<uint8_t> obj(800, 0); // numObjects = 0 until the first frame m_obj_ubo->update(obj.data(), obj.size()); { @@ -80,7 +78,8 @@ namespace Donut } auto BlackHoleRenderer::render_geodesic(RHI::CommandList& cmd, const GeodesicView& view, - const BlackHoleParams& params, RHI::Texture* cubemap) -> void + const BlackHoleParams& params, + const std::vector<SceneObject>& objects, RHI::Texture* cubemap) -> void { CamUBO cam{}; cam.pos = view.position; cam.right = view.right; cam.up = view.up; cam.fwd = view.forward; @@ -113,6 +112,25 @@ namespace Donut }; m_disk_ubo->update(disk, sizeof(disk)); + // 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 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; diff --git a/src/rendering/black_hole_renderer.h b/src/rendering/black_hole_renderer.h index 63c5aa2..2ae03dd 100644 --- a/src/rendering/black_hole_renderer.h +++ b/src/rendering/black_hole_renderer.h @@ -1,8 +1,9 @@ #pragma once #include "rhi.h" -#include "scene/scene_types.h" // BlackHoleParams +#include "scene/scene_types.h" // BlackHoleParams, SceneObject #include <glm/glm.hpp> +#include <vector> namespace Donut { @@ -29,9 +30,12 @@ namespace Donut auto init(RHI::Device& device) -> bool; // Off-screen geodesic pass (opens/closes its own render pass). Call before - // the swapchain pass; `cubemap` is the shared HDRI environment. + // the swapchain pass; `cubemap` is the shared HDRI environment. `objects` + // are the Scene's spheres — ray-traced as lensed spheres around the hole + // (1 scene unit = 1 Schwarzschild radius). Grid units == r_s. auto render_geodesic(RHI::CommandList& cmd, const GeodesicView& view, - const BlackHoleParams& params, RHI::Texture* cubemap) -> void; + const BlackHoleParams& params, + const std::vector<SceneObject>& objects, RHI::Texture* cubemap) -> void; // Upscales the geodesic target onto the swapchain (call inside the open // swapchain render pass, before ImGui). diff --git a/src/rendering/render_path.cpp b/src/rendering/render_path.cpp index 172e13c..115d182 100644 --- a/src/rendering/render_path.cpp +++ b/src/rendering/render_path.cpp @@ -42,8 +42,7 @@ namespace Donut cmd.begin_render_pass(nullptr, clear); if (view == View::Scene) { - float aspect = (float)fb_width / (float)std::max(fb_height, 1); - scene.scene_camera.set_projection(45.0f, aspect, 0.1f, 1000.0f); + // Projection was set by the Application this frame (shared with the gizmo). CameraView cv; cv.view = scene.scene_camera.get_view_matrix(); cv.projection = scene.scene_camera.get_projection_matrix(); @@ -75,7 +74,7 @@ namespace Donut gv.moving = moving; gv.time = time; - m_black_hole_renderer->render_geodesic(cmd, gv, scene.black_hole, m_cubemap.get()); + m_black_hole_renderer->render_geodesic(cmd, gv, scene.black_hole, scene.objects, m_cubemap.get()); cmd.begin_render_pass(nullptr, clear); m_black_hole_renderer->blit(cmd, fb_width, fb_height); m_device->imgui_render(cmd); diff --git a/src/rendering/rhi.h b/src/rendering/rhi.h index 2110926..e4e7e62 100644 --- a/src/rendering/rhi.h +++ b/src/rendering/rhi.h @@ -128,6 +128,10 @@ namespace Donut::RHI virtual auto resize(int width, int height) -> void = 0; virtual auto wait_idle() -> void = 0; + // Present pacing: GL toggles the swap interval; Vulkan swaps the present + // mode (FIFO vs IMMEDIATE) and rebuilds the swapchain. Default: no-op. + virtual auto set_vsync(bool enabled) -> void { (void)enabled; } + virtual auto create_buffer(BufferType type, size_t size, const void* data = nullptr) -> Ref<Buffer> = 0; virtual auto create_texture(int width, int height, Format format, Filter filter, const void* data = nullptr) -> Ref<Texture> = 0; virtual auto create_cubemap_from_hdri(const std::string& equirect_path) -> Ref<Texture> = 0; diff --git a/src/rendering/scene_renderer.cpp b/src/rendering/scene_renderer.cpp index 5ee7657..50b666d 100644 --- a/src/rendering/scene_renderer.cpp +++ b/src/rendering/scene_renderer.cpp @@ -42,6 +42,9 @@ namespace Donut struct SkyboxUBO { glm::mat4 projection; glm::mat4 view; }; static_assert(sizeof(SkyboxUBO) == 128, "SkyboxUBO std140 layout mismatch"); + + // The hole's event horizon (1 r_s) as a marker at the origin. + constexpr float BH_SCENE_RADIUS = SCENE_UNITS_PER_RS; } auto SceneRenderer::init(RHI::Device& device) -> bool @@ -137,6 +140,8 @@ namespace Donut m_skybox_pipeline = device.create_pipeline(d); } + m_bh_ubo = device.create_buffer(BufferType::Uniform, sizeof(SphereUBO)); + DONUT_INFO("SceneRenderer ready ({} grid verts, {} sphere indices, skybox)", m_grid_vertex_count, m_sphere_index_count); return true; @@ -196,6 +201,26 @@ namespace Donut cmd.bind_index_buffer(m_sphere_ib.get()); cmd.draw_indexed(m_sphere_index_count); } + + // Black-hole marker: a near-black sphere at the origin (radius = r_s in + // scene units), so the Scene shows where the hole the Simulation renders + // sits. 1 grid unit == 1 Schwarzschild radius. + SphereUBO bh{}; + bh.view_projection = vp; + bh.transform = glm::scale(glm::mat4(1.0f), glm::vec3(BH_SCENE_RADIUS)); + bh.color = glm::vec3(0.02f); // near-black event horizon + bh.specular = 0.10f; + bh.light_pos = glm::vec3(10.0f, 20.0f, 10.0f); + bh.camera_pos = cam.position; + bh.is_selected = 1; // reuse the rim as an + bh.outline_color = glm::vec3(0.98f, 0.62f, 0.20f); // amber accretion glow + bh.outline_width = 0.30f; + m_bh_ubo->update(&bh, sizeof(bh)); + cmd.bind_uniform(0, m_bh_ubo.get()); + cmd.bind_texture(1, cubemap); + cmd.bind_vertex_buffer(m_sphere_vb.get()); + cmd.bind_index_buffer(m_sphere_ib.get()); + cmd.draw_indexed(m_sphere_index_count); } // Transparent grid on top (tests depth, doesn't write). diff --git a/src/rendering/scene_renderer.h b/src/rendering/scene_renderer.h index c3ee02f..f7ff992 100644 --- a/src/rendering/scene_renderer.h +++ b/src/rendering/scene_renderer.h @@ -48,6 +48,7 @@ namespace Donut Ref<RHI::Buffer> m_sphere_ib; int m_sphere_index_count = 0; std::vector<Ref<RHI::Buffer>> m_sphere_ubos; // one per object slot + Ref<RHI::Buffer> m_bh_ubo; // the black-hole marker sphere at the origin Ref<RHI::Pipeline> m_skybox_pipeline; Ref<RHI::Buffer> m_skybox_vb; diff --git a/src/scene/scene.cpp b/src/scene/scene.cpp index db70a8d..b8ab005 100644 --- a/src/scene/scene.cpp +++ b/src/scene/scene.cpp @@ -23,8 +23,8 @@ namespace Donut // Scene camera: normal-scale orbital world-builder viewer. scene_camera.set_camera_mode(CameraMode::Orbital); scene_camera.set_orbital_target(glm::vec3(0.0f)); - scene_camera.set_orbital_radius(15.0); - scene_camera.set_orbital_limits(2.0, 200.0); + scene_camera.set_orbital_radius(30.0); // frame the hole + orbiting objects (in r_s) + scene_camera.set_orbital_limits(3.0, 400.0); scene_camera.set_orbital_speed(0.01f); scene_camera.set_zoom_speed(2.0); scene_camera.set_azimuth(0.0f); diff --git a/src/scene/scene.h b/src/scene/scene.h index 27d1327..23d884f 100644 --- a/src/scene/scene.h +++ b/src/scene/scene.h @@ -17,8 +17,9 @@ namespace Donut public: Scene(); - // world-builder content - std::vector<SceneObject> objects{ SceneObject{} }; + // world-builder content. The default object orbits clear of the hole at the + // origin (positions are in Schwarzschild radii; see BH_SCENE_RADIUS). + std::vector<SceneObject> objects{ SceneObject{ { 18.0f, 6.0f, 0.0f }, 2.0f, { 0.85f, 0.35f, 0.2f } } }; int selected_object = 0; // black hole / accretion disk diff --git a/src/scene/scene_types.h b/src/scene/scene_types.h index d34983f..61bea35 100644 --- a/src/scene/scene_types.h +++ b/src/scene/scene_types.h @@ -8,6 +8,12 @@ namespace Donut // portable renderers (SceneRenderer, BlackHoleRenderer). The rendering itself // is written once on the RHI (rendering/rhi.h); these are just the inputs. + // The scene editor and the simulation share ONE world at this scale: this many + // grid units == one Schwarzschild radius. The SceneRenderer draws the hole's + // horizon at this radius; the BlackHoleRenderer maps scene positions into + // physical units (x SagA_rs / SCENE_UNITS_PER_RS) so placed objects line up. + constexpr float SCENE_UNITS_PER_RS = 3.0f; + // A placeable/editable sphere in the world-builder scene view. The app owns // the list; SceneRenderer draws it the same way on every backend. struct SceneObject diff --git a/src/ui/ui_layer.cpp b/src/ui/ui_layer.cpp index 6748be2..4aa470b 100644 --- a/src/ui/ui_layer.cpp +++ b/src/ui/ui_layer.cpp @@ -6,7 +6,12 @@ #include "rendering/render_api.h" #include <imgui.h> +#include <ImGuizmo.h> #include <glm/glm.hpp> +#include <glm/gtc/matrix_transform.hpp> +#include <algorithm> +#include <cstdlib> +#include <cstdio> #include <string> namespace Donut @@ -52,7 +57,7 @@ namespace Donut namespace { - // Setup: renderer backend + environment + // Setup: display / renderer / overlay / environment settings class SetupWorkspace : public Workspace { public: @@ -60,9 +65,52 @@ namespace Donut 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)) + { + const bool fs = ctx.actions.is_fullscreen(); + int cw = 0, ch = 0; ctx.actions.get_window_size(cw, ch); + + // 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(); + + 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)) + { + 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 (ImGui::CollapsingHeader("Renderer", ImGuiTreeNodeFlags_DefaultOpen)) { - auto& s = SettingsManager::get_settings(); const char* apis[] = { "OpenGL", "Vulkan" }; int cur = (s.graphics.render_api == "Vulkan") ? 1 : 0; if (ImGui::Combo("Graphics API", &cur, apis, 2)) @@ -74,7 +122,15 @@ namespace Donut 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", running); + ImGui::TextDisabled("Active backend: %s (%s)", running, ctx.device_name.c_str()); + } + + 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(); } if (ImGui::CollapsingHeader("Environment", ImGuiTreeNodeFlags_DefaultOpen)) @@ -95,6 +151,8 @@ namespace Donut ImGui::TextDisabled("Switching rebuilds the cubemap (brief pause)."); } } + private: + float m_ui_scale = 1.0f; }; // Scene: place and edit objects @@ -141,8 +199,36 @@ namespace Donut 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)"); + + // 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. + ImGuizmo::SetOrthographic(false); + ImGuizmo::SetDrawlist(ImGui::GetBackgroundDrawList()); + ImGuiIO& io = ImGui::GetIO(); + ImGuizmo::SetRect(0.0f, 0.0f, io.DisplaySize.x, io.DisplaySize.y); + glm::mat4 view = sc.scene_camera.get_view_matrix(); + 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])) + { + 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); + } } } + private: + ImGuizmo::OPERATION m_op = ImGuizmo::TRANSLATE; }; // Simulation: camera + accretion disk + quality @@ -239,6 +325,8 @@ namespace Donut 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); @@ -248,8 +336,14 @@ namespace Donut ImGuiIO& io = ImGui::GetIO(); ImGui::TextDisabled("%s", ctx.device_name.empty() ? "GPU" : ctx.device_name.c_str()); - ImGui::Text("%.0f FPS", io.Framerate); - ImGui::SameLine(); ImGui::TextDisabled("%.1f ms/frame", io.Framerate > 0 ? 1000.0f / io.Framerate : 0.0f); + 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); + } ImGui::Spacing(); if (ImGui::BeginTabBar("workspaces")) diff --git a/src/ui/workspace.h b/src/ui/workspace.h index 134d517..804d5a8 100644 --- a/src/ui/workspace.h +++ b/src/ui/workspace.h @@ -7,12 +7,26 @@ namespace Donut { class Scene; + // App-level actions a workspace can request (window/device changes it can't do + // itself). The Application implements this — it owns the window and device. + struct AppActions + { + virtual ~AppActions() = default; + virtual auto set_resolution(int w, int h) -> void = 0; + virtual auto set_fullscreen(bool on) -> void = 0; + virtual auto set_vsync(bool on) -> void = 0; + 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; + }; + // What a workspace gets each frame to build its panels. `scene` is mutable — - // workspaces edit the document through it. + // workspaces edit the document through it; `actions` performs app-level changes. struct UIContext { Scene& scene; std::string device_name; + AppActions& actions; }; // A Dorico-style workspace: one tab that owns its side panels and declares |
