feat: add basic canvas / improve inspector

This commit is contained in:
2026-10-18 20:42:04 +02:00
parent 7df65f52b5
commit ad16daea7c
47 changed files with 3142 additions and 405 deletions
+8
View File
@@ -50,6 +50,11 @@ set(SRC_FILES
"src/Components/Physics/Collider.cpp"
"src/Components/Physics/SphereCollider.cpp"
"src/Components/UI/Canvas.cpp"
"src/Components/UI/RectTransform.cpp"
"src/Components/UI/UIImage.cpp"
"src/Components/UI/UIText.cpp"
"src/Graphics/BindlessSetManager.cpp"
"src/Graphics/Camera.cpp"
"src/Graphics/DebugDraw.cpp"
@@ -80,6 +85,7 @@ set(SRC_FILES
"src/Graphics/Resources/NBuffer.cpp"
"src/Graphics/Resources/Cubemap.cpp"
"src/Graphics/Resources/Font.cpp"
"src/Graphics/Resources/FontCache.cpp"
"src/Graphics/Resources/PrimitiveMeshes.cpp"
"src/Graphics/Pipelines/MeshPipeline.cpp"
@@ -117,6 +123,8 @@ set(SRC_FILES
"src/Util/DeltaTime.cpp"
"src/Util/ImGuiUtils.cpp"
"src/Util/TransformGizmo.cpp"
"src/Util/RectGizmo.cpp"
"src/Util/SceneInspector.cpp"
"src/Physics/PhysicsWorld.cpp"
"src/Physics/SimplePhysicsWorld.cpp"
@@ -40,6 +40,7 @@ public:
void AddImpulse(const glm::vec3& impulse);
void SetLinearVelocity(const glm::vec3& velocity);
void Teleport(const glm::vec3& worldPosition);
[[nodiscard]] glm::vec3 GetLinearVelocity() const;
[[nodiscard]] RigidbodyType GetType() const { return m_Type; }
@@ -0,0 +1,52 @@
#ifndef DESTRUM_CANVAS_H
#define DESTRUM_CANVAS_H
#include <destrum/Components/UI/RectTransform.h>
#include <destrum/ObjectModel/Component.h>
#include <glm/glm.hpp>
enum class CanvasScaleMode {
ConstantPixelSize,
ScaleWithScreenHeight,
};
// Draws the UIGraphics on its object and every active child, depth first, so later siblings draw on top.
// A child with its own Canvas is skipped and draws itself.
class Canvas final : public Component {
public:
explicit Canvas(GameObject& parent);
std::string GetTypeName() const override { return "Canvas"; }
void Start() override;
void Render(const RenderContext& ctx) override;
void Draw(const glm::vec2& screenSize);
nlohmann::json Serialize() const override;
void Deserialize(const nlohmann::json& data) override;
void ImGuiInspector() override;
[[nodiscard]] float GetScale(const glm::vec2& screenSize) const;
[[nodiscard]] static Canvas* FindFor(GameObject& object);
void SetScaleMode(CanvasScaleMode mode) { m_ScaleMode = mode; }
void SetReferenceHeight(float height) { m_ReferenceHeight = height; }
void SetScaleFactor(float factor) { m_ScaleFactor = factor; }
void SetDrawDebugRects(bool draw) { m_DrawDebugRects = draw; }
[[nodiscard]] CanvasScaleMode GetScaleMode() const { return m_ScaleMode; }
[[nodiscard]] float GetReferenceHeight() const { return m_ReferenceHeight; }
[[nodiscard]] float GetScaleFactor() const { return m_ScaleFactor; }
private:
void DrawObject(GameObject& object, const UIRect& rect, float scale, bool visible) const;
CanvasScaleMode m_ScaleMode{CanvasScaleMode::ScaleWithScreenHeight};
float m_ReferenceHeight{1080.0f};
float m_ScaleFactor{1.0f};
bool m_DrawDebugRects{false};
};
#endif // DESTRUM_CANVAS_H
@@ -0,0 +1,68 @@
#ifndef DESTRUM_RECTTRANSFORM_H
#define DESTRUM_RECTTRANSFORM_H
#include <destrum/ObjectModel/Component.h>
#include <glm/glm.hpp>
// Screen space rectangle in draw image pixels, origin top left, y down
struct UIRect {
glm::vec2 min{0.0f};
glm::vec2 size{0.0f};
[[nodiscard]] glm::vec2 Max() const { return min + size; }
[[nodiscard]] glm::vec2 Center() const { return min + size * 0.5f; }
[[nodiscard]] bool Contains(const glm::vec2& point) const;
};
// Anchors and pivot are normalized (0,0 top left), position and size are in canvas units
class RectTransform final : public Component {
public:
explicit RectTransform(GameObject& parent);
std::string GetTypeName() const override { return "RectTransform"; }
nlohmann::json Serialize() const override;
void Deserialize(const nlohmann::json& data) override;
void ImGuiInspector() override;
[[nodiscard]] UIRect ComputeRect(const UIRect& parentRect, float scale) const;
void SetAnchors(const glm::vec2& anchorMin, const glm::vec2& anchorMax);
void SetAnchor(const glm::vec2& anchor) { SetAnchors(anchor, anchor); }
void SetPivot(const glm::vec2& pivot) { m_Pivot = pivot; }
void SetPosition(const glm::vec2& position) { m_Position = position; }
void SetSize(const glm::vec2& size) { m_Size = size; }
void Stretch();
[[nodiscard]] const glm::vec2& GetAnchorMin() const { return m_AnchorMin; }
[[nodiscard]] const glm::vec2& GetAnchorMax() const { return m_AnchorMax; }
[[nodiscard]] const glm::vec2& GetPivot() const { return m_Pivot; }
[[nodiscard]] const glm::vec2& GetPosition() const { return m_Position; }
[[nodiscard]] const glm::vec2& GetSize() const { return m_Size; }
// Where the canvas last drew this object
[[nodiscard]] const UIRect& GetScreenRect() const { return m_ScreenRect; }
[[nodiscard]] const UIRect& GetParentRect() const { return m_ParentRect; }
void SetScreenRect(const UIRect& rect, const UIRect& parentRect, float scale)
{
m_ScreenRect = rect;
m_ParentRect = parentRect;
m_LastScale = scale;
}
void ApplyAnchorPreset(const glm::vec2& preset);
private:
glm::vec2 m_AnchorMin{0.5f};
glm::vec2 m_AnchorMax{0.5f};
glm::vec2 m_Pivot{0.5f};
glm::vec2 m_Position{0.0f};
glm::vec2 m_Size{100.0f};
UIRect m_ScreenRect{};
UIRect m_ParentRect{};
float m_LastScale{1.0f};
};
#endif // DESTRUM_RECTTRANSFORM_H
@@ -0,0 +1,23 @@
#ifndef DESTRUM_UIGRAPHIC_H
#define DESTRUM_UIGRAPHIC_H
#include <destrum/Components/UI/RectTransform.h>
#include <destrum/ObjectModel/Component.h>
#include <glm/glm.hpp>
// Anything a Canvas draws into the rect of the object it sits on
class UIGraphic : public Component {
public:
virtual void DrawUI(const UIRect& rect, float scale) = 0;
[[nodiscard]] const glm::vec4& GetColor() const { return m_Color; }
void SetColor(const glm::vec4& color) { m_Color = color; }
protected:
UIGraphic(GameObject& parent, const std::string& name) : Component(parent, name) {}
glm::vec4 m_Color{1.0f};
};
#endif // DESTRUM_UIGRAPHIC_H
@@ -0,0 +1,51 @@
#ifndef DESTRUM_UIIMAGE_H
#define DESTRUM_UIIMAGE_H
#include <string>
#include <destrum/Components/UI/UIGraphic.h>
#include <destrum/Graphics/Managers/Renderer2D.h>
#include <destrum/Graphics/ids.h>
enum class UIImageShape {
Rect,
Ellipse,
};
class UIImage final : public UIGraphic {
public:
explicit UIImage(GameObject& parent, const glm::vec4& color = glm::vec4{1.0f});
std::string GetTypeName() const override { return "UIImage"; }
void DrawUI(const UIRect& rect, float scale) override;
nlohmann::json Serialize() const override;
void Deserialize(const nlohmann::json& data) override;
void ImGuiInspector() override;
void SetShape(UIImageShape shape) { m_Shape = shape; }
void SetOutline(float thickness) { m_Outline = thickness; }
void SetCornerRadius(float radius) { m_CornerRadius = radius; }
void SetTexture(const std::string& assetPath) { m_TexturePath = assetPath; }
void SetFilter(TextureFilter filter) { m_Filter = filter; }
[[nodiscard]] UIImageShape GetShape() const { return m_Shape; }
[[nodiscard]] float GetOutline() const { return m_Outline; }
[[nodiscard]] float GetCornerRadius() const { return m_CornerRadius; }
[[nodiscard]] const std::string& GetTexture() const { return m_TexturePath; }
private:
ImageID ResolveTexture();
UIImageShape m_Shape{UIImageShape::Rect};
float m_Outline{0.0f};
float m_CornerRadius{0.0f};
std::string m_TexturePath;
TextureFilter m_Filter{TextureFilter::Linear};
std::string m_LoadedTexturePath;
ImageID m_Texture{NULL_IMAGE_ID};
};
#endif // DESTRUM_UIIMAGE_H
@@ -0,0 +1,52 @@
#ifndef DESTRUM_UITEXT_H
#define DESTRUM_UITEXT_H
#include <string>
#include <destrum/Components/UI/UIGraphic.h>
class Font;
enum class UITextAlign {
Start,
Center,
End,
};
class UIText final : public UIGraphic {
public:
explicit UIText(GameObject& parent, std::string text = {}, float fontSize = 24.0f);
std::string GetTypeName() const override { return "UIText"; }
void DrawUI(const UIRect& rect, float scale) override;
nlohmann::json Serialize() const override;
void Deserialize(const nlohmann::json& data) override;
void ImGuiInspector() override;
void SetText(std::string text) { m_Text = std::move(text); }
void SetFont(std::string assetPath) { m_FontPath = std::move(assetPath); }
void SetFontSize(float size) { m_FontSize = size; }
void SetOutline(float thickness, const glm::vec4& color);
void SetAlignment(UITextAlign horizontal, UITextAlign vertical);
[[nodiscard]] const std::string& GetText() const { return m_Text; }
[[nodiscard]] const std::string& GetFont() const { return m_FontPath; }
[[nodiscard]] float GetFontSize() const { return m_FontSize; }
[[nodiscard]] UITextAlign GetHorizontalAlign() const { return m_HorizontalAlign; }
[[nodiscard]] UITextAlign GetVerticalAlign() const { return m_VerticalAlign; }
[[nodiscard]] glm::vec2 PlaceText(const UIRect& rect, const glm::vec2& textSize) const;
private:
std::string m_Text;
std::string m_FontPath;
float m_FontSize{24.0f};
float m_Outline{0.0f};
glm::vec4 m_OutlineColor{0.0f, 0.0f, 0.0f, 1.0f};
UITextAlign m_HorizontalAlign{UITextAlign::Center};
UITextAlign m_VerticalAlign{UITextAlign::Center};
};
#endif // DESTRUM_UITEXT_H
@@ -0,0 +1,29 @@
#ifndef DESTRUM_FONTCACHE_H
#define DESTRUM_FONTCACHE_H
#include <memory>
#include <string>
#include <string_view>
#include <unordered_map>
#include <destrum/Graphics/Resources/Font.h>
#include <destrum/Singleton.h>
class FontCache final : public Singleton<FontCache> {
public:
friend class Singleton<FontCache>;
static constexpr std::string_view DefaultFont = "engine://fonts/Roboto-Medium.ttf";
[[nodiscard]] const Font* Get(std::string_view assetPath);
// The atlases belong to RenderResources, call before it is cleaned up
void Clear() { fonts.clear(); }
private:
FontCache() = default;
std::unordered_map<std::string, std::unique_ptr<Font>> fonts;
};
#endif // DESTRUM_FONTCACHE_H
@@ -59,6 +59,9 @@ public:
[[nodiscard]] const std::vector<Transform*>& GetChildren() const;
[[nodiscard]] GameObject* GetOwner() const;
[[nodiscard]] int GetSiblingIndex() const;
void SetSiblingIndex(int index);
private:
void AddChild(Transform* transform);
void RemoveChild(Transform* transform);
+10
View File
@@ -8,6 +8,7 @@
#include <destrum/Event.h>
#include <destrum/ObjectModel/ObjectId.h>
#include <destrum/Scene/SceneManager.h>
#include <destrum/Util/SceneInspector.h>
#include "destrum/Physics/JoltPhysicsWorld.h"
#include "destrum/Physics/PhysicsSceneBridge.h"
@@ -66,7 +67,15 @@ public:
[[nodiscard]] const std::vector<std::shared_ptr<GameObject>>& GetObjects() const {
return m_objects;
}
[[nodiscard]] const std::vector<std::shared_ptr<GameObject>>& GetPendingAdditions() const {
return m_pendingAdditions;
}
[[nodiscard]] GameObject* FindGameObject(ObjectId id) const;
// Reparents object (nullptr is the scene root) and puts it right before insertBefore, or last when that is
// nullptr. Also reorders the object list so the order survives a save. False if the move isn't allowed.
bool MoveInHierarchy(GameObject& object, GameObject* parent, const GameObject* insertBefore = nullptr);
void SetSelectedObject(ObjectId id) { m_SelectedObjectId = id; }
[[nodiscard]] GameObject* GetSelectedObject() const { return FindGameObject(m_SelectedObjectId); }
void SetRegisterBindings(std::function<void()> registerBindings) {
@@ -150,6 +159,7 @@ private:
//Imgui vars
bool m_ShowDemoWindow{false};
ObjectId m_SelectedObjectId{InvalidObjectId};
SceneInspector m_Inspector;
ObjectId m_CurrentCameraId{InvalidObjectId};
@@ -12,6 +12,10 @@
#include <destrum/Components/Physics/CapsuleCollider.h>
#include <destrum/Components/Physics/Rigidbody.h>
#include <destrum/Components/Physics/SphereCollider.h>
#include <destrum/Components/UI/Canvas.h>
#include <destrum/Components/UI/RectTransform.h>
#include <destrum/Components/UI/UIImage.h>
#include <destrum/Components/UI/UIText.h>
#define DESTRUM_ENGINE_COMPONENTS(X) \
X(MeshRendererComponent) \
@@ -25,5 +29,9 @@
X(BoxCollider) \
X(SphereCollider) \
X(CapsuleCollider) \
X(Canvas) \
X(RectTransform) \
X(UIImage) \
X(UIText) \
#endif // DESTRUM_ENGINECOMPONENTLIST_H
+16 -8
View File
@@ -3,14 +3,19 @@
#include <functional>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
#include <glm/glm.hpp>
#include <destrum/ObjectModel/ObjectId.h>
#include <destrum/ObjectModel/ObjectRef.h>
class GameObject;
class Scene;
using ImGuiInputTextFlags = int;
namespace ImGuiUtils {
[[nodiscard]] bool DrawVec3Control(
@@ -18,14 +23,17 @@ namespace ImGuiUtils {
glm::vec3& value,
float speed = 0.1f);
void RenderSceneInspector(
std::string_view sceneName,
const std::vector<std::shared_ptr<GameObject>>& objects,
const std::vector<std::shared_ptr<GameObject>>& pendingAdditions,
ObjectId& selectedObjectId,
const std::function<GameObject*()>& createGameObject,
const std::function<void()>& drawSceneSettings,
bool* open = nullptr);
inline constexpr const char* GameObjectPayload = "DESTRUM_GAMEOBJECT";
bool InputString(const char* label, std::string& value, ImGuiInputTextFlags flags = 0, const char* hint = nullptr);
bool InputStringMultiline(const char* label, std::string& value);
// Combo over the scene's objects, filter can hide objects that can't be picked
bool DrawObjectRefPicker(
const char* label,
ObjectRef& ref,
const Scene& scene,
const std::function<bool(const GameObject&)>& filter = {});
}
#endif // DESTRUM_IMGUIUTILS_H
+27
View File
@@ -0,0 +1,27 @@
#ifndef DESTRUM_RECTGIZMO_H
#define DESTRUM_RECTGIZMO_H
#include <glm/glm.hpp>
class GameObject;
class RectGizmo final {
public:
[[nodiscard]] static bool CanManipulate(GameObject& object);
// drawImageSize is what the canvas renders into, it is stretched over the main ImGui viewport
bool Manipulate(GameObject& object, const glm::vec2& drawImageSize);
[[nodiscard]] bool IsUsing() const { return m_ActiveHandle >= 0; }
bool snap{false};
float snapStep{5.0f};
private:
int m_ActiveHandle{-1};
glm::vec2 m_DragStartMouse{0.0f};
glm::vec2 m_DragStartPosition{0.0f};
glm::vec2 m_DragStartSize{0.0f};
};
#endif // DESTRUM_RECTGIZMO_H
@@ -0,0 +1,60 @@
#ifndef DESTRUM_SCENEINSPECTOR_H
#define DESTRUM_SCENEINSPECTOR_H
#include <functional>
#include <string>
#include <unordered_set>
#include <nlohmann/json.hpp>
#include <destrum/ObjectModel/ObjectId.h>
class Component;
class GameObject;
class Scene;
class SceneInspector final {
public:
void Draw(Scene& scene, ObjectId& selectedObjectId, const std::function<void()>& drawSceneSettings, bool* open);
private:
struct PendingMove {
ObjectId object{InvalidObjectId};
ObjectId parent{InvalidObjectId};
ObjectId anchor{InvalidObjectId};
bool after{false};
};
void DrawHierarchy(Scene& scene, ObjectId& selectedObjectId);
void DrawNode(Scene& scene, GameObject& object, ObjectId& selectedObjectId, std::unordered_set<const GameObject*>& visited);
void DrawRenameField(GameObject& object);
void DrawNodeDragDrop(GameObject& object);
void DrawObjectMenu(Scene& scene, GameObject* object, ObjectId& selectedObjectId);
void HandleShortcuts(Scene& scene, ObjectId& selectedObjectId);
void ApplyPendingActions(Scene& scene, ObjectId& selectedObjectId);
void DrawObject(GameObject& object);
void DrawTransform(GameObject& object);
void DrawComponents(GameObject& object);
void DrawComponentMenu(GameObject& object, Component& component, std::size_t index, bool removable);
void DrawAddComponent(GameObject& object);
void BeginRename(const GameObject& object);
std::string m_Filter;
std::string m_ComponentFilter;
ObjectId m_RenamingId{InvalidObjectId};
std::string m_RenameBuffer;
bool m_FocusRename{false};
ObjectId m_OpenNextFrame{InvalidObjectId};
PendingMove m_PendingMove;
ObjectId m_PendingDelete{InvalidObjectId};
ObjectId m_PendingDuplicate{InvalidObjectId};
std::string m_ClipboardType;
nlohmann::json m_Clipboard;
};
#endif // DESTRUM_SCENEINSPECTOR_H
+2
View File
@@ -6,6 +6,7 @@
#include <destrum/App.h>
#include <destrum/FS/AssetFS.h>
#include <destrum/Graphics/Resources/FontCache.h>
#include <destrum/Scene/SceneManager.h>
#include <destrum/Util/DeltaTime.h>
@@ -358,6 +359,7 @@ void App::cleanup()
attempt([this] { customCleanup(); });
}
attempt([] { SceneManager::GetInstance().Destroy(); });
attempt([] { FontCache::GetInstance().Clear(); });
attempt([this] { renderer.cleanup(gfxDevice); });
attempt([this] { imguiPass.cleanup(); });
attempt([this] { resources.cleanup(gfxDevice); });
@@ -91,6 +91,17 @@ void Rigidbody::SetLinearVelocity(const glm::vec3& velocity) {
m_World->SetLinearVelocity(m_Body, velocity);
}
void Rigidbody::Teleport(const glm::vec3& worldPosition) {
Transform& transform = GetTransform();
transform.SetWorldPosition(worldPosition);
if (!HasPhysicsBody()) {
return;
}
m_World->SetBodyTransform(m_Body, PhysicsTransform{worldPosition, transform.GetWorldRotation()});
m_World->SetLinearVelocity(m_Body, glm::vec3{0.0f});
}
glm::vec3 Rigidbody::GetLinearVelocity() const {
if (!HasPhysicsBody()) {
return glm::vec3{0.0f};
+147
View File
@@ -0,0 +1,147 @@
#include <destrum/Components/UI/Canvas.h>
#include <array>
#include <destrum/Components/UI/UIGraphic.h>
#include <destrum/Components/UI/UIText.h>
#include <destrum/Graphics/Managers/Renderer2D.h>
#include <destrum/Graphics/Resources/FontCache.h>
#include <destrum/ObjectModel/GameObject.h>
#include <imgui.h>
#include "tracy/Tracy.hpp"
Canvas::Canvas(GameObject& parent)
: Component(parent, "Canvas")
{
}
namespace {
void WarmFonts(const GameObject& object)
{
for (const auto& component : object.GetComponents()) {
if (const auto* text = dynamic_cast<const UIText*>(component.get())) {
(void)FontCache::GetInstance().Get(text->GetFont());
}
}
for (const Transform* child : object.GetTransform().GetChildren()) {
if (child != nullptr && child->GetOwner() != nullptr) {
WarmFonts(*child->GetOwner());
}
}
}
}
// Building a font atlas takes seconds in Debug, so do it now instead of the first time hidden text shows up
void Canvas::Start()
{
WarmFonts(*GetGameObject());
}
Canvas* Canvas::FindFor(GameObject& object)
{
for (Transform* transform = &object.GetTransform(); transform != nullptr; transform = transform->GetParent()) {
GameObject* owner = transform->GetOwner();
if (owner == nullptr) {
continue;
}
if (auto* canvas = owner->GetComponent<Canvas>()) {
return canvas;
}
}
return nullptr;
}
void Canvas::Render(const RenderContext& ctx)
{
const VkExtent3D extent = ctx.renderer.getDrawImage().extent;
Draw(glm::vec2{static_cast<float>(extent.width), static_cast<float>(extent.height)});
}
void Canvas::Draw(const glm::vec2& screenSize)
{
ZoneScopedN("Canvas::Draw");
const UIRect screenRect{.min = glm::vec2{0.0f}, .size = screenSize};
if (auto* rectTransform = GetGameObject()->GetComponent<RectTransform>()) {
rectTransform->SetScreenRect(screenRect, screenRect, GetScale(screenSize));
}
DrawObject(*GetGameObject(), screenRect, GetScale(screenSize), true);
}
float Canvas::GetScale(const glm::vec2& screenSize) const
{
if (m_ScaleMode == CanvasScaleMode::ScaleWithScreenHeight && m_ReferenceHeight > 0.0f) {
return screenSize.y / m_ReferenceHeight;
}
return m_ScaleFactor;
}
// Hidden objects are still laid out so the editor can show and move them
void Canvas::DrawObject(GameObject& object, const UIRect& rect, float scale, bool visible) const
{
if (visible) {
for (const auto& component : object.GetComponents()) {
auto* graphic = dynamic_cast<UIGraphic*>(component.get());
if (graphic != nullptr && graphic->isEnabled() && !graphic->IsBeingDestroyed()) {
graphic->DrawUI(rect, scale);
}
}
if (m_DrawDebugRects) {
Renderer2D::GetInstance().DrawRectOutline(rect.min, rect.size, 1.0f, glm::vec4{1.0f, 0.0f, 1.0f, 0.8f});
}
}
for (Transform* childTransform : object.GetTransform().GetChildren()) {
GameObject* child = childTransform != nullptr ? childTransform->GetOwner() : nullptr;
if (child == nullptr || child->IsBeingDestroyed() || child->HasComponent<Canvas>()) {
continue;
}
UIRect childRect = rect;
if (auto* rectTransform = child->GetComponent<RectTransform>()) {
childRect = rectTransform->ComputeRect(rect, scale);
rectTransform->SetScreenRect(childRect, rect, scale);
}
DrawObject(*child, childRect, scale, visible && child->IsActive());
}
}
nlohmann::json Canvas::Serialize() const
{
return {
{"scaleMode", m_ScaleMode == CanvasScaleMode::ConstantPixelSize ? "constantPixelSize" : "scaleWithScreenHeight"},
{"referenceHeight", m_ReferenceHeight},
{"scaleFactor", m_ScaleFactor},
{"drawDebugRects", m_DrawDebugRects},
};
}
void Canvas::Deserialize(const nlohmann::json& data)
{
m_ScaleMode = data.value("scaleMode", std::string{"scaleWithScreenHeight"}) == "constantPixelSize"
? CanvasScaleMode::ConstantPixelSize
: CanvasScaleMode::ScaleWithScreenHeight;
m_ReferenceHeight = data.value("referenceHeight", m_ReferenceHeight);
m_ScaleFactor = data.value("scaleFactor", m_ScaleFactor);
m_DrawDebugRects = data.value("drawDebugRects", m_DrawDebugRects);
}
void Canvas::ImGuiInspector()
{
constexpr std::array modeNames{"Constant Pixel Size", "Scale With Screen Height"};
int mode = static_cast<int>(m_ScaleMode);
if (ImGui::Combo("Scale Mode", &mode, modeNames.data(), static_cast<int>(modeNames.size()))) {
m_ScaleMode = static_cast<CanvasScaleMode>(mode);
}
if (m_ScaleMode == CanvasScaleMode::ScaleWithScreenHeight) {
ImGui::DragFloat("Reference Height", &m_ReferenceHeight, 1.0f, 1.0f, 8192.0f);
} else {
ImGui::DragFloat("Scale Factor", &m_ScaleFactor, 0.01f, 0.01f, 10.0f);
}
ImGui::Checkbox("Draw Debug Rects", &m_DrawDebugRects);
}
+129
View File
@@ -0,0 +1,129 @@
#include <destrum/Components/UI/RectTransform.h>
#include <destrum/Serialization/GlmJson.h>
#include <array>
#include <imgui.h>
bool UIRect::Contains(const glm::vec2& point) const
{
const glm::vec2 max = Max();
return point.x >= min.x && point.y >= min.y && point.x <= max.x && point.y <= max.y;
}
RectTransform::RectTransform(GameObject& parent)
: Component(parent, "RectTransform")
{
}
UIRect RectTransform::ComputeRect(const UIRect& parentRect, float scale) const
{
const glm::vec2 anchorMin = parentRect.min + parentRect.size * m_AnchorMin;
const glm::vec2 anchorMax = parentRect.min + parentRect.size * m_AnchorMax;
const glm::vec2 anchorSize = anchorMax - anchorMin;
const glm::vec2 size = glm::max(anchorSize + m_Size * scale, glm::vec2{0.0f});
const glm::vec2 pivotPosition = anchorMin + anchorSize * m_Pivot + m_Position * scale;
return UIRect{.min = pivotPosition - size * m_Pivot, .size = size};
}
void RectTransform::SetAnchors(const glm::vec2& anchorMin, const glm::vec2& anchorMax)
{
m_AnchorMin = anchorMin;
m_AnchorMax = anchorMax;
}
void RectTransform::ApplyAnchorPreset(const glm::vec2& preset)
{
for (int axis = 0; axis < 2; ++axis) {
const float value = preset[axis];
if (value == 0.0f || value == 0.5f || value == 1.0f) {
m_Size[axis] = m_LastScale > 0.0f ? m_ScreenRect.size[axis] / m_LastScale : m_Size[axis];
m_AnchorMin[axis] = value;
m_AnchorMax[axis] = value;
m_Pivot[axis] = value;
} else {
m_Size[axis] = 0.0f;
m_AnchorMin[axis] = 0.0f;
m_AnchorMax[axis] = 1.0f;
m_Pivot[axis] = 0.5f;
}
m_Position[axis] = 0.0f;
}
}
void RectTransform::Stretch()
{
SetAnchors(glm::vec2{0.0f}, glm::vec2{1.0f});
m_Pivot = glm::vec2{0.5f};
m_Position = glm::vec2{0.0f};
m_Size = glm::vec2{0.0f};
}
nlohmann::json RectTransform::Serialize() const
{
return {
{"anchorMin", m_AnchorMin},
{"anchorMax", m_AnchorMax},
{"pivot", m_Pivot},
{"position", m_Position},
{"size", m_Size},
};
}
void RectTransform::Deserialize(const nlohmann::json& data)
{
m_AnchorMin = data.value("anchorMin", m_AnchorMin);
m_AnchorMax = data.value("anchorMax", m_AnchorMax);
m_Pivot = data.value("pivot", m_Pivot);
m_Position = data.value("position", m_Position);
m_Size = data.value("size", m_Size);
}
void RectTransform::ImGuiInspector()
{
constexpr float StretchPreset = -1.0f;
constexpr std::array<float, 4> presetValues{0.0f, 0.5f, 1.0f, StretchPreset};
constexpr std::array<const char*, 4> rowNames{"Top", "Middle", "Bottom", "Stretch"};
constexpr std::array<const char*, 4> columnNames{"Left", "Center", "Right", "Stretch"};
constexpr std::array<char, 4> rowLetters{'T', 'M', 'B', 'S'};
constexpr std::array<char, 4> columnLetters{'L', 'C', 'R', 'S'};
const auto matches = [this](int axis, float value) {
return value == StretchPreset ? m_AnchorMin[axis] == 0.0f && m_AnchorMax[axis] == 1.0f
: m_AnchorMin[axis] == value && m_AnchorMax[axis] == value;
};
ImGui::TextDisabled("Anchor presets");
for (std::size_t row = 0; row < presetValues.size(); ++row) {
for (std::size_t column = 0; column < presetValues.size(); ++column) {
if (column > 0) {
ImGui::SameLine();
}
const bool selected = matches(0, presetValues[column]) && matches(1, presetValues[row]);
const char label[] = {rowLetters[row], columnLetters[column], '\0'};
ImGui::PushID(static_cast<int>(row * presetValues.size() + column));
if (selected) {
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive));
}
if (ImGui::Button(label, ImVec2{28.0f, 0.0f})) {
ApplyAnchorPreset({presetValues[column], presetValues[row]});
}
if (selected) {
ImGui::PopStyleColor();
}
ImGui::SetItemTooltip("%s %s", rowNames[row], columnNames[column]);
ImGui::PopID();
}
}
ImGui::DragFloat2("Anchor Min", &m_AnchorMin.x, 0.01f, 0.0f, 1.0f);
ImGui::DragFloat2("Anchor Max", &m_AnchorMax.x, 0.01f, 0.0f, 1.0f);
ImGui::DragFloat2("Pivot", &m_Pivot.x, 0.01f, 0.0f, 1.0f);
ImGui::DragFloat2("Position", &m_Position.x, 1.0f);
ImGui::DragFloat2("Size", &m_Size.x, 1.0f);
ImGui::TextDisabled("Screen: %.0f, %.0f %.0f x %.0f",
m_ScreenRect.min.x, m_ScreenRect.min.y, m_ScreenRect.size.x, m_ScreenRect.size.y);
}
+108
View File
@@ -0,0 +1,108 @@
#include <destrum/Components/UI/UIImage.h>
#include <array>
#include <destrum/FS/AssetFS.h>
#include <destrum/Graphics/RenderResources.h>
#include <destrum/Graphics/Renderer.h>
#include <destrum/Serialization/GlmJson.h>
#include <destrum/Util/GameState.h>
#include <destrum/Util/ImGuiUtils.h>
#include <imgui.h>
#include <spdlog/spdlog.h>
UIImage::UIImage(GameObject& parent, const glm::vec4& color)
: UIGraphic(parent, "UIImage")
{
m_Color = color;
}
void UIImage::DrawUI(const UIRect& rect, float scale)
{
if (rect.size.x <= 0.0f || rect.size.y <= 0.0f || m_Color.w <= 0.0f) {
return;
}
const ShapeStyle style{
.color = m_Color,
.outline = m_Outline * scale,
.cornerRadius = m_CornerRadius * scale,
.texture = {.id = ResolveTexture(), .filter = m_Filter},
};
auto& renderer2D = Renderer2D::GetInstance();
if (m_Shape == UIImageShape::Ellipse) {
renderer2D.DrawEllipse(rect.Center(), rect.size * 0.5f, style);
} else {
renderer2D.DrawRect(rect.min, rect.size, style);
}
}
ImageID UIImage::ResolveTexture()
{
if (m_TexturePath == m_LoadedTexturePath) {
return m_Texture;
}
auto& gameState = GameState::GetInstance();
if (m_TexturePath.empty()) {
m_Texture = NULL_IMAGE_ID;
} else if (gameState.HasGfxDevice() && gameState.HasRenderer() && gameState.Renderer().getResources() != nullptr) {
try {
m_Texture = gameState.Renderer().getResources()->loadImageFromFile(
gameState.Gfx(), AssetFS::GetInstance().GetFullPath(m_TexturePath));
} catch (const std::exception& exception) {
spdlog::warn("UIImage: failed to load texture '{}': {}", m_TexturePath, exception.what());
m_Texture = NULL_IMAGE_ID;
}
} else {
return NULL_IMAGE_ID;
}
m_LoadedTexturePath = m_TexturePath;
return m_Texture;
}
nlohmann::json UIImage::Serialize() const
{
return {
{"color", m_Color},
{"shape", m_Shape == UIImageShape::Ellipse ? "ellipse" : "rect"},
{"outline", m_Outline},
{"cornerRadius", m_CornerRadius},
{"texture", m_TexturePath},
{"filter", m_Filter == TextureFilter::Nearest ? "nearest" : "linear"},
};
}
void UIImage::Deserialize(const nlohmann::json& data)
{
m_Color = data.value("color", m_Color);
m_Shape = data.value("shape", std::string{"rect"}) == "ellipse" ? UIImageShape::Ellipse : UIImageShape::Rect;
m_Outline = data.value("outline", m_Outline);
m_CornerRadius = data.value("cornerRadius", m_CornerRadius);
m_TexturePath = data.value("texture", m_TexturePath);
m_Filter = data.value("filter", std::string{"linear"}) == "nearest" ? TextureFilter::Nearest : TextureFilter::Linear;
}
void UIImage::ImGuiInspector()
{
constexpr std::array shapeNames{"Rect", "Ellipse"};
int shape = static_cast<int>(m_Shape);
if (ImGui::Combo("Shape", &shape, shapeNames.data(), static_cast<int>(shapeNames.size()))) {
m_Shape = static_cast<UIImageShape>(shape);
}
ImGui::ColorEdit4("Color", &m_Color.x);
ImGui::DragFloat("Outline", &m_Outline, 0.1f, 0.0f, 100.0f);
if (m_Shape == UIImageShape::Rect) {
ImGui::DragFloat("Corner Radius", &m_CornerRadius, 0.1f, 0.0f, 500.0f);
}
ImGuiUtils::InputString("Texture", m_TexturePath);
bool nearest = m_Filter == TextureFilter::Nearest;
if (ImGui::Checkbox("Nearest Filter", &nearest)) {
m_Filter = nearest ? TextureFilter::Nearest : TextureFilter::Linear;
}
}
+133
View File
@@ -0,0 +1,133 @@
#include <destrum/Components/UI/UIText.h>
#include <array>
#include <destrum/Graphics/Managers/Renderer2D.h>
#include <destrum/Graphics/Resources/FontCache.h>
#include <destrum/Serialization/GlmJson.h>
#include <destrum/Util/ImGuiUtils.h>
#include <imgui.h>
namespace {
[[nodiscard]] float AlignFactor(UITextAlign align)
{
switch (align) {
case UITextAlign::Start: return 0.0f;
case UITextAlign::Center: return 0.5f;
case UITextAlign::End: return 1.0f;
}
return 0.0f;
}
[[nodiscard]] const char* AlignName(UITextAlign align)
{
switch (align) {
case UITextAlign::Start: return "start";
case UITextAlign::Center: return "center";
case UITextAlign::End: return "end";
}
return "start";
}
[[nodiscard]] UITextAlign ParseAlign(const std::string& name, UITextAlign fallback)
{
if (name == "start") return UITextAlign::Start;
if (name == "center") return UITextAlign::Center;
if (name == "end") return UITextAlign::End;
return fallback;
}
}
UIText::UIText(GameObject& parent, std::string text, float fontSize)
: UIGraphic(parent, "UIText")
, m_Text(std::move(text))
, m_FontPath(FontCache::DefaultFont)
, m_FontSize(fontSize)
{
}
void UIText::SetOutline(float thickness, const glm::vec4& color)
{
m_Outline = thickness;
m_OutlineColor = color;
}
void UIText::SetAlignment(UITextAlign horizontal, UITextAlign vertical)
{
m_HorizontalAlign = horizontal;
m_VerticalAlign = vertical;
}
glm::vec2 UIText::PlaceText(const UIRect& rect, const glm::vec2& textSize) const
{
return rect.min + (rect.size - textSize) *
glm::vec2{AlignFactor(m_HorizontalAlign), AlignFactor(m_VerticalAlign)};
}
void UIText::DrawUI(const UIRect& rect, float scale)
{
if (m_Text.empty() || m_FontSize <= 0.0f || m_Color.w <= 0.0f) {
return;
}
const Font* font = FontCache::GetInstance().Get(m_FontPath);
if (font == nullptr) {
return;
}
const float size = m_FontSize * scale;
const glm::vec2 topLeft = PlaceText(rect, font->MeasureText(m_Text, size));
Renderer2D::GetInstance().DrawText(
*font,
m_Text,
topLeft,
size,
TextStyle{.color = m_Color, .outline = m_Outline * scale, .outlineColor = m_OutlineColor});
}
nlohmann::json UIText::Serialize() const
{
return {
{"text", m_Text},
{"font", m_FontPath},
{"fontSize", m_FontSize},
{"color", m_Color},
{"outline", m_Outline},
{"outlineColor", m_OutlineColor},
{"horizontalAlign", AlignName(m_HorizontalAlign)},
{"verticalAlign", AlignName(m_VerticalAlign)},
};
}
void UIText::Deserialize(const nlohmann::json& data)
{
m_Text = data.value("text", m_Text);
m_FontPath = data.value("font", m_FontPath);
m_FontSize = data.value("fontSize", m_FontSize);
m_Color = data.value("color", m_Color);
m_Outline = data.value("outline", m_Outline);
m_OutlineColor = data.value("outlineColor", m_OutlineColor);
m_HorizontalAlign = ParseAlign(data.value("horizontalAlign", std::string{}), m_HorizontalAlign);
m_VerticalAlign = ParseAlign(data.value("verticalAlign", std::string{}), m_VerticalAlign);
}
void UIText::ImGuiInspector()
{
ImGuiUtils::InputStringMultiline("Text", m_Text);
ImGuiUtils::InputString("Font", m_FontPath);
ImGui::DragFloat("Font Size", &m_FontSize, 0.5f, 1.0f, 1000.0f);
ImGui::ColorEdit4("Color", &m_Color.x);
ImGui::DragFloat("Outline", &m_Outline, 0.1f, 0.0f, 16.0f);
ImGui::ColorEdit4("Outline Color", &m_OutlineColor.x);
constexpr std::array alignNames{"Start", "Center", "End"};
int horizontal = static_cast<int>(m_HorizontalAlign);
if (ImGui::Combo("Horizontal", &horizontal, alignNames.data(), static_cast<int>(alignNames.size()))) {
m_HorizontalAlign = static_cast<UITextAlign>(horizontal);
}
int vertical = static_cast<int>(m_VerticalAlign);
if (ImGui::Combo("Vertical", &vertical, alignNames.data(), static_cast<int>(alignNames.size()))) {
m_VerticalAlign = static_cast<UITextAlign>(vertical);
}
}
@@ -0,0 +1,34 @@
#include <destrum/Graphics/Resources/FontCache.h>
#include <destrum/FS/AssetFS.h>
#include <destrum/Graphics/Renderer.h>
#include <destrum/Util/GameState.h>
#include <spdlog/spdlog.h>
const Font* FontCache::Get(std::string_view assetPath)
{
auto it = fonts.find(std::string{assetPath});
if (it == fonts.end()) {
std::unique_ptr<Font> font;
try {
font = std::make_unique<Font>(Font::LoadFromFile(AssetFS::GetInstance().GetFullPath(assetPath)));
} catch (const std::exception& exception) {
spdlog::warn("FontCache: failed to load '{}': {}", assetPath, exception.what());
}
// Failures are cached too so a bad path doesn't hit the disk every frame
it = fonts.emplace(std::string{assetPath}, std::move(font)).first;
}
Font* font = it->second.get();
if (font == nullptr) {
return nullptr;
}
auto& gameState = GameState::GetInstance();
if (font->GetAtlas() == NULL_IMAGE_ID && gameState.HasGfxDevice() && gameState.HasRenderer() &&
gameState.Renderer().getResources() != nullptr) {
font->Upload(gameState.Gfx(), *gameState.Renderer().getResources());
}
return font;
}
+22
View File
@@ -195,6 +195,28 @@ void Transform::RemoveChild(Transform* transform) {
}
}
int Transform::GetSiblingIndex() const {
if (m_Parent == nullptr) {
return 0;
}
const auto& siblings = m_Parent->m_Children;
return static_cast<int>(std::find(siblings.begin(), siblings.end(), this) - siblings.begin());
}
void Transform::SetSiblingIndex(int index) {
if (m_Parent == nullptr) {
return;
}
auto& siblings = m_Parent->m_Children;
const auto it = std::find(siblings.begin(), siblings.end(), this);
if (it == siblings.end()) {
return;
}
siblings.erase(it);
const int clamped = std::clamp(index, 0, static_cast<int>(siblings.size()));
siblings.insert(siblings.begin() + clamped, this);
}
void Transform::AddChild(Transform* transform) {
if (transform == nullptr || transform == this) {
return;
+62 -9
View File
@@ -1,7 +1,6 @@
#include <destrum/Scene/Scene.h>
#include <destrum/Components/CameraComponent.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/Util/ImGuiUtils.h>
#include <algorithm>
#include <cmath>
@@ -238,14 +237,68 @@ void Scene::SetCurrentCamera(const CameraComponent* camera) {
}
void Scene::RenderImgui(bool* open) {
ImGuiUtils::RenderSceneInspector(
m_name,
m_objects,
m_pendingAdditions,
m_SelectedObjectId,
[this] { return CreateGameObject("GameObject"); },
[this] { DrawSettingsImGui(); },
open);
m_Inspector.Draw(*this, m_SelectedObjectId, [this] { DrawSettingsImGui(); }, open);
}
bool Scene::MoveInHierarchy(GameObject& object, GameObject* parent, const GameObject* insertBefore) {
if (IsIterating() || object.IsBeingDestroyed() || object.GetScene() != this || parent == &object) {
return false;
}
Transform& transform = object.GetTransform();
Transform* parentTransform = parent != nullptr ? &parent->GetTransform() : nullptr;
if (parentTransform != nullptr && transform.IsChild(parentTransform)) {
return false;
}
transform.SetParent(parentTransform, true);
if (transform.GetParent() != parentTransform) {
return false;
}
const auto findIn = [](auto& objects, const GameObject* target) {
return std::find_if(objects.begin(), objects.end(), [target](const auto& candidate) {
return candidate.get() == target;
});
};
if (parentTransform != nullptr) {
int index = parentTransform->GetChildCount();
if (insertBefore != nullptr && insertBefore->GetTransform().GetParent() == parentTransform) {
index = insertBefore->GetTransform().GetSiblingIndex();
if (transform.GetSiblingIndex() < index) {
--index;
}
}
transform.SetSiblingIndex(index);
}
// Loading rebuilds sibling order from the object list, so keep the list in the same order
const auto it = findIn(m_objects, &object);
if (it == m_objects.end()) {
return true;
}
std::shared_ptr<GameObject> moved = *it;
m_objects.erase(it);
auto position = m_objects.end();
if (insertBefore != nullptr) {
position = findIn(m_objects, insertBefore);
} else if (parentTransform != nullptr) {
// After the last sibling that is already in the list, or right after the parent
const GameObject* anchor = parent;
for (const Transform* sibling : parentTransform->GetChildren()) {
if (sibling != &transform && findIn(m_objects, sibling->GetOwner()) != m_objects.end()) {
anchor = sibling->GetOwner();
}
}
position = findIn(m_objects, anchor);
if (position != m_objects.end()) {
++position;
}
}
m_objects.insert(position, std::move(moved));
return true;
}
void Scene::CleanupDestroyedGameObjects() {
+60 -343
View File
@@ -4,7 +4,6 @@
#include <cfloat>
#include <exception>
#include <string>
#include <unordered_set>
#include <vector>
#include <imgui.h>
@@ -15,90 +14,21 @@
#include <destrum/ObjectModel/Component.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/ObjectModel/Transform.h>
#include <destrum/Serialization/ComponentFactory.h>
#include <destrum/Serialization/ComponentRegistry.h>
#include <destrum/Serialization/PrefabSerializer.h>
#include <destrum/Scene/Scene.h>
namespace {
int ResizeStringCallback(ImGuiInputTextCallbackData* data) {
if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) {
auto* string = static_cast<std::string*>(data->UserData);
string->resize(static_cast<std::size_t>(data->BufTextLen));
data->Buf = string->data();
}
return 0;
}
[[nodiscard]] bool IsInspectableObject(const GameObject* object) {
return object != nullptr && !object->IsBeingDestroyed();
}
[[nodiscard]] bool HasInspectableChildren(const GameObject& object) {
return std::ranges::any_of(object.GetTransform().GetChildren(),
[](const Transform* child) {
return child != nullptr && IsInspectableObject(child->GetOwner());
});
}
void DrawGameObjectNode(
GameObject& object,
ObjectId& selectedObjectId,
std::unordered_set<const GameObject*>& visited,
bool defaultOpen = false) {
if (!IsInspectableObject(&object) || !visited.insert(&object).second) {
return;
}
const bool hasChildren = HasInspectableChildren(object);
ImGuiTreeNodeFlags treeFlags = ImGuiTreeNodeFlags_SpanAvailWidth;
if (object.GetId() == selectedObjectId) {
treeFlags |= ImGuiTreeNodeFlags_Selected;
}
if (defaultOpen) {
treeFlags |= ImGuiTreeNodeFlags_DefaultOpen;
}
if (!hasChildren) {
treeFlags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen;
}
const std::string displayName = object.GetName().empty()
? "<unnamed>"
: object.GetName();
const bool isOpen = ImGui::TreeNodeEx(
static_cast<const void*>(&object),
treeFlags,
"%s",
displayName.c_str());
if (ImGui::IsItemClicked()) {
selectedObjectId = object.GetId();
}
if (!object.IsActive()) {
ImGui::SameLine();
ImGui::TextDisabled("[inactive]");
} else if (!object.IsActiveInHierarchy()) {
ImGui::SameLine();
ImGui::TextDisabled("[parent inactive]");
}
if (isOpen && hasChildren) {
for (Transform* childTransform : object.GetTransform().GetChildren()) {
if (childTransform == nullptr) {
continue;
}
GameObject* child = childTransform->GetOwner();
if (IsInspectableObject(child)) {
DrawGameObjectNode(*child, selectedObjectId, visited);
}
}
ImGui::TreePop();
}
}
[[nodiscard]] GameObject* FindSelectedObject(
const std::vector<GameObject*>& objects,
ObjectId selectedObjectId) {
const auto it = std::find_if(
objects.begin(),
objects.end(),
[selectedObjectId](const GameObject* object) {
return object != nullptr && object->GetId() == selectedObjectId;
});
return it == objects.end() ? nullptr : *it;
}
}
namespace ImGuiUtils {
@@ -110,7 +40,7 @@ namespace ImGuiUtils {
ImGui::PushID(label);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(label);
ImGui::SameLine();
ImGui::SameLine(ImGui::GetFontSize() * 5.0f);
ImGui::SetNextItemWidth(-FLT_MIN);
const bool changed = ImGui::DragFloat3(
"##value",
@@ -120,272 +50,59 @@ namespace ImGuiUtils {
return changed;
}
void RenderSceneInspector(
std::string_view sceneName,
const std::vector<std::shared_ptr<GameObject>>& objectsSource,
const std::vector<std::shared_ptr<GameObject>>& pendingAdditions,
ObjectId& selectedObjectId,
const std::function<GameObject*()>& createGameObject,
const std::function<void()>& drawSceneSettings,
bool* open) {
RegisterEngineComponents();
bool InputString(const char* label, std::string& value, ImGuiInputTextFlags flags, const char* hint)
{
flags |= ImGuiInputTextFlags_CallbackResize;
if (hint != nullptr) {
return ImGui::InputTextWithHint(label, hint, value.data(), value.capacity() + 1, flags, ResizeStringCallback, &value);
}
return ImGui::InputText(label, value.data(), value.capacity() + 1, flags, ResizeStringCallback, &value);
}
std::vector<GameObject*> objects;
objects.reserve(objectsSource.size() + pendingAdditions.size());
bool InputStringMultiline(const char* label, std::string& value)
{
return ImGui::InputTextMultiline(label, value.data(), value.capacity() + 1, ImVec2{0.0f, ImGui::GetTextLineHeight() * 4.0f},
ImGuiInputTextFlags_CallbackResize, ResizeStringCallback, &value);
}
const auto appendInspectableObjects = [&objects](const auto& source) {
for (const auto& object : source) {
if (IsInspectableObject(object.get())) {
objects.push_back(object.get());
bool DrawObjectRefPicker(const char* label, ObjectRef& ref, const Scene& scene,
const std::function<bool(const GameObject&)>& filter)
{
const GameObject* current = ref.Get(scene);
const std::string preview = current != nullptr ? current->GetName()
: ref.IsSet() ? "Missing object " + std::to_string(ref.GetId())
: "None";
bool changed = false;
if (ImGui::BeginCombo(label, preview.c_str())) {
if (ImGui::Selectable("None", !ref.IsSet())) {
ref = ObjectRef{};
changed = true;
}
for (const auto& object : scene.GetObjects()) {
if (!IsInspectableObject(object.get()) || (filter && !filter(*object))) {
continue;
}
ImGui::PushID(static_cast<int>(object->GetId()));
if (ImGui::Selectable(object->GetName().c_str(), object.get() == current)) {
ref = ObjectRef(*object);
changed = true;
}
ImGui::PopID();
}
ImGui::EndCombo();
}
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(GameObjectPayload)) {
GameObject* dropped = scene.FindGameObject(*static_cast<const ObjectId*>(payload->Data));
if (dropped != nullptr && (!filter || filter(*dropped))) {
ref = ObjectRef(*dropped);
changed = true;
}
}
};
appendInspectableObjects(objectsSource);
appendInspectableObjects(pendingAdditions);
std::vector<GameObject*> roots;
roots.reserve(objects.size());
for (GameObject* object : objects) {
Transform* parent = object->GetTransform().GetParent();
if (parent == nullptr || !IsInspectableObject(parent->GetOwner())) {
roots.push_back(object);
}
ImGui::EndDragDropTarget();
}
if (FindSelectedObject(objects, selectedObjectId) == nullptr) {
selectedObjectId = InvalidObjectId;
}
ImGui::SetNextWindowSize(ImVec2(640.0f, 520.0f), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Inspector", open)) {
ImGui::End();
return;
}
const std::string displaySceneName = sceneName.empty()
? "<unnamed>"
: std::string{sceneName};
ImGui::Text("Scene: %s", displaySceneName.c_str());
ImGui::SameLine();
ImGui::TextDisabled("%zu GameObjects | %zu roots", objects.size(), roots.size());
if (createGameObject) {
ImGui::SameLine();
if (ImGui::Button("+ Add GameObject")) {
if (GameObject* created = createGameObject()) {
objects.push_back(created);
selectedObjectId = created->GetId();
}
}
}
if (drawSceneSettings && ImGui::CollapsingHeader("Scene Settings")) {
drawSceneSettings();
}
ImGui::Separator();
const float availableWidth = ImGui::GetContentRegionAvail().x;
const float hierarchyWidth = std::max(180.0f, availableWidth * 0.45f);
if (ImGui::BeginChild(
"SceneHierarchy",
ImVec2(hierarchyWidth, 0.0f),
ImGuiChildFlags_Borders)) {
std::unordered_set<const GameObject*> visited;
visited.reserve(objects.size());
for (GameObject* root : roots) {
DrawGameObjectNode(*root, selectedObjectId, visited, true);
}
// Keep malformed or partially loaded hierarchies visible instead of
// silently dropping objects whose parent is not in this list.
bool hasUnvisitedObjects = false;
for (GameObject* object : objects) {
if (!visited.contains(object)) {
if (!hasUnvisitedObjects) {
ImGui::Separator();
ImGui::TextDisabled("OTHER OBJECTS");
hasUnvisitedObjects = true;
}
DrawGameObjectNode(*object, selectedObjectId, visited);
}
}
if (objects.empty()) {
ImGui::TextDisabled("This scene has no GameObjects.");
}
}
ImGui::EndChild();
GameObject* selectedObject = FindSelectedObject(objects, selectedObjectId);
if (selectedObject == nullptr) {
selectedObjectId = InvalidObjectId;
}
ImGui::SameLine();
if (ImGui::BeginChild(
"ObjectInspector",
ImVec2(0.0f, 0.0f),
ImGuiChildFlags_Borders)) {
if (selectedObject == nullptr) {
ImGui::TextDisabled("Select a GameObject to inspect it.");
} else {
const std::string displayName = selectedObject->GetName().empty()
? "<unnamed>"
: selectedObject->GetName();
ImGui::TextUnformatted(displayName.c_str());
ImGui::TextDisabled(
"GameObject | ID %llu",
static_cast<unsigned long long>(selectedObject->GetId()));
ImGui::Separator();
bool active = selectedObject->IsActive();
if (ImGui::Checkbox("Active", &active)) {
selectedObject->SetActive(active);
}
ImGui::SameLine();
const bool duplicateShortcut = ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) &&
ImGui::IsKeyChordPressed(ImGuiMod_Ctrl | ImGuiKey_D);
if (ImGui::Button("Duplicate") || duplicateShortcut) {
if (GameObject* duplicate = PrefabSerializer::Duplicate(*selectedObject)) {
selectedObjectId = duplicate->GetId();
}
}
const Transform* parent = selectedObject->GetTransform().GetParent();
if (parent != nullptr && parent->GetOwner() != nullptr) {
ImGui::Text("Parent: %s", parent->GetOwner()->GetName().c_str());
} else {
ImGui::Text("Parent: <scene root>");
}
ImGui::Text("Children: %d", selectedObject->GetTransform().GetChildCount());
if (ImGui::CollapsingHeader(
"Transform",
ImGuiTreeNodeFlags_DefaultOpen)) {
Transform& transform = selectedObject->GetTransform();
bool transformChanged = false;
glm::vec3 localPosition = transform.GetLocalPosition();
if (DrawVec3Control("Position", localPosition, 0.05f)) {
transform.SetLocalPosition(localPosition);
transformChanged = true;
}
glm::vec3 localRotation = glm::degrees(
glm::eulerAngles(transform.GetLocalRotation()));
if (DrawVec3Control("Rotation", localRotation, 0.5f)) {
transform.SetLocalRotation(localRotation);
transformChanged = true;
}
glm::vec3 localScale = transform.GetLocalScale();
if (DrawVec3Control("Scale", localScale, 0.05f)) {
transform.SetLocalScale(localScale);
transformChanged = true;
}
if (transformChanged) {
selectedObject->RefreshPhysicsInHierarchy();
}
const glm::vec3& worldPosition = transform.GetWorldPosition();
ImGui::Text(
"World position: %.2f, %.2f, %.2f",
worldPosition.x,
worldPosition.y,
worldPosition.z);
}
const bool componentsOpen = ImGui::CollapsingHeader(
"Components",
ImGuiTreeNodeFlags_DefaultOpen);
if (componentsOpen) {
if (ImGui::Button("+ Add Component")) {
ImGui::OpenPopup("AddComponentPopup");
}
std::string addComponentError;
if (ImGui::BeginPopup("AddComponentPopup")) {
std::vector<std::string> componentTypes =
ComponentFactory::GetRegisteredTypeNames();
std::sort(componentTypes.begin(), componentTypes.end());
bool hasAvailableComponent = false;
for (const std::string& componentType : componentTypes) {
const bool alreadyAdded = std::any_of(
selectedObject->GetComponents().begin(),
selectedObject->GetComponents().end(),
[&componentType](const auto& component) {
return component != nullptr &&
!component->IsBeingDestroyed() &&
component->GetTypeName() == componentType;
});
if (alreadyAdded) {
const std::string label =
componentType + " (already added)";
ImGui::BeginDisabled();
ImGui::MenuItem(label.c_str());
ImGui::EndDisabled();
continue;
}
hasAvailableComponent = true;
if (ImGui::MenuItem(componentType.c_str())) {
try {
if (ComponentFactory::Create(
componentType,
*selectedObject) == nullptr) {
addComponentError =
"Component type is not registered: " +
componentType;
} else {
ImGui::CloseCurrentPopup();
}
} catch (const std::exception& exception) {
addComponentError = exception.what();
}
}
}
if (!hasAvailableComponent) {
ImGui::TextDisabled("All components are already added.");
}
if (!addComponentError.empty()) {
ImGui::TextWrapped("Failed to add component: %s",
addComponentError.c_str());
}
ImGui::EndPopup();
}
bool hasComponents = false;
for (const auto& component : selectedObject->GetComponents()) {
if (component == nullptr || component->IsBeingDestroyed()) {
continue;
}
hasComponents = true;
ImGui::PushID(component.get());
const std::string typeName = component->GetTypeName();
if (ImGui::CollapsingHeader(typeName.c_str())) {
bool enabled = component->isEnabled();
if (ImGui::Checkbox("Enabled", &enabled)) {
component->SetEnabled(enabled);
}
component->ImGuiInspector();
}
ImGui::PopID();
}
if (!hasComponents) {
ImGui::TextDisabled("No components.");
}
}
}
}
ImGui::EndChild();
ImGui::End();
return changed;
}
}
+167
View File
@@ -0,0 +1,167 @@
#include <destrum/Util/RectGizmo.h>
#include <array>
#include <cmath>
#include <destrum/Components/UI/Canvas.h>
#include <destrum/Components/UI/RectTransform.h>
#include <destrum/ObjectModel/GameObject.h>
#include <imgui.h>
namespace {
constexpr int MoveHandle = 0;
constexpr float HandleRadius = 5.0f;
constexpr std::array<glm::vec2, 8> HandleDirections{{
{-1.0f, -1.0f}, {0.0f, -1.0f}, {1.0f, -1.0f}, {1.0f, 0.0f},
{1.0f, 1.0f}, {0.0f, 1.0f}, {-1.0f, 1.0f}, {-1.0f, 0.0f},
}};
constexpr ImU32 OutlineColor = IM_COL32(60, 200, 255, 255);
constexpr ImU32 ActiveColor = IM_COL32(255, 200, 60, 255);
constexpr ImU32 HiddenColor = IM_COL32(150, 150, 150, 200);
constexpr ImU32 AnchorColor = IM_COL32(255, 255, 255, 200);
[[nodiscard]] ImVec2 ToImVec(const glm::vec2& value) { return ImVec2{value.x, value.y}; }
[[nodiscard]] glm::vec2 ToVec(const ImVec2& value) { return glm::vec2{value.x, value.y}; }
[[nodiscard]] ImGuiMouseCursor CursorFor(int handle)
{
if (handle == MoveHandle) {
return ImGuiMouseCursor_ResizeAll;
}
const glm::vec2 direction = HandleDirections[static_cast<std::size_t>(handle - 1)];
if (direction.x == 0.0f) {
return ImGuiMouseCursor_ResizeNS;
}
if (direction.y == 0.0f) {
return ImGuiMouseCursor_ResizeEW;
}
return direction.x == direction.y ? ImGuiMouseCursor_ResizeNWSE : ImGuiMouseCursor_ResizeNESW;
}
void DrawAnchor(ImDrawList& drawList, const glm::vec2& point, const glm::vec2& outwards)
{
constexpr float size = 8.0f;
const glm::vec2 tip = point;
const glm::vec2 a = point + glm::vec2{outwards.x * size, outwards.y * size * 0.4f};
const glm::vec2 b = point + glm::vec2{outwards.x * size * 0.4f, outwards.y * size};
drawList.AddTriangleFilled(ToImVec(tip), ToImVec(a), ToImVec(b), AnchorColor);
}
}
bool RectGizmo::CanManipulate(GameObject& object)
{
return object.GetComponent<RectTransform>() != nullptr && object.GetComponent<Canvas>() == nullptr &&
Canvas::FindFor(object) != nullptr;
}
bool RectGizmo::Manipulate(GameObject& object, const glm::vec2& drawImageSize)
{
auto* rectTransform = object.GetComponent<RectTransform>();
const Canvas* canvas = Canvas::FindFor(object);
if (!CanManipulate(object) || drawImageSize.x <= 0.0f || drawImageSize.y <= 0.0f) {
m_ActiveHandle = -1;
return false;
}
const ImGuiViewport* viewport = ImGui::GetMainViewport();
const glm::vec2 viewOrigin = ToVec(viewport->Pos);
const glm::vec2 viewScale = ToVec(viewport->Size) / drawImageSize;
const auto toView = [&](const glm::vec2& point) { return viewOrigin + point * viewScale; };
const UIRect& rect = rectTransform->GetScreenRect();
const glm::vec2 viewMin = toView(rect.min);
const glm::vec2 viewMax = toView(rect.Max());
const glm::vec2 viewCenter = (viewMin + viewMax) * 0.5f;
const glm::vec2 viewHalfSize = (viewMax - viewMin) * 0.5f;
const glm::vec2 mouse = ToVec(ImGui::GetIO().MousePos);
int hovered = -1;
const bool overImGui = ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow) || ImGui::IsAnyItemActive();
if (m_ActiveHandle < 0 && !overImGui) {
for (std::size_t i = 0; i < HandleDirections.size(); ++i) {
const glm::vec2 handle = viewCenter + HandleDirections[i] * viewHalfSize;
if (glm::length(mouse - handle) <= HandleRadius + 2.0f) {
hovered = static_cast<int>(i) + 1;
break;
}
}
if (hovered < 0 && mouse.x >= viewMin.x && mouse.y >= viewMin.y && mouse.x <= viewMax.x && mouse.y <= viewMax.y) {
hovered = MoveHandle;
}
if (hovered >= 0 && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
m_ActiveHandle = hovered;
m_DragStartMouse = mouse;
m_DragStartPosition = rectTransform->GetPosition();
m_DragStartSize = rectTransform->GetSize();
}
}
bool changed = false;
if (m_ActiveHandle >= 0) {
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
m_ActiveHandle = -1;
} else {
const float canvasScale = canvas->GetScale(drawImageSize);
glm::vec2 delta = (mouse - m_DragStartMouse) / (viewScale * canvasScale);
if (snap && snapStep > 0.0f) {
delta = glm::round(delta / snapStep) * snapStep;
}
glm::vec2 position = m_DragStartPosition;
glm::vec2 size = m_DragStartSize;
if (m_ActiveHandle == MoveHandle) {
position += delta;
} else {
// The edge opposite the handle stays put, so the pivot moves by the pivot's share of the resize
const glm::vec2 direction = HandleDirections[static_cast<std::size_t>(m_ActiveHandle - 1)];
const glm::vec2& pivot = rectTransform->GetPivot();
for (int axis = 0; axis < 2; ++axis) {
if (direction[axis] == 0.0f) {
continue;
}
size[axis] += direction[axis] * delta[axis];
position[axis] += delta[axis] * (direction[axis] > 0.0f ? pivot[axis] : 1.0f - pivot[axis]);
}
}
if (position != rectTransform->GetPosition() || size != rectTransform->GetSize()) {
rectTransform->SetPosition(position);
rectTransform->SetSize(size);
changed = true;
}
}
}
const int highlighted = m_ActiveHandle >= 0 ? m_ActiveHandle : hovered;
if (highlighted >= 0) {
ImGui::SetMouseCursor(CursorFor(highlighted));
// Keeps the click from reaching the game, same as the 3D gizmo
ImGui::SetNextFrameWantCaptureMouse(true);
}
ImDrawList& drawList = *ImGui::GetBackgroundDrawList();
const ImU32 outlineColor = object.IsActiveInHierarchy() ? OutlineColor : HiddenColor;
const UIRect& parentRect = rectTransform->GetParentRect();
const glm::vec2 anchorMin = toView(parentRect.min + parentRect.size * rectTransform->GetAnchorMin());
const glm::vec2 anchorMax = toView(parentRect.min + parentRect.size * rectTransform->GetAnchorMax());
DrawAnchor(drawList, {anchorMin.x, anchorMin.y}, {-1.0f, -1.0f});
DrawAnchor(drawList, {anchorMax.x, anchorMin.y}, {1.0f, -1.0f});
DrawAnchor(drawList, {anchorMax.x, anchorMax.y}, {1.0f, 1.0f});
DrawAnchor(drawList, {anchorMin.x, anchorMax.y}, {-1.0f, 1.0f});
drawList.AddRect(ToImVec(viewMin), ToImVec(viewMax), highlighted == MoveHandle ? ActiveColor : outlineColor, 0.0f, 0, 1.5f);
const glm::vec2 pivot = viewMin + (viewMax - viewMin) * rectTransform->GetPivot();
drawList.AddCircle(ToImVec(pivot), 4.0f, outlineColor, 12, 1.5f);
for (std::size_t i = 0; i < HandleDirections.size(); ++i) {
const glm::vec2 handle = viewCenter + HandleDirections[i] * viewHalfSize;
const ImU32 color = highlighted == static_cast<int>(i) + 1 ? ActiveColor : outlineColor;
drawList.AddRectFilled(ToImVec(handle - glm::vec2{HandleRadius}), ToImVec(handle + glm::vec2{HandleRadius}), color);
}
return changed;
}
+745
View File
@@ -0,0 +1,745 @@
#include <destrum/Util/SceneInspector.h>
#include <algorithm>
#include <cctype>
#include <cfloat>
#include <utility>
#include <vector>
#include <imgui.h>
#include <spdlog/spdlog.h>
#include <destrum/Components/UI/Canvas.h>
#include <destrum/Components/UI/RectTransform.h>
#include <destrum/Components/UI/UIImage.h>
#include <destrum/Components/UI/UIText.h>
#include <destrum/ObjectModel/Component.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/ObjectModel/Transform.h>
#include <destrum/Scene/Scene.h>
#include <destrum/Serialization/ComponentFactory.h>
#include <destrum/Serialization/ComponentRegistry.h>
#include <destrum/Serialization/PrefabSerializer.h>
#include <destrum/Util/ImGuiUtils.h>
namespace {
[[nodiscard]] bool IsInspectable(const GameObject* object)
{
return object != nullptr && !object->IsBeingDestroyed();
}
[[nodiscard]] GameObject* ParentOf(const GameObject& object)
{
const Transform* parent = object.GetTransform().GetParent();
return parent != nullptr ? parent->GetOwner() : nullptr;
}
[[nodiscard]] bool HasInspectableChildren(const GameObject& object)
{
return std::ranges::any_of(object.GetTransform().GetChildren(), [](const Transform* child) {
return child != nullptr && IsInspectable(child->GetOwner());
});
}
[[nodiscard]] std::vector<GameObject*> CollectObjects(const Scene& scene)
{
std::vector<GameObject*> objects;
objects.reserve(scene.GetObjects().size() + scene.GetPendingAdditions().size());
for (const auto* source : {&scene.GetObjects(), &scene.GetPendingAdditions()}) {
for (const auto& object : *source) {
if (IsInspectable(object.get())) {
objects.push_back(object.get());
}
}
}
return objects;
}
[[nodiscard]] bool IsRoot(const GameObject& object)
{
return !IsInspectable(ParentOf(object));
}
[[nodiscard]] GameObject* NextSibling(const Scene& scene, const GameObject& object)
{
if (const Transform* parent = object.GetTransform().GetParent()) {
const auto& siblings = parent->GetChildren();
const auto it = std::ranges::find(siblings, &object.GetTransform());
for (auto next = it == siblings.end() ? it : it + 1; next != siblings.end(); ++next) {
if (IsInspectable((*next)->GetOwner())) {
return (*next)->GetOwner();
}
}
return nullptr;
}
const auto objects = CollectObjects(scene);
const auto it = std::ranges::find(objects, &object);
for (auto next = it == objects.end() ? it : it + 1; next != objects.end(); ++next) {
if (IsRoot(**next)) {
return *next;
}
}
return nullptr;
}
[[nodiscard]] bool ContainsIgnoreCase(std::string_view text, std::string_view query)
{
const auto it = std::search(text.begin(), text.end(), query.begin(), query.end(), [](char a, char b) {
return std::tolower(static_cast<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(b));
});
return it != text.end();
}
[[nodiscard]] const char* DisplayName(const GameObject& object)
{
return object.GetName().empty() ? "<unnamed>" : object.GetName().c_str();
}
GameObject* CreateChild(Scene& scene, GameObject* parent, const std::string& name)
{
GameObject* object = scene.CreateGameObject(name);
if (parent != nullptr) {
object->GetTransform().SetParent(&parent->GetTransform(), false);
}
return object;
}
GameObject* CreateUIChild(Scene& scene, GameObject* parent, const std::string& name)
{
if (parent == nullptr || Canvas::FindFor(*parent) == nullptr) {
parent = CreateChild(scene, parent, "Canvas");
parent->AddComponent<Canvas>();
}
GameObject* object = CreateChild(scene, parent, name);
object->AddComponent<RectTransform>();
return object;
}
// Returns the created object so the caller can select and rename it
GameObject* DrawCreateMenu(Scene& scene, GameObject* parent)
{
GameObject* created = nullptr;
if (ImGui::MenuItem("Empty")) {
created = CreateChild(scene, parent, "GameObject");
}
if (ImGui::BeginMenu("UI")) {
if (ImGui::MenuItem("Canvas")) {
created = CreateChild(scene, parent, "Canvas");
created->AddComponent<Canvas>();
}
if (ImGui::MenuItem("Rect")) {
created = CreateUIChild(scene, parent, "Rect");
}
if (ImGui::MenuItem("Image")) {
created = CreateUIChild(scene, parent, "Image");
created->AddComponent<UIImage>();
}
if (ImGui::MenuItem("Text")) {
created = CreateUIChild(scene, parent, "Text");
created->GetComponent<RectTransform>()->SetSize({300.0f, 60.0f});
created->AddComponent<UIText>("Text", 32.0f);
}
ImGui::EndMenu();
}
return created;
}
// Right aligned controls drawn over a collapsing header, x is from the start of the line
[[nodiscard]] float RightAlignedX(float lineStartX, float lineWidth, float itemsWidth)
{
return lineStartX + lineWidth - itemsWidth;
}
}
void SceneInspector::Draw(Scene& scene, ObjectId& selectedObjectId, const std::function<void()>& drawSceneSettings, bool* open)
{
RegisterEngineComponents();
if (scene.FindGameObject(selectedObjectId) == nullptr) {
selectedObjectId = InvalidObjectId;
}
ImGui::SetNextWindowSize(ImVec2(720.0f, 560.0f), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Inspector", open)) {
ImGui::End();
return;
}
HandleShortcuts(scene, selectedObjectId);
if (ImGui::BeginChild("Hierarchy", ImVec2(std::max(220.0f, ImGui::GetContentRegionAvail().x * 0.4f), 0.0f),
ImGuiChildFlags_Borders | ImGuiChildFlags_ResizeX)) {
DrawHierarchy(scene, selectedObjectId);
}
ImGui::EndChild();
ImGui::SameLine();
if (ImGui::BeginChild("Properties", ImVec2(0.0f, 0.0f), ImGuiChildFlags_Borders)) {
if (GameObject* selected = scene.FindGameObject(selectedObjectId)) {
DrawObject(*selected);
} else if (drawSceneSettings) {
drawSceneSettings();
}
}
ImGui::EndChild();
ImGui::End();
ApplyPendingActions(scene, selectedObjectId);
}
void SceneInspector::HandleShortcuts(Scene& scene, ObjectId& selectedObjectId)
{
if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) || ImGui::GetIO().WantTextInput ||
selectedObjectId == InvalidObjectId) {
return;
}
if (ImGui::IsKeyPressed(ImGuiKey_Delete, false)) {
m_PendingDelete = selectedObjectId;
}
if (ImGui::IsKeyChordPressed(ImGuiMod_Ctrl | ImGuiKey_D)) {
m_PendingDuplicate = selectedObjectId;
}
if (ImGui::IsKeyPressed(ImGuiKey_F2, false)) {
if (const GameObject* selected = scene.FindGameObject(selectedObjectId)) {
BeginRename(*selected);
}
}
}
void SceneInspector::DrawHierarchy(Scene& scene, ObjectId& selectedObjectId)
{
if (ImGui::Button("+")) {
ImGui::OpenPopup("CreateObject");
}
if (ImGui::BeginPopup("CreateObject")) {
if (GameObject* created = DrawCreateMenu(scene, nullptr)) {
selectedObjectId = created->GetId();
BeginRename(*created);
}
ImGui::EndPopup();
}
ImGui::SameLine();
ImGui::SetNextItemWidth(-FLT_MIN);
ImGuiUtils::InputString("##Search", m_Filter, 0, "Search");
if (!ImGui::BeginChild("Tree")) {
ImGui::EndChild();
return;
}
const std::vector<GameObject*> objects = CollectObjects(scene);
std::unordered_set<const GameObject*> visited;
visited.reserve(objects.size());
if (m_Filter.empty()) {
for (GameObject* object : objects) {
if (IsRoot(*object)) {
DrawNode(scene, *object, selectedObjectId, visited);
}
}
// Anything not reachable from a root is stuck in a parent cycle, still show it
std::unordered_set<const GameObject*> reachable;
std::vector<const GameObject*> pending;
for (const GameObject* object : objects) {
if (IsRoot(*object)) {
pending.push_back(object);
}
}
while (!pending.empty()) {
const GameObject* object = pending.back();
pending.pop_back();
if (!reachable.insert(object).second) {
continue;
}
for (const Transform* child : object->GetTransform().GetChildren()) {
if (child != nullptr && IsInspectable(child->GetOwner())) {
pending.push_back(child->GetOwner());
}
}
}
for (GameObject* object : objects) {
if (!reachable.contains(object)) {
DrawNode(scene, *object, selectedObjectId, visited);
}
}
} else {
for (GameObject* object : objects) {
if (ContainsIgnoreCase(object->GetName(), m_Filter)) {
DrawNode(scene, *object, selectedObjectId, visited);
}
}
}
// The empty space below the tree: click to deselect, drop to move to the root, right click to create
const ImVec2 remaining = ImGui::GetContentRegionAvail();
ImGui::InvisibleButton("##Background", ImVec2(std::max(remaining.x, 1.0f), std::max(remaining.y, ImGui::GetFrameHeight())));
if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) {
selectedObjectId = InvalidObjectId;
}
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(ImGuiUtils::GameObjectPayload)) {
m_PendingMove = {.object = *static_cast<const ObjectId*>(payload->Data)};
}
ImGui::EndDragDropTarget();
}
if (ImGui::BeginPopupContextItem("BackgroundMenu")) {
DrawObjectMenu(scene, nullptr, selectedObjectId);
ImGui::EndPopup();
}
ImGui::EndChild();
}
void SceneInspector::DrawNode(Scene& scene, GameObject& object, ObjectId& selectedObjectId,
std::unordered_set<const GameObject*>& visited)
{
if (!IsInspectable(&object) || !visited.insert(&object).second) {
return;
}
const bool flat = !m_Filter.empty();
const bool hasChildren = !flat && HasInspectableChildren(object);
const bool renaming = m_RenamingId == object.GetId();
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_SpanFullWidth |
ImGuiTreeNodeFlags_DrawLinesToNodes;
if (object.GetId() == selectedObjectId) {
flags |= ImGuiTreeNodeFlags_Selected;
}
if (!hasChildren) {
flags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen;
}
if (m_OpenNextFrame == object.GetId()) {
ImGui::SetNextItemOpen(true);
m_OpenNextFrame = InvalidObjectId;
}
const bool inactive = !object.IsActiveInHierarchy();
if (inactive) {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
}
const bool open = ImGui::TreeNodeEx(reinterpret_cast<void*>(static_cast<std::uintptr_t>(object.GetId())), flags, "%s",
renaming ? "" : DisplayName(object));
if (inactive) {
ImGui::PopStyleColor();
}
if ((ImGui::IsItemClicked(ImGuiMouseButton_Left) || ImGui::IsItemClicked(ImGuiMouseButton_Right)) &&
!ImGui::IsItemToggledOpen()) {
selectedObjectId = object.GetId();
}
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && !ImGui::IsItemToggledOpen()) {
BeginRename(object);
}
DrawNodeDragDrop(object);
if (ImGui::BeginPopupContextItem("ObjectMenu")) {
DrawObjectMenu(scene, &object, selectedObjectId);
ImGui::EndPopup();
}
if (renaming) {
ImGui::SameLine();
DrawRenameField(object);
}
if (open && hasChildren) {
for (Transform* child : object.GetTransform().GetChildren()) {
if (child != nullptr && IsInspectable(child->GetOwner())) {
DrawNode(scene, *child->GetOwner(), selectedObjectId, visited);
}
}
ImGui::TreePop();
}
}
void SceneInspector::DrawNodeDragDrop(GameObject& object)
{
const ObjectId id = object.GetId();
if (ImGui::BeginDragDropSource()) {
ImGui::SetDragDropPayload(ImGuiUtils::GameObjectPayload, &id, sizeof(id));
ImGui::TextUnformatted(DisplayName(object));
ImGui::EndDragDropSource();
}
if (!ImGui::BeginDragDropTarget()) {
return;
}
const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(
ImGuiUtils::GameObjectPayload,
ImGuiDragDropFlags_AcceptBeforeDelivery | ImGuiDragDropFlags_AcceptNoDrawDefaultRect);
if (payload != nullptr) {
const ObjectId dragged = *static_cast<const ObjectId*>(payload->Data);
const ImVec2 min = ImGui::GetItemRectMin();
const ImVec2 max = ImGui::GetItemRectMax();
const float zone = (max.y - min.y) * 0.25f;
const float mouseY = ImGui::GetMousePos().y;
const bool before = mouseY < min.y + zone;
const bool after = mouseY > max.y - zone;
ImDrawList* drawList = ImGui::GetWindowDrawList();
const ImU32 color = ImGui::GetColorU32(ImGuiCol_DragDropTarget);
if (before) {
drawList->AddLine(min, ImVec2(max.x, min.y), color, 2.0f);
} else if (after) {
drawList->AddLine(ImVec2(min.x, max.y), max, color, 2.0f);
} else {
drawList->AddRect(min, max, color, 0.0f, 0, 2.0f);
}
if (payload->IsDelivery() && dragged != id) {
const GameObject* parent = ParentOf(object);
const ObjectId parentId = IsInspectable(parent) ? parent->GetId() : InvalidObjectId;
if (before || after) {
m_PendingMove = {.object = dragged, .parent = parentId, .anchor = id, .after = after};
} else {
m_PendingMove = {.object = dragged, .parent = id};
}
}
}
ImGui::EndDragDropTarget();
}
void SceneInspector::DrawRenameField(GameObject& object)
{
if (m_FocusRename) {
ImGui::SetKeyboardFocusHere();
m_FocusRename = false;
}
ImGui::SetNextItemWidth(-FLT_MIN);
const bool submitted = ImGuiUtils::InputString("##Rename", m_RenameBuffer,
ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll);
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) {
m_RenamingId = InvalidObjectId;
return;
}
if (submitted || ImGui::IsItemDeactivated()) {
if (!m_RenameBuffer.empty()) {
object.SetName(m_RenameBuffer);
}
m_RenamingId = InvalidObjectId;
}
}
void SceneInspector::BeginRename(const GameObject& object)
{
m_RenamingId = object.GetId();
m_RenameBuffer = object.GetName();
m_FocusRename = true;
}
void SceneInspector::DrawObjectMenu(Scene& scene, GameObject* object, ObjectId& selectedObjectId)
{
if (ImGui::BeginMenu(object != nullptr ? "Create Child" : "Create")) {
if (GameObject* created = DrawCreateMenu(scene, object)) {
selectedObjectId = created->GetId();
if (object != nullptr) {
m_OpenNextFrame = object->GetId();
}
BeginRename(*created);
}
ImGui::EndMenu();
}
if (object == nullptr) {
return;
}
ImGui::Separator();
if (ImGui::MenuItem("Rename", "F2")) {
BeginRename(*object);
}
if (ImGui::MenuItem("Duplicate", "Ctrl+D")) {
m_PendingDuplicate = object->GetId();
}
if (ImGui::MenuItem("Unparent", nullptr, false, !IsRoot(*object))) {
m_PendingMove = {.object = object->GetId()};
}
bool active = object->IsActive();
if (ImGui::MenuItem("Active", nullptr, &active)) {
object->SetActive(active);
}
ImGui::Separator();
if (ImGui::MenuItem("Delete", "Del")) {
m_PendingDelete = object->GetId();
}
}
void SceneInspector::ApplyPendingActions(Scene& scene, ObjectId& selectedObjectId)
{
if (m_PendingMove.object != InvalidObjectId) {
GameObject* object = scene.FindGameObject(m_PendingMove.object);
GameObject* parent = scene.FindGameObject(m_PendingMove.parent);
GameObject* insertBefore = scene.FindGameObject(m_PendingMove.anchor);
if (insertBefore != nullptr && m_PendingMove.after) {
insertBefore = NextSibling(scene, *insertBefore);
}
if (insertBefore == object && object != nullptr) {
insertBefore = NextSibling(scene, *object);
}
if (object != nullptr && scene.MoveInHierarchy(*object, parent, insertBefore) && parent != nullptr) {
m_OpenNextFrame = parent->GetId();
}
m_PendingMove = {};
}
if (m_PendingDuplicate != InvalidObjectId) {
if (GameObject* object = scene.FindGameObject(m_PendingDuplicate)) {
if (const GameObject* duplicate = PrefabSerializer::Duplicate(*object)) {
selectedObjectId = duplicate->GetId();
}
}
m_PendingDuplicate = InvalidObjectId;
}
if (m_PendingDelete != InvalidObjectId) {
if (GameObject* object = scene.FindGameObject(m_PendingDelete)) {
object->Destroy();
}
if (selectedObjectId == m_PendingDelete) {
selectedObjectId = InvalidObjectId;
}
m_PendingDelete = InvalidObjectId;
}
}
void SceneInspector::DrawObject(GameObject& object)
{
bool active = object.IsActive();
if (ImGui::Checkbox("##Active", &active)) {
object.SetActive(active);
}
ImGui::SetItemTooltip("Active");
ImGui::SameLine();
ImGui::SetNextItemWidth(-FLT_MIN);
std::string name = object.GetName();
if (ImGuiUtils::InputString("##Name", name)) {
object.SetName(name);
}
ImGui::Spacing();
DrawTransform(object);
DrawComponents(object);
DrawAddComponent(object);
}
void SceneInspector::DrawTransform(GameObject& object)
{
auto* rectTransform = object.GetComponent<RectTransform>();
// Everything under a canvas has a RectTransform, a stretched one keeps the old layout
if (rectTransform == nullptr && object.GetComponent<Canvas>() == nullptr && Canvas::FindFor(object) != nullptr) {
rectTransform = object.AddComponent<RectTransform>();
rectTransform->Stretch();
}
if (rectTransform != nullptr) {
const bool open = ImGui::CollapsingHeader("Rect Transform", ImGuiTreeNodeFlags_DefaultOpen);
if (ImGui::BeginPopupContextItem("RectTransformMenu")) {
if (ImGui::MenuItem("Stretch")) {
rectTransform->Stretch();
}
ImGui::EndPopup();
}
if (open) {
ImGui::PushID(rectTransform);
rectTransform->ImGuiInspector();
ImGui::PopID();
}
return;
}
Transform& transform = object.GetTransform();
const bool open = ImGui::CollapsingHeader("Transform", ImGuiTreeNodeFlags_DefaultOpen);
bool changed = false;
if (ImGui::BeginPopupContextItem("TransformMenu")) {
if (ImGui::MenuItem("Reset Position")) {
transform.SetLocalPosition(glm::vec3{0.0f});
changed = true;
}
if (ImGui::MenuItem("Reset Rotation")) {
transform.SetLocalRotation(glm::quat{1.0f, 0.0f, 0.0f, 0.0f});
changed = true;
}
if (ImGui::MenuItem("Reset Scale")) {
transform.SetLocalScale(glm::vec3{1.0f});
changed = true;
}
ImGui::EndPopup();
}
if (open) {
glm::vec3 position = transform.GetLocalPosition();
if (ImGuiUtils::DrawVec3Control("Position", position, 0.05f)) {
transform.SetLocalPosition(position);
changed = true;
}
glm::vec3 rotation = glm::degrees(glm::eulerAngles(transform.GetLocalRotation()));
if (ImGuiUtils::DrawVec3Control("Rotation", rotation, 0.5f)) {
transform.SetLocalRotation(rotation);
changed = true;
}
glm::vec3 scale = transform.GetLocalScale();
if (ImGuiUtils::DrawVec3Control("Scale", scale, 0.05f)) {
transform.SetLocalScale(scale);
changed = true;
}
}
if (changed) {
object.RefreshPhysicsInHierarchy();
}
}
void SceneInspector::DrawComponents(GameObject& object)
{
auto& components = object.GetComponents();
const auto* rectTransform = object.GetComponent<RectTransform>();
const bool underCanvas = Canvas::FindFor(object) != nullptr;
for (std::size_t i = 0; i < components.size(); ++i) {
Component* component = components[i].get();
if (component == nullptr || component->IsBeingDestroyed() || component == rectTransform) {
continue;
}
ImGui::PushID(component);
const float lineStart = ImGui::GetCursorPosX();
const float lineWidth = ImGui::GetContentRegionAvail().x;
const bool enabled = component->isEnabled();
if (!enabled) {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
}
const std::string typeName = component->GetTypeName();
const bool open = ImGui::CollapsingHeader(typeName.c_str(),
ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_AllowOverlap);
if (!enabled) {
ImGui::PopStyleColor();
}
const bool removable = !(underCanvas && dynamic_cast<RectTransform*>(component) != nullptr);
if (ImGui::BeginPopupContextItem("ComponentMenu")) {
DrawComponentMenu(object, *component, i, removable);
ImGui::EndPopup();
}
const ImGuiStyle& style = ImGui::GetStyle();
const float buttonWidth = ImGui::CalcTextSize("...").x + style.FramePadding.x * 2.0f;
const float controlsWidth = ImGui::GetFrameHeight() + style.ItemSpacing.x + buttonWidth + style.FramePadding.x;
ImGui::SameLine(RightAlignedX(lineStart, lineWidth, controlsWidth));
bool enabledToggle = enabled;
if (ImGui::Checkbox("##Enabled", &enabledToggle)) {
component->SetEnabled(enabledToggle);
}
ImGui::SetItemTooltip("Enabled");
ImGui::SameLine();
if (ImGui::Button("...")) {
ImGui::OpenPopup("ComponentMenu");
}
if (open) {
component->ImGuiInspector();
ImGui::Spacing();
}
ImGui::PopID();
// A move or remove from the menu changed the list, the rest is drawn next frame
if (components.size() <= i || components[i].get() != component) {
break;
}
}
}
void SceneInspector::DrawComponentMenu(GameObject& object, Component& component, std::size_t index, bool removable)
{
auto& components = object.GetComponents();
if (ImGui::MenuItem("Move Up", nullptr, false, index > 0)) {
std::swap(components[index], components[index - 1]);
}
if (ImGui::MenuItem("Move Down", nullptr, false, index + 1 < components.size())) {
std::swap(components[index], components[index + 1]);
}
ImGui::Separator();
const std::string typeName = component.GetTypeName();
if (ImGui::MenuItem("Copy Values")) {
m_ClipboardType = typeName;
m_Clipboard = component.Serialize();
}
if (ImGui::MenuItem("Paste Values", nullptr, false, m_ClipboardType == typeName)) {
try {
component.Deserialize(m_Clipboard);
if (component.AffectsPhysics()) {
object.RefreshPhysics();
}
} catch (const std::exception& exception) {
spdlog::warn("Pasting {} values failed: {}", typeName, exception.what());
}
}
ImGui::Separator();
if (ImGui::MenuItem("Remove", nullptr, false, removable)) {
const bool affectsPhysics = component.AffectsPhysics();
component.Destroy();
if (affectsPhysics) {
object.RefreshPhysics();
}
}
}
void SceneInspector::DrawAddComponent(GameObject& object)
{
ImGui::Spacing();
if (ImGui::Button("Add Component", ImVec2(-FLT_MIN, 0.0f))) {
m_ComponentFilter.clear();
ImGui::OpenPopup("AddComponent");
}
ImGui::SetNextWindowSizeConstraints(ImVec2(ImGui::GetItemRectSize().x, 0.0f), ImVec2(FLT_MAX, 320.0f));
if (!ImGui::BeginPopup("AddComponent")) {
return;
}
if (ImGui::IsWindowAppearing()) {
ImGui::SetKeyboardFocusHere();
}
ImGui::SetNextItemWidth(-FLT_MIN);
const bool submitted = ImGuiUtils::InputString("##ComponentSearch", m_ComponentFilter,
ImGuiInputTextFlags_EnterReturnsTrue, "Search");
std::vector<std::string> types = ComponentFactory::GetRegisteredTypeNames();
std::ranges::sort(types);
std::erase_if(types, [&](const std::string& type) {
const bool added = std::ranges::any_of(object.GetComponents(), [&](const auto& component) {
return component != nullptr && !component->IsBeingDestroyed() && component->GetTypeName() == type;
});
return added || !ContainsIgnoreCase(type, m_ComponentFilter);
});
std::string chosen = submitted && !types.empty() ? types.front() : std::string{};
for (const std::string& type : types) {
if (ImGui::Selectable(type.c_str())) {
chosen = type;
}
}
if (!chosen.empty()) {
try {
if (ComponentFactory::Create(chosen, object) == nullptr) {
spdlog::warn("Component type is not registered: {}", chosen);
}
} catch (const std::exception& exception) {
spdlog::warn("Adding {} failed: {}", chosen, exception.what());
}
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}