#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 { // 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); // 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::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, (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; } 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(); const ImVec2 origin = ImGui::GetCursorScreenPos(); const ImVec2 size = ImGui::GetContentRegionAvail(); if (size.x < 1.0f || size.y < 1.0f) { 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); 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); // 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); } // 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)}; handleSelectionInput(viewport); drawSelection(viewport); ImGuizmo::SetDrawlist(); ImGuizmo::SetRect(origin.x, origin.y, size.x, size.y); drawGizmo(view, aspect); drawList->PopClipRect(); ImGui::SetCursorScreenPos(ImVec2(origin.x + 8.0f, origin.y + 8.0f)); ImGui::TextDisabled("%.0f x %.0f", size.x, size.y); } 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; const ImU32 gridColor = IM_COL32(255, 255, 255, 18); const ImVec2 end(origin.x + size.x, origin.y + size.y); 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); } } 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}; // 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::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; } 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 = 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; } ImDrawList* drawList = ImGui::GetWindowDrawList(); const auto& transform = selected.getComponent(); if (selected.hasComponent()) { glm::mat4 model = selected.getComponent().getModel(transform); for (size_t i = 0; i < UNIT_QUAD.size(); i++) { 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); } } } } // Mark the transform's position (the sprite's pivot), which is also all // 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); } } 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& sprite = entity.getComponent(); glm::mat4 model = sprite.getModel(entity.getComponent()); std::optional distance = intersectQuad(ray.origin, ray.direction, model); if (!distance || *distance > meshDistance) { return; } // Negated distance, so nearer compares greater. std::tuple order{sprite.sortOrder, -*distance}; if (!topOrder || order > *topOrder) { topSprite = entity; topOrder = order; } }); return topSprite ? topSprite : nearestMesh; }