diff --git a/destrum/CMakeLists.txt b/destrum/CMakeLists.txt index b1ee8d4..4161cad 100644 --- a/destrum/CMakeLists.txt +++ b/destrum/CMakeLists.txt @@ -40,6 +40,7 @@ set(SRC_FILES "src/Components/CameraComponent.cpp" "src/Components/LightComponent.cpp" "src/Components/MeshRendererComponent.cpp" + "src/Components/MeshRendererInspector.cpp" "src/Components/Rotator.cpp" "src/Components/Spinner.cpp" "src/Components/OrbitAndSpin.cpp" @@ -128,6 +129,7 @@ set(SRC_FILES "src/Util/TransformGizmo.cpp" "src/Util/RectGizmo.cpp" "src/Util/SceneInspector.cpp" + "src/Util/AssetBrowser.cpp" "src/Physics/PhysicsWorld.cpp" "src/Physics/PhysicsSceneBridge.cpp" diff --git a/destrum/include/destrum/App.h b/destrum/include/destrum/App.h index 312c4f4..aea0fe0 100644 --- a/destrum/include/destrum/App.h +++ b/destrum/include/destrum/App.h @@ -17,6 +17,7 @@ #include #include #include +#include #include @@ -53,6 +54,7 @@ public: virtual void onWindowResize(int, int) {}; virtual void onViewportResize(int, int) {} virtual void drawViewportOverlay() {} + virtual void onViewportAssetDropped(std::string_view) {} virtual void onPlayModeChanged(bool) {} @@ -107,10 +109,12 @@ protected: bool physics{true}; bool inspector{true}; bool imageCache{false}; + bool assets{true}; }; DebugWindows m_DebugWindows{}; ResourcesInspector m_ResourcesInspector; + AssetBrowser m_AssetBrowser; void drawWindowsMenu(); void drawPhysicsPanel(float dt, float fixedDt, float accumulator); diff --git a/destrum/include/destrum/Components/MeshRendererComponent.h b/destrum/include/destrum/Components/MeshRendererComponent.h index 979c937..b7299ba 100644 --- a/destrum/include/destrum/Components/MeshRendererComponent.h +++ b/destrum/include/destrum/Components/MeshRendererComponent.h @@ -4,6 +4,13 @@ #include #include +#include + +#include +#include + +class GameRenderer; +class RenderResources; class MeshRendererComponent final: public Component { public: @@ -30,6 +37,18 @@ public: } private: + void DrawMeshField(RenderResources& resources); + void DrawPrimitiveSettings(RenderResources& resources); + void DrawMaterialField(RenderResources& resources, GameRenderer& renderer); + void DrawMaterialEditor(RenderResources& resources, GameRenderer& renderer); + void AssignPrimitive(RenderResources& resources, const PrimitiveMeshes::Desc& desc); + void LoadDroppedModel(RenderResources& resources, const std::string& path); + + std::string m_MeshSearch; + std::string m_MaterialSearch; + std::optional m_PrimitiveDraft; + std::string m_PrimitiveDraftKey; + MeshID meshID{NULL_MESH_ID}; MaterialID materialID{NULL_MATERIAL_ID}; std::string meshKey; diff --git a/destrum/include/destrum/FS/AssetFS.h b/destrum/include/destrum/FS/AssetFS.h index f6ac897..68d5bff 100644 --- a/destrum/include/destrum/FS/AssetFS.h +++ b/destrum/include/destrum/FS/AssetFS.h @@ -34,6 +34,8 @@ public: [[nodiscard]] std::optional ToVirtualPath(const std::filesystem::path& fullPath) const; + [[nodiscard]] const std::vector& GetMounts() const { return mounts; } + private: static std::vector ReadFile(const std::filesystem::path& fullPath); diff --git a/destrum/include/destrum/Graphics/Caches/MaterialCache.h b/destrum/include/destrum/Graphics/Caches/MaterialCache.h index a43c20e..0abae75 100644 --- a/destrum/include/destrum/Graphics/Caches/MaterialCache.h +++ b/destrum/include/destrum/Graphics/Caches/MaterialCache.h @@ -34,6 +34,7 @@ public: MaterialID addSimpleTextureMaterial(ImageID textureID); [[nodiscard]] const Material& getMaterial(MaterialID id) const; [[nodiscard]] const std::string& getMaterialKey(MaterialID id) const; + [[nodiscard]] std::size_t getMaterialCount() const { return materials.size(); } [[nodiscard]] std::optional findMaterialByKey(std::string_view key) const; [[nodiscard]] std::optional findMaterialByName( std::string_view name, diff --git a/destrum/include/destrum/Graphics/Resources/PrimitiveMeshes.h b/destrum/include/destrum/Graphics/Resources/PrimitiveMeshes.h index e874464..939ca18 100644 --- a/destrum/include/destrum/Graphics/Resources/PrimitiveMeshes.h +++ b/destrum/include/destrum/Graphics/Resources/PrimitiveMeshes.h @@ -1,6 +1,7 @@ #ifndef PRIMITIVEMESHES_H #define PRIMITIVEMESHES_H +#include #include #include #include @@ -10,9 +11,36 @@ #include namespace PrimitiveMeshes { + enum class Shape { + Box, + Plane, + Sphere, + Cylinder, + Capsule, + }; + + inline constexpr std::array AllShapes{ + Shape::Box, Shape::Plane, Shape::Sphere, Shape::Cylinder, Shape::Capsule, + }; + + struct Desc { + Shape shape{Shape::Box}; + glm::vec3 size{1.0f}; + float radius{0.5f}; + float height{2.0f}; + + bool operator==(const Desc&) const = default; + }; + + [[nodiscard]] const char* ShapeName(Shape shape); + [[nodiscard]] Desc DefaultDesc(Shape shape); + + [[nodiscard]] CPUMesh Make(const Desc& desc); [[nodiscard]] CPUMesh MakeBox(const glm::vec3& size); + [[nodiscard]] std::string MakeKey(const Desc& desc); [[nodiscard]] std::string BoxKey(const glm::vec3& size); + [[nodiscard]] std::optional ParseKey(std::string_view key); [[nodiscard]] bool IsPrimitiveKey(std::string_view key); [[nodiscard]] std::string_view StripDuplicateSuffix(std::string_view key); [[nodiscard]] std::optional MakeFromKey(std::string_view key); diff --git a/destrum/include/destrum/Util/AssetBrowser.h b/destrum/include/destrum/Util/AssetBrowser.h new file mode 100644 index 0000000..e758a17 --- /dev/null +++ b/destrum/include/destrum/Util/AssetBrowser.h @@ -0,0 +1,32 @@ +#ifndef DESTRUM_ASSETBROWSER_H +#define DESTRUM_ASSETBROWSER_H + +#include +#include +#include +#include + +class AssetBrowser final { +public: + void Draw(bool* open = nullptr); + + [[nodiscard]] static bool IsModel(std::string_view path); + +private: + struct Entry { + std::string name; + bool directory{false}; + }; + + void Refresh(); + void Navigate(const std::string& scheme, const std::filesystem::path& folder); + bool DrawEntry(const Entry& entry); + + std::string m_Scheme; + std::filesystem::path m_Folder; + std::vector m_Entries; + std::string m_Filter; + bool m_Dirty{true}; +}; + +#endif // DESTRUM_ASSETBROWSER_H diff --git a/destrum/include/destrum/Util/ImGuiUtils.h b/destrum/include/destrum/Util/ImGuiUtils.h index 6433f03..ffc9307 100644 --- a/destrum/include/destrum/Util/ImGuiUtils.h +++ b/destrum/include/destrum/Util/ImGuiUtils.h @@ -24,6 +24,7 @@ namespace ImGuiUtils { float speed = 0.1f); inline constexpr const char* GameObjectPayload = "DESTRUM_GAMEOBJECT"; + inline constexpr const char* AssetPayload = "DESTRUM_ASSET"; bool InputString(const char* label, std::string& value, ImGuiInputTextFlags flags = 0, const char* hint = nullptr); bool InputStringMultiline(const char* label, std::string& value); diff --git a/destrum/src/App.cpp b/destrum/src/App.cpp index 17fa8ce..26b865a 100644 --- a/destrum/src/App.cpp +++ b/destrum/src/App.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include "imgui.h" #include "imgui_internal.h" @@ -261,6 +262,11 @@ if (!isRunning) break; drawPhysicsPanel(dt, fixedDt, accumulator); } + if (m_DebugWindows.assets) + { + m_AssetBrowser.Draw(&m_DebugWindows.assets); + } + if (m_DebugWindows.imageCache) { m_ResourcesInspector.drawImageCacheWindow(&m_DebugWindows.imageCache, resources, imguiPass); @@ -450,6 +456,7 @@ void App::drawWindowsMenu() ImGui::MenuItem("Physics", nullptr, &m_DebugWindows.physics); ImGui::MenuItem("Inspector", nullptr, &m_DebugWindows.inspector); ImGui::MenuItem("Image Cache", nullptr, &m_DebugWindows.imageCache); + ImGui::MenuItem("Assets", nullptr, &m_DebugWindows.assets); customWindowsMenu(); @@ -480,11 +487,13 @@ void App::drawDockSpace() ImGuiID center = dockspaceId; const ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.25f, nullptr, ¢er); const ImGuiID left = ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.22f, nullptr, ¢er); + const ImGuiID bottom = ImGui::DockBuilderSplitNode(center, ImGuiDir_Down, 0.28f, nullptr, ¢er); ImGui::DockBuilderDockWindow("Viewport", center); ImGui::DockBuilderDockWindow("Inspector", right); ImGui::DockBuilderDockWindow("Debug", left); ImGui::DockBuilderDockWindow("Physics", left); + ImGui::DockBuilderDockWindow("Assets", bottom); ImGui::DockBuilderFinish(dockspaceId); } @@ -520,6 +529,14 @@ void App::drawViewportWindow() if (const ImTextureID texture = imguiPass.getFrameTexture(drawImage.imageView); texture != ImTextureID{}) { ImGui::Image(texture, ImVec2{viewportRect.size.x, viewportRect.size.y}); + if (ImGui::BeginDragDropTarget()) + { + if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(ImGuiUtils::AssetPayload)) + { + onViewportAssetDropped(static_cast(payload->Data)); + } + ImGui::EndDragDropTarget(); + } } viewportHovered = ImGui::IsWindowHovered(); diff --git a/destrum/src/Components/MeshRendererComponent.cpp b/destrum/src/Components/MeshRendererComponent.cpp index b917717..86d53ef 100644 --- a/destrum/src/Components/MeshRendererComponent.cpp +++ b/destrum/src/Components/MeshRendererComponent.cpp @@ -9,7 +9,6 @@ #include "destrum/ObjectModel/GameObject.h" #include "destrum/Util/GameState.h" -#include #include #include @@ -179,30 +178,6 @@ void MeshRendererComponent::ResolveReferences(const ObjectMap&) { } } -void MeshRendererComponent::ImGuiInspector() { - if (meshID == NULL_MESH_ID) { - ImGui::TextDisabled("Mesh: "); - } else { - ImGui::Text("Mesh ID: %zu", meshID); - ImGui::TextWrapped( - "Mesh key: %s", - meshKey.empty() ? "" : meshKey.c_str()); - } - - if (materialID == NULL_MATERIAL_ID) { - ImGui::TextDisabled("Material: "); - } else { - ImGui::Text("Material ID: %u", materialID); - ImGui::TextWrapped( - "Material key: %s", - materialKey.empty() ? "" : materialKey.c_str()); - } - - ImGui::Text( - "Skinning: %s", - m_skinnedMesh != nullptr ? "active" : "static"); -} - void MeshRendererComponent::SetMeshID(MeshID id) { meshID = id; meshKey.clear(); diff --git a/destrum/src/Components/MeshRendererInspector.cpp b/destrum/src/Components/MeshRendererInspector.cpp new file mode 100644 index 0000000..0e926d4 --- /dev/null +++ b/destrum/src/Components/MeshRendererInspector.cpp @@ -0,0 +1,423 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + struct TextureSlot { + const char* label; + ImageID Material::* member; + TextureIntent intent; + }; + + constexpr std::array TextureSlots{{ + {"Diffuse", &Material::diffuseTexture, TextureIntent::ColorSrgb}, + {"Normal", &Material::normalTexture, TextureIntent::DataLinear}, + {"Metallic", &Material::metallicTexture, TextureIntent::DataLinear}, + {"Roughness", &Material::roughnessTexture, TextureIntent::DataLinear}, + {"Occlusion", &Material::occlusionTexture, TextureIntent::DataLinear}, + {"Emissive", &Material::emissiveTexture, TextureIntent::ColorSrgb}, + }}; + + [[nodiscard]] std::string Lowercase(std::string_view value) + { + std::string result{value}; + std::ranges::transform(result, result.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return result; + } + + [[nodiscard]] bool Matches(std::string_view text, const std::string& filter) + { + return filter.empty() || Lowercase(text).find(Lowercase(filter)) != std::string::npos; + } + + [[nodiscard]] bool IsImage(std::string_view path) + { + const std::string extension = Lowercase(std::filesystem::path{path}.extension().string()); + return extension == ".png" || extension == ".jpg" || extension == ".jpeg" || extension == ".tga" || + extension == ".bmp"; + } + + [[nodiscard]] std::optional AcceptAssetDrop() + { + std::optional dropped; + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(ImGuiUtils::AssetPayload)) { + dropped = static_cast(payload->Data); + } + ImGui::EndDragDropTarget(); + } + return dropped; + } + + [[nodiscard]] bool PickerButton(const char* label, const std::string& value) + { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + ImGui::SameLine(90.0f); + const bool clicked = ImGui::Button((value + "##" + label).c_str(), ImVec2{-FLT_MIN, 0.0f}); + ImGui::SetItemTooltip("%s\nClick to pick, or drop a file from the Assets window", value.c_str()); + return clicked; + } + + [[nodiscard]] std::string TextureLabel(const RenderResources& resources, ImageID id) + { + if (id == NULL_IMAGE_ID) { + return ""; + } + if (const ImageCache::LoadedImageInfo* info = resources.images().findLoadedImageInfo(id)) { + const auto virtualPath = AssetFS::GetInstance().ToVirtualPath(info->path); + return virtualPath ? *virtualPath : info->path.filename().string(); + } + return "texture " + std::to_string(id); + } + + [[nodiscard]] int CountMaterialUsers(const GameObject* self, MaterialID id) + { + const Scene* scene = self != nullptr ? self->GetScene() : nullptr; + if (scene == nullptr) { + return 1; + } + int users = 0; + for (const auto& object : scene->GetObjects()) { + for (const auto& component : object->GetComponents()) { + const auto* meshRenderer = dynamic_cast(component.get()); + if (meshRenderer != nullptr && meshRenderer->GetMaterialID() == id) { + ++users; + } + } + } + return users; + } + + template + void PickerList(const char* id, Fn&& drawItems) + { + const float height = std::min(ImGui::GetTextLineHeightWithSpacing() * 14.0f, 320.0f); + if (ImGui::BeginChild(id, ImVec2{420.0f, height}, ImGuiChildFlags_None)) { + drawItems(); + } + ImGui::EndChild(); + } +} + +void MeshRendererComponent::ImGuiInspector() +{ + if (!GameState::GetInstance().HasRenderer() || !GameState::GetInstance().HasGfxDevice()) { + ImGui::TextDisabled("Meshes and materials can only be picked with a renderer"); + return; + } + GameRenderer& renderer = GameState::GetInstance().Renderer(); + RenderResources* resources = renderer.getResources(); + if (resources == nullptr) { + return; + } + + DrawMeshField(*resources); + ImGui::Spacing(); + DrawMaterialField(*resources, renderer); +} + +void MeshRendererComponent::DrawMeshField(RenderResources& resources) +{ + // The skinning buffer is sized for the mesh in Start, a different mesh would overrun it + const bool skinned = GetGameObject()->GetComponent() != nullptr; + ImGui::BeginDisabled(skinned); + if (PickerButton("Mesh", meshID == NULL_MESH_ID ? "" : meshKey)) { + m_MeshSearch.clear(); + ImGui::OpenPopup("MeshPicker"); + } + if (const auto dropped = AcceptAssetDrop(); dropped && AssetBrowser::IsModel(*dropped)) { + LoadDroppedModel(resources, *dropped); + std::vector parts; + for (MeshID id = 0; id < resources.meshes().getMeshes().size(); ++id) { + if (resources.meshes().getMeshKey(id).starts_with(*dropped + "#") && !resources.meshes().getMesh(id).hasSkeleton) { + parts.push_back(id); + } + } + if (parts.size() == 1) { + SetMeshID(parts.front()); + } else if (!parts.empty()) { + m_MeshSearch = *dropped; + ImGui::OpenPopup("MeshPicker"); + } + } + ImGui::EndDisabled(); + if (skinned) { + ImGui::TextDisabled("Animated meshes can't be swapped here"); + } + + if (ImGui::BeginPopup("MeshPicker")) { + if (ImGui::IsWindowAppearing()) { + ImGui::SetKeyboardFocusHere(); + } + ImGui::SetNextItemWidth(420.0f); + ImGuiUtils::InputString("##Search", m_MeshSearch, 0, "Search"); + + ImGui::SeparatorText("Primitives"); + for (const PrimitiveMeshes::Shape shape : PrimitiveMeshes::AllShapes) { + const char* name = PrimitiveMeshes::ShapeName(shape); + if (Matches(name, m_MeshSearch) && ImGui::Selectable(name)) { + AssignPrimitive(resources, PrimitiveMeshes::DefaultDesc(shape)); + ImGui::CloseCurrentPopup(); + } + } + + ImGui::SeparatorText("Loaded"); + PickerList("LoadedMeshes", [&] { + const MeshCache& meshes = resources.meshes(); + for (MeshID id = 0; id < meshes.getMeshes().size(); ++id) { + const std::string& key = meshes.getMeshKey(id); + if (meshes.getMesh(id).hasSkeleton || !Matches(key, m_MeshSearch)) { + continue; + } + ImGui::PushID(static_cast(id)); + if (ImGui::Selectable(key.c_str(), id == meshID)) { + SetMeshID(id); + ImGui::CloseCurrentPopup(); + } + ImGui::PopID(); + } + }); + ImGui::EndPopup(); + } + + DrawPrimitiveSettings(resources); +} + +void MeshRendererComponent::DrawPrimitiveSettings(RenderResources& resources) +{ + if (meshID == NULL_MESH_ID || !PrimitiveMeshes::IsPrimitiveKey(meshKey)) { + m_PrimitiveDraft.reset(); + return; + } + // Edits go into a draft and only build a mesh once the drag ends, every step would add one to the cache + if (!m_PrimitiveDraft || m_PrimitiveDraftKey != meshKey) { + m_PrimitiveDraft = PrimitiveMeshes::ParseKey(meshKey); + m_PrimitiveDraftKey = meshKey; + } + if (!m_PrimitiveDraft) { + return; + } + + PrimitiveMeshes::Desc& draft = *m_PrimitiveDraft; + bool apply = false; + const auto edited = [&apply] { apply = apply || ImGui::IsItemDeactivatedAfterEdit(); }; + constexpr float Min = 0.01f; + constexpr float Max = 1000.0f; + + switch (draft.shape) { + case PrimitiveMeshes::Shape::Box: + ImGui::DragFloat3("Size", &draft.size.x, 0.05f, Min, Max, "%.2f m"); + edited(); + break; + case PrimitiveMeshes::Shape::Plane: { + float size[2]{draft.size.x, draft.size.z}; + if (ImGui::DragFloat2("Size", size, 0.05f, Min, Max, "%.2f m")) { + draft.size.x = size[0]; + draft.size.z = size[1]; + } + edited(); + break; + } + case PrimitiveMeshes::Shape::Sphere: + ImGui::DragFloat("Radius", &draft.radius, 0.01f, Min, Max, "%.2f m"); + edited(); + break; + case PrimitiveMeshes::Shape::Cylinder: + case PrimitiveMeshes::Shape::Capsule: + ImGui::DragFloat("Radius", &draft.radius, 0.01f, Min, Max, "%.2f m"); + edited(); + ImGui::DragFloat("Height", &draft.height, 0.05f, Min, Max, "%.2f m"); + edited(); + break; + } + + if (apply) { + draft.size = glm::max(draft.size, glm::vec3{Min}); + draft.radius = std::max(draft.radius, Min); + draft.height = std::max(draft.height, Min); + AssignPrimitive(resources, draft); + } +} + +void MeshRendererComponent::AssignPrimitive(RenderResources& resources, const PrimitiveMeshes::Desc& desc) +{ + const std::string key = PrimitiveMeshes::MakeKey(desc); + auto id = resources.meshes().findMeshByKey(key); + if (!id) { + id = resources.meshes().addMesh(GameState::GetInstance().Gfx(), PrimitiveMeshes::Make(desc)); + } + SetMeshID(*id); +} + +void MeshRendererComponent::LoadDroppedModel(RenderResources& resources, const std::string& path) +{ + try { + resources.loadModelAsset(GameState::GetInstance().Gfx(), path); + } catch (const std::exception& exception) { + spdlog::error("Could not load {}: {}", path, exception.what()); + } +} + +void MeshRendererComponent::DrawMaterialField(RenderResources& resources, GameRenderer& renderer) +{ + MaterialCache& materials = resources.materials(); + if (PickerButton("Material", materialID == NULL_MATERIAL_ID ? "" : materialKey)) { + m_MaterialSearch.clear(); + ImGui::OpenPopup("MaterialPicker"); + } + if (const auto dropped = AcceptAssetDrop(); dropped && AssetBrowser::IsModel(*dropped)) { + LoadDroppedModel(resources, *dropped); + m_MaterialSearch = *dropped; + ImGui::OpenPopup("MaterialPicker"); + } + + if (ImGui::BeginPopup("MaterialPicker")) { + if (ImGui::IsWindowAppearing()) { + ImGui::SetKeyboardFocusHere(); + } + ImGui::SetNextItemWidth(420.0f); + ImGuiUtils::InputString("##Search", m_MaterialSearch, 0, "Search"); + + if (ImGui::Selectable("New Material")) { + SetMaterialID(materials.addMaterial(Material::SimpleColor(glm::vec3{1.0f}, "Material"))); + ImGui::CloseCurrentPopup(); + } + + ImGui::SeparatorText("Loaded"); + PickerList("LoadedMaterials", [&] { + for (MaterialID id = 0; id < materials.getMaterialCount(); ++id) { + const std::string& key = materials.getMaterialKey(id); + if (id == materials.getPlaceholderMaterialId() || !Matches(key, m_MaterialSearch)) { + continue; + } + ImGui::PushID(static_cast(id)); + if (ImGui::Selectable(key.c_str(), id == materialID)) { + SetMaterialID(id); + ImGui::CloseCurrentPopup(); + } + ImGui::PopID(); + } + }); + ImGui::EndPopup(); + } + + if (materialID != NULL_MATERIAL_ID) { + DrawMaterialEditor(resources, renderer); + } +} + +void MeshRendererComponent::DrawMaterialEditor(RenderResources& resources, GameRenderer& renderer) +{ + if (!ImGui::TreeNodeEx("Material Settings", ImGuiTreeNodeFlags_DefaultOpen)) { + return; + } + + MaterialCache& materials = resources.materials(); + const bool placeholder = materialID == materials.getPlaceholderMaterialId(); + const bool fileBacked = !materials.getMaterial(materialID).assetReference.empty(); + const int users = CountMaterialUsers(GetGameObject(), materialID); + + // A copy without a source file is saved inside the scene, so it can be edited freely + const auto copyForThisObject = [&] { + Material copy = materials.getMaterial(materialID); + copy.assetReference = {}; + if (copy.name.empty() || placeholder) { + copy.name = "Material"; + } + SetMaterialID(materials.addMaterial(std::move(copy))); + }; + + bool copied = false; + if (placeholder) { + ImGui::TextDisabled("The default material, pick New Material to edit one"); + } else if (fileBacked) { + ImGui::TextDisabled("From %s, edits would not be saved", materials.getMaterial(materialID).assetReference.path.c_str()); + if (ImGui::Button("Make Editable Copy")) { + copyForThisObject(); + copied = true; + } + } else if (users > 1) { + ImGui::Text("Shared by %d objects, edits change all of them", users); + ImGui::SameLine(); + if (ImGui::SmallButton("Make Unique")) { + copyForThisObject(); + copied = true; + } + } + if (copied) { + // addMaterial may have moved the material storage, draw the copy next frame + ImGui::TreePop(); + return; + } + + ImGui::BeginDisabled(placeholder || fileBacked); + Material& material = materials.getMaterialMutable(materialID); + bool changed = false; + changed |= ImGui::ColorEdit3("Base Color", &material.baseColor.x); + changed |= ImGui::SliderFloat("Metallic", &material.metallicFactor, 0.0f, 1.0f); + changed |= ImGui::SliderFloat("Roughness", &material.roughnessFactor, 0.0f, 1.0f); + changed |= ImGui::ColorEdit3("Emissive", &material.emissiveColor.x, ImGuiColorEditFlags_HDR | ImGuiColorEditFlags_Float); + changed |= ImGui::DragFloat("Normal Scale", &material.normalScale, 0.01f, 0.0f, 4.0f); + changed |= ImGui::SliderFloat("Occlusion", &material.occlusionStrength, 0.0f, 1.0f); + + constexpr std::array filteringNames{"Nearest", "Linear", "Anisotropic"}; + int filtering = static_cast(material.textureFilteringMode); + if (ImGui::Combo("Filtering", &filtering, filteringNames.data(), static_cast(filteringNames.size()))) { + material.textureFilteringMode = static_cast(filtering); + changed = true; + } + + ImGui::SeparatorText("Textures"); + for (const TextureSlot& slot : TextureSlots) { + ImGui::PushID(slot.label); + ImageID& texture = material.*slot.member; + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(slot.label); + ImGui::SameLine(90.0f); + const float clearWidth = ImGui::GetFrameHeight(); + ImGui::Button(TextureLabel(resources, texture).c_str(), + ImVec2{ImGui::GetContentRegionAvail().x - clearWidth - ImGui::GetStyle().ItemSpacing.x, 0.0f}); + ImGui::SetItemTooltip("Drop an image from the Assets window"); + if (const auto dropped = AcceptAssetDrop(); dropped && IsImage(*dropped)) { + try { + texture = resources.loadImageFromFile(GameState::GetInstance().Gfx(), AssetFS::GetInstance().GetFullPath(*dropped), + VK_IMAGE_USAGE_SAMPLED_BIT, true, slot.intent); + changed = true; + } catch (const std::exception& exception) { + spdlog::error("Could not load {}: {}", *dropped, exception.what()); + } + } + ImGui::SameLine(); + ImGui::BeginDisabled(texture == NULL_IMAGE_ID); + if (ImGui::Button("x", ImVec2{clearWidth, 0.0f})) { + texture = NULL_IMAGE_ID; + changed = true; + } + ImGui::EndDisabled(); + ImGui::PopID(); + } + ImGui::EndDisabled(); + + if (changed) { + renderer.updateMaterialGPU(materialID); + } + ImGui::TreePop(); +} diff --git a/destrum/src/Graphics/Resources/PrimitiveMeshes.cpp b/destrum/src/Graphics/Resources/PrimitiveMeshes.cpp index ac29af9..cefb018 100644 --- a/destrum/src/Graphics/Resources/PrimitiveMeshes.cpp +++ b/destrum/src/Graphics/Resources/PrimitiveMeshes.cpp @@ -1,17 +1,28 @@ #include +#include #include #include +#include #include #include +#include +#include +#include -CPUMesh PrimitiveMeshes::MakeBox(const glm::vec3& size) -{ - struct Face { +#include + +namespace { + constexpr std::string_view PrimitiveScheme = "prim://"; + constexpr std::uint32_t Segments = 32; + constexpr std::uint32_t Rings = 16; + + struct BoxFace { glm::vec3 normal; glm::vec3 tangent; }; - constexpr std::array faces{{ + + constexpr std::array BoxFaces{{ {{1.0f, 0.0f, 0.0f}, {0.0f, 0.0f, -1.0f}}, {{-1.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 1.0f}}, {{0.0f, 1.0f, 0.0f}, {1.0f, 0.0f, 0.0f}}, @@ -19,17 +30,13 @@ CPUMesh PrimitiveMeshes::MakeBox(const glm::vec3& size) {{0.0f, 0.0f, 1.0f}, {1.0f, 0.0f, 0.0f}}, {{0.0f, 0.0f, -1.0f}, {-1.0f, 0.0f, 0.0f}}, }}; - constexpr std::array corners{{ - {-1.0f, -1.0f}, {1.0f, -1.0f}, {1.0f, 1.0f}, {-1.0f, 1.0f}, - }}; - CPUMesh mesh{}; - // The name becomes the mesh cache key, saved scenes rebuild the box from it - mesh.name = BoxKey(size); - mesh.minPos = -size * 0.5f; - mesh.maxPos = size * 0.5f; + void AddBoxFace(CPUMesh& mesh, const BoxFace& face, const glm::vec3& size) + { + constexpr std::array corners{{ + {-1.0f, -1.0f}, {1.0f, -1.0f}, {1.0f, 1.0f}, {-1.0f, 1.0f}, + }}; - for (const Face& face : faces) { const glm::vec3 bitangent = glm::cross(face.normal, face.tangent); const float width = glm::dot(glm::abs(face.tangent), size); const float height = glm::dot(glm::abs(bitangent), size); @@ -51,17 +58,299 @@ CPUMesh PrimitiveMeshes::MakeBox(const glm::vec3& size) } } + // Same convention as the box faces: the shader's cross(N, T) * w points along increasing v + [[nodiscard]] float TangentSign(const glm::vec3& normal, const glm::vec3& tangent, const glm::vec3& alongV) + { + return glm::dot(glm::cross(normal, tangent), alongV) >= 0.0f ? 1.0f : -1.0f; + } + + // Front faces are counter clockwise seen from outside, flip the ones a generator got backwards + void AddTriangle(CPUMesh& mesh, std::uint32_t a, std::uint32_t b, std::uint32_t c) + { + const auto& va = mesh.vertices[a]; + const auto& vb = mesh.vertices[b]; + const auto& vc = mesh.vertices[c]; + const glm::vec3 faceNormal = glm::cross(vb.position - va.position, vc.position - va.position); + if (glm::dot(faceNormal, va.normal + vb.normal + vc.normal) < 0.0f) { + std::swap(b, c); + } + mesh.indices.insert(mesh.indices.end(), {a, b, c}); + } + + struct ProfilePoint { + float radius; + float y; + glm::vec2 normal; + }; + + // Spins a top to bottom outline around the y axis, used for spheres, capsules and cylinder sides + void AddLathe(CPUMesh& mesh, const std::vector& profile, float uScale) + { + std::vector v(profile.size(), 0.0f); + for (std::size_t k = 1; k < profile.size(); ++k) { + const glm::vec2 step{profile[k].radius - profile[k - 1].radius, profile[k].y - profile[k - 1].y}; + v[k] = v[k - 1] + glm::length(step); + } + + const auto first = static_cast(mesh.vertices.size()); + for (std::size_t k = 0; k < profile.size(); ++k) { + const ProfilePoint& point = profile[k]; + const ProfilePoint& next = profile[k + 1 < profile.size() ? k + 1 : k]; + const ProfilePoint& previous = profile[k + 1 < profile.size() ? k : k - 1]; + for (std::uint32_t s = 0; s <= Segments; ++s) { + const float angle = glm::two_pi() * static_cast(s) / static_cast(Segments); + const float c = std::cos(angle); + const float sn = std::sin(angle); + const glm::vec3 normal = glm::normalize(glm::vec3{point.normal.x * c, point.normal.y, point.normal.x * sn}); + const glm::vec3 tangent{-sn, 0.0f, c}; + const glm::vec3 alongV{(next.radius - previous.radius) * c, next.y - previous.y, (next.radius - previous.radius) * sn}; + mesh.vertices.push_back(CPUMesh::Vertex{ + .position = {point.radius * c, point.y, point.radius * sn}, + .uv_x = angle * uScale, + .normal = normal, + .uv_y = v[k], + .tangent = glm::vec4{tangent, TangentSign(normal, tangent, alongV)}, + }); + } + } + + const std::uint32_t stride = Segments + 1; + for (std::uint32_t k = 0; k + 1 < profile.size(); ++k) { + for (std::uint32_t s = 0; s < Segments; ++s) { + const std::uint32_t a = first + k * stride + s; + const std::uint32_t b = a + 1; + const std::uint32_t c = a + stride; + const std::uint32_t d = c + 1; + AddTriangle(mesh, a, c, b); + AddTriangle(mesh, b, c, d); + } + } + } + + void AddDisc(CPUMesh& mesh, float y, float normalY, float radius) + { + const glm::vec3 normal{0.0f, normalY, 0.0f}; + const glm::vec3 tangent{1.0f, 0.0f, 0.0f}; + const glm::vec4 packedTangent{tangent, TangentSign(normal, tangent, {0.0f, 0.0f, 1.0f})}; + const auto center = static_cast(mesh.vertices.size()); + mesh.vertices.push_back(CPUMesh::Vertex{ + .position = {0.0f, y, 0.0f}, .uv_x = 0.0f, .normal = normal, .uv_y = 0.0f, .tangent = packedTangent, + }); + for (std::uint32_t s = 0; s <= Segments; ++s) { + const float angle = glm::two_pi() * static_cast(s) / static_cast(Segments); + const glm::vec3 position{radius * std::cos(angle), y, radius * std::sin(angle)}; + mesh.vertices.push_back(CPUMesh::Vertex{ + .position = position, .uv_x = position.x, .normal = normal, .uv_y = position.z, .tangent = packedTangent, + }); + } + for (std::uint32_t s = 0; s < Segments; ++s) { + AddTriangle(mesh, center, center + 1 + s, center + 2 + s); + } + } + + [[nodiscard]] std::vector RoundProfile(float radius, float straightHalfHeight) + { + std::vector profile; + for (std::uint32_t k = 0; k <= Rings; ++k) { + const float theta = glm::pi() * static_cast(k) / static_cast(Rings); + const glm::vec2 normal{std::sin(theta), std::cos(theta)}; + const float offset = k * 2 < Rings ? straightHalfHeight : -straightHalfHeight; + if (k * 2 == Rings && straightHalfHeight > 0.0f) { + // The equator twice, once per hemisphere, the band between them is the straight part + profile.push_back({radius, straightHalfHeight, normal}); + profile.push_back({radius, -straightHalfHeight, normal}); + continue; + } + profile.push_back({radius * normal.x, radius * normal.y + offset, normal}); + } + return profile; + } + + void SetBounds(CPUMesh& mesh) + { + mesh.minPos = glm::vec3{std::numeric_limits::max()}; + mesh.maxPos = glm::vec3{std::numeric_limits::lowest()}; + for (const auto& vertex : mesh.vertices) { + mesh.minPos = glm::min(mesh.minPos, vertex.position); + mesh.maxPos = glm::max(mesh.maxPos, vertex.position); + } + } + + [[nodiscard]] std::optional> ParseFloats(std::string_view text) + { + std::vector values; + while (true) { + float value = 0.0f; + const auto [next, error] = std::from_chars(text.data(), text.data() + text.size(), value); + if (error != std::errc{} || !(value > 0.0f) || value == std::numeric_limits::infinity()) { + return std::nullopt; + } + values.push_back(value); + text.remove_prefix(static_cast(next - text.data())); + if (text.empty()) { + return values; + } + if (!text.starts_with(',')) { + return std::nullopt; + } + text.remove_prefix(1); + } + } +} + +const char* PrimitiveMeshes::ShapeName(Shape shape) +{ + switch (shape) { + case Shape::Box: return "box"; + case Shape::Plane: return "plane"; + case Shape::Sphere: return "sphere"; + case Shape::Cylinder: return "cylinder"; + case Shape::Capsule: return "capsule"; + } + return "box"; +} + +PrimitiveMeshes::Desc PrimitiveMeshes::DefaultDesc(Shape shape) +{ + Desc desc{.shape = shape}; + if (shape == Shape::Plane) { + desc.size = {10.0f, 1.0f, 10.0f}; + } + return desc; +} + +CPUMesh PrimitiveMeshes::Make(const Desc& desc) +{ + CPUMesh mesh{}; + // The name becomes the mesh cache key, saved scenes rebuild the mesh from it + mesh.name = MakeKey(desc); + + switch (desc.shape) { + case Shape::Box: + for (const BoxFace& face : BoxFaces) { + AddBoxFace(mesh, face, desc.size); + } + break; + case Shape::Plane: + AddBoxFace(mesh, BoxFaces[2], {desc.size.x, 0.0f, desc.size.z}); + break; + case Shape::Sphere: + AddLathe(mesh, RoundProfile(desc.radius, 0.0f), desc.radius); + break; + case Shape::Cylinder: { + const float half = desc.height * 0.5f; + AddLathe(mesh, {{desc.radius, half, {1.0f, 0.0f}}, {desc.radius, -half, {1.0f, 0.0f}}}, desc.radius); + AddDisc(mesh, half, 1.0f, desc.radius); + AddDisc(mesh, -half, -1.0f, desc.radius); + break; + } + case Shape::Capsule: + // height is the full length tip to tip, like the CapsuleCollider + AddLathe(mesh, RoundProfile(desc.radius, std::max(desc.height * 0.5f - desc.radius, 0.0f)), desc.radius); + break; + } + + SetBounds(mesh); return mesh; } -namespace { - constexpr std::string_view PrimitiveScheme = "prim://"; - constexpr std::string_view BoxPrefix = "prim://box?size="; +CPUMesh PrimitiveMeshes::MakeBox(const glm::vec3& size) +{ + return Make({.shape = Shape::Box, .size = size}); +} + +std::string PrimitiveMeshes::MakeKey(const Desc& desc) +{ + switch (desc.shape) { + case Shape::Box: + return std::format("{}box?size={},{},{}", PrimitiveScheme, desc.size.x, desc.size.y, desc.size.z); + case Shape::Plane: + return std::format("{}plane?size={},{}", PrimitiveScheme, desc.size.x, desc.size.z); + case Shape::Sphere: + return std::format("{}sphere?radius={}", PrimitiveScheme, desc.radius); + case Shape::Cylinder: + case Shape::Capsule: + return std::format("{}{}?radius={}&height={}", PrimitiveScheme, ShapeName(desc.shape), desc.radius, desc.height); + } + return {}; } std::string PrimitiveMeshes::BoxKey(const glm::vec3& size) { - return std::format("{}{},{},{}", BoxPrefix, size.x, size.y, size.z); + return MakeKey({.shape = Shape::Box, .size = size}); +} + +std::optional PrimitiveMeshes::ParseKey(std::string_view key) +{ + key = StripDuplicateSuffix(key); + if (!key.starts_with(PrimitiveScheme)) { + return std::nullopt; + } + key.remove_prefix(PrimitiveScheme.size()); + + const auto query = key.find('?'); + if (query == std::string_view::npos) { + return std::nullopt; + } + const std::string_view shapeName = key.substr(0, query); + std::optional shape; + for (const Shape candidate : AllShapes) { + if (shapeName == ShapeName(candidate)) { + shape = candidate; + } + } + if (!shape) { + return std::nullopt; + } + + std::optional> size; + std::optional> radius; + std::optional> height; + std::string_view params = key.substr(query + 1); + while (!params.empty()) { + const auto end = params.find('&'); + const std::string_view param = params.substr(0, end); + params = end == std::string_view::npos ? std::string_view{} : params.substr(end + 1); + + const auto equals = param.find('='); + if (equals == std::string_view::npos) { + return std::nullopt; + } + const std::string_view name = param.substr(0, equals); + auto values = ParseFloats(param.substr(equals + 1)); + auto* target = name == "size" ? &size : name == "radius" ? &radius : name == "height" ? &height : nullptr; + if (target == nullptr || target->has_value() || !values) { + return std::nullopt; + } + *target = std::move(values); + } + + const auto single = [](const std::optional>& values) { + return values.has_value() && values->size() == 1; + }; + + Desc desc{.shape = *shape}; + switch (*shape) { + case Shape::Box: + if (!size || size->size() != 3 || radius || height) return std::nullopt; + desc.size = {(*size)[0], (*size)[1], (*size)[2]}; + break; + case Shape::Plane: + if (!size || size->size() != 2 || radius || height) return std::nullopt; + desc.size = {(*size)[0], 1.0f, (*size)[1]}; + break; + case Shape::Sphere: + if (!single(radius) || size || height) return std::nullopt; + desc.radius = radius->front(); + break; + case Shape::Cylinder: + case Shape::Capsule: + if (!single(radius) || !single(height) || size) return std::nullopt; + desc.radius = radius->front(); + desc.height = height->front(); + break; + } + return desc; } bool PrimitiveMeshes::IsPrimitiveKey(std::string_view key) @@ -86,32 +375,9 @@ std::string_view PrimitiveMeshes::StripDuplicateSuffix(std::string_view key) std::optional PrimitiveMeshes::MakeFromKey(std::string_view key) { - key = StripDuplicateSuffix(key); - if (!key.starts_with(BoxPrefix)) { + const auto desc = ParseKey(key); + if (!desc) { return std::nullopt; } - - std::string_view rest = key.substr(BoxPrefix.size()); - glm::vec3 size{0.0f}; - for (int i = 0; i < 3; ++i) { - const char* end = rest.data() + rest.size(); - float value = 0.0f; - const auto [next, error] = std::from_chars(rest.data(), end, value); - if (error != std::errc{} || !(value > 0.0f)) { - return std::nullopt; - } - size[i] = value; - rest.remove_prefix(static_cast(next - rest.data())); - if (i < 2) { - if (!rest.starts_with(',')) { - return std::nullopt; - } - rest.remove_prefix(1); - } - } - if (!rest.empty()) { - return std::nullopt; - } - - return MakeBox(size); + return Make(*desc); } diff --git a/destrum/src/Util/AssetBrowser.cpp b/destrum/src/Util/AssetBrowser.cpp new file mode 100644 index 0000000..f1d53ac --- /dev/null +++ b/destrum/src/Util/AssetBrowser.cpp @@ -0,0 +1,164 @@ +#include + +#include +#include +#include + +#include + +#include +#include + +namespace { + [[nodiscard]] std::string Lowercase(std::string_view value) { + std::string result{value}; + std::ranges::transform(result, result.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return result; + } + + [[nodiscard]] bool HasExtension(std::string_view path, std::initializer_list extensions) { + const std::string extension = Lowercase(std::filesystem::path{path}.extension().string()); + return std::ranges::find(extensions, extension) != extensions.end(); + } + + // Cooker outputs and bookkeeping sit next to the sources in the runtime folders, nobody picks those + [[nodiscard]] bool IsHidden(const std::filesystem::path& path) { + return path.filename() == "manifest.json" || HasExtension(path.string(), {".dmodel", ".dtex", ".spv"}); + } + + [[nodiscard]] const FSMount* FindMount(const std::string& scheme) { + for (const FSMount& mount : AssetFS::GetInstance().GetMounts()) { + if (mount.scheme == scheme) { + return &mount; + } + } + return nullptr; + } +} + +bool AssetBrowser::IsModel(std::string_view path) { + return HasExtension(path, {".fbx", ".glb", ".gltf", ".obj"}); +} + +void AssetBrowser::Draw(bool* open) { + if (!ImGui::Begin("Assets", open)) { + ImGui::End(); + return; + } + + const auto& mounts = AssetFS::GetInstance().GetMounts(); + if (mounts.empty()) { + ImGui::TextDisabled("No asset folders are mounted"); + ImGui::End(); + return; + } + if (FindMount(m_Scheme) == nullptr) { + Navigate(mounts.front().scheme, {}); + } + + for (const FSMount& mount : mounts) { + if (ImGui::RadioButton(mount.scheme.c_str(), m_Scheme == mount.scheme)) { + Navigate(mount.scheme, {}); + } + ImGui::SameLine(); + } + if (ImGui::Button("Refresh")) { + m_Dirty = true; + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGuiUtils::InputString("##Filter", m_Filter, 0, "Search this folder"); + + ImGui::BeginDisabled(m_Folder.empty()); + if (ImGui::ArrowButton("##Up", ImGuiDir_Up)) { + Navigate(m_Scheme, m_Folder.parent_path()); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::TextUnformatted((m_Scheme + "://" + m_Folder.generic_string()).c_str()); + ImGui::Separator(); + + if (m_Dirty) { + Refresh(); + } + + if (ImGui::BeginChild("Entries")) { + const std::string filter = Lowercase(m_Filter); + for (const Entry& entry : m_Entries) { + if ((filter.empty() || Lowercase(entry.name).find(filter) != std::string::npos) && DrawEntry(entry)) { + // Entered a folder, the list is rebuilt next frame + break; + } + } + } + ImGui::EndChild(); + + ImGui::End(); +} + +bool AssetBrowser::DrawEntry(const Entry& entry) { + bool navigated = false; + const std::string virtualPath = m_Scheme + "://" + (m_Folder / entry.name).generic_string(); + const std::string label = (entry.directory ? "[dir] " : IsModel(entry.name) ? "[model] " : "") + entry.name; + + ImGui::PushID(entry.name.c_str()); + if (ImGui::Selectable(label.c_str(), false, ImGuiSelectableFlags_AllowDoubleClick) && + entry.directory && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + Navigate(m_Scheme, m_Folder / entry.name); + navigated = true; + } + + if (!entry.directory && ImGui::BeginDragDropSource()) { + ImGui::SetDragDropPayload(ImGuiUtils::AssetPayload, virtualPath.c_str(), virtualPath.size() + 1); + ImGui::TextUnformatted(virtualPath.c_str()); + if (IsModel(entry.name)) { + ImGui::TextDisabled("Drop it in the viewport to place it"); + } + ImGui::EndDragDropSource(); + } + + if (ImGui::BeginPopupContextItem("EntryMenu")) { + if (ImGui::MenuItem("Copy Path")) { + ImGui::SetClipboardText(virtualPath.c_str()); + } + ImGui::EndPopup(); + } + ImGui::SetItemTooltip("%s", virtualPath.c_str()); + ImGui::PopID(); + return navigated; +} + +void AssetBrowser::Navigate(const std::string& scheme, const std::filesystem::path& folder) { + m_Scheme = scheme; + m_Folder = folder; + m_Filter.clear(); + m_Dirty = true; +} + +void AssetBrowser::Refresh() { + m_Dirty = false; + m_Entries.clear(); + + const FSMount* mount = FindMount(m_Scheme); + if (mount == nullptr) { + return; + } + + std::error_code error; + for (const auto& item : std::filesystem::directory_iterator(mount->root / m_Folder, error)) { + if (IsHidden(item.path())) { + continue; + } + m_Entries.push_back({ + .name = item.path().filename().string(), + .directory = item.is_directory(error), + }); + } + + std::ranges::sort(m_Entries, [](const Entry& a, const Entry& b) { + if (a.directory != b.directory) { + return a.directory; + } + return Lowercase(a.name) < Lowercase(b.name); + }); +} diff --git a/lightkeeper/include/Lightkeeper.h b/lightkeeper/include/Lightkeeper.h index e8989ab..dc51b83 100644 --- a/lightkeeper/include/Lightkeeper.h +++ b/lightkeeper/include/Lightkeeper.h @@ -29,6 +29,7 @@ public: void onViewportResize(int newWidth, int newHeight) override; void drawViewportOverlay() override; + void onViewportAssetDropped(std::string_view path) override; void customWindowsMenu() override; void onPlayModeChanged(bool nowPlaying) override; diff --git a/lightkeeper/src/LightkeeperDebugUi.cpp b/lightkeeper/src/LightkeeperDebugUi.cpp index 614d8e5..6167f47 100644 --- a/lightkeeper/src/LightkeeperDebugUi.cpp +++ b/lightkeeper/src/LightkeeperDebugUi.cpp @@ -10,7 +10,10 @@ #include #include #include +#include #include +#include +#include #include #include @@ -23,6 +26,7 @@ #include #include "imgui.h" +#include "spdlog/spdlog.h" namespace { std::filesystem::path ResolveScenePath( @@ -187,6 +191,32 @@ void LightKeeper::drawDebugMenuBar() ImGui::EndMainMenuBar(); } +void LightKeeper::onViewportAssetDropped(std::string_view path) +{ + if (!AssetBrowser::IsModel(path)) { + sceneStatus = "Only models can be dropped in the viewport: " + std::string{path}; + return; + } + + ModelDoc::LoadOptions options{}; + options.meshImportMode = ModelDoc::MeshImportMode::MergedPerNode; + options.loadMaterials = true; + + try { + Scene& scene = SceneManager::GetInstance().GetCurrentScene(); + const std::string name = std::filesystem::path{path}.stem().string(); + GameObject* model = ModelSpawner::SpawnStaticModel(scene, gfxDevice, resources, path, options, name); + + const Camera& view = getViewCamera(); + model->GetTransform().SetWorldPosition(view.GetPosition() + view.GetForward() * 5.0f); + scene.SetSelectedObject(model->GetId()); + sceneStatus = "Placed " + std::string{path}; + } catch (const std::exception& exception) { + spdlog::error("Could not place {}: {}", path, exception.what()); + sceneStatus = "Could not place " + std::string{path}; + } +} + void LightKeeper::drawViewportOverlay() { if (!showTransformGizmo || isPlayerViewActive()) { diff --git a/tests/graphics_tests.cpp b/tests/graphics_tests.cpp index d4213f3..d2c0cbc 100644 --- a/tests/graphics_tests.cpp +++ b/tests/graphics_tests.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -263,6 +264,74 @@ namespace { "invalid primitive keys must not build a mesh"); } + void testPrimitiveShapes() + { + using PrimitiveMeshes::Shape; + const std::array descs{{ + {.shape = Shape::Box, .size = {2.0f, 0.5f, 3.0f}}, + {.shape = Shape::Plane, .size = {4.0f, 1.0f, 6.0f}}, + {.shape = Shape::Sphere, .radius = 0.75f}, + {.shape = Shape::Cylinder, .radius = 0.5f, .height = 3.0f}, + {.shape = Shape::Capsule, .radius = 0.4f, .height = 2.0f}, + {.shape = Shape::Capsule, .radius = 1.0f, .height = 1.0f}, + }}; + + for (const auto& desc : descs) { + const std::string key = PrimitiveMeshes::MakeKey(desc); + const auto parsed = PrimitiveMeshes::ParseKey(key + "#2"); + check(parsed.has_value() && *parsed == desc, "every primitive must rebuild exactly from its key"); + + const CPUMesh mesh = PrimitiveMeshes::Make(desc); + check(mesh.name == key, "primitives must be named by their key"); + check(!mesh.indices.empty() && mesh.indices.size() % 3 == 0, "primitives must be made of triangles"); + + bool valid = true; + for (const std::uint32_t index : mesh.indices) { + valid = valid && index < mesh.vertices.size(); + } + check(valid, "primitive indices must point at their own vertices"); + + bool outward = true; + bool tangents = true; + for (const auto& vertex : mesh.vertices) { + outward = outward && close(glm::length(vertex.normal), 1.0f) && glm::dot(vertex.position, vertex.normal) > -0.001f; + tangents = tangents && close(glm::dot(glm::vec3{vertex.tangent}, vertex.normal), 0.0f) && + close(std::abs(vertex.tangent.w), 1.0f); + } + check(outward, "primitive normals must be unit length and point away from the center"); + check(tangents, "primitive tangents must lie in the surface with a handedness of +-1"); + + bool counterClockwise = true; + for (std::size_t i = 0; i < mesh.indices.size(); i += 3) { + const auto& a = mesh.vertices[mesh.indices[i]]; + const auto& b = mesh.vertices[mesh.indices[i + 1]]; + const auto& c = mesh.vertices[mesh.indices[i + 2]]; + const glm::vec3 faceNormal = glm::cross(b.position - a.position, c.position - a.position); + counterClockwise = counterClockwise && glm::dot(faceNormal, a.normal + b.normal + c.normal) >= 0.0f; + } + check(counterClockwise, "primitive triangles must be counter clockwise seen from outside"); + } + + const CPUMesh sphere = PrimitiveMeshes::Make(descs[2]); + check(close(sphere.maxPos.x, 0.75f) && close(sphere.minPos.y, -0.75f), "a sphere must reach its radius"); + const CPUMesh plane = PrimitiveMeshes::Make(descs[1]); + check(close(plane.maxPos.x, 2.0f) && close(plane.maxPos.z, 3.0f) && close(plane.maxPos.y, 0.0f), + "a plane must be flat on y = 0 with its size on x and z"); + const CPUMesh capsule = PrimitiveMeshes::Make(descs[4]); + check(close(capsule.maxPos.y, 1.0f) && close(capsule.maxPos.x, 0.4f), + "a capsule height must be its full length, like the collider"); + const CPUMesh cylinder = PrimitiveMeshes::Make(descs[3]); + check(close(cylinder.maxPos.y, 1.5f) && close(cylinder.minPos.y, -1.5f), "a cylinder must be centered on its height"); + + check(!PrimitiveMeshes::ParseKey("prim://sphere?radius=1&radius=2").has_value() && + !PrimitiveMeshes::ParseKey("prim://cylinder?radius=1").has_value() && + !PrimitiveMeshes::ParseKey("prim://plane?size=1,2,3").has_value() && + !PrimitiveMeshes::ParseKey("prim://cone?radius=1").has_value() && + !PrimitiveMeshes::ParseKey("prim://sphere?radius=-1").has_value() && + !PrimitiveMeshes::ParseKey("prim://sphere").has_value(), + "malformed primitive keys must be rejected"); + } + void testIndirectDrawListBatchesByMesh() { std::vector meshes(2); @@ -357,6 +426,7 @@ void RunGraphicsTests() testPrimitiveBoxMesh(); testSimpleColorMaterial(); testPrimitiveMeshKeys(); + testPrimitiveShapes(); testDeletionQueueWaitsForFrame(); testIndirectDrawListBatchesByMesh(); }