From 8a82df59ca3ce1a34eb76da04a93f00f35d79956 Mon Sep 17 00:00:00 2001 From: Caleb Burke Date: Sat, 26 Sep 2026 01:50:09 -0700 Subject: [PATCH] cool viewport --- editor/main.cpp | 15 +-- editor/panels/viewport_panel.cpp | 194 ++++++++++++++++++++++++++++--- editor/panels/viewport_panel.hpp | 41 ++++++- 3 files changed, 219 insertions(+), 31 deletions(-) diff --git a/editor/main.cpp b/editor/main.cpp index 81fb414..b95d944 100644 --- a/editor/main.cpp +++ b/editor/main.cpp @@ -21,16 +21,15 @@ class Editor : public sophia::Application { sophia::SceneHandle sceneHandle = scenes().create(); sophia::Scene& scene = scenes().get(sceneHandle); - sophia::Entity camera = createDemoScene(scene); + createDemoScene(scene); panels().emplace(scene, selected); panels().emplace(selected, textures); panels().emplace(); sophia::RenderTargetHandle sceneTarget = resources().create(); - views().create( - {.scene = sceneHandle, .target = sceneTarget, .camera = camera}); - panels().emplace(ui(), sceneTarget); + views().create({.scene = sceneHandle, .target = sceneTarget}); + panels().emplace(ui(), sceneTarget, scene, selected); panels().setDefaultLayout( {.left = {"Scene Hierarchy"}, @@ -42,10 +41,8 @@ class Editor : public sophia::Application { void onUpdate(const sophia::FrameInfo& frameInfo) override {} private: - sophia::Entity createDemoScene(sophia::Scene& scene) { - sophia::Entity camera = scene.createEntity("Main Camera"); - camera.addComponent(); - scene.setActiveCamera(camera); + void createDemoScene(sophia::Scene& scene) { + scene.createCamera("Main Camera"); sophia::Entity checker = scene.createEntity("Checker"); checker.getComponent().position = {-1.5f, 0.f, @@ -66,8 +63,6 @@ class Editor : public sophia::Application { sophia::SpriteComponent{.color = {0.25f, 0.45f, 0.95f, 0.6f}, .size = {2.f, 2.f}, .sortOrder = 1}); - - return camera; } sophia::TextureHandle createCheckerTexture() { diff --git a/editor/panels/viewport_panel.cpp b/editor/panels/viewport_panel.cpp index 74ec7b8..f8b793d 100644 --- a/editor/panels/viewport_panel.cpp +++ b/editor/panels/viewport_panel.cpp @@ -2,7 +2,86 @@ #include +#include +#include +#include +#include + +namespace { + +// Corners of the unit quad a SpriteComponent's model matrix maps to world. +constexpr std::array UNIT_QUAD{ + glm::vec2{0.f, 0.f}, glm::vec2{1.f, 0.f}, glm::vec2{1.f, 1.f}, + glm::vec2{0.f, 1.f}}; + +const ImU32 SELECTION_COLOR = IM_COL32(255, 170, 40, 255); + +} // namespace + +ImVec2 ViewportPanel::ViewportTransform::worldToScreen( + const glm::vec3& world) const { + glm::vec4 clip = viewProjection * glm::vec4{world, 1.f}; + glm::vec2 ndc = glm::vec2{clip} / clip.w; + // Vulkan NDC has +y pointing down, same as the screen. + return {origin.x + (ndc.x * 0.5f + 0.5f) * size.x, + origin.y + (ndc.y * 0.5f + 0.5f) * size.y}; +} + +glm::vec2 ViewportPanel::ViewportTransform::screenToWorld( + const ImVec2& screen) const { + glm::vec2 ndc{(screen.x - origin.x) / size.x * 2.f - 1.f, + (screen.y - origin.y) / size.y * 2.f - 1.f}; + glm::vec4 world = inverseViewProjection * glm::vec4{ndc, 0.f, 1.f}; + return glm::vec2{world} / world.w; +} + void ViewportPanel::onUpdate(const sophia::FrameInfo&) { + drawToolbar(); + ImGui::Separator(); + + const ImVec2 origin = ImGui::GetCursorScreenPos(); + const ImVec2 size = ImGui::GetContentRegionAvail(); + if (size.x < 1.0f || size.y < 1.0f) { + return; + } + + ui.image(target); + + // The image doesn't take mouse input, so without this, dragging in it + // would drag the window instead. + ImGui::SetCursorScreenPos(origin); + ImGui::InvisibleButton("##viewport", size); + + ImDrawList* drawList = ImGui::GetWindowDrawList(); + drawList->PushClipRect(origin, ImVec2(origin.x + size.x, origin.y + size.y), + true); + + if (showGrid) { + drawGrid(origin, size); + } + + sophia::Entity camera = scene.getActiveCamera(); + if (camera && camera.hasComponent()) { + // Use the panel's size rather than the target's: the target is resized + // a frame late, and the mouse is over the panel. + float aspect = size.x / size.y; + glm::mat4 viewProjection = + camera.getComponent().getViewProjection( + camera.getComponent(), aspect); + + ViewportTransform viewport{origin, size, viewProjection, + glm::inverse(viewProjection)}; + handleInput(viewport); + drawSelection(viewport); + } + + drawList->PopClipRect(); + + ImGui::SetCursorScreenPos(ImVec2(origin.x + 8.0f, origin.y + 8.0f)); + ImGui::TextDisabled("%.0f x %.0f", size.x, size.y); +} + +void ViewportPanel::drawToolbar() { const struct { const char* label; GizmoMode mode; @@ -17,30 +96,107 @@ void ViewportPanel::onUpdate(const sophia::FrameInfo&) { ImGui::SameLine(); } ImGui::Checkbox("Grid", &showGrid); +} - ImGui::Separator(); - - const ImVec2 origin = ImGui::GetCursorScreenPos(); - const ImVec2 size = ImGui::GetContentRegionAvail(); +void ViewportPanel::drawGrid(ImVec2 origin, ImVec2 size) { + constexpr float GRID_STEP = 40.0f; + const ImU32 gridColor = IM_COL32(255, 255, 255, 18); const ImVec2 end(origin.x + size.x, origin.y + size.y); - ui.image(target); - ImDrawList* drawList = ImGui::GetWindowDrawList(); + for (float x = GRID_STEP; x < size.x; x += GRID_STEP) { + drawList->AddLine(ImVec2(origin.x + x, origin.y), + ImVec2(origin.x + x, end.y), gridColor); + } + for (float y = GRID_STEP; y < size.y; y += GRID_STEP) { + drawList->AddLine(ImVec2(origin.x, origin.y + y), + ImVec2(end.x, origin.y + y), gridColor); + } +} - if (showGrid) { - constexpr float GRID_STEP = 40.0f; - const ImU32 gridColor = IM_COL32(255, 255, 255, 18); - for (float x = GRID_STEP; x < size.x; x += GRID_STEP) { - drawList->AddLine(ImVec2(origin.x + x, origin.y), - ImVec2(origin.x + x, end.y), gridColor); - } - for (float y = GRID_STEP; y < size.y; y += GRID_STEP) { - drawList->AddLine(ImVec2(origin.x, origin.y + y), - ImVec2(end.x, origin.y + y), gridColor); - } +void ViewportPanel::handleInput(const ViewportTransform& viewport) { + const ImGuiIO& io = ImGui::GetIO(); + + if (ImGui::IsItemActivated()) { + selected = pick(viewport.screenToWorld(io.MousePos)); + dragging = selected && gizmoMode == GizmoMode::Move; } - ImGui::SetCursorScreenPos(ImVec2(origin.x + 8.0f, origin.y + 8.0f)); - ImGui::TextDisabled("%.0f x %.0f", size.x, size.y); + if (!ImGui::IsItemActive()) { + dragging = false; + return; + } + + if (dragging && (io.MouseDelta.x != 0.f || io.MouseDelta.y != 0.f)) { + ImVec2 previous(io.MousePos.x - io.MouseDelta.x, + io.MousePos.y - io.MouseDelta.y); + glm::vec2 delta = + viewport.screenToWorld(io.MousePos) - viewport.screenToWorld(previous); + + auto& transform = selected.getComponent(); + transform.position.x += delta.x; + transform.position.y += delta.y; + } +} + +void ViewportPanel::drawSelection(const ViewportTransform& viewport) { + if (!selected) { + return; + } + + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const auto& transform = selected.getComponent(); + + if (selected.hasComponent()) { + glm::mat4 model = + selected.getComponent().getModel(transform); + + std::array corners; + for (size_t i = 0; i < UNIT_QUAD.size(); i++) { + corners[i] = viewport.worldToScreen( + glm::vec3{model * glm::vec4{UNIT_QUAD[i], 0.f, 1.f}}); + } + drawList->AddQuad(corners[0], corners[1], corners[2], corners[3], + SELECTION_COLOR, 2.0f); + } + + // Mark the transform's position (the sprite's pivot), which is also all + // there is to show for entities without a sprite, such as the camera. + ImVec2 center = viewport.worldToScreen(transform.position); + drawList->AddCircle(center, 4.0f, SELECTION_COLOR, 0, 2.0f); +} + +sophia::Entity ViewportPanel::pick(glm::vec2 world) { + sophia::Entity best; + std::optional> bestOrder; + + scene.forEachEntity([&](sophia::Entity entity) { + if (!entity.hasComponent()) { + return; + } + + const auto& transform = entity.getComponent(); + const auto& sprite = entity.getComponent(); + + glm::mat4 model = sprite.getModel(transform); + // Zero size or scale; nothing to hit. + if (glm::determinant(model) == 0.f) { + return; + } + + glm::vec4 local = + glm::inverse(model) * glm::vec4{world, transform.position.z, 1.f}; + if (local.x < 0.f || local.x > 1.f || local.y < 0.f || local.y > 1.f) { + return; + } + + // Same ordering the renderer draws in, so this is the one on top. + std::tuple order{sprite.sortOrder, transform.position.z}; + if (!bestOrder || order >= *bestOrder) { + best = entity; + bestOrder = order; + } + }); + + return best; } diff --git a/editor/panels/viewport_panel.hpp b/editor/panels/viewport_panel.hpp index d470529..408b539 100644 --- a/editor/panels/viewport_panel.hpp +++ b/editor/panels/viewport_panel.hpp @@ -1,27 +1,64 @@ #pragma once +#include #include #include +#include +#include #include #include +/** + * Shows the scene through its active camera. Click a sprite to select it, + * drag it to move it (in Move mode). + * + * Picking and moving are 2D: they assume an orthographic camera rotated only + * around Z, and work in the z = 0 plane. + */ class ViewportPanel : public sophia::Panel { public: - ViewportPanel(sophia::UI& ui, sophia::RenderTargetHandle target) + ViewportPanel(sophia::UI& ui, sophia::RenderTargetHandle target, + sophia::Scene& scene, sophia::Entity& selected) : Panel{{.name = "Viewport", .windowFlags = ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse}}, ui{ui}, - target{target} {} + target{target}, + scene{scene}, + selected{selected} {} void onUpdate(const sophia::FrameInfo& frameInfo) override; private: + // Where the scene image is on screen this frame, and the camera looking + // through it. + struct ViewportTransform { + ImVec2 origin; + ImVec2 size; + glm::mat4 viewProjection; + glm::mat4 inverseViewProjection; + + ImVec2 worldToScreen(const glm::vec3& world) const; + glm::vec2 screenToWorld(const ImVec2& screen) const; + }; + + void drawToolbar(); + void drawGrid(ImVec2 origin, ImVec2 size); + void handleInput(const ViewportTransform& viewport); + void drawSelection(const ViewportTransform& viewport); + + // The top-most sprite under `world`, or a null Entity. + sophia::Entity pick(glm::vec2 world); + sophia::UI& ui; sophia::RenderTargetHandle target; + sophia::Scene& scene; + sophia::Entity& selected; enum class GizmoMode { Move, Rotate, Scale }; GizmoMode gizmoMode = GizmoMode::Move; bool showGrid = true; + // Set while a drag that started on the selected entity is moving it. + bool dragging = false; };