From 6e48b0605cc0690fb0e42a2c393744df5f9fc4ff Mon Sep 17 00:00:00 2001 From: Caleb Burke Date: Fri, 2 Oct 2026 11:36:33 -0700 Subject: [PATCH] adding and integrating ImGuizmo --- .gitmodules | 3 + editor/CMakeLists.txt | 1 + editor/editor_camera.cpp | 99 ++++ editor/editor_camera.hpp | 49 ++ editor/editor_context.hpp | 89 +++ editor/main.cpp | 97 +++- editor/panels/game_panel.cpp | 20 + editor/panels/game_panel.hpp | 39 ++ editor/panels/inspector_panel.cpp | 37 +- editor/panels/inspector_panel.hpp | 11 +- editor/panels/scene_hierarchy_panel.cpp | 104 +++- editor/panels/scene_hierarchy_panel.hpp | 32 +- editor/panels/scene_panel.cpp | 528 ++++++++++++++++-- editor/panels/scene_panel.hpp | 100 +++- editor/utilities/logger.hpp | 8 + engine/include/sophia/aabb.hpp | 30 + .../include/sophia/resource_registry_ref.hpp | 5 + engine/include/sophia/scene.hpp | 8 + engine/include/sophia/scene_view.hpp | 4 +- engine/include/sophia/ui/panel.hpp | 2 + engine/include/sophia/util/logger.hpp | 21 + engine/src/application.cpp | 1 + engine/src/resources/mesh.cpp | 9 + engine/src/resources/mesh.hpp | 6 + .../src/resources/resource_registry_ref.cpp | 8 + engine/src/ui/panel.cpp | 5 +- third_party/CMakeLists.txt | 14 + third_party/ImGuizmo | 1 + 28 files changed, 1223 insertions(+), 108 deletions(-) create mode 100644 editor/editor_camera.cpp create mode 100644 editor/editor_camera.hpp create mode 100644 editor/editor_context.hpp create mode 100644 editor/panels/game_panel.cpp create mode 100644 editor/panels/game_panel.hpp create mode 100644 editor/utilities/logger.hpp create mode 100644 engine/include/sophia/aabb.hpp create mode 160000 third_party/ImGuizmo diff --git a/.gitmodules b/.gitmodules index 0109930..c4d9b71 100644 --- a/.gitmodules +++ b/.gitmodules @@ -11,3 +11,6 @@ [submodule "third_party/tracy"] path = third_party/tracy url = https://github.com/wolfpld/tracy.git +[submodule "third_party/ImGuizmo"] + path = third_party/ImGuizmo + url = https://github.com/CedricGuillemet/ImGuizmo.git diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 151e498..cf177ec 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(${NAME}_deps INTERFACE) target_link_libraries(${NAME}_deps INTERFACE ${ENGINE_NAME} + imguizmo ) target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_17) diff --git a/editor/editor_camera.cpp b/editor/editor_camera.cpp new file mode 100644 index 0000000..6f4afd7 --- /dev/null +++ b/editor/editor_camera.cpp @@ -0,0 +1,99 @@ +#include "editor_camera.hpp" + +#include +#include + +namespace { + +constexpr float ORBIT_RADIANS_PER_PIXEL = 0.005f; +const float MAX_PITCH = glm::radians(89.f); +// Each wheel step moves this fraction of the way towards the pivot. +constexpr float ZOOM_STEP = 0.1f; +constexpr float MIN_DISTANCE = 0.05f; +constexpr float MIN_ORTHO_HEIGHT = 0.01f; +// Fly speed per unit of distance from the pivot, so it suits any scale. +constexpr float FLY_SPEED = 1.5f; + +} // namespace + +void EditorCamera::orbit(glm::vec2 pixels) { + if (mode != Mode::Scene3D) { + return; + } + yaw -= pixels.x * ORBIT_RADIANS_PER_PIXEL; + pitch = std::clamp(pitch - pixels.y * ORBIT_RADIANS_PER_PIXEL, -MAX_PITCH, + MAX_PITCH); +} + +void EditorCamera::pan(glm::vec2 pixels, float viewportHeight) { + if (viewportHeight <= 0.f) { + return; + } + + // World units one pixel covers at the pivot's depth. + float unitsPerPixel = + mode == Mode::Scene3D + ? 2.f * distance * std::tan(fov / 2.f) / viewportHeight + : orthoHeight / viewportHeight; + + // Screen y runs down, world y up. Dragging moves the scene with the mouse, + // so the camera moves the other way. + glm::vec3 offset{-pixels.x * unitsPerPixel, pixels.y * unitsPerPixel, 0.f}; + pivot += mode == Mode::Scene3D ? getRotation() * offset : offset; +} + +void EditorCamera::zoom(float steps) { + float scale = std::pow(1.f - ZOOM_STEP, steps); + if (mode == Mode::Scene3D) { + distance = std::max(distance * scale, MIN_DISTANCE); + } else { + orthoHeight = std::max(orthoHeight * scale, MIN_ORTHO_HEIGHT); + } +} + +void EditorCamera::fly(glm::vec3 direction, float deltaTime) { + if (mode != Mode::Scene3D || direction == glm::vec3{0.f}) { + return; + } + float speed = FLY_SPEED * std::max(distance, 1.f); + pivot += getRotation() * glm::normalize(direction) * speed * deltaTime; +} + +void EditorCamera::frame(glm::vec3 target, float radius) { + pivot = target; + radius = std::max(radius, 0.1f); + // Far enough that the sphere fits in the vertical field of view. + distance = radius / std::sin(fov / 2.f); + orthoHeight = radius * 2.5f; +} + +sophia::TransformComponent EditorCamera::getTransform() const { + sophia::TransformComponent transform; + if (mode == Mode::Scene3D) { + transform.rotation = getRotation(); + transform.position = + pivot + transform.rotation * glm::vec3{0.f, 0.f, distance}; + } else { + transform.position = pivot; + } + return transform; +} + +sophia::CameraComponent EditorCamera::getCamera() const { + sophia::CameraComponent camera; + if (mode == Mode::Scene3D) { + camera.projection = sophia::CameraComponent::Projection::Perspective; + camera.perspective.fov = fov; + // Room to zoom far out without the scene clipping. + camera.perspective.farPlane = 1000.f; + } else { + camera.projection = sophia::CameraComponent::Projection::Orthographic; + camera.ortho.height = orthoHeight; + } + return camera; +} + +glm::quat EditorCamera::getRotation() const { + return glm::angleAxis(yaw, glm::vec3{0.f, 1.f, 0.f}) * + glm::angleAxis(pitch, glm::vec3{1.f, 0.f, 0.f}); +} diff --git a/editor/editor_camera.hpp b/editor/editor_camera.hpp new file mode 100644 index 0000000..52ca28b --- /dev/null +++ b/editor/editor_camera.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +/** + * The Scene panel's camera. Not part of any scene the editor edits, and never + * saved with one. + * + * In 3D it orbits a pivot point at some distance; in 2D it looks straight + * down -Z at the pivot through an orthographic projection, like a 2D game + * camera. + */ +class EditorCamera { + public: + enum class Mode { Scene3D, Scene2D }; + + Mode mode = Mode::Scene3D; + + // Turns around the pivot by a mouse movement in pixels. 3D only. + void orbit(glm::vec2 pixels); + // Moves the pivot so the scene follows a mouse movement in pixels across a + // viewport `viewportHeight` pixels tall. + void pan(glm::vec2 pixels, float viewportHeight); + // Moves in (positive) or out by mouse wheel steps. + void zoom(float steps); + // Moves the pivot along the camera's axes: x right, y up, z backwards. + // `direction` needn't be normalized. 3D only. + void fly(glm::vec3 direction, float deltaTime); + // Centers on `target`, backing off enough to see a sphere of `radius`. + void frame(glm::vec3 target, float radius); + + sophia::TransformComponent getTransform() const; + sophia::CameraComponent getCamera() const; + + private: + glm::quat getRotation() const; + + glm::vec3 pivot{0.f, 0.f, 0.f}; + + // 3D: looking at the pivot from in front, a little to the right and above. + float yaw = glm::radians(30.f); + float pitch = glm::radians(-25.f); + float distance = 8.f; + float fov = glm::radians(60.f); + + // 2D: world units visible from the bottom of the view to the top. + float orthoHeight = 10.f; +}; diff --git a/editor/editor_context.hpp b/editor/editor_context.hpp new file mode 100644 index 0000000..92a5f13 --- /dev/null +++ b/editor/editor_context.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +/** + * What the editor is working on, shared by its panels. Panels read it every + * frame instead of holding on to a scene, so switching scenes is one call. + */ +class EditorContext { + public: + // A scene the editor has open, and can switch to. + struct OpenScene { + sophia::SceneHandle handle; + std::string name; + }; + + explicit EditorContext(sophia::SceneRegistry& scenes) : scenes{scenes} {} + + // Creates an open scene with a "Main Camera", without switching to it. + sophia::SceneHandle createScene(std::string name) { + sophia::SceneHandle handle = scenes.create(); + scenes.get(handle).createCamera("Main Camera"); + openScenes.push_back({handle, std::move(name)}); + return handle; + } + + const std::vector& getOpenScenes() const { return openScenes; } + + // Null if `handle` isn't an open scene. + const std::string* getSceneName(sophia::SceneHandle handle) const { + for (const OpenScene& open : openScenes) { + if (open.handle == handle) { + return &open.name; + } + } + return nullptr; + } + + void renameScene(sophia::SceneHandle handle, std::string name) { + for (OpenScene& open : openScenes) { + if (open.handle == handle) { + open.name = std::move(name); + } + } + } + + bool hasScene() const { return scenes.isAlive(activeScene); } + sophia::SceneHandle getSceneHandle() const { return activeScene; } + sophia::Scene& getScene() { return scenes.get(activeScene); } + + // Also clears the selection, which belonged to the previous scene. + void setScene(sophia::SceneHandle handle) { + activeScene = handle; + selected = {}; + } + + // Null when nothing is selected. Always an entity of the active scene. + sophia::Entity selected; + + private: + sophia::SceneRegistry& scenes; + sophia::SceneHandle activeScene = sophia::INVALID_HANDLE; + // Kept open even when not active: with no saving yet, closing one would + // lose it. + std::vector openScenes; +}; + +// Points `view` at the context's active scene, and renders it only while +// there is one and its panel is `shown`. Call every frame from the panel that +// owns the view. +inline void syncSceneView(EditorContext& context, + sophia::SceneViewRegistry& sceneViews, + sophia::SceneViewHandle view, bool shown) { + bool enabled = shown && context.hasScene(); + const sophia::SceneView& current = sceneViews.peek(view); + // get() re-sorts the render queue, so only use it when something changed. + if (current.enabled != enabled || + (context.hasScene() && current.scene != context.getSceneHandle())) { + sophia::SceneView& updated = sceneViews.get(view); + updated.enabled = enabled; + updated.scene = context.getSceneHandle(); + } +} diff --git a/editor/main.cpp b/editor/main.cpp index 4778bd5..e9d6550 100644 --- a/editor/main.cpp +++ b/editor/main.cpp @@ -1,49 +1,86 @@ -#include #include #include #include +#include +#include +#include "editor_context.hpp" #include "panels/asset_browser_panel.hpp" +#include "panels/game_panel.hpp" #include "panels/inspector_panel.hpp" #include "panels/scene_hierarchy_panel.hpp" #include "panels/scene_panel.hpp" +#include "utilities/logger.hpp" class Editor : public sophia::Application { public: Editor() : Application{{.width = 750, .height = 1000, .name = "Editor"}} {} void onCreate() override { - std::cout << "Editor created" << std::endl; + log("Editor created"); debugUI().show(); textures.emplace_back("Checker", createCheckerTexture()); + cubeMesh = createShadedCube(); - sophia::SceneHandle sceneHandle = scenes().create(); - sophia::Scene& scene = scenes().get(sceneHandle); - createDemoScene(scene); + sophia::SceneHandle sceneHandle = context.createScene("Demo"); + createDemoScene(scenes().get(sceneHandle)); + context.setScene(sceneHandle); - panels().emplace(scene, selected); - panels().emplace(selected, textures); - panels().emplace(); + // The editor's own scene, never edited or rendered. It holds the camera + // the Scene panel looks through, which a SceneView may take from any + // scene. + sophia::SceneHandle editorSceneHandle = scenes().create(); + sophia::Entity editorCamera = + scenes().get(editorSceneHandle).createCamera("Editor Camera"); + + // Views start without a scene; their panels point them at the active + // scene each frame. sophia::RenderTargetHandle sceneTarget = resources().create(); - sceneViews().create({.scene = sceneHandle, .target = sceneTarget}); - panels().emplace(ui(), sceneTarget, scene, selected); + sophia::SceneViewHandle sceneView = sceneViews().create( + {.target = sceneTarget, .camera = editorCamera, .enabled = false}); + + // No camera: the Game panel shows the scene through its active one. + sophia::RenderTargetHandle gameTarget = + resources().create(); + sophia::SceneViewHandle gameView = + sceneViews().create({.target = gameTarget, .enabled = false}); + + panels().emplace(context, cubeMesh); + panels().emplace(context, textures); + panels().emplace(); + viewPanels.push_back( + {panels().emplace(ui(), resources(), context, sceneViews(), + sceneView, sceneTarget, editorCamera), + sceneView}); + viewPanels.push_back( + {panels().emplace(ui(), context, sceneViews(), gameView, + gameTarget), + gameView}); panels().setDefaultLayout( {.left = {"Scene Hierarchy"}, .right = {"Inspector", "Frame Stats", "PanelRegistry"}, .bottom = {"Asset Browser", "Console"}, - .center = {"Scene"}}); + .center = {"Scene", "Game"}}); } - void onUpdate(const sophia::FrameInfo& frameInfo) override {} + void onUpdate(const sophia::FrameInfo& frameInfo) override { + // A closed panel's onUpdate() doesn't run, so it can't stop its view + // rendering itself. + for (const ViewPanel& viewPanel : viewPanels) { + if (!panels().get(viewPanel.panel).isVisible() && + sceneViews().peek(viewPanel.view).enabled) { + sceneViews().get(viewPanel.view).enabled = false; + } + } + } private: + // Adds to a new scene, which already has a camera. void createDemoScene(sophia::Scene& scene) { - scene.createCamera("Main Camera"); - sophia::Entity checker = scene.createEntity("Checker"); checker.getComponent().position = {-1.5f, 0.f, 0.f}; @@ -63,6 +100,27 @@ class Editor : public sophia::Application { sophia::SpriteComponent{.color = {0.25f, 0.45f, 0.95f, 0.6f}, .size = {2.f, 2.f}, .sortOrder = 1}); + + sophia::Entity cube = scene.createEntity("Cube"); + auto& cubeTransform = cube.getComponent(); + cubeTransform.position = {0.f, 2.5f, 0.f}; + cubeTransform.rotation = + glm::quat{glm::radians(glm::vec3{30.f, 45.f, 0.f})}; + cube.addComponent(sophia::MeshComponent{ + .handle = cubeMesh, .color = {0.4f, 0.8f, 0.5f, 1.f}}); + } + + // Meshes are unlit, so a plain cube is a flat silhouette. Bake a fixed + // light into its vertex colors so its faces read in 3D until there's real + // lighting; MeshComponent::color still tints it. + sophia::MeshHandle createShadedCube() { + const glm::vec3 lightDirection = glm::normalize(glm::vec3{0.4f, 1.f, 0.7f}); + sophia::MeshData data = sophia::primitives::cube(); + for (sophia::MeshVertex& vertex : data.vertices) { + float light = glm::dot(vertex.normal, lightDirection) * 0.5f + 0.5f; + vertex.color = glm::vec4{glm::vec3{0.35f + 0.65f * light}, 1.f}; + } + return resources().create(data); } sophia::TextureHandle createCheckerTexture() { @@ -79,8 +137,17 @@ class Editor : public sophia::Application { return resources().create(SIZE, SIZE, std::move(pixels)); } - sophia::Entity selected; + // A panel showing a scene view, which should only render while the panel + // is on screen. + struct ViewPanel { + sophia::PanelHandle panel; + sophia::SceneViewHandle view; + }; + + EditorContext context{scenes()}; + std::vector viewPanels; InspectorPanel::TextureList textures; + sophia::MeshHandle cubeMesh; }; SOPHIA_MAIN(Editor) diff --git a/editor/panels/game_panel.cpp b/editor/panels/game_panel.cpp new file mode 100644 index 0000000..4b193cc --- /dev/null +++ b/editor/panels/game_panel.cpp @@ -0,0 +1,20 @@ +#include "game_panel.hpp" + +#include + +void GamePanel::onUpdate(const sophia::FrameInfo&) { + syncSceneView(context, sceneViews, view, isShown()); + + if (!context.hasScene()) { + ImGui::TextDisabled("No scene open"); + return; + } + + const ImVec2 origin = ImGui::GetCursorScreenPos(); + ui.image(target); + + if (!context.getScene().getActiveCamera()) { + ImGui::SetCursorScreenPos(ImVec2(origin.x + 8.0f, origin.y + 8.0f)); + ImGui::TextDisabled("The scene has no active camera"); + } +} diff --git a/editor/panels/game_panel.hpp b/editor/panels/game_panel.hpp new file mode 100644 index 0000000..687d8b6 --- /dev/null +++ b/editor/panels/game_panel.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../editor_context.hpp" + +/** + * Shows the active scene through its own active camera: what the game will + * see. + */ +class GamePanel : public sophia::Panel { + public: + // Renders into `target` through `view`, a SceneView with no camera of its + // own that this panel keeps pointed at the active scene. + GamePanel(sophia::UI& ui, EditorContext& context, + sophia::SceneViewRegistry& sceneViews, sophia::SceneViewHandle view, + sophia::RenderTargetHandle target) + : Panel{{.name = "Game", + .windowFlags = ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse}}, + ui{ui}, + context{context}, + sceneViews{sceneViews}, + view{view}, + target{target} {} + + void onUpdate(const sophia::FrameInfo& frameInfo) override; + + private: + sophia::UI& ui; + EditorContext& context; + sophia::SceneViewRegistry& sceneViews; + sophia::SceneViewHandle view; + sophia::RenderTargetHandle target; +}; diff --git a/editor/panels/inspector_panel.cpp b/editor/panels/inspector_panel.cpp index 4592a5c..09eb269 100644 --- a/editor/panels/inspector_panel.cpp +++ b/editor/panels/inspector_panel.cpp @@ -6,6 +6,7 @@ #include void InspectorPanel::onUpdate(const sophia::FrameInfo&) { + selected = context.selected; if (!selected) { ImGui::TextDisabled("No entity selected"); return; @@ -16,6 +17,7 @@ void InspectorPanel::onUpdate(const sophia::FrameInfo&) { drawTransform(); drawCamera(); drawSprite(); + drawMesh(); ImGui::Separator(); drawAddComponent(); @@ -40,11 +42,13 @@ void InspectorPanel::drawTransform() { auto& transform = selected.getComponent(); ImGui::DragFloat3("Position", &transform.position.x, 0.1f); - // 2D only: edits rotation around Z and drops any X/Y rotation. - float degrees = glm::degrees(glm::eulerAngles(transform.rotation).z); - if (ImGui::DragFloat("Rotation", °rees, 1.0f)) { - transform.rotation = - glm::angleAxis(glm::radians(degrees), glm::vec3{0.f, 0.f, 1.f}); + // Euler angles in degrees, recomputed from the quaternion every frame. + // Near 90 degrees of pitch the angles can jump between equivalent values + // while dragging. + // TODO: keep each entity's edited angles, or replace with gizmo rotation. + glm::vec3 degrees = glm::degrees(glm::eulerAngles(transform.rotation)); + if (ImGui::DragFloat3("Rotation", °rees.x, 1.0f)) { + transform.rotation = glm::quat{glm::radians(degrees)}; } ImGui::DragFloat3("Scale", &transform.scale.x, 0.1f); @@ -118,6 +122,29 @@ void InspectorPanel::drawSprite() { } } +void InspectorPanel::drawMesh() { + if (!selected.hasComponent()) { + return; + } + + bool open = true; + if (!ImGui::CollapsingHeader("Mesh", &open, ImGuiTreeNodeFlags_DefaultOpen)) { + if (!open) { + selected.removeComponent(); + } + return; + } + + auto& mesh = selected.getComponent(); + // TODO: pick the mesh once assets have names. + ImGui::Text("Mesh: %u", static_cast(mesh.handle.index)); + ImGui::ColorEdit3("Color", &mesh.color.x); + + if (!open) { + selected.removeComponent(); + } +} + void InspectorPanel::drawAddComponent() { if (ImGui::Button("Add Component")) { ImGui::OpenPopup("AddComponent"); diff --git a/editor/panels/inspector_panel.hpp b/editor/panels/inspector_panel.hpp index 429d302..3816ed4 100644 --- a/editor/panels/inspector_panel.hpp +++ b/editor/panels/inspector_panel.hpp @@ -8,14 +8,16 @@ #include #include +#include "../editor_context.hpp" + class InspectorPanel : public sophia::Panel { public: // Stand-in for an asset browser: the textures a sprite can pick from. using TextureList = std::vector>; - InspectorPanel(sophia::Entity& selected, const TextureList& textures) - : Panel{{.name = "Inspector"}}, selected{selected}, textures{textures} {} + InspectorPanel(EditorContext& context, const TextureList& textures) + : Panel{{.name = "Inspector"}}, context{context}, textures{textures} {} void onUpdate(const sophia::FrameInfo& frameInfo) override; @@ -24,8 +26,11 @@ class InspectorPanel : public sophia::Panel { void drawTransform(); void drawCamera(); void drawSprite(); + void drawMesh(); void drawAddComponent(); - sophia::Entity& selected; + EditorContext& context; + // The selected entity, for the duration of onUpdate(). + sophia::Entity selected; const TextureList& textures; }; diff --git a/editor/panels/scene_hierarchy_panel.cpp b/editor/panels/scene_hierarchy_panel.cpp index fc22caa..ebc41f5 100644 --- a/editor/panels/scene_hierarchy_panel.cpp +++ b/editor/panels/scene_hierarchy_panel.cpp @@ -2,18 +2,37 @@ #include +#include #include +#include void SceneHierarchyPanel::onUpdate(const sophia::FrameInfo&) { + drawSceneSelector(); + ImGui::Separator(); + + if (!context.hasScene()) { + ImGui::TextDisabled("No scene open"); + return; + } + + sophia::Scene& scene = context.getScene(); + sophia::Entity& selected = context.selected; + if (ImGui::Button("+ Sprite")) { selected = scene.createEntity("Sprite"); selected.addComponent(); } + ImGui::SameLine(); + if (ImGui::Button("+ Cube")) { + selected = scene.createEntity("Cube"); + selected.addComponent( + sophia::MeshComponent{.handle = cubeMesh}); + } ImGui::Separator(); // Flat for now: there is no parent/child component yet. - scene.forEachEntity([this](sophia::Entity entity) { + scene.forEachEntity([&selected](sophia::Entity entity) { ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen | ImGuiTreeNodeFlags_SpanAvailWidth; @@ -30,3 +49,86 @@ void SceneHierarchyPanel::onUpdate(const sophia::FrameInfo&) { ImGui::PopID(); }); } + +namespace { +// See SceneHierarchyPanel::renameFocusFrames. +constexpr int RENAME_FOCUS_FRAMES = 2; +} // namespace + +void SceneHierarchyPanel::drawSceneSelector() { + // Leave room for the button: its label, its padding, and the gap to it. + constexpr const char* ADD_SCENE_LABEL = "Add Scene"; + const ImGuiStyle& style = ImGui::GetStyle(); + ImGui::SetNextItemWidth(-(ImGui::CalcTextSize(ADD_SCENE_LABEL).x + + style.FramePadding.x * 2.f + style.ItemSpacing.x)); + + const std::string* current = context.getSceneName(context.getSceneHandle()); + bool startRename = false; + + if (renamingScene && current) { + drawSceneRename(); + } else { + renamingScene = false; + if (ImGui::BeginCombo("##scene", current ? current->c_str() : "No scene")) { + for (const EditorContext::OpenScene& open : context.getOpenScenes()) { + ImGui::PushID(static_cast(open.handle.index)); + bool isCurrent = open.handle == context.getSceneHandle(); + if (ImGui::Selectable(open.name.c_str(), isCurrent) && !isCurrent) { + context.setScene(open.handle); + } + ImGui::PopID(); + } + ImGui::EndCombo(); + } + + if (current && ImGui::BeginPopupContextItem("##sceneMenu")) { + startRename = ImGui::MenuItem("Rename", "F2"); + ImGui::EndPopup(); + } + startRename = startRename || (current && ImGui::IsWindowFocused() && + !ImGui::GetIO().WantTextInput && + ImGui::IsKeyPressed(ImGuiKey_F2)); + } + + ImGui::SameLine(); + if (ImGui::Button(ADD_SCENE_LABEL)) { + std::string name = "Untitled " + std::to_string(++this->untitledSceneCount); + context.setScene(context.createScene(std::move(name))); + startRename = false; + } + + if (startRename) { + renamingScene = true; + renameFocusFrames = RENAME_FOCUS_FRAMES; + std::snprintf(renameBuffer.data(), renameBuffer.size(), "%s", + current->c_str()); + } +} + +void SceneHierarchyPanel::drawSceneRename() { + if (renameFocusFrames == RENAME_FOCUS_FRAMES) { + ImGui::SetKeyboardFocusHere(); + } + + ImGui::InputText("##sceneName", renameBuffer.data(), renameBuffer.size(), + ImGuiInputTextFlags_AutoSelectAll); + + bool active = ImGui::IsItemActive(); + if (active) { + renameFocusFrames = 0; + } else if (renameFocusFrames > 0) { + renameFocusFrames--; + return; + } + + // Escape reverts the text before deactivating, so committing whatever is + // left when the field closes covers Enter, clicking away and Escape alike. + if (!active) { + std::string name = renameBuffer.data(); + // Keep the old name rather than leave the scene nameless. + if (name.find_first_not_of(' ') != std::string::npos) { + context.renameScene(context.getSceneHandle(), std::move(name)); + } + renamingScene = false; + } +} diff --git a/editor/panels/scene_hierarchy_panel.hpp b/editor/panels/scene_hierarchy_panel.hpp index c732068..8cf14b3 100644 --- a/editor/panels/scene_hierarchy_panel.hpp +++ b/editor/panels/scene_hierarchy_panel.hpp @@ -1,20 +1,38 @@ #pragma once -#include +#include #include -#include +#include #include +#include "../editor_context.hpp" + class SceneHierarchyPanel : public sophia::Panel { public: - SceneHierarchyPanel(sophia::Scene& scene, sophia::Entity& selected) + // `cubeMesh` is what "+ Cube" gives new entities. + SceneHierarchyPanel(EditorContext& context, sophia::MeshHandle cubeMesh) : Panel{{.name = "Scene Hierarchy", .locked = true}}, - scene{scene}, - selected{selected} {} + context{context}, + cubeMesh{cubeMesh} {} void onUpdate(const sophia::FrameInfo& frameInfo) override; private: - sophia::Scene& scene; - sophia::Entity& selected; + // Switches between open scenes, renames the active one, and creates new + // ones. + void drawSceneSelector(); + // Replaces the selector with a text field editing the active scene's name. + void drawSceneRename(); + + EditorContext& context; + sophia::MeshHandle cubeMesh; + // For naming new scenes "Untitled 1", "Untitled 2", ... + int untitledSceneCount = 0; + + // Set while the scene selector is a text field for renaming. + bool renamingScene = false; + // Frames the text field has left to become active after renaming starts; + // focus can take a frame to land. If it never does, renaming ends. + int renameFocusFrames = 0; + std::array renameBuffer{}; }; diff --git a/editor/panels/scene_panel.cpp b/editor/panels/scene_panel.cpp index 3ff1c3a..698d113 100644 --- a/editor/panels/scene_panel.cpp +++ b/editor/panels/scene_panel.cpp @@ -1,11 +1,20 @@ #include "scene_panel.hpp" #include +// After imgui.h, which it relies on. +#include +#include #include +#include +#include +#include #include +#include #include #include +#include +#include namespace { @@ -16,17 +25,125 @@ constexpr std::array UNIT_QUAD{ const ImU32 SELECTION_COLOR = IM_COL32(255, 170, 40, 255); +// Holding Ctrl snaps the gizmo to these steps. +constexpr float TRANSLATE_SNAP = 0.5f; +constexpr float ROTATE_SNAP_DEGREES = 15.f; +constexpr float SCALE_SNAP = 0.1f; + +// Writes a matrix built from a translation, rotation and scale (no shear) +// back into `transform`. A mirroring matrix comes back with a negative x +// scale. +void decompose(const glm::mat4& matrix, sophia::TransformComponent& transform) { + transform.position = glm::vec3{matrix[3]}; + + glm::vec3 scale{glm::length(glm::vec3{matrix[0]}), + glm::length(glm::vec3{matrix[1]}), + glm::length(glm::vec3{matrix[2]})}; + if (glm::determinant(glm::mat3{matrix}) < 0.f) { + scale.x = -scale.x; + } + transform.scale = scale; + + // A zero scale loses the axis, and with it the rotation; keep the old one. + if (scale.x == 0.f || scale.y == 0.f || scale.z == 0.f) { + return; + } + glm::mat3 rotation{glm::vec3{matrix[0]} / scale.x, + glm::vec3{matrix[1]} / scale.y, + glm::vec3{matrix[2]} / scale.z}; + transform.rotation = glm::normalize(glm::quat_cast(rotation)); +} + +// How far along a ray (from `origin`, along the normalized `direction`) it +// enters `bounds` placed by `model`, or null if it misses. A ray starting +// inside hits at 0. +std::optional intersectBox(const glm::vec3& origin, + const glm::vec3& direction, + const glm::mat4& model, + const sophia::AABB& bounds) { + if (glm::determinant(model) == 0.f) { + return std::nullopt; + } + + // Test in the box's space, where it's axis-aligned. The direction isn't + // renormalized, so distances along it are still world distances. + glm::mat4 inverse = glm::inverse(model); + glm::vec3 localOrigin = glm::vec3{inverse * glm::vec4{origin, 1.f}}; + glm::vec3 localDirection = glm::vec3{inverse * glm::vec4{direction, 0.f}}; + + float enter = 0.f; + float exit = std::numeric_limits::infinity(); + for (int axis = 0; axis < 3; axis++) { + if (std::abs(localDirection[axis]) < 1e-8f) { + // Parallel to this pair of faces: hits only if already between them. + if (localOrigin[axis] < bounds.min[axis] || + localOrigin[axis] > bounds.max[axis]) { + return std::nullopt; + } + continue; + } + + float toMin = (bounds.min[axis] - localOrigin[axis]) / localDirection[axis]; + float toMax = (bounds.max[axis] - localOrigin[axis]) / localDirection[axis]; + if (toMin > toMax) { + std::swap(toMin, toMax); + } + enter = std::max(enter, toMin); + exit = std::min(exit, toMax); + if (enter > exit) { + return std::nullopt; + } + } + return enter; +} + +// How far along a ray it hits the unit quad (0, 0)-(1, 1) in the XY plane, +// placed by `model`, or null if it misses. +std::optional intersectQuad(const glm::vec3& origin, + const glm::vec3& direction, + const glm::mat4& model) { + if (glm::determinant(model) == 0.f) { + return std::nullopt; + } + + glm::mat4 inverse = glm::inverse(model); + glm::vec3 localOrigin = glm::vec3{inverse * glm::vec4{origin, 1.f}}; + glm::vec3 localDirection = glm::vec3{inverse * glm::vec4{direction, 0.f}}; + // Edge on to the quad. + if (std::abs(localDirection.z) < 1e-8f) { + return std::nullopt; + } + + float distance = -localOrigin.z / localDirection.z; + if (distance < 0.f) { + return std::nullopt; + } + glm::vec3 hit = localOrigin + distance * localDirection; + if (hit.x < 0.f || hit.x > 1.f || hit.y < 0.f || hit.y > 1.f) { + return std::nullopt; + } + return distance; +} + } // namespace -ImVec2 ScenePanel::ViewportTransform::worldToScreen( - const glm::vec3& world) const { - glm::vec4 clip = viewProjection * glm::vec4{world, 1.f}; +ImVec2 ScenePanel::ViewportTransform::clipToScreen( + const glm::vec4& clip) const { 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}; } +std::optional ScenePanel::ViewportTransform::worldToScreen( + const glm::vec3& world) const { + glm::vec4 clip = viewProjection * glm::vec4{world, 1.f}; + if (clip.w <= 0.f) { + return std::nullopt; + } + return clipToScreen(clip); +} + glm::vec2 ScenePanel::ViewportTransform::screenToWorld( const ImVec2& screen) const { glm::vec2 ndc{(screen.x - origin.x) / size.x * 2.f - 1.f, @@ -35,7 +152,42 @@ glm::vec2 ScenePanel::ViewportTransform::screenToWorld( return glm::vec2{world} / world.w; } -void ScenePanel::onUpdate(const sophia::FrameInfo&) { +ScenePanel::Ray ScenePanel::ViewportTransform::screenToRay( + 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}; + // Depth runs 0 at the near plane to 1 at the far one. + glm::vec4 nearPoint = inverseViewProjection * glm::vec4{ndc, 0.f, 1.f}; + glm::vec4 farPoint = inverseViewProjection * glm::vec4{ndc, 1.f, 1.f}; + glm::vec3 from = glm::vec3{nearPoint} / nearPoint.w; + glm::vec3 to = glm::vec3{farPoint} / farPoint.w; + return {from, glm::normalize(to - from)}; +} + +void ScenePanel::ViewportTransform::drawLine(ImDrawList* drawList, + const glm::vec3& a, + const glm::vec3& b, ImU32 color, + float thickness) const { + // Just in front of the camera. Points behind it project mirrored, so the + // part of the line there is cut off rather than drawn. + constexpr float MIN_W = 1e-4f; + + glm::vec4 clipA = viewProjection * glm::vec4{a, 1.f}; + glm::vec4 clipB = viewProjection * glm::vec4{b, 1.f}; + if (clipA.w < MIN_W && clipB.w < MIN_W) { + return; + } + if (clipA.w < MIN_W) { + clipA = glm::mix(clipA, clipB, (MIN_W - clipA.w) / (clipB.w - clipA.w)); + } else if (clipB.w < MIN_W) { + clipB = glm::mix(clipB, clipA, (MIN_W - clipB.w) / (clipA.w - clipB.w)); + } + drawList->AddLine(clipToScreen(clipA), clipToScreen(clipB), color, thickness); +} + +void ScenePanel::onUpdate(const sophia::FrameInfo& frameInfo) { + syncSceneView(context, sceneViews, view, isShown()); + drawToolbar(); ImGui::Separator(); @@ -45,35 +197,75 @@ void ScenePanel::onUpdate(const sophia::FrameInfo&) { return; } + if (!context.hasScene()) { + ImGui::TextDisabled("No scene open"); + return; + } + ui.image(target); + // Rolls over ImGuizmo's "mouse is over a handle" state, so IsOver() below + // reports last frame's. + ImGuizmo::BeginFrame(); + // A handle can't be grabbed while any ImGui item is hovered, so while the + // mouse is over one, leave out the viewport button, and keep the window + // still so dragging the handle doesn't drag it. Unless a camera drag is + // already under way: dropping the button would end it. + bool overGizmo = + (ImGuizmo::IsOver() || ImGuizmo::IsUsing()) && !viewportActive; + getConfig().windowFlags = + overGizmo ? WINDOW_FLAGS | ImGuiWindowFlags_NoMove : WINDOW_FLAGS; + // 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); + if (overGizmo) { + ImGui::Dummy(size); + } else { + ImGui::InvisibleButton("##viewport", size, + ImGuiButtonFlags_MouseButtonLeft | + ImGuiButtonFlags_MouseButtonRight | + ImGuiButtonFlags_MouseButtonMiddle); + } + viewportActive = !overGizmo && ImGui::IsItemActive(); + + handleCameraInput(frameInfo, size); + handleGizmoHotkeys(); + + // Drive the view's camera from the editor camera. The scene view renders + // after the panels update, so this frame already uses it. + camera.getComponent() = + editorCamera.getTransform(); + camera.getComponent() = editorCamera.getCamera(); ImDrawList* drawList = ImGui::GetWindowDrawList(); drawList->PushClipRect(origin, ImVec2(origin.x + size.x, origin.y + size.y), true); - if (showGrid) { + // Screen-space, so it only lines up with anything in 2D. + // TODO: a world-space grid on the XZ plane for 3D, once there's debug + // drawing in scene views. + if (showGrid && editorCamera.mode == EditorCamera::Mode::Scene2D) { 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); + // 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 view = sophia::CameraComponent::getView( + camera.getComponent()); + glm::mat4 viewProjection = + camera.getComponent().getProjection(aspect) * + view; + ViewportTransform viewport{origin, size, viewProjection, + glm::inverse(viewProjection)}; - ViewportTransform viewport{origin, size, viewProjection, - glm::inverse(viewProjection)}; - handleInput(viewport); - drawSelection(viewport); - } + handleSelectionInput(viewport); + drawSelection(viewport); + + ImGuizmo::SetDrawlist(); + ImGuizmo::SetRect(origin.x, origin.y, size.x, size.y); + drawGizmo(view, aspect); drawList->PopClipRect(); @@ -81,7 +273,106 @@ void ScenePanel::onUpdate(const sophia::FrameInfo&) { ImGui::TextDisabled("%.0f x %.0f", size.x, size.y); } -void ScenePanel::drawToolbar() { ImGui::Checkbox("Grid", &showGrid); } +void ScenePanel::drawToolbar() { + bool is2D = editorCamera.mode == EditorCamera::Mode::Scene2D; + if (ImGui::Checkbox("2D", &is2D)) { + editorCamera.mode = + is2D ? EditorCamera::Mode::Scene2D : EditorCamera::Mode::Scene3D; + dragging = false; + } + ImGui::SameLine(); + ImGui::BeginDisabled(!is2D); + ImGui::Checkbox("Grid", &showGrid); + ImGui::EndDisabled(); + + ImGui::SameLine(); + ImGui::TextDisabled("|"); + ImGui::SameLine(); + const struct { + const char* label; + GizmoOperation operation; + } operations[] = {{"Move (W)", GizmoOperation::Translate}, + {"Rotate (E)", GizmoOperation::Rotate}, + {"Scale (R)", GizmoOperation::Scale}}; + for (const auto& entry : operations) { + if (ImGui::RadioButton(entry.label, gizmoOperation == entry.operation)) { + gizmoOperation = entry.operation; + } + ImGui::SameLine(); + } + if (ImGui::Button(gizmoLocal ? "Local" : "World")) { + gizmoLocal = !gizmoLocal; + } +} + +void ScenePanel::handleGizmoHotkeys() { + const ImGuiIO& io = ImGui::GetIO(); + // W/E/Q fly the camera while the right button is held. + if (!ImGui::IsWindowFocused() || io.WantTextInput || + ImGui::IsMouseDown(ImGuiMouseButton_Right)) { + return; + } + if (ImGui::IsKeyPressed(ImGuiKey_W, false)) { + gizmoOperation = GizmoOperation::Translate; + } else if (ImGui::IsKeyPressed(ImGuiKey_E, false)) { + gizmoOperation = GizmoOperation::Rotate; + } else if (ImGui::IsKeyPressed(ImGuiKey_R, false)) { + gizmoOperation = GizmoOperation::Scale; + } +} + +void ScenePanel::drawGizmo(const glm::mat4& view, float aspect) { + sophia::Entity selected = context.selected; + if (!selected) { + return; + } + + bool is2D = editorCamera.mode == EditorCamera::Mode::Scene2D; + + // In 2D, only the axes that keep things in the XY plane. + ImGuizmo::OPERATION operation{}; + float snapStep = 0.f; + switch (gizmoOperation) { + case GizmoOperation::Translate: + operation = is2D ? ImGuizmo::TRANSLATE_X | ImGuizmo::TRANSLATE_Y + : ImGuizmo::TRANSLATE; + snapStep = TRANSLATE_SNAP; + break; + case GizmoOperation::Rotate: + operation = is2D ? ImGuizmo::ROTATE_Z : ImGuizmo::ROTATE; + snapStep = ROTATE_SNAP_DEGREES; + break; + case GizmoOperation::Scale: + operation = + is2D ? ImGuizmo::SCALE_X | ImGuizmo::SCALE_Y : ImGuizmo::SCALE; + snapStep = SCALE_SNAP; + break; + } + std::array snap{snapStep, snapStep, snapStep}; + bool snapping = ImGui::GetIO().KeyCtrl; + + // ImGuizmo maps clip space to the screen the OpenGL way, with +y up, so it + // needs the projection without the Y flip CameraComponent adds for Vulkan. + glm::mat4 projection = + camera.getComponent().getProjection(aspect); + projection[1][1] *= -1.f; + + auto& transform = selected.getComponent(); + glm::mat4 model = transform.mat4(); + + ImGuizmo::SetOrthographic(is2D); + // Per entity, so a drag in progress doesn't carry over to a new selection. + ImGuizmo::PushID(static_cast(static_cast(selected))); + bool changed = ImGuizmo::Manipulate( + glm::value_ptr(view), glm::value_ptr(projection), operation, + gizmoLocal ? ImGuizmo::LOCAL : ImGuizmo::WORLD, glm::value_ptr(model), + nullptr, snapping ? snap.data() : nullptr); + ImGuizmo::PopID(); + + if (changed) { + decompose(model, transform); + } +} void ScenePanel::drawGrid(ImVec2 origin, ImVec2 size) { constexpr float GRID_STEP = 40.0f; @@ -99,15 +390,55 @@ void ScenePanel::drawGrid(ImVec2 origin, ImVec2 size) { } } -void ScenePanel::handleInput(const ViewportTransform& viewport) { +void ScenePanel::handleCameraInput(const sophia::FrameInfo& frameInfo, + ImVec2 size) { const ImGuiIO& io = ImGui::GetIO(); + bool is3D = editorCamera.mode == EditorCamera::Mode::Scene3D; + glm::vec2 mouseDelta{io.MouseDelta.x, io.MouseDelta.y}; - if (ImGui::IsItemActivated()) { - selected = pick(viewport.screenToWorld(io.MousePos)); - dragging = selected; + // The button stays active while any of its mouse buttons is held, even + // once the mouse leaves the panel. + if (ImGui::IsItemActive()) { + bool rightDown = ImGui::IsMouseDown(ImGuiMouseButton_Right); + bool middleDown = ImGui::IsMouseDown(ImGuiMouseButton_Middle); + + if (is3D && rightDown) { + editorCamera.orbit(mouseDelta); + + glm::vec3 direction{0.f}; + if (ImGui::IsKeyDown(ImGuiKey_D)) direction.x += 1.f; + if (ImGui::IsKeyDown(ImGuiKey_A)) direction.x -= 1.f; + if (ImGui::IsKeyDown(ImGuiKey_E)) direction.y += 1.f; + if (ImGui::IsKeyDown(ImGuiKey_Q)) direction.y -= 1.f; + if (ImGui::IsKeyDown(ImGuiKey_S)) direction.z += 1.f; + if (ImGui::IsKeyDown(ImGuiKey_W)) direction.z -= 1.f; + editorCamera.fly(direction, static_cast(frameInfo.deltaTime)); + } else if (middleDown || (!is3D && rightDown)) { + editorCamera.pan(mouseDelta, size.y); + } } - if (!ImGui::IsItemActive()) { + if (ImGui::IsItemHovered() && io.MouseWheel != 0.f) { + editorCamera.zoom(io.MouseWheel); + } + + if (ImGui::IsWindowFocused() && !io.WantTextInput && + ImGui::IsKeyPressed(ImGuiKey_F, false)) { + frameSelection(); + } +} + +void ScenePanel::handleSelectionInput(const ViewportTransform& viewport) { + const ImGuiIO& io = ImGui::GetIO(); + + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { + context.selected = pick(viewport.screenToRay(io.MousePos)); + // Dragging the selection around is 2D only; in 3D, use the gizmo. + dragging = + context.selected && editorCamera.mode == EditorCamera::Mode::Scene2D; + } + + if (!ImGui::IsItemActive() || !ImGui::IsMouseDown(ImGuiMouseButton_Left)) { dragging = false; return; } @@ -118,13 +449,15 @@ void ScenePanel::handleInput(const ViewportTransform& viewport) { glm::vec2 delta = viewport.screenToWorld(io.MousePos) - viewport.screenToWorld(previous); - auto& transform = selected.getComponent(); + auto& transform = + context.selected.getComponent(); transform.position.x += delta.x; transform.position.y += delta.y; } } void ScenePanel::drawSelection(const ViewportTransform& viewport) { + sophia::Entity selected = context.selected; if (!selected) { return; } @@ -135,53 +468,136 @@ void ScenePanel::drawSelection(const ViewportTransform& viewport) { 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}}); + const glm::vec2& from = UNIT_QUAD[i]; + const glm::vec2& to = UNIT_QUAD[(i + 1) % UNIT_QUAD.size()]; + viewport.drawLine(drawList, glm::vec3{model * glm::vec4{from, 0.f, 1.f}}, + glm::vec3{model * glm::vec4{to, 0.f, 1.f}}, + SELECTION_COLOR, 2.0f); + } + } + + if (std::optional bounds = getMeshBounds(selected)) { + glm::mat4 model = transform.mat4(); + std::array corners = bounds->getCorners(); + for (glm::vec3& corner : corners) { + corner = glm::vec3{model * glm::vec4{corner, 1.f}}; + } + // Corners whose indices differ in one bit share an edge. + for (int i = 0; i < 8; i++) { + for (int bit : {1, 2, 4}) { + if ((i & bit) == 0) { + viewport.drawLine(drawList, corners[i], corners[i | bit], + SELECTION_COLOR, 2.0f); + } + } } - 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); + // there is to show for entities without a sprite or mesh, such as the + // camera. + if (std::optional center = + viewport.worldToScreen(transform.position)) { + drawList->AddCircle(*center, 4.0f, SELECTION_COLOR, 0, 2.0f); + } } -sophia::Entity ScenePanel::pick(glm::vec2 world) { - sophia::Entity best; - std::optional> bestOrder; +void ScenePanel::frameSelection() { + sophia::Entity selected = context.selected; + if (!selected) { + return; + } + const auto& transform = selected.getComponent(); + + // The selection's corners in world space, from its mesh or sprite. + std::vector points; + if (std::optional bounds = getMeshBounds(selected)) { + glm::mat4 model = transform.mat4(); + for (const glm::vec3& corner : bounds->getCorners()) { + points.push_back(glm::vec3{model * glm::vec4{corner, 1.f}}); + } + } + if (selected.hasComponent()) { + glm::mat4 model = + selected.getComponent().getModel(transform); + for (const glm::vec2& corner : UNIT_QUAD) { + points.push_back(glm::vec3{model * glm::vec4{corner, 0.f, 1.f}}); + } + } + + if (points.empty()) { + // Nothing with a size: frame a unit around its position. + editorCamera.frame(transform.position, 0.5f); + return; + } + + glm::vec3 min = points.front(); + glm::vec3 max = points.front(); + for (const glm::vec3& point : points) { + min = glm::min(min, point); + max = glm::max(max, point); + } + editorCamera.frame((min + max) * 0.5f, glm::length(max - min) * 0.5f); +} + +std::optional ScenePanel::getMeshBounds(sophia::Entity entity) { + if (!entity.hasComponent()) { + return std::nullopt; + } + sophia::MeshHandle mesh = entity.getComponent().handle; + if (!resources.isAlive(mesh)) { + return std::nullopt; + } + return resources.getBounds(mesh); +} + +sophia::Entity ScenePanel::pick(const Ray& ray) { + sophia::Scene& scene = context.getScene(); + + // Meshes are opaque and depth tested, so the nearest one hides anything + // behind it. + sophia::Entity nearestMesh; + float meshDistance = std::numeric_limits::infinity(); + scene.forEachEntity([&](sophia::Entity entity) { + std::optional bounds = getMeshBounds(entity); + if (!bounds) { + return; + } + glm::mat4 model = entity.getComponent().mat4(); + std::optional distance = + intersectBox(ray.origin, ray.direction, model, *bounds); + if (distance && *distance < meshDistance) { + nearestMesh = entity; + meshDistance = *distance; + } + }); + + // Sprites in front of that mesh draw over it, in the renderer's order: + // higher sortOrder on top, then nearer. + sophia::Entity topSprite; + std::optional> topOrder; 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) { + glm::mat4 model = + sprite.getModel(entity.getComponent()); + std::optional distance = + intersectQuad(ray.origin, ray.direction, model); + if (!distance || *distance > meshDistance) { 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; + // Negated distance, so nearer compares greater. + std::tuple order{sprite.sortOrder, -*distance}; + if (!topOrder || order > *topOrder) { + topSprite = entity; + topOrder = order; } }); - return best; + return topSprite ? topSprite : nearestMesh; } diff --git a/editor/panels/scene_panel.hpp b/editor/panels/scene_panel.hpp index 6a1edff..3097cce 100644 --- a/editor/panels/scene_panel.hpp +++ b/editor/panels/scene_panel.hpp @@ -1,35 +1,64 @@ #pragma once +#include +#include #include #include #include #include -#include +#include +#include #include #include +#include "../editor_camera.hpp" +#include "../editor_context.hpp" + /** - * Shows the scene through its active camera. Click a sprite to select it, - * drag it to move it (in Move mode). + * Shows the active scene through the editor's own camera. * - * Picking and moving are 2D: they assume an orthographic camera rotated only - * around Z, and work in the z = 0 plane. + * 3D: right-drag orbits, middle-drag pans, the wheel zooms, right-drag with + * WASD/QE flies. 2D: right- or middle-drag pans, the wheel zooms. F frames + * the selection in both. + * + * The selection gets a transform gizmo: W moves, E rotates, R scales, and + * holding Ctrl snaps. Clicking selects the mesh or sprite under the mouse; + * in 2D, dragging it also moves it. */ class ScenePanel : public sophia::Panel { public: - ScenePanel(sophia::UI& ui, sophia::RenderTargetHandle target, - sophia::Scene& scene, sophia::Entity& selected) - : Panel{{.name = "Scene", - .windowFlags = ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoScrollWithMouse}}, + // Renders into `target` through `view`, a SceneView this panel keeps + // pointed at the active scene. `camera` is the entity the view looks + // through; it belongs to a scene of the editor's own, and this panel + // drives it from its EditorCamera. + ScenePanel(sophia::UI& ui, sophia::ResourceRegistryRef resources, + EditorContext& context, sophia::SceneViewRegistry& sceneViews, + sophia::SceneViewHandle view, sophia::RenderTargetHandle target, + sophia::Entity camera) + : Panel{{.name = "Scene", .windowFlags = WINDOW_FLAGS}}, ui{ui}, + resources{resources}, + context{context}, + sceneViews{sceneViews}, + view{view}, target{target}, - scene{scene}, - selected{selected} {} + camera{camera} {} void onUpdate(const sophia::FrameInfo& frameInfo) override; private: + static constexpr ImGuiWindowFlags WINDOW_FLAGS = + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; + + enum class GizmoOperation { Translate, Rotate, Scale }; + + // A half-line in world space. `direction` is normalized, so distances + // along it are in world units. + struct Ray { + glm::vec3 origin; + glm::vec3 direction; + }; + // Where the scene image is on screen this frame, and the camera looking // through it. struct ViewportTransform { @@ -38,22 +67,57 @@ class ScenePanel : public sophia::Panel { glm::mat4 viewProjection; glm::mat4 inverseViewProjection; - ImVec2 worldToScreen(const glm::vec3& world) const; + // Null for points behind the camera. + std::optional worldToScreen(const glm::vec3& world) const; + // Where `screen` is on the camera's near plane. Only the x and y, which + // is all that's needed in 2D. glm::vec2 screenToWorld(const ImVec2& screen) const; + // From the near plane through `screen`, into the scene. + Ray screenToRay(const ImVec2& screen) const; + + // Draws a world-space line, cut off where it passes behind the camera. + void drawLine(ImDrawList* drawList, const glm::vec3& a, const glm::vec3& b, + ImU32 color, float thickness) const; + + private: + ImVec2 clipToScreen(const glm::vec4& clip) const; }; void drawToolbar(); void drawGrid(ImVec2 origin, ImVec2 size); - void handleInput(const ViewportTransform& viewport); + void handleCameraInput(const sophia::FrameInfo& frameInfo, ImVec2 size); + void handleSelectionInput(const ViewportTransform& viewport); + void handleGizmoHotkeys(); + // Draws the transform gizmo on the selection, and applies any change made + // with it. `view` is the editor camera's, and `aspect` the viewport's + // width over its height. + void drawGizmo(const glm::mat4& view, float aspect); void drawSelection(const ViewportTransform& viewport); + void frameSelection(); - // The top-most sprite under `world`, or a null Entity. - sophia::Entity pick(glm::vec2 world); + // The bounds of the entity's mesh in the mesh's own space, if it has one. + std::optional getMeshBounds(sophia::Entity entity); + + // The entity drawn in front along `ray`, or a null Entity. Only meshes and + // sprites can be hit. + sophia::Entity pick(const Ray& ray); sophia::UI& ui; + sophia::ResourceRegistryRef resources; + EditorContext& context; + sophia::SceneViewRegistry& sceneViews; + sophia::SceneViewHandle view; sophia::RenderTargetHandle target; - sophia::Scene& scene; - sophia::Entity& selected; + sophia::Entity camera; + + EditorCamera editorCamera; + + GizmoOperation gizmoOperation = GizmoOperation::Translate; + // Whether the gizmo works along the entity's own axes or the world's. + bool gizmoLocal = true; + // Whether the viewport button was active last frame, i.e. a camera drag or + // click is under way, which the gizmo mustn't interrupt. + bool viewportActive = false; bool showGrid = true; // Set while a drag that started on the selected entity is moving it. diff --git a/editor/utilities/logger.hpp b/editor/utilities/logger.hpp new file mode 100644 index 0000000..846896f --- /dev/null +++ b/editor/utilities/logger.hpp @@ -0,0 +1,8 @@ +#pragma once + +#include + +template +void log(Args... args) { + sophia::log::print("\033[1;38;2;170;90;255m[editor]\033[0m", args...); +} diff --git a/engine/include/sophia/aabb.hpp b/engine/include/sophia/aabb.hpp new file mode 100644 index 0000000..a875e61 --- /dev/null +++ b/engine/include/sophia/aabb.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +namespace sophia { + +/** @brief Axis-Aligned Bounding Box + * + * An axis-aligned box, from its smallest corner to its largest. + */ +struct AABB { + vec3 min{0.f, 0.f, 0.f}; + vec3 max{0.f, 0.f, 0.f}; + + vec3 getCenter() const { return (min + max) * 0.5f; } + vec3 getSize() const { return max - min; } + + // The 8 corners. Bit 0 of the index picks x from max, bit 1 y, bit 2 z. + std::array getCorners() const { + std::array corners; + for (int i = 0; i < 8; i++) { + corners[i] = {(i & 1) ? max.x : min.x, (i & 2) ? max.y : min.y, + (i & 4) ? max.z : min.z}; + } + return corners; + } +}; + +} // namespace sophia diff --git a/engine/include/sophia/resource_registry_ref.hpp b/engine/include/sophia/resource_registry_ref.hpp index ae2c73c..30667f3 100644 --- a/engine/include/sophia/resource_registry_ref.hpp +++ b/engine/include/sophia/resource_registry_ref.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -28,6 +29,10 @@ class ResourceRegistryRef { glm::vec2 getExtent(RenderTargetHandle handle) const; glm::vec2 getExtent(TextureHandle handle) const; + // In the mesh's own space. + AABB getBounds(MeshHandle handle) const; + + bool isAlive(MeshHandle handle) const; bool isAlive(RenderTargetHandle handle) const; bool isAlive(TextureHandle handle) const; diff --git a/engine/include/sophia/scene.hpp b/engine/include/sophia/scene.hpp index 5571a53..f957663 100644 --- a/engine/include/sophia/scene.hpp +++ b/engine/include/sophia/scene.hpp @@ -25,12 +25,20 @@ class Scene { Entity getActiveCamera(); void setActiveCamera(Entity camera); + bool isVisible() const { return this->visible; } + void setVisible(bool visible) { this->visible = visible; } + + bool isActive() const { return this->active; } + void setActive(bool active) { this->active = active; } + private: friend class Entity; friend class SceneRenderer; // !TEMP remove this entt::registry registry; entt::entity activeCamera = entt::null; + bool visible = true; + bool active = true; }; } // namespace sophia diff --git a/engine/include/sophia/scene_view.hpp b/engine/include/sophia/scene_view.hpp index f6e0c1d..cdff21b 100644 --- a/engine/include/sophia/scene_view.hpp +++ b/engine/include/sophia/scene_view.hpp @@ -19,7 +19,9 @@ struct SceneView { SceneHandle scene = INVALID_HANDLE; RenderTargetHandle target = INVALID_HANDLE; // null implies render to swapchain - Entity camera; // null implies use scene's active camera + // Null implies the scene's active camera. May belong to another scene, + // e.g. an editor camera that isn't part of the scene being shown. + Entity camera; // TODO(#76): not used yet; every view renders to its whole target. vec4 viewport{0.0f, 0.0f, 1.0f, 1.0f}; diff --git a/engine/include/sophia/ui/panel.hpp b/engine/include/sophia/ui/panel.hpp index 078104e..ecd2406 100644 --- a/engine/include/sophia/ui/panel.hpp +++ b/engine/include/sophia/ui/panel.hpp @@ -48,6 +48,7 @@ class Panel { bool isLocked() const { return config.locked; } bool isVisible() const { return this->visible; } + bool isShown() const { return this->visible && this->shown; } void setVisible(bool visible) { this->visible = visible; } void toggleVisible() { this->visible = !this->visible; } @@ -55,6 +56,7 @@ class Panel { virtual void onUpdate(const FrameInfo& frameInfo) = 0; bool visible = true; + bool shown = false; private: Config config; diff --git a/engine/include/sophia/util/logger.hpp b/engine/include/sophia/util/logger.hpp index 46d4bf1..cc3110d 100644 --- a/engine/include/sophia/util/logger.hpp +++ b/engine/include/sophia/util/logger.hpp @@ -5,6 +5,7 @@ * using namespace sophia; * log::fatal("fatal message") * log::info("Infomation message") + * log::print("no prefix, just the message") * * TODO Impl some form of log error handling */ @@ -122,4 +123,24 @@ void trace(Args... args) { log(LEVEL::TRACE, args...); } +// Like the functions above, but with no "[LEVEL]" prefix: just the arguments, +// separated by spaces, then a newline. With none, an empty line. +inline void print() { +#ifndef NDEBUG + std::cout << std::endl; +#endif // !NDEBUG +} + +template +void print(First first, Rest... rest) { +#ifndef NDEBUG + std::cout << first; + ((std::cout << ' ' << rest), ...); + std::cout << std::endl; +#else + (void)first; + (void)std::initializer_list{((void)rest, 0)...}; +#endif // !NDEBUG +} + } // namespace sophia::log diff --git a/engine/src/application.cpp b/engine/src/application.cpp index d886429..51d3b2d 100644 --- a/engine/src/application.cpp +++ b/engine/src/application.cpp @@ -173,6 +173,7 @@ class Application::Impl { const SceneView& view = sceneViewRegistry.peek(handle); bool toSwapchain = !view.target.isValid(); if (!sceneRegistry.isAlive(view.scene) || + !sceneRegistry.get(view.scene).isVisible() || (!toSwapchain && !renderTargetPool.isAlive(view.target))) { continue; } diff --git a/engine/src/resources/mesh.cpp b/engine/src/resources/mesh.cpp index 88d89b9..c521c7d 100644 --- a/engine/src/resources/mesh.cpp +++ b/engine/src/resources/mesh.cpp @@ -20,6 +20,15 @@ Mesh::getAttributeDescriptions() { } Mesh::Mesh(Device& device, const MeshData& data) : device{device} { + if (!data.vertices.empty()) { + this->bounds = {data.vertices.front().position, + data.vertices.front().position}; + for (const MeshVertex& vertex : data.vertices) { + this->bounds.min = glm::min(this->bounds.min, vertex.position); + this->bounds.max = glm::max(this->bounds.max, vertex.position); + } + } + createVertexBuffers(data.vertices); createIndexBuffers(data.indices); } diff --git a/engine/src/resources/mesh.hpp b/engine/src/resources/mesh.hpp index 6f698f4..a193651 100644 --- a/engine/src/resources/mesh.hpp +++ b/engine/src/resources/mesh.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -26,6 +27,9 @@ class Mesh { Mesh(const Mesh&) = delete; Mesh& operator=(const Mesh&) = delete; + // Bounds of the vertices, in the mesh's own space. + const AABB& getBounds() const { return this->bounds; } + void bind(vk::CommandBuffer commandBuffer) const; void draw(vk::CommandBuffer commandBuffer, u32 instanceCount = 1, u32 firstInstance = 0) const; @@ -36,6 +40,8 @@ class Mesh { Device& device; + AABB bounds; + std::unique_ptr vertexBuffer; u32 vertexCount; diff --git a/engine/src/resources/resource_registry_ref.cpp b/engine/src/resources/resource_registry_ref.cpp index 06c950f..a329abf 100644 --- a/engine/src/resources/resource_registry_ref.cpp +++ b/engine/src/resources/resource_registry_ref.cpp @@ -86,6 +86,14 @@ glm::vec2 ResourceRegistryRef::getExtent(RenderTargetHandle handle) const { return {static_cast(extent.width), static_cast(extent.height)}; } +AABB ResourceRegistryRef::getBounds(MeshHandle handle) const { + return registry->meshPool.get(handle).getBounds(); +} + +bool ResourceRegistryRef::isAlive(MeshHandle handle) const { + return registry->meshPool.isAlive(handle); +} + bool ResourceRegistryRef::isAlive(RenderTargetHandle handle) const { return registry->renderTargetPool.isAlive(handle); } diff --git a/engine/src/ui/panel.cpp b/engine/src/ui/panel.cpp index 0fc6ebc..dce694c 100644 --- a/engine/src/ui/panel.cpp +++ b/engine/src/ui/panel.cpp @@ -47,8 +47,9 @@ void Panel::draw(const FrameInfo& frameInfo) { } bool open = true; - ImGui::Begin(config.name.c_str(), config.hasCloseButton() ? &open : nullptr, - config.getWindowFlags()); + this->shown = ImGui::Begin(config.name.c_str(), + config.hasCloseButton() ? &open : nullptr, + config.getWindowFlags()); onUpdate(frameInfo); ImGui::End(); diff --git a/third_party/CMakeLists.txt b/third_party/CMakeLists.txt index d8cb1c7..26d4260 100644 --- a/third_party/CMakeLists.txt +++ b/third_party/CMakeLists.txt @@ -78,3 +78,17 @@ target_link_libraries(imgui PRIVATE if(UNIX AND NOT APPLE) target_compile_options(imgui PRIVATE -fPIC) endif() + +# ImGuizmo: only the transform gizmo. The repo's own CMakeLists also builds +# its other widgets, and doesn't link imgui when used as a subdirectory. +add_library(imguizmo STATIC + ${CMAKE_CURRENT_SOURCE_DIR}/ImGuizmo/src/ImGuizmo.cpp +) + +target_include_directories(imguizmo PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/ImGuizmo/src +) + +target_link_libraries(imguizmo PUBLIC + imgui +) diff --git a/third_party/ImGuizmo b/third_party/ImGuizmo new file mode 160000 index 0000000..18cef5e --- /dev/null +++ b/third_party/ImGuizmo @@ -0,0 +1 @@ +Subproject commit 18cef5e031d8c6973d80284c67f60549fafd78c1