Adding 3D support #77

Merged
burkkyy merged 3 commits from dev/adding-3d-support into main 2026-10-02 20:12:46 -07:00
28 changed files with 1223 additions and 108 deletions
Showing only changes of commit 6e48b0605c - Show all commits
+3
View File
@@ -11,3 +11,6 @@
[submodule "third_party/tracy"] [submodule "third_party/tracy"]
path = third_party/tracy path = third_party/tracy
url = https://github.com/wolfpld/tracy.git url = https://github.com/wolfpld/tracy.git
[submodule "third_party/ImGuizmo"]
path = third_party/ImGuizmo
url = https://github.com/CedricGuillemet/ImGuizmo.git
+1
View File
@@ -7,6 +7,7 @@ add_library(${NAME}_deps INTERFACE)
target_link_libraries(${NAME}_deps INTERFACE target_link_libraries(${NAME}_deps INTERFACE
${ENGINE_NAME} ${ENGINE_NAME}
imguizmo
) )
target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_17) target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_17)
+99
View File
@@ -0,0 +1,99 @@
#include "editor_camera.hpp"
#include <algorithm>
#include <cmath>
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});
}
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <sophia/components.hpp>
#include <sophia/math.hpp>
/**
* 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;
};
+89
View File
@@ -0,0 +1,89 @@
#pragma once
#include <sophia/entity.hpp>
#include <sophia/handles.hpp>
#include <sophia/scene.hpp>
#include <sophia/scene_registry.hpp>
#include <sophia/scene_view_registry.hpp>
#include <string>
#include <vector>
/**
* 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<OpenScene>& 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<OpenScene> 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();
}
}
+82 -15
View File
@@ -1,49 +1,86 @@
#include <iostream>
#include <sophia/application.hpp> #include <sophia/application.hpp>
#include <sophia/components.hpp> #include <sophia/components.hpp>
#include <sophia/entity.hpp> #include <sophia/entity.hpp>
#include <sophia/primitives.hpp>
#include <vector>
#include "editor_context.hpp"
#include "panels/asset_browser_panel.hpp" #include "panels/asset_browser_panel.hpp"
#include "panels/game_panel.hpp"
#include "panels/inspector_panel.hpp" #include "panels/inspector_panel.hpp"
#include "panels/scene_hierarchy_panel.hpp" #include "panels/scene_hierarchy_panel.hpp"
#include "panels/scene_panel.hpp" #include "panels/scene_panel.hpp"
#include "utilities/logger.hpp"
class Editor : public sophia::Application { class Editor : public sophia::Application {
public: public:
Editor() : Application{{.width = 750, .height = 1000, .name = "Editor"}} {} Editor() : Application{{.width = 750, .height = 1000, .name = "Editor"}} {}
void onCreate() override { void onCreate() override {
std::cout << "Editor created" << std::endl; log("Editor created");
debugUI().show(); debugUI().show();
textures.emplace_back("Checker", createCheckerTexture()); textures.emplace_back("Checker", createCheckerTexture());
cubeMesh = createShadedCube();
sophia::SceneHandle sceneHandle = scenes().create(); sophia::SceneHandle sceneHandle = context.createScene("Demo");
sophia::Scene& scene = scenes().get(sceneHandle); createDemoScene(scenes().get(sceneHandle));
createDemoScene(scene); context.setScene(sceneHandle);
panels().emplace<SceneHierarchyPanel>(scene, selected); // The editor's own scene, never edited or rendered. It holds the camera
panels().emplace<InspectorPanel>(selected, textures); // the Scene panel looks through, which a SceneView may take from any
panels().emplace<AssetBrowserPanel>(); // 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 = sophia::RenderTargetHandle sceneTarget =
resources().create<sophia::RenderTarget>(); resources().create<sophia::RenderTarget>();
sceneViews().create({.scene = sceneHandle, .target = sceneTarget}); sophia::SceneViewHandle sceneView = sceneViews().create(
panels().emplace<ScenePanel>(ui(), sceneTarget, scene, selected); {.target = sceneTarget, .camera = editorCamera, .enabled = false});
// No camera: the Game panel shows the scene through its active one.
sophia::RenderTargetHandle gameTarget =
resources().create<sophia::RenderTarget>();
sophia::SceneViewHandle gameView =
sceneViews().create({.target = gameTarget, .enabled = false});
panels().emplace<SceneHierarchyPanel>(context, cubeMesh);
panels().emplace<InspectorPanel>(context, textures);
panels().emplace<AssetBrowserPanel>();
viewPanels.push_back(
{panels().emplace<ScenePanel>(ui(), resources(), context, sceneViews(),
sceneView, sceneTarget, editorCamera),
sceneView});
viewPanels.push_back(
{panels().emplace<GamePanel>(ui(), context, sceneViews(), gameView,
gameTarget),
gameView});
panels().setDefaultLayout( panels().setDefaultLayout(
{.left = {"Scene Hierarchy"}, {.left = {"Scene Hierarchy"},
.right = {"Inspector", "Frame Stats", "PanelRegistry"}, .right = {"Inspector", "Frame Stats", "PanelRegistry"},
.bottom = {"Asset Browser", "Console"}, .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: private:
// Adds to a new scene, which already has a camera.
void createDemoScene(sophia::Scene& scene) { void createDemoScene(sophia::Scene& scene) {
scene.createCamera("Main Camera");
sophia::Entity checker = scene.createEntity("Checker"); sophia::Entity checker = scene.createEntity("Checker");
checker.getComponent<sophia::TransformComponent>().position = {-1.5f, 0.f, checker.getComponent<sophia::TransformComponent>().position = {-1.5f, 0.f,
0.f}; 0.f};
@@ -63,6 +100,27 @@ class Editor : public sophia::Application {
sophia::SpriteComponent{.color = {0.25f, 0.45f, 0.95f, 0.6f}, sophia::SpriteComponent{.color = {0.25f, 0.45f, 0.95f, 0.6f},
.size = {2.f, 2.f}, .size = {2.f, 2.f},
.sortOrder = 1}); .sortOrder = 1});
sophia::Entity cube = scene.createEntity("Cube");
auto& cubeTransform = cube.getComponent<sophia::TransformComponent>();
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>(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<sophia::Mesh>(data);
} }
sophia::TextureHandle createCheckerTexture() { sophia::TextureHandle createCheckerTexture() {
@@ -79,8 +137,17 @@ class Editor : public sophia::Application {
return resources().create<sophia::Texture>(SIZE, SIZE, std::move(pixels)); return resources().create<sophia::Texture>(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<ViewPanel> viewPanels;
InspectorPanel::TextureList textures; InspectorPanel::TextureList textures;
sophia::MeshHandle cubeMesh;
}; };
SOPHIA_MAIN(Editor) SOPHIA_MAIN(Editor)
+20
View File
@@ -0,0 +1,20 @@
#include "game_panel.hpp"
#include <imgui.h>
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");
}
}
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <sophia/frame_info.hpp>
#include <sophia/handles.hpp>
#include <sophia/scene_view_registry.hpp>
#include <sophia/ui/panel.hpp>
#include <sophia/ui/ui.hpp>
#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;
};
+32 -5
View File
@@ -6,6 +6,7 @@
#include <sophia/components.hpp> #include <sophia/components.hpp>
void InspectorPanel::onUpdate(const sophia::FrameInfo&) { void InspectorPanel::onUpdate(const sophia::FrameInfo&) {
selected = context.selected;
if (!selected) { if (!selected) {
ImGui::TextDisabled("No entity selected"); ImGui::TextDisabled("No entity selected");
return; return;
@@ -16,6 +17,7 @@ void InspectorPanel::onUpdate(const sophia::FrameInfo&) {
drawTransform(); drawTransform();
drawCamera(); drawCamera();
drawSprite(); drawSprite();
drawMesh();
ImGui::Separator(); ImGui::Separator();
drawAddComponent(); drawAddComponent();
@@ -40,11 +42,13 @@ void InspectorPanel::drawTransform() {
auto& transform = selected.getComponent<sophia::TransformComponent>(); auto& transform = selected.getComponent<sophia::TransformComponent>();
ImGui::DragFloat3("Position", &transform.position.x, 0.1f); ImGui::DragFloat3("Position", &transform.position.x, 0.1f);
// 2D only: edits rotation around Z and drops any X/Y rotation. // Euler angles in degrees, recomputed from the quaternion every frame.
float degrees = glm::degrees(glm::eulerAngles(transform.rotation).z); // Near 90 degrees of pitch the angles can jump between equivalent values
if (ImGui::DragFloat("Rotation", &degrees, 1.0f)) { // while dragging.
transform.rotation = // TODO: keep each entity's edited angles, or replace with gizmo rotation.
glm::angleAxis(glm::radians(degrees), glm::vec3{0.f, 0.f, 1.f}); glm::vec3 degrees = glm::degrees(glm::eulerAngles(transform.rotation));
if (ImGui::DragFloat3("Rotation", &degrees.x, 1.0f)) {
transform.rotation = glm::quat{glm::radians(degrees)};
} }
ImGui::DragFloat3("Scale", &transform.scale.x, 0.1f); ImGui::DragFloat3("Scale", &transform.scale.x, 0.1f);
@@ -118,6 +122,29 @@ void InspectorPanel::drawSprite() {
} }
} }
void InspectorPanel::drawMesh() {
if (!selected.hasComponent<sophia::MeshComponent>()) {
return;
}
bool open = true;
if (!ImGui::CollapsingHeader("Mesh", &open, ImGuiTreeNodeFlags_DefaultOpen)) {
if (!open) {
selected.removeComponent<sophia::MeshComponent>();
}
return;
}
auto& mesh = selected.getComponent<sophia::MeshComponent>();
// TODO: pick the mesh once assets have names.
ImGui::Text("Mesh: %u", static_cast<sophia::u32>(mesh.handle.index));
ImGui::ColorEdit3("Color", &mesh.color.x);
if (!open) {
selected.removeComponent<sophia::MeshComponent>();
}
}
void InspectorPanel::drawAddComponent() { void InspectorPanel::drawAddComponent() {
if (ImGui::Button("Add Component")) { if (ImGui::Button("Add Component")) {
ImGui::OpenPopup("AddComponent"); ImGui::OpenPopup("AddComponent");
+8 -3
View File
@@ -8,14 +8,16 @@
#include <utility> #include <utility>
#include <vector> #include <vector>
#include "../editor_context.hpp"
class InspectorPanel : public sophia::Panel { class InspectorPanel : public sophia::Panel {
public: public:
// Stand-in for an asset browser: the textures a sprite can pick from. // Stand-in for an asset browser: the textures a sprite can pick from.
using TextureList = using TextureList =
std::vector<std::pair<std::string, sophia::TextureHandle>>; std::vector<std::pair<std::string, sophia::TextureHandle>>;
InspectorPanel(sophia::Entity& selected, const TextureList& textures) InspectorPanel(EditorContext& context, const TextureList& textures)
: Panel{{.name = "Inspector"}}, selected{selected}, textures{textures} {} : Panel{{.name = "Inspector"}}, context{context}, textures{textures} {}
void onUpdate(const sophia::FrameInfo& frameInfo) override; void onUpdate(const sophia::FrameInfo& frameInfo) override;
@@ -24,8 +26,11 @@ class InspectorPanel : public sophia::Panel {
void drawTransform(); void drawTransform();
void drawCamera(); void drawCamera();
void drawSprite(); void drawSprite();
void drawMesh();
void drawAddComponent(); void drawAddComponent();
sophia::Entity& selected; EditorContext& context;
// The selected entity, for the duration of onUpdate().
sophia::Entity selected;
const TextureList& textures; const TextureList& textures;
}; };
+103 -1
View File
@@ -2,18 +2,37 @@
#include <imgui.h> #include <imgui.h>
#include <cstdio>
#include <sophia/components.hpp> #include <sophia/components.hpp>
#include <string>
void SceneHierarchyPanel::onUpdate(const sophia::FrameInfo&) { 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")) { if (ImGui::Button("+ Sprite")) {
selected = scene.createEntity("Sprite"); selected = scene.createEntity("Sprite");
selected.addComponent<sophia::SpriteComponent>(); selected.addComponent<sophia::SpriteComponent>();
} }
ImGui::SameLine();
if (ImGui::Button("+ Cube")) {
selected = scene.createEntity("Cube");
selected.addComponent<sophia::MeshComponent>(
sophia::MeshComponent{.handle = cubeMesh});
}
ImGui::Separator(); ImGui::Separator();
// Flat for now: there is no parent/child component yet. // 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 flags = ImGuiTreeNodeFlags_Leaf |
ImGuiTreeNodeFlags_NoTreePushOnOpen | ImGuiTreeNodeFlags_NoTreePushOnOpen |
ImGuiTreeNodeFlags_SpanAvailWidth; ImGuiTreeNodeFlags_SpanAvailWidth;
@@ -30,3 +49,86 @@ void SceneHierarchyPanel::onUpdate(const sophia::FrameInfo&) {
ImGui::PopID(); 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<int>(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;
}
}
+25 -7
View File
@@ -1,20 +1,38 @@
#pragma once #pragma once
#include <sophia/entity.hpp> #include <array>
#include <sophia/frame_info.hpp> #include <sophia/frame_info.hpp>
#include <sophia/scene.hpp> #include <sophia/handles.hpp>
#include <sophia/ui/panel.hpp> #include <sophia/ui/panel.hpp>
#include "../editor_context.hpp"
class SceneHierarchyPanel : public sophia::Panel { class SceneHierarchyPanel : public sophia::Panel {
public: 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}}, : Panel{{.name = "Scene Hierarchy", .locked = true}},
scene{scene}, context{context},
selected{selected} {} cubeMesh{cubeMesh} {}
void onUpdate(const sophia::FrameInfo& frameInfo) override; void onUpdate(const sophia::FrameInfo& frameInfo) override;
private: private:
sophia::Scene& scene; // Switches between open scenes, renames the active one, and creates new
sophia::Entity& selected; // 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<char, 128> renameBuffer{};
}; };
+472 -56
View File
@@ -1,11 +1,20 @@
#include "scene_panel.hpp" #include "scene_panel.hpp"
#include <imgui.h> #include <imgui.h>
// After imgui.h, which it relies on.
#include <ImGuizmo.h>
#include <algorithm>
#include <array> #include <array>
#include <cmath>
#include <glm/gtc/type_ptr.hpp>
#include <limits>
#include <optional> #include <optional>
#include <sophia/aabb.hpp>
#include <sophia/components.hpp> #include <sophia/components.hpp>
#include <tuple> #include <tuple>
#include <utility>
#include <vector>
namespace { namespace {
@@ -16,17 +25,125 @@ constexpr std::array<glm::vec2, 4> UNIT_QUAD{
const ImU32 SELECTION_COLOR = IM_COL32(255, 170, 40, 255); 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<float> 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<float>::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<float> 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 } // namespace
ImVec2 ScenePanel::ViewportTransform::worldToScreen( ImVec2 ScenePanel::ViewportTransform::clipToScreen(
const glm::vec3& world) const { const glm::vec4& clip) const {
glm::vec4 clip = viewProjection * glm::vec4{world, 1.f};
glm::vec2 ndc = glm::vec2{clip} / clip.w; glm::vec2 ndc = glm::vec2{clip} / clip.w;
// Vulkan NDC has +y pointing down, same as the screen. // Vulkan NDC has +y pointing down, same as the screen.
return {origin.x + (ndc.x * 0.5f + 0.5f) * size.x, return {origin.x + (ndc.x * 0.5f + 0.5f) * size.x,
origin.y + (ndc.y * 0.5f + 0.5f) * size.y}; origin.y + (ndc.y * 0.5f + 0.5f) * size.y};
} }
std::optional<ImVec2> 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( glm::vec2 ScenePanel::ViewportTransform::screenToWorld(
const ImVec2& screen) const { const ImVec2& screen) const {
glm::vec2 ndc{(screen.x - origin.x) / size.x * 2.f - 1.f, 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; 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(); drawToolbar();
ImGui::Separator(); ImGui::Separator();
@@ -45,35 +197,75 @@ void ScenePanel::onUpdate(const sophia::FrameInfo&) {
return; return;
} }
if (!context.hasScene()) {
ImGui::TextDisabled("No scene open");
return;
}
ui.image(target); 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 // The image doesn't take mouse input, so without this, dragging in it
// would drag the window instead. // would drag the window instead.
ImGui::SetCursorScreenPos(origin); 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<sophia::TransformComponent>() =
editorCamera.getTransform();
camera.getComponent<sophia::CameraComponent>() = editorCamera.getCamera();
ImDrawList* drawList = ImGui::GetWindowDrawList(); ImDrawList* drawList = ImGui::GetWindowDrawList();
drawList->PushClipRect(origin, ImVec2(origin.x + size.x, origin.y + size.y), drawList->PushClipRect(origin, ImVec2(origin.x + size.x, origin.y + size.y),
true); 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); drawGrid(origin, size);
} }
sophia::Entity camera = scene.getActiveCamera(); // Use the panel's size rather than the target's: the target is resized a
if (camera && camera.hasComponent<sophia::CameraComponent>()) { // frame late, and the mouse is over the panel.
// Use the panel's size rather than the target's: the target is resized float aspect = size.x / size.y;
// a frame late, and the mouse is over the panel. glm::mat4 view = sophia::CameraComponent::getView(
float aspect = size.x / size.y; camera.getComponent<sophia::TransformComponent>());
glm::mat4 viewProjection = glm::mat4 viewProjection =
camera.getComponent<sophia::CameraComponent>().getViewProjection( camera.getComponent<sophia::CameraComponent>().getProjection(aspect) *
camera.getComponent<sophia::TransformComponent>(), aspect); view;
ViewportTransform viewport{origin, size, viewProjection,
glm::inverse(viewProjection)};
ViewportTransform viewport{origin, size, viewProjection, handleSelectionInput(viewport);
glm::inverse(viewProjection)}; drawSelection(viewport);
handleInput(viewport);
drawSelection(viewport); ImGuizmo::SetDrawlist();
} ImGuizmo::SetRect(origin.x, origin.y, size.x, size.y);
drawGizmo(view, aspect);
drawList->PopClipRect(); drawList->PopClipRect();
@@ -81,7 +273,106 @@ void ScenePanel::onUpdate(const sophia::FrameInfo&) {
ImGui::TextDisabled("%.0f x %.0f", size.x, size.y); 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<float, 3> 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<sophia::CameraComponent>().getProjection(aspect);
projection[1][1] *= -1.f;
auto& transform = selected.getComponent<sophia::TransformComponent>();
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<int>(static_cast<sophia::u32>(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) { void ScenePanel::drawGrid(ImVec2 origin, ImVec2 size) {
constexpr float GRID_STEP = 40.0f; 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(); const ImGuiIO& io = ImGui::GetIO();
bool is3D = editorCamera.mode == EditorCamera::Mode::Scene3D;
glm::vec2 mouseDelta{io.MouseDelta.x, io.MouseDelta.y};
if (ImGui::IsItemActivated()) { // The button stays active while any of its mouse buttons is held, even
selected = pick(viewport.screenToWorld(io.MousePos)); // once the mouse leaves the panel.
dragging = selected; 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<float>(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; dragging = false;
return; return;
} }
@@ -118,13 +449,15 @@ void ScenePanel::handleInput(const ViewportTransform& viewport) {
glm::vec2 delta = glm::vec2 delta =
viewport.screenToWorld(io.MousePos) - viewport.screenToWorld(previous); viewport.screenToWorld(io.MousePos) - viewport.screenToWorld(previous);
auto& transform = selected.getComponent<sophia::TransformComponent>(); auto& transform =
context.selected.getComponent<sophia::TransformComponent>();
transform.position.x += delta.x; transform.position.x += delta.x;
transform.position.y += delta.y; transform.position.y += delta.y;
} }
} }
void ScenePanel::drawSelection(const ViewportTransform& viewport) { void ScenePanel::drawSelection(const ViewportTransform& viewport) {
sophia::Entity selected = context.selected;
if (!selected) { if (!selected) {
return; return;
} }
@@ -135,53 +468,136 @@ void ScenePanel::drawSelection(const ViewportTransform& viewport) {
if (selected.hasComponent<sophia::SpriteComponent>()) { if (selected.hasComponent<sophia::SpriteComponent>()) {
glm::mat4 model = glm::mat4 model =
selected.getComponent<sophia::SpriteComponent>().getModel(transform); selected.getComponent<sophia::SpriteComponent>().getModel(transform);
std::array<ImVec2, 4> corners;
for (size_t i = 0; i < UNIT_QUAD.size(); i++) { for (size_t i = 0; i < UNIT_QUAD.size(); i++) {
corners[i] = viewport.worldToScreen( const glm::vec2& from = UNIT_QUAD[i];
glm::vec3{model * glm::vec4{UNIT_QUAD[i], 0.f, 1.f}}); 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<sophia::AABB> bounds = getMeshBounds(selected)) {
glm::mat4 model = transform.mat4();
std::array<glm::vec3, 8> 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 // 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. // there is to show for entities without a sprite or mesh, such as the
ImVec2 center = viewport.worldToScreen(transform.position); // camera.
drawList->AddCircle(center, 4.0f, SELECTION_COLOR, 0, 2.0f); if (std::optional<ImVec2> center =
viewport.worldToScreen(transform.position)) {
drawList->AddCircle(*center, 4.0f, SELECTION_COLOR, 0, 2.0f);
}
} }
sophia::Entity ScenePanel::pick(glm::vec2 world) { void ScenePanel::frameSelection() {
sophia::Entity best; sophia::Entity selected = context.selected;
std::optional<std::tuple<sophia::s32, float>> bestOrder; if (!selected) {
return;
}
const auto& transform = selected.getComponent<sophia::TransformComponent>();
// The selection's corners in world space, from its mesh or sprite.
std::vector<glm::vec3> points;
if (std::optional<sophia::AABB> 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<sophia::SpriteComponent>()) {
glm::mat4 model =
selected.getComponent<sophia::SpriteComponent>().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<sophia::AABB> ScenePanel::getMeshBounds(sophia::Entity entity) {
if (!entity.hasComponent<sophia::MeshComponent>()) {
return std::nullopt;
}
sophia::MeshHandle mesh = entity.getComponent<sophia::MeshComponent>().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<float>::infinity();
scene.forEachEntity([&](sophia::Entity entity) {
std::optional<sophia::AABB> bounds = getMeshBounds(entity);
if (!bounds) {
return;
}
glm::mat4 model = entity.getComponent<sophia::TransformComponent>().mat4();
std::optional<float> 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<std::tuple<sophia::s32, float>> topOrder;
scene.forEachEntity([&](sophia::Entity entity) { scene.forEachEntity([&](sophia::Entity entity) {
if (!entity.hasComponent<sophia::SpriteComponent>()) { if (!entity.hasComponent<sophia::SpriteComponent>()) {
return; return;
} }
const auto& transform = entity.getComponent<sophia::TransformComponent>();
const auto& sprite = entity.getComponent<sophia::SpriteComponent>(); const auto& sprite = entity.getComponent<sophia::SpriteComponent>();
glm::mat4 model =
glm::mat4 model = sprite.getModel(transform); sprite.getModel(entity.getComponent<sophia::TransformComponent>());
// Zero size or scale; nothing to hit. std::optional<float> distance =
if (glm::determinant(model) == 0.f) { intersectQuad(ray.origin, ray.direction, model);
if (!distance || *distance > meshDistance) {
return; return;
} }
glm::vec4 local = // Negated distance, so nearer compares greater.
glm::inverse(model) * glm::vec4{world, transform.position.z, 1.f}; std::tuple order{sprite.sortOrder, -*distance};
if (local.x < 0.f || local.x > 1.f || local.y < 0.f || local.y > 1.f) { if (!topOrder || order > *topOrder) {
return; topSprite = entity;
} topOrder = order;
// 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; return topSprite ? topSprite : nearestMesh;
} }
+82 -18
View File
@@ -1,35 +1,64 @@
#pragma once #pragma once
#include <optional>
#include <sophia/aabb.hpp>
#include <sophia/entity.hpp> #include <sophia/entity.hpp>
#include <sophia/frame_info.hpp> #include <sophia/frame_info.hpp>
#include <sophia/handles.hpp> #include <sophia/handles.hpp>
#include <sophia/math.hpp> #include <sophia/math.hpp>
#include <sophia/scene.hpp> #include <sophia/resource_registry_ref.hpp>
#include <sophia/scene_view_registry.hpp>
#include <sophia/ui/panel.hpp> #include <sophia/ui/panel.hpp>
#include <sophia/ui/ui.hpp> #include <sophia/ui/ui.hpp>
#include "../editor_camera.hpp"
#include "../editor_context.hpp"
/** /**
* Shows the scene through its active camera. Click a sprite to select it, * Shows the active scene through the editor's own camera.
* drag it to move it (in Move mode).
* *
* Picking and moving are 2D: they assume an orthographic camera rotated only * 3D: right-drag orbits, middle-drag pans, the wheel zooms, right-drag with
* around Z, and work in the z = 0 plane. * 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 { class ScenePanel : public sophia::Panel {
public: public:
ScenePanel(sophia::UI& ui, sophia::RenderTargetHandle target, // Renders into `target` through `view`, a SceneView this panel keeps
sophia::Scene& scene, sophia::Entity& selected) // pointed at the active scene. `camera` is the entity the view looks
: Panel{{.name = "Scene", // through; it belongs to a scene of the editor's own, and this panel
.windowFlags = ImGuiWindowFlags_NoScrollbar | // drives it from its EditorCamera.
ImGuiWindowFlags_NoScrollWithMouse}}, 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}, ui{ui},
resources{resources},
context{context},
sceneViews{sceneViews},
view{view},
target{target}, target{target},
scene{scene}, camera{camera} {}
selected{selected} {}
void onUpdate(const sophia::FrameInfo& frameInfo) override; void onUpdate(const sophia::FrameInfo& frameInfo) override;
private: 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 // Where the scene image is on screen this frame, and the camera looking
// through it. // through it.
struct ViewportTransform { struct ViewportTransform {
@@ -38,22 +67,57 @@ class ScenePanel : public sophia::Panel {
glm::mat4 viewProjection; glm::mat4 viewProjection;
glm::mat4 inverseViewProjection; glm::mat4 inverseViewProjection;
ImVec2 worldToScreen(const glm::vec3& world) const; // Null for points behind the camera.
std::optional<ImVec2> 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; 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 drawToolbar();
void drawGrid(ImVec2 origin, ImVec2 size); 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 drawSelection(const ViewportTransform& viewport);
void frameSelection();
// The top-most sprite under `world`, or a null Entity. // The bounds of the entity's mesh in the mesh's own space, if it has one.
sophia::Entity pick(glm::vec2 world); std::optional<sophia::AABB> 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::UI& ui;
sophia::ResourceRegistryRef resources;
EditorContext& context;
sophia::SceneViewRegistry& sceneViews;
sophia::SceneViewHandle view;
sophia::RenderTargetHandle target; sophia::RenderTargetHandle target;
sophia::Scene& scene; sophia::Entity camera;
sophia::Entity& selected;
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; bool showGrid = true;
// Set while a drag that started on the selected entity is moving it. // Set while a drag that started on the selected entity is moving it.
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include <sophia/util/logger.hpp>
template <typename... Args>
void log(Args... args) {
sophia::log::print("\033[1;38;2;170;90;255m[editor]\033[0m", args...);
}
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <array>
#include <sophia/math.hpp>
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<vec3, 8> getCorners() const {
std::array<vec3, 8> 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
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <sophia/aabb.hpp>
#include <sophia/handles.hpp> #include <sophia/handles.hpp>
#include <sophia/math.hpp> #include <sophia/math.hpp>
#include <sophia/mesh_data.hpp> #include <sophia/mesh_data.hpp>
@@ -28,6 +29,10 @@ class ResourceRegistryRef {
glm::vec2 getExtent(RenderTargetHandle handle) const; glm::vec2 getExtent(RenderTargetHandle handle) const;
glm::vec2 getExtent(TextureHandle 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(RenderTargetHandle handle) const;
bool isAlive(TextureHandle handle) const; bool isAlive(TextureHandle handle) const;
+8
View File
@@ -25,12 +25,20 @@ class Scene {
Entity getActiveCamera(); Entity getActiveCamera();
void setActiveCamera(Entity camera); 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: private:
friend class Entity; friend class Entity;
friend class SceneRenderer; // !TEMP remove this friend class SceneRenderer; // !TEMP remove this
entt::registry registry; entt::registry registry;
entt::entity activeCamera = entt::null; entt::entity activeCamera = entt::null;
bool visible = true;
bool active = true;
}; };
} // namespace sophia } // namespace sophia
+3 -1
View File
@@ -19,7 +19,9 @@ struct SceneView {
SceneHandle scene = INVALID_HANDLE; SceneHandle scene = INVALID_HANDLE;
RenderTargetHandle target = RenderTargetHandle target =
INVALID_HANDLE; // null implies render to swapchain 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. // TODO(#76): not used yet; every view renders to its whole target.
vec4 viewport{0.0f, 0.0f, 1.0f, 1.0f}; vec4 viewport{0.0f, 0.0f, 1.0f, 1.0f};
+2
View File
@@ -48,6 +48,7 @@ class Panel {
bool isLocked() const { return config.locked; } bool isLocked() const { return config.locked; }
bool isVisible() const { return this->visible; } bool isVisible() const { return this->visible; }
bool isShown() const { return this->visible && this->shown; }
void setVisible(bool visible) { this->visible = visible; } void setVisible(bool visible) { this->visible = visible; }
void toggleVisible() { this->visible = !this->visible; } void toggleVisible() { this->visible = !this->visible; }
@@ -55,6 +56,7 @@ class Panel {
virtual void onUpdate(const FrameInfo& frameInfo) = 0; virtual void onUpdate(const FrameInfo& frameInfo) = 0;
bool visible = true; bool visible = true;
bool shown = false;
private: private:
Config config; Config config;
+21
View File
@@ -5,6 +5,7 @@
* using namespace sophia; * using namespace sophia;
* log::fatal("fatal message") * log::fatal("fatal message")
* log::info("Infomation message") * log::info("Infomation message")
* log::print("no prefix, just the message")
* *
* TODO Impl some form of log error handling * TODO Impl some form of log error handling
*/ */
@@ -122,4 +123,24 @@ void trace(Args... args) {
log(LEVEL::TRACE, 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 <typename First, typename... Rest>
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<int>{((void)rest, 0)...};
#endif // !NDEBUG
}
} // namespace sophia::log } // namespace sophia::log
+1
View File
@@ -173,6 +173,7 @@ class Application::Impl {
const SceneView& view = sceneViewRegistry.peek(handle); const SceneView& view = sceneViewRegistry.peek(handle);
bool toSwapchain = !view.target.isValid(); bool toSwapchain = !view.target.isValid();
if (!sceneRegistry.isAlive(view.scene) || if (!sceneRegistry.isAlive(view.scene) ||
!sceneRegistry.get(view.scene).isVisible() ||
(!toSwapchain && !renderTargetPool.isAlive(view.target))) { (!toSwapchain && !renderTargetPool.isAlive(view.target))) {
continue; continue;
} }
+9
View File
@@ -20,6 +20,15 @@ Mesh::getAttributeDescriptions() {
} }
Mesh::Mesh(Device& device, const MeshData& data) : device{device} { 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); createVertexBuffers(data.vertices);
createIndexBuffers(data.indices); createIndexBuffers(data.indices);
} }
+6
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <memory> #include <memory>
#include <sophia/aabb.hpp>
#include <sophia/handles.hpp> #include <sophia/handles.hpp>
#include <sophia/math.hpp> #include <sophia/math.hpp>
#include <sophia/mesh_data.hpp> #include <sophia/mesh_data.hpp>
@@ -26,6 +27,9 @@ class Mesh {
Mesh(const Mesh&) = delete; Mesh(const Mesh&) = delete;
Mesh& operator=(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 bind(vk::CommandBuffer commandBuffer) const;
void draw(vk::CommandBuffer commandBuffer, u32 instanceCount = 1, void draw(vk::CommandBuffer commandBuffer, u32 instanceCount = 1,
u32 firstInstance = 0) const; u32 firstInstance = 0) const;
@@ -36,6 +40,8 @@ class Mesh {
Device& device; Device& device;
AABB bounds;
std::unique_ptr<Buffer> vertexBuffer; std::unique_ptr<Buffer> vertexBuffer;
u32 vertexCount; u32 vertexCount;
@@ -86,6 +86,14 @@ glm::vec2 ResourceRegistryRef::getExtent(RenderTargetHandle handle) const {
return {static_cast<float>(extent.width), static_cast<float>(extent.height)}; return {static_cast<float>(extent.width), static_cast<float>(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 { bool ResourceRegistryRef::isAlive(RenderTargetHandle handle) const {
return registry->renderTargetPool.isAlive(handle); return registry->renderTargetPool.isAlive(handle);
} }
+3 -2
View File
@@ -47,8 +47,9 @@ void Panel::draw(const FrameInfo& frameInfo) {
} }
bool open = true; bool open = true;
ImGui::Begin(config.name.c_str(), config.hasCloseButton() ? &open : nullptr, this->shown = ImGui::Begin(config.name.c_str(),
config.getWindowFlags()); config.hasCloseButton() ? &open : nullptr,
config.getWindowFlags());
onUpdate(frameInfo); onUpdate(frameInfo);
ImGui::End(); ImGui::End();
+14
View File
@@ -78,3 +78,17 @@ target_link_libraries(imgui PRIVATE
if(UNIX AND NOT APPLE) if(UNIX AND NOT APPLE)
target_compile_options(imgui PRIVATE -fPIC) target_compile_options(imgui PRIVATE -fPIC)
endif() 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
)
Vendored Submodule
+1
Submodule third_party/ImGuizmo added at 18cef5e031