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
@@ -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);
}
}