diff --git a/CMakeLists.txt b/CMakeLists.txt index a49659e..44fef56 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,6 +8,12 @@ set(CMAKE_CXX_EXTENSIONS OFF) include(CTest) option(DESTRUM_ENABLE_WARNINGS "Enable warnings for first-party targets" ON) +option(DESTRUM_STRICT_ASSET_COOKING "Fail the build when an asset falls back to its source instead of being cooked" ON) + +set(DESTRUM_CHEF_FLAGS) +if (DESTRUM_STRICT_ASSET_COOKING) + list(APPEND DESTRUM_CHEF_FLAGS --strict) +endif() function(destrum_enable_warnings target) if (NOT DESTRUM_ENABLE_WARNINGS) @@ -55,6 +61,12 @@ add_dependencies(CookAssets _internal_cook_engine_assets ) +add_custom_target(ValidateAssets) +add_dependencies(ValidateAssets + _internal_validate_game_assets + _internal_validate_engine_assets +) + option(ENABLE_SANITIZERS "Enable Address/Undefined sanitizers" OFF) if (ENABLE_SANITIZERS AND CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") diff --git a/TheChef b/TheChef index 1981a21..3d4b505 160000 --- a/TheChef +++ b/TheChef @@ -1 +1 @@ -Subproject commit 1981a21481b486e4995eec97cb5ec9158c62d287 +Subproject commit 3d4b50529e4318cd81605b2cb979f645d3a8fb66 diff --git a/destrum/CMakeLists.txt b/destrum/CMakeLists.txt index cf550d3..e8ff40f 100644 --- a/destrum/CMakeLists.txt +++ b/destrum/CMakeLists.txt @@ -232,10 +232,21 @@ add_custom_target(_internal_cook_engine_assets COMMAND TheChef --input "${ASSETS_SRC_DIR}" --output "${ASSETS_RUNTIME_DIR}" + ${DESTRUM_CHEF_FLAGS} DEPENDS TheChef ${ENGINE_ASSET_SOURCES} VERBATIM ) +add_custom_target(_internal_validate_engine_assets + COMMAND TheChef + --input "${ASSETS_SRC_DIR}" + --output "${ASSETS_RUNTIME_DIR}" + --validate + ${DESTRUM_CHEF_FLAGS} + DEPENDS _internal_cook_engine_assets + VERBATIM +) + find_program(SPIRV_VAL_EXECUTABLE NAMES spirv-val spirv-val.exe) if (SPIRV_VAL_EXECUTABLE) file(GLOB_RECURSE ENGINE_SHADER_SOURCES CONFIGURE_DEPENDS diff --git a/destrum/assets_src/chef.json b/destrum/assets_src/chef.json new file mode 100644 index 0000000..0c4dd1f --- /dev/null +++ b/destrum/assets_src/chef.json @@ -0,0 +1,5 @@ +{ + "allowFallback": [ + "run.fbx" + ] +} diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index dbdda3c..a1517ca 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -6,6 +6,7 @@ set(EDITOR_SRC src/PlayMode.cpp src/UndoStack.cpp src/ShaderHotReload.cpp + src/SceneTree.cpp src/SceneInspector.cpp src/AssetBrowser.cpp src/TransformGizmo.cpp diff --git a/editor/include/editor/AssetBrowser.h b/editor/include/editor/AssetBrowser.h index 9d2d5e7..d407558 100644 --- a/editor/include/editor/AssetBrowser.h +++ b/editor/include/editor/AssetBrowser.h @@ -1,37 +1,115 @@ #ifndef EDITOR_ASSETBROWSER_H #define EDITOR_ASSETBROWSER_H +#include #include #include #include #include +#include #include +#include + +#include + +class GfxDevice; +class ImguiPass; +struct FSMount; + class AssetBrowser final { public: + AssetBrowser(GfxDevice& gfxDevice, ImguiPass& imguiPass); + ~AssetBrowser(); + + AssetBrowser(const AssetBrowser&) = delete; + AssetBrowser& operator=(const AssetBrowser&) = delete; + std::optional Draw(bool* open = nullptr); void ConfigureCooking(std::filesystem::path cooker, std::vector schemes); + enum class Kind { Folder, Model, Scene, Prefab, Material, Image, Audio, Shader, Font, Text, Other }; + private: struct Entry { std::string name; + Kind kind{Kind::Other}; bool directory{false}; + std::uintmax_t size{0}; + std::filesystem::path fullPath; + std::filesystem::path previewPath; + glm::vec3 tint{1.0f}; + }; + + struct Location { + std::string scheme; + std::filesystem::path folder; + }; + + struct ThumbnailPixels { + int width{0}; + int height{0}; + int sourceWidth{0}; + int sourceHeight{0}; + std::vector rgba; + }; + + struct Thumbnail { + GPUImage image; + std::filesystem::file_time_type writeTime; + int sourceWidth{0}; + int sourceHeight{0}; + bool failed{false}; }; void Refresh(); void StartCook(); void PollCook(); - void Navigate(const std::string& scheme, const std::filesystem::path& folder); + void Navigate(std::string scheme, std::filesystem::path folder, bool record = true); + void GoBack(); + void GoForward(); + void GoUp(); [[nodiscard]] std::string VirtualPath(const std::string& name) const; - void DrawEntry(const Entry& entry, std::optional& opened, bool& navigated); + + void DrawToolbar(); + void DrawBreadcrumbs(float width); + void DrawFolderTree(); + void DrawFolderNode(const FSMount& mount, const std::filesystem::path& folder, const std::string& label); + void DrawEntries(std::optional& opened); + void DrawGrid(const std::vector& entries, std::optional& opened); + void DrawList(const std::vector& entries, std::optional& opened); + void DrawStatusBar(); + void HandleEntryInput(const Entry& entry, std::optional& opened, bool& navigated); + void Open(const Entry& entry, std::optional& opened, bool& navigated); void DrawFolderMenu(); + void DrawEntryIcon(const Entry& entry, float x, float y, float size); + [[nodiscard]] const std::vector& Subfolders(const FSMount& mount, const std::filesystem::path& folder); + + [[nodiscard]] const Thumbnail* RequestThumbnail(const std::filesystem::path& path); + [[nodiscard]] static ThumbnailPixels DecodeThumbnail(const std::filesystem::path& path); + void PollThumbnails(); + void DestroyThumbnail(Thumbnail& thumbnail); + + GfxDevice& m_GfxDevice; + ImguiPass& m_ImguiPass; std::string m_Scheme; std::filesystem::path m_Folder; std::vector m_Entries; std::string m_Filter; + std::string m_Selected; + std::size_t m_VisibleCount{0}; bool m_Dirty{true}; + bool m_RevealFolder{true}; + float m_TileSize{88.0f}; + + std::vector m_History; + std::size_t m_HistoryIndex{0}; + std::unordered_map> m_Subfolders; + + std::unordered_map m_Thumbnails; + std::unordered_map> m_PendingThumbnails; std::filesystem::path m_Cooker; std::vector m_CookSchemes; diff --git a/editor/include/editor/Editor.h b/editor/include/editor/Editor.h index da4ed3c..2cb6b41 100644 --- a/editor/include/editor/Editor.h +++ b/editor/include/editor/Editor.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -54,6 +55,7 @@ public: private: struct Panels { + bool sceneTree{true}; bool inspector{true}; bool assets{true}; bool stats{true}; @@ -118,6 +120,7 @@ private: EditorCamera m_Camera; PlayMode m_PlayMode; UndoStack m_UndoStack; + SceneTree m_SceneTree; SceneInspector m_Inspector; AssetBrowser m_AssetBrowser; ResourcesInspector m_ResourcesInspector; @@ -137,6 +140,7 @@ private: bool m_ItemWasActive{false}; bool m_EditPending{false}; bool m_CameraHadInput{false}; + bool m_DockLayoutChecked{false}; }; #endif // EDITOR_EDITOR_H diff --git a/editor/include/editor/SceneInspector.h b/editor/include/editor/SceneInspector.h index a2adff0..790f119 100644 --- a/editor/include/editor/SceneInspector.h +++ b/editor/include/editor/SceneInspector.h @@ -3,7 +3,6 @@ #include #include -#include #include @@ -15,44 +14,17 @@ class Scene; class SceneInspector final { public: - void Draw(Scene& scene, ObjectId& selectedObjectId, const std::function& drawSceneSettings, bool* open); + void Draw(Scene& scene, ObjectId selectedObjectId, const std::function& 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& 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; }; diff --git a/editor/include/editor/SceneTree.h b/editor/include/editor/SceneTree.h new file mode 100644 index 0000000..3d760f8 --- /dev/null +++ b/editor/include/editor/SceneTree.h @@ -0,0 +1,46 @@ +#ifndef EDITOR_SCENETREE_H +#define EDITOR_SCENETREE_H + +#include +#include + +#include + +class GameObject; +class Scene; + +class SceneTree final { +public: + void Draw(Scene& scene, ObjectId& selectedObjectId, 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& 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 BeginRename(const GameObject& object); + + std::string m_Filter; + + 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}; +}; + +#endif // EDITOR_SCENETREE_H diff --git a/editor/src/AssetBrowser.cpp b/editor/src/AssetBrowser.cpp index c38627a..c7ba488 100644 --- a/editor/src/AssetBrowser.cpp +++ b/editor/src/AssetBrowser.cpp @@ -1,24 +1,44 @@ #include #include +#include #include +#include #include +#include +#include #include #include +#include +#include +#include #include #include +#include #include #include #include #include +#include #include +#include +#include #include #include #include namespace { + using Kind = AssetBrowser::Kind; + + constexpr int ThumbnailResolution = 128; + constexpr std::size_t MaxThumbnailJobs = 4; + // Each upload is an immediate submit, a folder full of new images shouldn't stall one frame + constexpr int MaxThumbnailUploadsPerFrame = 4; + constexpr float MinTileSize = 48.0f; + constexpr float MaxTileSize = 160.0f; + [[nodiscard]] std::string Lowercase(std::string_view value) { std::string result{value}; @@ -41,16 +61,385 @@ namespace { return it != mounts.end() ? &*it : nullptr; } - [[nodiscard]] const char* Tag(const std::string& name, bool directory) + [[nodiscard]] bool IsWithin(const std::filesystem::path& path, const std::filesystem::path& folder) { - if (directory) return "[dir] "; - if (AssetTypes::IsModel(name)) return "[model] "; - if (AssetTypes::IsScene(name)) return "[scene] "; - if (AssetTypes::IsPrefab(name)) return "[prefab] "; - if (AssetTypes::IsMaterial(name)) return "[material] "; - if (AssetTypes::IsImage(name)) return "[image] "; - if (AssetTypes::IsAudio(name)) return "[audio] "; - return ""; + return std::mismatch(folder.begin(), folder.end(), path.begin(), path.end()).first == folder.end(); + } + + [[nodiscard]] Kind KindOf(const std::string& name, bool directory) + { + if (directory) return Kind::Folder; + if (AssetTypes::IsModel(name)) return Kind::Model; + if (AssetTypes::IsScene(name)) return Kind::Scene; + if (AssetTypes::IsPrefab(name)) return Kind::Prefab; + if (AssetTypes::IsMaterial(name)) return Kind::Material; + if (AssetTypes::IsImage(name) || AssetTypes::HasExtension(name, {".exr", ".hdr"})) return Kind::Image; + if (AssetTypes::IsAudio(name)) return Kind::Audio; + if (AssetTypes::HasExtension(name, {".vert", ".frag", ".comp", ".glsl", ".geom", ".tesc", ".tese"})) return Kind::Shader; + if (AssetTypes::HasExtension(name, {".ttf", ".otf"})) return Kind::Font; + if (AssetTypes::HasExtension(name, {".json", ".txt", ".md", ".ini", ".cfg", ".csv"})) return Kind::Text; + return Kind::Other; + } + + // ImGui draws straight into an sRGB target, colors are picked in sRGB and linearized so they show up as picked + [[nodiscard]] ImVec4 Srgb(float r, float g, float b, float a = 1.0f) + { + return ImVec4{std::pow(r, 2.2f), std::pow(g, 2.2f), std::pow(b, 2.2f), a}; + } + + [[nodiscard]] ImU32 SrgbU32(int r, int g, int b, int a = 255) + { + return ImGui::ColorConvertFloat4ToU32(Srgb(static_cast(r) / 255.0f, static_cast(g) / 255.0f, + static_cast(b) / 255.0f, static_cast(a) / 255.0f)); + } + + struct KindStyle { + const char* label; + ImVec4 color; + }; + + [[nodiscard]] KindStyle StyleOf(Kind kind) + { + switch (kind) { + case Kind::Folder: return {"Folder", Srgb(0.92f, 0.72f, 0.32f)}; + case Kind::Model: return {"Model", Srgb(0.38f, 0.62f, 0.95f)}; + case Kind::Scene: return {"Scene", Srgb(0.45f, 0.80f, 0.45f)}; + case Kind::Prefab: return {"Prefab", Srgb(0.30f, 0.80f, 0.85f)}; + case Kind::Material: return {"Material", Srgb(0.88f, 0.48f, 0.78f)}; + case Kind::Image: return {"Texture", Srgb(0.66f, 0.52f, 0.96f)}; + case Kind::Audio: return {"Audio", Srgb(0.96f, 0.56f, 0.30f)}; + case Kind::Shader: return {"Shader", Srgb(0.40f, 0.86f, 0.70f)}; + case Kind::Font: return {"Font", Srgb(0.88f, 0.86f, 0.56f)}; + case Kind::Text: return {"Text", Srgb(0.70f, 0.72f, 0.78f)}; + case Kind::Other: break; + } + return {"File", Srgb(0.56f, 0.58f, 0.63f)}; + } + + // What double clicking does in the editor, see Editor::OnAssetDropped + [[nodiscard]] const char* ActionLabel(Kind kind) + { + switch (kind) { + case Kind::Folder: return "Open"; + case Kind::Scene: return "Open Scene"; + case Kind::Model: + case Kind::Prefab: + case Kind::Audio: return "Add to Scene"; + case Kind::Material: return "Apply to Selection"; + default: return nullptr; + } + } + + [[nodiscard]] std::string FormatSize(std::uintmax_t bytes) + { + constexpr std::array units{"B", "KB", "MB", "GB"}; + double size = static_cast(bytes); + std::size_t unit = 0; + while (size >= 1024.0 && unit + 1 < units.size()) { + size /= 1024.0; + ++unit; + } + std::array buffer{}; + std::snprintf(buffer.data(), buffer.size(), unit == 0 ? "%.0f %s" : "%.1f %s", size, units[unit]); + return buffer.data(); + } + + [[nodiscard]] std::string ExtensionLabel(const std::string& name) + { + std::string extension = AssetTypes::Extension(name); + if (!extension.empty()) { + extension.erase(0, 1); + } + std::ranges::transform(extension, extension.begin(), [](unsigned char c) { return static_cast(std::toupper(c)); }); + return extension.substr(0, 5); + } + + struct MaterialPreview { + glm::vec3 tint{1.0f}; + std::filesystem::path texture; + }; + + [[nodiscard]] MaterialPreview ReadMaterialPreview(const std::filesystem::path& path) + { + MaterialPreview preview; + try { + std::ifstream file(path); + const nlohmann::json data = nlohmann::json::parse(file, nullptr, false); + if (!data.is_object()) { + return preview; + } + if (const auto color = data.find("baseColor"); color != data.end() && color->is_array() && color->size() >= 3) { + preview.tint = glm::vec3{color->at(0).get(), color->at(1).get(), color->at(2).get()}; + } + const auto textures = data.find("textures"); + if (textures == data.end() || !textures->is_object() || !textures->contains("diffuse")) { + return preview; + } + if (const auto reference = AssetDatabase::GetInstance().ReadReference(textures->at("diffuse"))) { + const std::filesystem::path texture = reference->path.find("://") != std::string::npos + ? AssetFS::GetInstance().GetFullPath(reference->path) + : std::filesystem::path{reference->path}; + if (AssetTypes::IsImage(texture.string())) { + preview.texture = texture; + } + } + } catch (const std::exception&) { + } + return preview; + } + +#ifdef _WIN32 + void ShowInExplorer(const std::filesystem::path& path, bool select) + { + std::string command = std::string{"explorer "} + (select ? "/select," : "") + "\"" + + path.lexically_normal().make_preferred().string() + "\""; + (void)JobSystem::GetInstance().Async([command = std::move(command)] { return std::system(command.c_str()); }); + } +#endif + + // shade darkens or brightens as if done in sRGB, the color itself is linear already + [[nodiscard]] ImU32 ToU32(const ImVec4& color, float shade = 1.0f, float alpha = 1.0f) + { + const float linearShade = std::pow(shade, 2.2f); + return ImGui::ColorConvertFloat4ToU32(ImVec4{color.x * linearShade, color.y * linearShade, color.z * linearShade, color.w * alpha}); + } + + // Icons are drawn in a unit square, x and y are fractions of its size + [[nodiscard]] ImVec2 At(ImVec2 origin, float size, float x, float y) + { + return ImVec2{origin.x + size * x, origin.y + size * y}; + } + + // SameLine position right after the last item, GetCursorPosX is already on the next line by then + [[nodiscard]] float AfterLastItemX() + { + return ImGui::GetItemRectMax().x - ImGui::GetWindowPos().x + ImGui::GetScrollX() + ImGui::GetStyle().ItemSpacing.x; + } + + void DrawFolderIcon(ImDrawList* drawList, ImVec2 p, float s, const ImVec4& color) + { + const float rounding = s * 0.06f; + drawList->AddRectFilled(At(p, s, 0.08f, 0.16f), At(p, s, 0.46f, 0.32f), ToU32(color, 0.72f), rounding, ImDrawFlags_RoundCornersTop); + drawList->AddRectFilled(At(p, s, 0.08f, 0.24f), At(p, s, 0.92f, 0.84f), ToU32(color, 0.72f), rounding); + drawList->AddRectFilled(At(p, s, 0.08f, 0.34f), At(p, s, 0.92f, 0.84f), ToU32(color), rounding); + drawList->AddLine(At(p, s, 0.12f, 0.38f), At(p, s, 0.88f, 0.38f), ToU32(color, 1.2f, 0.7f), std::max(1.0f, s * 0.02f)); + } + + void DrawCubeIcon(ImDrawList* drawList, ImVec2 p, float s, const ImVec4& color, bool outlined) + { + const ImVec2 center = At(p, s, 0.5f, 0.52f); + const float r = s * 0.38f; + const float dx = r * 0.866f; + const float dy = r * 0.5f; + const ImVec2 top{center.x, center.y - r}; + const ImVec2 upRight{center.x + dx, center.y - dy}; + const ImVec2 downRight{center.x + dx, center.y + dy}; + const ImVec2 bottom{center.x, center.y + r}; + const ImVec2 downLeft{center.x - dx, center.y + dy}; + const ImVec2 upLeft{center.x - dx, center.y - dy}; + + const std::array topFace{top, upRight, center, upLeft}; + const std::array leftFace{upLeft, center, bottom, downLeft}; + const std::array rightFace{center, upRight, downRight, bottom}; + drawList->AddConvexPolyFilled(topFace.data(), 4, ToU32(color, 1.15f)); + drawList->AddConvexPolyFilled(leftFace.data(), 4, ToU32(color, 0.82f)); + drawList->AddConvexPolyFilled(rightFace.data(), 4, ToU32(color, 0.58f)); + + if (outlined) { + const float thickness = std::max(1.0f, s * 0.03f); + const ImU32 edge = ToU32(color, 1.6f); + const std::array outline{top, upRight, downRight, bottom, downLeft, upLeft}; + drawList->AddPolyline(outline.data(), static_cast(outline.size()), edge, ImDrawFlags_Closed, thickness); + drawList->AddLine(center, upLeft, edge, thickness); + drawList->AddLine(center, upRight, edge, thickness); + drawList->AddLine(center, bottom, edge, thickness); + } + } + + void DrawSceneIcon(ImDrawList* drawList, ImVec2 p, float s, const ImVec4& color) + { + const float rounding = s * 0.05f; + drawList->AddRectFilled(At(p, s, 0.12f, 0.40f), At(p, s, 0.88f, 0.84f), ToU32(color), rounding); + drawList->AddRectFilled(At(p, s, 0.12f, 0.22f), At(p, s, 0.88f, 0.36f), ToU32(color, 0.45f), rounding); + for (int i = 0; i < 4; ++i) { + const float x = 0.16f + static_cast(i) * 0.19f; + const std::array stripe{At(p, s, x + 0.06f, 0.22f), At(p, s, x + 0.14f, 0.22f), At(p, s, x + 0.08f, 0.36f), At(p, s, x, 0.36f)}; + drawList->AddConvexPolyFilled(stripe.data(), 4, ToU32(color, 1.25f)); + } + drawList->AddTriangleFilled(At(p, s, 0.44f, 0.50f), At(p, s, 0.60f, 0.62f), At(p, s, 0.44f, 0.74f), ToU32(color, 0.45f)); + } + + void DrawMaterialIcon(ImDrawList* drawList, ImVec2 p, float s, const glm::vec3& tint, ImTextureID texture) + { + const ImVec2 center = At(p, s, 0.5f, 0.5f); + const float r = s * 0.4f; + const ImVec4 base{tint.x, tint.y, tint.z, 1.0f}; + + if (texture != ImTextureID{}) { + drawList->AddImageRounded(texture, ImVec2{center.x - r, center.y - r}, ImVec2{center.x + r, center.y + r}, + ImVec2{0.0f, 0.0f}, ImVec2{1.0f, 1.0f}, ToU32(base), r); + } else { + // Shrinking circles moving towards the light fake a lit sphere + constexpr int steps = 16; + for (int i = 0; i < steps; ++i) { + const float t = static_cast(i) / static_cast(steps - 1); + const float offset = r * 0.3f * t; + drawList->AddCircleFilled(ImVec2{center.x - offset, center.y - offset}, r * (1.0f - 0.7f * t), ToU32(base, 0.35f + 0.8f * t)); + } + } + + for (int i = 0; i < 5; ++i) { + const float t = static_cast(i) / 5.0f; + drawList->AddCircle(center, r * (1.0f - t * 0.12f), IM_COL32(0, 0, 0, static_cast(70.0f * (1.0f - t))), 0, r * 0.06f); + } + drawList->AddCircleFilled(ImVec2{center.x - r * 0.36f, center.y - r * 0.38f}, r * 0.16f, IM_COL32(255, 255, 255, 90)); + } + + void DrawImageIcon(ImDrawList* drawList, ImVec2 p, float s, const ImVec4& color) + { + const float rounding = s * 0.05f; + drawList->AddRectFilled(At(p, s, 0.12f, 0.18f), At(p, s, 0.88f, 0.82f), ToU32(color, 0.42f), rounding); + drawList->AddRect(At(p, s, 0.12f, 0.18f), At(p, s, 0.88f, 0.82f), ToU32(color), rounding, 0, std::max(1.0f, s * 0.03f)); + drawList->AddTriangleFilled(At(p, s, 0.18f, 0.76f), At(p, s, 0.40f, 0.44f), At(p, s, 0.62f, 0.76f), ToU32(color, 0.9f)); + drawList->AddTriangleFilled(At(p, s, 0.46f, 0.76f), At(p, s, 0.64f, 0.54f), At(p, s, 0.82f, 0.76f), ToU32(color, 1.2f)); + drawList->AddCircleFilled(At(p, s, 0.70f, 0.34f), s * 0.07f, SrgbU32(255, 216, 102)); + } + + void DrawImageThumbnail(ImDrawList* drawList, ImVec2 p, float s, ImTextureID texture, const VkExtent3D& extent) + { + const float box = s * 0.9f; + const float width = static_cast(extent.width); + const float height = static_cast(extent.height); + const float scale = box / std::max({width, height, 1.0f}); + const ImVec2 size{std::max(1.0f, width * scale), std::max(1.0f, height * scale)}; + const ImVec2 min{p.x + (s - size.x) * 0.5f, p.y + (s - size.y) * 0.5f}; + const ImVec2 max{min.x + size.x, min.y + size.y}; + + // Checkerboard behind transparent pixels + drawList->AddRectFilled(min, max, SrgbU32(52, 52, 58)); + const float cell = std::max(4.0f, box / 8.0f); + for (float y = min.y; y < max.y; y += cell) { + for (float x = min.x; x < max.x; x += cell) { + const int column = static_cast((x - min.x) / cell); + const int row = static_cast((y - min.y) / cell); + if (((column + row) & 1) != 0) { + drawList->AddRectFilled(ImVec2{x, y}, ImVec2{std::min(x + cell, max.x), std::min(y + cell, max.y)}, SrgbU32(72, 72, 80)); + } + } + } + drawList->AddImage(texture, min, max); + drawList->AddRect(min, max, IM_COL32(0, 0, 0, 90)); + } + + void DrawAudioIcon(ImDrawList* drawList, ImVec2 p, float s, const ImVec4& color) + { + drawList->AddRectFilled(At(p, s, 0.16f, 0.40f), At(p, s, 0.30f, 0.60f), ToU32(color), s * 0.02f); + const std::array cone{At(p, s, 0.28f, 0.40f), At(p, s, 0.50f, 0.22f), At(p, s, 0.50f, 0.78f), At(p, s, 0.28f, 0.60f)}; + drawList->AddConvexPolyFilled(cone.data(), 4, ToU32(color)); + const ImVec2 center = At(p, s, 0.50f, 0.50f); + for (int i = 1; i <= 2; ++i) { + drawList->PathArcTo(center, s * (0.12f + 0.13f * static_cast(i)), -0.9f, 0.9f); + drawList->PathStroke(ToU32(color, 1.0f, i == 1 ? 1.0f : 0.6f), 0, std::max(1.5f, s * 0.05f)); + } + } + + void DrawFontIcon(ImDrawList* drawList, ImVec2 p, float s, const ImVec4& color) + { + ImFont* font = ImGui::GetFont(); + const float fontSize = s * 0.5f; + const ImVec2 size = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, "Aa"); + drawList->AddText(font, fontSize, ImVec2{p.x + (s - size.x) * 0.5f, p.y + (s - size.y) * 0.5f}, ToU32(color), "Aa"); + } + + void DrawPageIcon(ImDrawList* drawList, ImVec2 p, float s, const ImVec4& color, const std::string& label) + { + constexpr float fold = 0.22f; + const std::array page{At(p, s, 0.20f, 0.10f), At(p, s, 0.80f - fold, 0.10f), At(p, s, 0.80f, 0.10f + fold), + At(p, s, 0.80f, 0.90f), At(p, s, 0.20f, 0.90f)}; + drawList->AddConvexPolyFilled(page.data(), static_cast(page.size()), ToU32(color, 0.78f)); + const std::array corner{At(p, s, 0.80f - fold, 0.10f), At(p, s, 0.80f, 0.10f + fold), At(p, s, 0.80f - fold, 0.10f + fold)}; + drawList->AddConvexPolyFilled(corner.data(), 3, ToU32(color, 1.15f)); + + const float thickness = std::max(1.0f, s * 0.03f); + for (int i = 0; i < 3; ++i) { + const float y = 0.38f + static_cast(i) * 0.09f; + drawList->AddLine(At(p, s, 0.30f, y), At(p, s, i == 2 ? 0.52f : 0.70f, y), ToU32(color, 0.45f), thickness); + } + + if (s >= 32.0f && !label.empty()) { + ImFont* font = ImGui::GetFont(); + const float fontSize = s * 0.16f; + const ImVec2 size = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, label.c_str()); + drawList->AddText(font, fontSize, ImVec2{p.x + (s - size.x) * 0.5f, p.y + s * 0.74f - size.y * 0.5f}, + ToU32(color, 0.3f), label.c_str()); + } + } + + [[nodiscard]] std::size_t SafeSplit(std::string_view text, std::size_t count) + { + // Never split a UTF-8 sequence + while (count > 0 && count < text.size() && (static_cast(text[count]) & 0xC0) == 0x80) { + --count; + } + return count; + } + + [[nodiscard]] std::size_t FittingPrefix(std::string_view text, float width, std::string_view suffix = {}) + { + const float suffixWidth = suffix.empty() ? 0.0f : ImGui::CalcTextSize(suffix.data(), suffix.data() + suffix.size()).x; + std::size_t low = 0; + std::size_t high = text.size(); + while (low < high) { + const std::size_t middle = (low + high + 1) / 2; + if (ImGui::CalcTextSize(text.data(), text.data() + middle).x + suffixWidth <= width) { + low = middle; + } else { + high = middle - 1; + } + } + return SafeSplit(text, low); + } + + // Two centered lines, preferably broken at a separator, the second one ends in "..." when it doesn't fit + void DrawTileLabel(ImDrawList* drawList, std::string_view name, ImVec2 pos, float width, float lineHeight, ImU32 color) + { + std::string_view rest = name; + for (int line = 0; line < 2 && !rest.empty(); ++line) { + std::size_t count = std::max(1, FittingPrefix(rest, width)); + std::string text; + if (line == 0 && count < rest.size()) { + const std::size_t separator = rest.substr(0, count).find_last_of("_-. "); + if (separator != std::string_view::npos && separator > 0) { + count = separator + 1; + } + text = rest.substr(0, count); + } else if (line == 1 && count < rest.size()) { + text = std::string{rest.substr(0, FittingPrefix(rest, width, "..."))} + "..."; + } else { + text = rest.substr(0, count); + } + const float textWidth = ImGui::CalcTextSize(text.c_str()).x; + drawList->AddText(ImVec2{pos.x + std::max(0.0f, (width - textWidth) * 0.5f), pos.y + static_cast(line) * lineHeight}, + color, text.c_str()); + rest.remove_prefix(std::min(count, rest.size())); + } + } +} + +AssetBrowser::AssetBrowser(GfxDevice& gfxDevice, ImguiPass& imguiPass) + : m_GfxDevice(gfxDevice), + m_ImguiPass(imguiPass) +{ +} + +AssetBrowser::~AssetBrowser() +{ + try { + for (auto& item : m_Thumbnails) { + DestroyThumbnail(item.second); + } + } catch (const std::exception& exception) { + spdlog::warn("Destroying the asset thumbnails failed: {}", exception.what()); } } @@ -71,6 +460,7 @@ std::optional AssetBrowser::Draw(bool* open) } m_AppFocused = appFocused; PollCook(); + PollThumbnails(); if (!ImGui::Begin("Assets", open)) { ImGui::End(); @@ -87,51 +477,49 @@ std::optional AssetBrowser::Draw(bool* open) Navigate(mounts.front().scheme, {}); } - for (const FSMount& mount : mounts) { - if (ImGui::RadioButton(mount.scheme.c_str(), m_Scheme == mount.scheme)) { - Navigate(mount.scheme, {}); + const ImGuiIO& io = ImGui::GetIO(); + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && !io.WantTextInput) { + if (ImGui::IsKeyPressed(ImGuiKey_Backspace, false) || (io.KeyAlt && ImGui::IsKeyPressed(ImGuiKey_UpArrow, false))) { + GoUp(); + } + if (io.KeyAlt && ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false)) { + GoBack(); + } + if (io.KeyAlt && ImGui::IsKeyPressed(ImGuiKey_RightArrow, false)) { + GoForward(); + } + if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) && !m_Dirty) { + const auto it = std::ranges::find(m_Entries, m_Selected, &Entry::name); + bool navigated = false; + if (it != m_Entries.end()) { + Open(*it, opened, navigated); + } } - ImGui::SameLine(); } - const bool cooking = m_Cook.valid(); - ImGui::BeginDisabled(cooking); - if (ImGui::Button(cooking ? "Cooking..." : "Refresh")) { - m_Dirty = true; - StartCook(); + // Mouse back and forward buttons + if (ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows)) { + if (ImGui::IsMouseClicked(3)) { + GoBack(); + } + if (ImGui::IsMouseClicked(4)) { + GoForward(); + } } - ImGui::EndDisabled(); - ImGui::SetItemTooltip("Cooks new and changed files from assets_src, then lists the folder again"); - 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()); + DrawToolbar(); + + const float statusHeight = ImGui::GetFrameHeightWithSpacing(); + if (ImGui::BeginChild("Folders", ImVec2(200.0f, -statusHeight), ImGuiChildFlags_Borders | ImGuiChildFlags_ResizeX)) { + DrawFolderTree(); } - ImGui::EndDisabled(); + ImGui::EndChild(); 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); - bool navigated = false; - for (const Entry& entry : m_Entries) { - if (filter.empty() || Lowercase(entry.name).find(filter) != std::string::npos) { - DrawEntry(entry, opened, navigated); - } - if (navigated) { - break; - } - } - DrawFolderMenu(); - } - ImGui::EndChild(); + DrawEntries(opened); + DrawStatusBar(); ImGui::End(); return opened; @@ -142,36 +530,430 @@ std::string AssetBrowser::VirtualPath(const std::string& name) const return m_Scheme + "://" + (m_Folder / name).generic_string(); } -void AssetBrowser::DrawEntry(const Entry& entry, std::optional& opened, bool& navigated) +void AssetBrowser::DrawToolbar() { - const std::string virtualPath = VirtualPath(entry.name); - const std::string label = Tag(entry.name, entry.directory) + entry.name; + ImGui::BeginDisabled(m_HistoryIndex == 0); + if (ImGui::ArrowButton("##Back", ImGuiDir_Left)) { + GoBack(); + } + ImGui::EndDisabled(); + ImGui::SetItemTooltip("Back (Alt+Left)"); + ImGui::SameLine(); + ImGui::BeginDisabled(m_HistoryIndex + 1 >= m_History.size()); + if (ImGui::ArrowButton("##Forward", ImGuiDir_Right)) { + GoForward(); + } + ImGui::EndDisabled(); + ImGui::SetItemTooltip("Forward (Alt+Right)"); + ImGui::SameLine(); + ImGui::BeginDisabled(m_Folder.empty()); + if (ImGui::ArrowButton("##Up", ImGuiDir_Up)) { + GoUp(); + } + ImGui::EndDisabled(); + ImGui::SetItemTooltip("Parent folder (Backspace)"); + ImGui::SameLine(); - ImGui::PushID(entry.name.c_str()); - if (ImGui::Selectable(label.c_str(), false, ImGuiSelectableFlags_AllowDoubleClick) && - ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { - if (entry.directory) { - Navigate(m_Scheme, m_Folder / entry.name); - navigated = true; - } else { - opened = virtualPath; + const ImGuiStyle& style = ImGui::GetStyle(); + const float refreshWidth = ImGui::CalcTextSize("Cooking...").x + style.FramePadding.x * 2.0f; + const float start = ImGui::GetCursorPosX(); + const float available = ImGui::GetContentRegionAvail().x; + const float searchWidth = std::clamp(available * 0.3f, 80.0f, 240.0f); + const float rightWidth = searchWidth + style.ItemSpacing.x + refreshWidth; + + DrawBreadcrumbs(available - rightWidth - style.ItemSpacing.x * 2.0f); + + ImGui::SameLine(std::max(AfterLastItemX(), start + available - rightWidth)); + ImGui::SetNextItemWidth(searchWidth); + ImGuiUtils::InputString("##Filter", m_Filter, 0, "Search this folder"); + ImGui::SameLine(); + const bool cooking = m_Cook.valid(); + ImGui::BeginDisabled(cooking); + if (ImGui::Button(cooking ? "Cooking..." : "Refresh", ImVec2(refreshWidth, 0.0f))) { + m_Dirty = true; + StartCook(); + } + ImGui::EndDisabled(); + ImGui::SetItemTooltip("Cooks new and changed files from assets_src, then lists the folder again"); +} + +void AssetBrowser::DrawBreadcrumbs(float width) +{ + struct Crumb { + std::string label; + std::filesystem::path folder; + }; + std::vector crumbs{{m_Scheme + "://", {}}}; + std::filesystem::path folder; + for (const auto& part : m_Folder) { + folder /= part; + crumbs.push_back({part.string(), folder}); + } + + const ImGuiStyle& style = ImGui::GetStyle(); + const float separatorWidth = ImGui::CalcTextSize(">").x + style.ItemSpacing.x * 2.0f; + const auto crumbWidth = [&](const Crumb& crumb) { return ImGui::CalcTextSize(crumb.label.c_str()).x + style.FramePadding.x * 2.0f; }; + + float total = 0.0f; + for (std::size_t i = 0; i < crumbs.size(); ++i) { + total += crumbWidth(crumbs[i]) + (i > 0 ? separatorWidth : 0.0f); + } + // Long paths keep the root and the deepest folders, the middle collapses into "..." + std::size_t first = 1; + const float ellipsisWidth = ImGui::CalcTextSize("...").x + separatorWidth; + while (total > width && first + 1 < crumbs.size()) { + total -= crumbWidth(crumbs[first]) + separatorWidth; + if (first == 1) { + total += ellipsisWidth; + } + ++first; + } + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4{0.0f, 0.0f, 0.0f, 0.0f}); + for (std::size_t i = 0; i < crumbs.size(); ++i) { + if (i > 0 && i < first) { + if (i == 1) { + ImGui::SameLine(); + ImGui::TextDisabled(">"); + ImGui::SameLine(); + ImGui::TextDisabled("..."); + } + continue; + } + if (i > 0) { + ImGui::SameLine(); + ImGui::TextDisabled(">"); + ImGui::SameLine(); + } + ImGui::PushID(static_cast(i)); + const bool current = i + 1 == crumbs.size(); + if (ImGui::Button(crumbs[i].label.c_str()) && !current) { + Navigate(m_Scheme, crumbs[i].folder); + } + ImGui::PopID(); + } + ImGui::PopStyleColor(); +} + +void AssetBrowser::DrawFolderTree() +{ + for (const FSMount& mount : AssetFS::GetInstance().GetMounts()) { + DrawFolderNode(mount, {}, mount.scheme); + } + m_RevealFolder = false; +} + +void AssetBrowser::DrawFolderNode(const FSMount& mount, const std::filesystem::path& folder, const std::string& label) +{ + const std::vector& children = Subfolders(mount, folder); + const bool inCurrentScheme = mount.scheme == m_Scheme; + + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_OpenOnDoubleClick | + ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_DrawLinesToNodes; + if (folder.empty()) { + flags |= ImGuiTreeNodeFlags_DefaultOpen; + } + if (children.empty()) { + flags |= ImGuiTreeNodeFlags_Leaf; + } + if (inCurrentScheme && folder == m_Folder) { + flags |= ImGuiTreeNodeFlags_Selected; + } + if (m_RevealFolder && inCurrentScheme && folder != m_Folder && IsWithin(m_Folder, folder)) { + ImGui::SetNextItemOpen(true); + } + + ImGui::PushID(label.c_str()); + const bool open = ImGui::TreeNodeEx("##Folder", flags); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && !ImGui::IsItemToggledOpen()) { + Navigate(mount.scheme, folder); + } + ImGui::SameLine(); + const float iconSize = ImGui::GetTextLineHeight(); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + DrawFolderIcon(ImGui::GetWindowDrawList(), pos, iconSize, StyleOf(Kind::Folder).color); + ImGui::Dummy(ImVec2{iconSize, iconSize}); + ImGui::SameLine(); + ImGui::TextUnformatted(label.c_str()); + + if (open) { + for (const std::string& child : children) { + DrawFolderNode(mount, folder / child, child); + } + ImGui::TreePop(); + } + ImGui::PopID(); +} + +void AssetBrowser::DrawEntries(std::optional& opened) +{ + const ImGuiIO& io = ImGui::GetIO(); + const float statusHeight = ImGui::GetFrameHeightWithSpacing(); + // Ctrl + wheel zooms, it shouldn't scroll at the same time + const ImGuiWindowFlags flags = io.KeyCtrl ? ImGuiWindowFlags_NoScrollWithMouse : ImGuiWindowFlags_None; + if (!ImGui::BeginChild("Entries", ImVec2{0.0f, -statusHeight}, ImGuiChildFlags_Borders, flags)) { + ImGui::EndChild(); + return; + } + + if (ImGui::IsWindowHovered() && io.KeyCtrl && io.MouseWheel != 0.0f) { + m_TileSize = std::clamp(m_TileSize + io.MouseWheel * 8.0f, MinTileSize, MaxTileSize); + } + + const std::string filter = Lowercase(m_Filter); + std::vector visible; + for (const Entry& entry : m_Entries) { + if (filter.empty() || Lowercase(entry.name).find(filter) != std::string::npos) { + visible.push_back(&entry); } } + m_VisibleCount = visible.size(); + + if (visible.empty()) { + const char* message = m_Entries.empty() ? "This folder is empty" : "Nothing here matches the search"; + const ImVec2 size = ImGui::CalcTextSize(message); + const ImVec2 available = ImGui::GetContentRegionAvail(); + ImGui::SetCursorPos(ImVec2{ImGui::GetCursorPosX() + std::max(0.0f, (available.x - size.x) * 0.5f), + ImGui::GetCursorPosY() + available.y * 0.4f}); + ImGui::TextDisabled("%s", message); + } else if (m_TileSize <= MinTileSize) { + DrawList(visible, opened); + } else { + DrawGrid(visible, opened); + } + + if (ImGui::IsWindowHovered() && !ImGui::IsAnyItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + m_Selected.clear(); + } + DrawFolderMenu(); + ImGui::EndChild(); +} + +void AssetBrowser::DrawGrid(const std::vector& entries, std::optional& opened) +{ + const ImGuiStyle& style = ImGui::GetStyle(); + const float lineHeight = ImGui::GetTextLineHeight(); + const float padding = std::round(ImGui::GetFontSize() * 0.4f); + const ImVec2 tileSize{m_TileSize + padding * 2.0f, m_TileSize + padding * 3.0f + lineHeight * 2.0f}; + const float spacing = style.ItemSpacing.x; + const int columns = std::max(1, static_cast((ImGui::GetContentRegionAvail().x + spacing) / (tileSize.x + spacing))); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2{spacing, spacing}); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + bool navigated = false; + for (std::size_t i = 0; i < entries.size() && !navigated; ++i) { + const Entry& entry = *entries[i]; + if (i % static_cast(columns) != 0) { + ImGui::SameLine(); + } + + ImGui::PushID(entry.name.c_str()); + const ImVec2 min = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton("##Tile", tileSize); + if (ImGui::IsItemVisible()) { + const ImVec2 max{min.x + tileSize.x, min.y + tileSize.y}; + const bool selected = entry.name == m_Selected; + if (selected || ImGui::IsItemHovered()) { + drawList->AddRectFilled(min, max, ImGui::GetColorU32(selected ? ImGuiCol_Header : ImGuiCol_HeaderHovered, selected ? 1.0f : 0.45f), + style.FrameRounding + 3.0f); + } + + const ImVec2 icon{min.x + padding, min.y + padding}; + DrawEntryIcon(entry, icon.x, icon.y, m_TileSize); + if (!entry.directory) { + const float stripeY = icon.y + m_TileSize + 1.0f; + drawList->AddRectFilled(ImVec2{icon.x + m_TileSize * 0.15f, stripeY}, ImVec2{icon.x + m_TileSize * 0.85f, stripeY + 2.0f}, + ToU32(StyleOf(entry.kind).color, 1.0f, 0.9f), 1.0f); + } + DrawTileLabel(drawList, entry.name, ImVec2{min.x + padding * 0.5f, icon.y + m_TileSize + padding}, + tileSize.x - padding, lineHeight, ImGui::GetColorU32(ImGuiCol_Text)); + } + HandleEntryInput(entry, opened, navigated); + ImGui::PopID(); + } + ImGui::PopStyleVar(); +} + +void AssetBrowser::DrawList(const std::vector& entries, std::optional& opened) +{ + constexpr ImGuiTableFlags tableFlags = ImGuiTableFlags_RowBg | ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV; + if (!ImGui::BeginTable("List", 3, tableFlags)) { + return; + } + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, ImGui::GetFontSize() * 6.0f); + ImGui::TableSetupColumn("Size", ImGuiTableColumnFlags_WidthFixed, ImGui::GetFontSize() * 5.0f); + ImGui::TableHeadersRow(); + + const float iconSize = ImGui::GetTextLineHeight(); + bool navigated = false; + for (std::size_t i = 0; i < entries.size() && !navigated; ++i) { + const Entry& entry = *entries[i]; + const KindStyle kindStyle = StyleOf(entry.kind); + ImGui::PushID(entry.name.c_str()); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::Selectable("##Row", entry.name == m_Selected, + ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap | ImGuiSelectableFlags_AllowDoubleClick, + ImVec2{0.0f, iconSize}); + const bool visible = ImGui::IsItemVisible(); + HandleEntryInput(entry, opened, navigated); + + ImGui::SameLine(); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + if (visible) { + DrawEntryIcon(entry, pos.x, pos.y, iconSize); + } + ImGui::Dummy(ImVec2{iconSize, iconSize}); + ImGui::SameLine(); + ImGui::TextUnformatted(entry.name.c_str()); + ImGui::TableNextColumn(); + ImGui::TextColored(kindStyle.color, "%s", kindStyle.label); + ImGui::TableNextColumn(); + if (!entry.directory) { + ImGui::TextDisabled("%s", FormatSize(entry.size).c_str()); + } + ImGui::PopID(); + } + ImGui::EndTable(); +} + +void AssetBrowser::DrawStatusBar() +{ + const float start = ImGui::GetCursorPosX(); + const float available = ImGui::GetContentRegionAvail().x; + + ImGui::AlignTextToFramePadding(); + if (m_VisibleCount == m_Entries.size()) { + ImGui::TextDisabled("%zu items", m_Entries.size()); + } else { + ImGui::TextDisabled("%zu of %zu items", m_VisibleCount, m_Entries.size()); + } + if (!m_Selected.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("|"); + ImGui::SameLine(); + ImGui::TextUnformatted(VirtualPath(m_Selected).c_str()); + } + if (m_Cook.valid()) { + ImGui::SameLine(); + ImGui::TextColored(StyleOf(Kind::Audio).color, "Cooking..."); + } + + const float sliderWidth = ImGui::GetFontSize() * 8.0f; + ImGui::SameLine(std::max(AfterLastItemX(), start + available - sliderWidth)); + ImGui::SetNextItemWidth(sliderWidth); + ImGui::SliderFloat("##TileSize", &m_TileSize, MinTileSize, MaxTileSize, m_TileSize <= MinTileSize ? "List" : "%.0f px"); + ImGui::SetItemTooltip("Tile size, or Ctrl + mouse wheel over the assets. All the way left shows a list"); +} + +void AssetBrowser::HandleEntryInput(const Entry& entry, std::optional& opened, bool& navigated) +{ + if (ImGui::IsItemClicked(ImGuiMouseButton_Left) || ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + m_Selected = entry.name; + } + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + Open(entry, opened, navigated); + return; + } + + const std::string virtualPath = VirtualPath(entry.name); + const KindStyle kindStyle = StyleOf(entry.kind); if (!entry.directory && ImGui::BeginDragDropSource()) { ImGui::SetDragDropPayload(ImGuiUtils::AssetPayload, virtualPath.c_str(), virtualPath.size() + 1); - ImGui::TextUnformatted(virtualPath.c_str()); + const float iconSize = ImGui::GetFrameHeight() * 1.5f; + const ImVec2 pos = ImGui::GetCursorScreenPos(); + DrawEntryIcon(entry, pos.x, pos.y, iconSize); + ImGui::Dummy(ImVec2{iconSize, iconSize}); + ImGui::SameLine(); + ImGui::BeginGroup(); + ImGui::TextUnformatted(entry.name.c_str()); + ImGui::TextColored(kindStyle.color, "%s", kindStyle.label); + ImGui::EndGroup(); ImGui::EndDragDropSource(); } if (ImGui::BeginPopupContextItem("EntryMenu")) { + if (const char* action = ActionLabel(entry.kind); action != nullptr && ImGui::MenuItem(action)) { + Open(entry, opened, navigated); + } if (ImGui::MenuItem("Copy Path")) { ImGui::SetClipboardText(virtualPath.c_str()); } +#ifdef _WIN32 + if (ImGui::MenuItem("Show in Explorer")) { + const FSMount* mount = FindMount(m_Scheme); + std::filesystem::path path = entry.fullPath; + if (mount != nullptr && mount->sourceRoot) { + std::error_code error; + if (const auto source = *mount->sourceRoot / m_Folder / entry.name; std::filesystem::exists(source, error)) { + path = source; + } + } + ShowInExplorer(path, true); + } +#endif ImGui::EndPopup(); } - ImGui::SetItemTooltip("%s", virtualPath.c_str()); - ImGui::PopID(); + + if (ImGui::BeginItemTooltip()) { + ImGui::TextUnformatted(entry.name.c_str()); + ImGui::TextColored(kindStyle.color, "%s", kindStyle.label); + ImGui::TextDisabled("%s", virtualPath.c_str()); + if (!entry.directory) { + ImGui::TextDisabled("%s", FormatSize(entry.size).c_str()); + } + if (entry.kind == Kind::Image && !entry.previewPath.empty()) { + if (const auto it = m_Thumbnails.find(entry.previewPath.string()); it != m_Thumbnails.end() && it->second.sourceWidth > 0) { + ImGui::TextDisabled("%d x %d", it->second.sourceWidth, it->second.sourceHeight); + } + } + ImGui::EndTooltip(); + } +} + +void AssetBrowser::Open(const Entry& entry, std::optional& opened, bool& navigated) +{ + if (entry.directory) { + Navigate(m_Scheme, m_Folder / entry.name); + navigated = true; + } else { + opened = VirtualPath(entry.name); + } +} + +void AssetBrowser::DrawEntryIcon(const Entry& entry, float x, float y, float size) +{ + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const ImVec2 origin{x, y}; + const ImVec4 color = StyleOf(entry.kind).color; + + const Thumbnail* thumbnail = entry.previewPath.empty() ? nullptr : RequestThumbnail(entry.previewPath); + ImTextureID texture{}; + if (thumbnail != nullptr && !thumbnail->failed) { + texture = m_ImguiPass.getFrameTexture(thumbnail->image.imageView); + } + + switch (entry.kind) { + case Kind::Folder: DrawFolderIcon(drawList, origin, size, color); break; + case Kind::Model: DrawCubeIcon(drawList, origin, size, color, false); break; + case Kind::Prefab: DrawCubeIcon(drawList, origin, size, color, true); break; + case Kind::Scene: DrawSceneIcon(drawList, origin, size, color); break; + case Kind::Material: DrawMaterialIcon(drawList, origin, size, entry.tint, texture); break; + case Kind::Image: + if (texture != ImTextureID{}) { + DrawImageThumbnail(drawList, origin, size, texture, thumbnail->image.extent); + } else { + DrawImageIcon(drawList, origin, size, color); + } + break; + case Kind::Audio: DrawAudioIcon(drawList, origin, size, color); break; + case Kind::Font: DrawFontIcon(drawList, origin, size, color); break; + case Kind::Shader: + case Kind::Text: + case Kind::Other: DrawPageIcon(drawList, origin, size, color, ExtensionLabel(entry.name)); break; + } } void AssetBrowser::DrawFolderMenu() @@ -187,17 +969,80 @@ void AssetBrowser::DrawFolderMenu() } const Material material = Material::SimpleColor(glm::vec3{1.0f}, std::filesystem::path{path}.stem().string()); AssetFS::GetInstance().WriteFile(path, MaterialSerialization::ToFile(material).dump(4)); + m_Selected = std::filesystem::path{path}.filename().string(); m_Dirty = true; } + ImGui::Separator(); + if (ImGui::MenuItem("Refresh", nullptr, false, !m_Cook.valid())) { + m_Dirty = true; + StartCook(); + } +#ifdef _WIN32 + if (const FSMount* mount = FindMount(m_Scheme); mount != nullptr && ImGui::MenuItem("Show in Explorer")) { + ShowInExplorer((mount->sourceRoot ? *mount->sourceRoot : mount->root) / m_Folder, false); + } +#endif ImGui::EndPopup(); } -void AssetBrowser::Navigate(const std::string& scheme, const std::filesystem::path& folder) +const std::vector& AssetBrowser::Subfolders(const FSMount& mount, const std::filesystem::path& folder) { - m_Scheme = scheme; - m_Folder = folder; + auto [it, inserted] = m_Subfolders.try_emplace(mount.scheme + "://" + folder.generic_string()); + if (inserted) { + std::error_code error; + for (const auto& item : std::filesystem::directory_iterator(mount.root / folder, error)) { + std::error_code typeError; + if (item.is_directory(typeError) && !IsHidden(item.path())) { + it->second.push_back(item.path().filename().string()); + } + } + std::ranges::sort(it->second, [](const std::string& a, const std::string& b) { return Lowercase(a) < Lowercase(b); }); + } + return it->second; +} + +void AssetBrowser::Navigate(std::string scheme, std::filesystem::path folder, bool record) +{ + if (record && (m_History.empty() || m_History[m_HistoryIndex].scheme != scheme || m_History[m_HistoryIndex].folder != folder)) { + if (!m_History.empty()) { + m_History.resize(m_HistoryIndex + 1); + } + m_History.push_back({scheme, folder}); + m_HistoryIndex = m_History.size() - 1; + } + + m_Scheme = std::move(scheme); + m_Folder = std::move(folder); m_Filter.clear(); + m_Selected.clear(); m_Dirty = true; + m_RevealFolder = true; +} + +void AssetBrowser::GoBack() +{ + if (m_HistoryIndex > 0) { + --m_HistoryIndex; + Navigate(m_History[m_HistoryIndex].scheme, m_History[m_HistoryIndex].folder, false); + } +} + +void AssetBrowser::GoForward() +{ + if (m_HistoryIndex + 1 < m_History.size()) { + ++m_HistoryIndex; + Navigate(m_History[m_HistoryIndex].scheme, m_History[m_HistoryIndex].folder, false); + } +} + +void AssetBrowser::GoUp() +{ + if (m_Folder.empty()) { + return; + } + std::string from = m_Folder.filename().string(); + Navigate(m_Scheme, m_Folder.parent_path()); + m_Selected = std::move(from); } void AssetBrowser::StartCook() @@ -253,16 +1098,32 @@ void AssetBrowser::Refresh() { m_Dirty = false; m_Entries.clear(); + m_Subfolders.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())) { - m_Entries.push_back({.name = item.path().filename().string(), .directory = item.is_directory(error)}); + if (const FSMount* mount = FindMount(m_Scheme)) { + std::error_code error; + for (const auto& item : std::filesystem::directory_iterator(mount->root / m_Folder, error)) { + if (IsHidden(item.path())) { + continue; + } + std::error_code itemError; + Entry entry; + entry.name = item.path().filename().string(); + entry.directory = item.is_directory(itemError); + entry.kind = KindOf(entry.name, entry.directory); + entry.fullPath = item.path(); + if (!entry.directory) { + const std::uintmax_t size = item.file_size(itemError); + entry.size = itemError ? 0 : size; + } + if (entry.kind == Kind::Image && AssetTypes::IsImage(entry.name)) { + entry.previewPath = entry.fullPath; + } else if (entry.kind == Kind::Material) { + MaterialPreview preview = ReadMaterialPreview(entry.fullPath); + entry.tint = preview.tint; + entry.previewPath = std::move(preview.texture); + } + m_Entries.push_back(std::move(entry)); } } @@ -272,4 +1133,130 @@ void AssetBrowser::Refresh() } return Lowercase(a.name) < Lowercase(b.name); }); + + // Thumbnails of other folders and of files that changed are made again when they show up + std::unordered_set shown; + for (const Entry& entry : m_Entries) { + if (!entry.previewPath.empty()) { + shown.insert(entry.previewPath.string()); + } + } + std::erase_if(m_Thumbnails, [&](auto& item) { + std::error_code error; + const bool stale = !shown.contains(item.first) || std::filesystem::last_write_time(item.first, error) != item.second.writeTime; + if (stale) { + DestroyThumbnail(item.second); + } + return stale; + }); +} + +const AssetBrowser::Thumbnail* AssetBrowser::RequestThumbnail(const std::filesystem::path& path) +{ + std::string key = path.string(); + if (const auto it = m_Thumbnails.find(key); it != m_Thumbnails.end()) { + return &it->second; + } + if (!m_PendingThumbnails.contains(key) && m_PendingThumbnails.size() < MaxThumbnailJobs) { + m_PendingThumbnails.emplace(std::move(key), JobSystem::GetInstance().Async([path] { return DecodeThumbnail(path); })); + } + return nullptr; +} + +AssetBrowser::ThumbnailPixels AssetBrowser::DecodeThumbnail(const std::filesystem::path& path) +{ + ThumbnailPixels result; + try { + const ImageData data = util::loadImage(path, TextureIntent::ColorSrgb); + const bool rgba8 = data.vkFormat == VK_FORMAT_R8G8B8A8_SRGB || data.vkFormat == VK_FORMAT_R8G8B8A8_UNORM; + const std::size_t texels = static_cast(data.width) * static_cast(data.height); + if (data.hdr || !rgba8 || data.pixels == nullptr || texels == 0 || data.byteSize < texels * 4) { + return result; + } + + result.sourceWidth = data.width; + result.sourceHeight = data.height; + const float scale = std::min(1.0f, static_cast(ThumbnailResolution) / static_cast(std::max(data.width, data.height))); + result.width = std::max(1, static_cast(std::lround(static_cast(data.width) * scale))); + result.height = std::max(1, static_cast(std::lround(static_cast(data.height) * scale))); + result.rgba.resize(static_cast(result.width) * static_cast(result.height) * 4); + + // Box filter, every thumbnail pixel averages the source texels it covers + for (int y = 0; y < result.height; ++y) { + const int y0 = y * data.height / result.height; + const int y1 = std::max(y0 + 1, (y + 1) * data.height / result.height); + for (int x = 0; x < result.width; ++x) { + const int x0 = x * data.width / result.width; + const int x1 = std::max(x0 + 1, (x + 1) * data.width / result.width); + std::array sum{}; + for (int sy = y0; sy < y1; ++sy) { + const unsigned char* row = data.pixels + (static_cast(sy) * static_cast(data.width)) * 4; + for (int sx = x0; sx < x1; ++sx) { + for (std::size_t c = 0; c < 4; ++c) { + sum[c] += row[static_cast(sx) * 4 + c]; + } + } + } + const auto count = static_cast((y1 - y0) * (x1 - x0)); + unsigned char* out = result.rgba.data() + (static_cast(y) * static_cast(result.width) + static_cast(x)) * 4; + for (std::size_t c = 0; c < 4; ++c) { + out[c] = static_cast(sum[c] / count); + } + } + } + } catch (const std::exception& exception) { + spdlog::warn("Could not make a thumbnail for {}: {}", path.string(), exception.what()); + result = {}; + } + return result; +} + +void AssetBrowser::PollThumbnails() +{ + int uploads = 0; + for (auto it = m_PendingThumbnails.begin(); it != m_PendingThumbnails.end() && uploads < MaxThumbnailUploadsPerFrame;) { + if (it->second.wait_for(std::chrono::seconds{0}) != std::future_status::ready) { + ++it; + continue; + } + + Thumbnail thumbnail; + try { + ThumbnailPixels pixels = it->second.get(); + std::error_code error; + thumbnail.writeTime = std::filesystem::last_write_time(it->first, error); + thumbnail.sourceWidth = pixels.sourceWidth; + thumbnail.sourceHeight = pixels.sourceHeight; + thumbnail.failed = pixels.rgba.empty(); + if (!thumbnail.failed) { + ImageData data; + data.cookedPixels = std::move(pixels.rgba); + data.pixels = data.cookedPixels.data(); + data.width = pixels.width; + data.height = pixels.height; + data.channels = 4; + data.vkFormat = VK_FORMAT_R8G8B8A8_SRGB; + data.byteSize = data.cookedPixels.size(); + if (auto image = m_GfxDevice.createImageFromData(data, VK_IMAGE_USAGE_SAMPLED_BIT, false, "Asset thumbnail")) { + thumbnail.image = std::move(*image); + } else { + thumbnail.failed = true; + } + ++uploads; + } + } catch (const std::exception& exception) { + spdlog::warn("Could not upload the thumbnail for {}: {}", it->first, exception.what()); + thumbnail.failed = true; + } + + if (auto [slot, inserted] = m_Thumbnails.try_emplace(it->first, std::move(thumbnail)); !inserted) { + DestroyThumbnail(thumbnail); + } + it = m_PendingThumbnails.erase(it); + } +} + +void AssetBrowser::DestroyThumbnail(Thumbnail& thumbnail) +{ + m_GfxDevice.destroyImage(thumbnail.image); } diff --git a/editor/src/Editor.cpp b/editor/src/Editor.cpp index 36de501..466ba56 100644 --- a/editor/src/Editor.cpp +++ b/editor/src/Editor.cpp @@ -35,6 +35,7 @@ Editor::Editor(App& app, EditorSettings settings) : m_App(app), m_Settings(std::move(settings)), m_PlayMode(app), + m_AssetBrowser(app.getGfxDevice(), app.getImguiPass()), m_ScenePath(m_Settings.scenePath) { } @@ -119,11 +120,14 @@ void Editor::onImGui(float) DrawViewport(); Scene* scene = CurrentScene(); - if (scene != nullptr && m_Panels.inspector) { + if (scene != nullptr && m_Panels.sceneTree) { ObjectId selected = scene->GetSelectedObjectId(); - m_Inspector.Draw(*scene, selected, [scene] { scene->DrawSettingsImGui(); }, &m_Panels.inspector); + m_SceneTree.Draw(*scene, selected, &m_Panels.sceneTree); scene->SetSelectedObject(selected); } + if (scene != nullptr && m_Panels.inspector) { + m_Inspector.Draw(*scene, scene->GetSelectedObjectId(), [scene] { scene->DrawSettingsImGui(); }, &m_Panels.inspector); + } if (m_Panels.assets) { if (const auto opened = m_AssetBrowser.Draw(&m_Panels.assets)) { OnAssetDropped(*opened); diff --git a/editor/src/EditorMenus.cpp b/editor/src/EditorMenus.cpp index 28bb8f8..52a658e 100644 --- a/editor/src/EditorMenus.cpp +++ b/editor/src/EditorMenus.cpp @@ -33,6 +33,33 @@ namespace { std::ranges::sort(scenes); return scenes; } + + // Layouts saved while the scene tree was part of the inspector have no spot for it, split one off the inspector + void DockSceneTreeIntoSavedLayout(ImGuiID dockspaceId) + { + const ImGuiWindowSettings* inspector = ImGui::FindWindowSettingsByID(ImHashStr("Inspector")); + if (ImGui::FindWindowSettingsByID(ImHashStr("Scene Tree")) != nullptr || inspector == nullptr) { + return; + } + const ImGuiID oldNode = inspector->DockId; + const ImGuiDockNode* node = ImGui::DockBuilderGetNode(oldNode); + if (node == nullptr || node->IsSplitNode()) { + return; + } + + ImGuiID inspectorNode = 0; + const ImGuiID sceneTreeNode = ImGui::DockBuilderSplitNode(oldNode, ImGuiDir_Up, 0.4f, nullptr, &inspectorNode); + // Windows aren't bound to their nodes yet, the ones tabbed with the inspector would point at the split node + ImGuiContext& context = *ImGui::GetCurrentContext(); + for (ImGuiWindowSettings* settings = context.SettingsWindows.begin(); settings != nullptr; + settings = context.SettingsWindows.next_chunk(settings)) { + if (settings->DockId == oldNode) { + settings->DockId = inspectorNode; + } + } + ImGui::DockBuilderDockWindow("Scene Tree", sceneTreeNode); + ImGui::DockBuilderFinish(dockspaceId); + } } void Editor::DrawDockSpace() @@ -42,20 +69,26 @@ void Editor::DrawDockSpace() const ImGuiViewport* mainViewport = ImGui::GetMainViewport(); ImGui::DockSpaceOverViewport(dockspaceId, mainViewport); if (hasSavedLayout) { + if (!std::exchange(m_DockLayoutChecked, true)) { + DockSceneTreeIntoSavedLayout(dockspaceId); + } return; } + m_DockLayoutChecked = true; - // First run without an imgui.ini: scene in the middle, inspector right, tools left, assets below + // First run without an imgui.ini: scene in the middle, scene tree and inspector right, tools left, assets below ImGui::DockBuilderRemoveNode(dockspaceId); ImGui::DockBuilderAddNode(dockspaceId, ImGuiDockNodeFlags_DockSpace); ImGui::DockBuilderSetNodeSize(dockspaceId, mainViewport->WorkSize); ImGuiID center = dockspaceId; - const ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.25f, nullptr, ¢er); + ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.25f, nullptr, ¢er); const ImGuiID left = ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.2f, nullptr, ¢er); const ImGuiID bottom = ImGui::DockBuilderSplitNode(center, ImGuiDir_Down, 0.28f, nullptr, ¢er); + const ImGuiID rightTop = ImGui::DockBuilderSplitNode(right, ImGuiDir_Up, 0.4f, nullptr, &right); ImGui::DockBuilderDockWindow("Viewport", center); + ImGui::DockBuilderDockWindow("Scene Tree", rightTop); ImGui::DockBuilderDockWindow("Inspector", right); ImGui::DockBuilderDockWindow("Stats", left); ImGui::DockBuilderDockWindow("Physics", left); @@ -155,6 +188,7 @@ void Editor::DrawViewMenu() return; } + ImGui::MenuItem("Scene Tree", nullptr, &m_Panels.sceneTree); ImGui::MenuItem("Inspector", nullptr, &m_Panels.inspector); ImGui::MenuItem("Assets", nullptr, &m_Panels.assets); ImGui::MenuItem("Stats", nullptr, &m_Panels.stats); diff --git a/editor/src/SceneInspector.cpp b/editor/src/SceneInspector.cpp index 13de46e..70b0e0d 100644 --- a/editor/src/SceneInspector.cpp +++ b/editor/src/SceneInspector.cpp @@ -11,78 +11,15 @@ #include #include -#include -#include #include #include #include #include #include #include -#include #include 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 CollectObjects(const Scene& scene) - { - std::vector 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) { @@ -91,60 +28,6 @@ namespace { return it != text.end(); } - [[nodiscard]] const char* DisplayName(const GameObject& object) - { - return object.GetName().empty() ? "" : 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(); - } - GameObject* object = CreateChild(scene, parent, name); - object->AddComponent(); - 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(); - } - if (ImGui::MenuItem("Rect")) { - created = CreateUIChild(scene, parent, "Rect"); - } - if (ImGui::MenuItem("Image")) { - created = CreateUIChild(scene, parent, "Image"); - created->AddComponent(); - } - if (ImGui::MenuItem("Text")) { - created = CreateUIChild(scene, parent, "Text"); - created->GetComponent()->SetSize({300.0f, 60.0f}); - created->AddComponent("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) { @@ -152,364 +35,24 @@ namespace { } } -void SceneInspector::Draw(Scene& scene, ObjectId& selectedObjectId, const std::function& drawSceneSettings, bool* open) +void SceneInspector::Draw(Scene& scene, ObjectId selectedObjectId, const std::function& drawSceneSettings, bool* open) { RegisterEngineComponents(); - if (scene.FindGameObject(selectedObjectId) == nullptr) { - selectedObjectId = InvalidObjectId; - } - - ImGui::SetNextWindowSize(ImVec2(720.0f, 560.0f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(420.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); + GameObject* selected = scene.FindGameObject(selectedObjectId); + if (selected != nullptr && !selected->IsBeingDestroyed()) { + DrawObject(*selected); + } else if (drawSceneSettings) { + drawSceneSettings(); } - 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 objects = CollectObjects(scene); - std::unordered_set 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 reachable; - std::vector 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(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& 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(static_cast(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); - - // Leaf nodes push no ID, without this every sibling shares one popup and draws it again - ImGui::PushID(reinterpret_cast(static_cast(object.GetId()))); - if (ImGui::BeginPopupContextItem("ObjectMenu")) { - DrawObjectMenu(scene, &object, selectedObjectId); - ImGui::EndPopup(); - } - ImGui::PopID(); - - 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(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) diff --git a/editor/src/SceneTree.cpp b/editor/src/SceneTree.cpp new file mode 100644 index 0000000..df23fd1 --- /dev/null +++ b/editor/src/SceneTree.cpp @@ -0,0 +1,484 @@ +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 CollectObjects(const Scene& scene) + { + std::vector 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(a)) == std::tolower(static_cast(b)); + }); + return it != text.end(); + } + + [[nodiscard]] const char* DisplayName(const GameObject& object) + { + return object.GetName().empty() ? "" : 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(); + } + GameObject* object = CreateChild(scene, parent, name); + object->AddComponent(); + 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(); + } + if (ImGui::MenuItem("Rect")) { + created = CreateUIChild(scene, parent, "Rect"); + } + if (ImGui::MenuItem("Image")) { + created = CreateUIChild(scene, parent, "Image"); + created->AddComponent(); + } + if (ImGui::MenuItem("Text")) { + created = CreateUIChild(scene, parent, "Text"); + created->GetComponent()->SetSize({300.0f, 60.0f}); + created->AddComponent("Text", 32.0f); + } + ImGui::EndMenu(); + } + return created; + } +} + +void SceneTree::Draw(Scene& scene, ObjectId& selectedObjectId, bool* open) +{ + if (scene.FindGameObject(selectedObjectId) == nullptr) { + selectedObjectId = InvalidObjectId; + } + + ImGui::SetNextWindowSize(ImVec2(320.0f, 560.0f), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Scene Tree", open)) { + ImGui::End(); + return; + } + + HandleShortcuts(scene, selectedObjectId); + DrawHierarchy(scene, selectedObjectId); + + ImGui::End(); + + ApplyPendingActions(scene, selectedObjectId); +} + +void SceneTree::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 SceneTree::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 objects = CollectObjects(scene); + std::unordered_set 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 reachable; + std::vector 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(payload->Data)}; + } + ImGui::EndDragDropTarget(); + } + if (ImGui::BeginPopupContextItem("BackgroundMenu")) { + DrawObjectMenu(scene, nullptr, selectedObjectId); + ImGui::EndPopup(); + } + + ImGui::EndChild(); +} + +void SceneTree::DrawNode(Scene& scene, GameObject& object, ObjectId& selectedObjectId, + std::unordered_set& 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(static_cast(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); + + // Leaf nodes push no ID, without this every sibling shares one popup and draws it again + ImGui::PushID(reinterpret_cast(static_cast(object.GetId()))); + if (ImGui::BeginPopupContextItem("ObjectMenu")) { + DrawObjectMenu(scene, &object, selectedObjectId); + ImGui::EndPopup(); + } + ImGui::PopID(); + + 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 SceneTree::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(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 SceneTree::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 SceneTree::BeginRename(const GameObject& object) +{ + m_RenamingId = object.GetId(); + m_RenameBuffer = object.GetName(); + m_FocusRename = true; +} + +void SceneTree::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 SceneTree::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; + } +} diff --git a/lightkeeper/CMakeLists.txt b/lightkeeper/CMakeLists.txt index 0a400ba..e747363 100644 --- a/lightkeeper/CMakeLists.txt +++ b/lightkeeper/CMakeLists.txt @@ -89,10 +89,21 @@ add_custom_target(_internal_cook_game_assets COMMAND TheChef --input "${ASSETS_SRC_DIR}" --output "${ASSETS_RUNTIME_DIR}" + ${DESTRUM_CHEF_FLAGS} DEPENDS TheChef ${GAME_ASSET_SOURCES} VERBATIM ) +add_custom_target(_internal_validate_game_assets + COMMAND TheChef + --input "${ASSETS_SRC_DIR}" + --output "${ASSETS_RUNTIME_DIR}" + --validate + ${DESTRUM_CHEF_FLAGS} + DEPENDS _internal_cook_game_assets + VERBATIM +) + add_dependencies(lightkeeper _internal_cook_game_assets) destrum_cook_engine_assets(lightkeeper "${CMAKE_CURRENT_BINARY_DIR}") diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b76135b..a6bc93a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -11,6 +11,7 @@ add_executable(destrum_tests editor_tests.cpp audio_tests.cpp jobs_tests.cpp + cooker_tests.cpp ${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Activatable.cpp ${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Interactable.cpp ${CMAKE_SOURCE_DIR}/lightkeeper/src/components/InteractableButton.cpp @@ -32,13 +33,15 @@ target_compile_definitions(destrum_tests PRIVATE SDL_MAIN_HANDLED DESTRUM_ENGINE_ASSETS_SRC_DIR="${CMAKE_SOURCE_DIR}/destrum/assets_src" LIGHTKEEPER_ASSETS_SRC_DIR="${CMAKE_SOURCE_DIR}/lightkeeper/assets_src" + DESTRUM_THECHEF_PATH="$" ) +add_dependencies(destrum_tests TheChef) target_include_directories(destrum_tests PRIVATE "${CMAKE_SOURCE_DIR}/lightkeeper/include") target_link_libraries(destrum_tests PRIVATE destrum::destrum destrum::editor) destrum_enable_warnings(destrum_tests) destrum_copy_runtime_dlls(destrum_tests) -foreach(suite core serialization physics assets graphics ui gameplay editor audio jobs) +foreach(suite core serialization physics assets graphics ui gameplay editor audio jobs cooker) add_test(NAME destrum_tests.${suite} COMMAND destrum_tests ${suite}) endforeach() diff --git a/tests/cooker_tests.cpp b/tests/cooker_tests.cpp new file mode 100644 index 0000000..4140428 --- /dev/null +++ b/tests/cooker_tests.cpp @@ -0,0 +1,320 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "TestUtils.h" + +using namespace test; +namespace fs = std::filesystem; + +namespace { + struct CookDirs { + fs::path root; + fs::path input; + fs::path output; + }; + + CookDirs makeDirs(const char* name) + { + CookDirs dirs; + dirs.root = fs::temp_directory_path() / name; + fs::remove_all(dirs.root); + dirs.input = dirs.root / "src"; + dirs.output = dirs.root / "out"; + fs::create_directories(dirs.input); + return dirs; + } + + void writeText(const fs::path& path, const std::string& text) + { + fs::create_directories(path.parent_path()); + std::ofstream{path, std::ios::binary} << text; + } + + // 2x2 uncompressed 32 bit TGA, top left origin + void writeTga(const fs::path& path) + { + const std::uint8_t header[18] = {0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, 32, 0x28}; + fs::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::binary); + file.write(reinterpret_cast(header), sizeof(header)); + for (int i = 0; i < 4; ++i) { + const std::uint8_t bgra[4] = {0, 0, 255, 255}; + file.write(reinterpret_cast(bgra), sizeof(bgra)); + } + } + + int runChef(const CookDirs& dirs, const std::string& flags = {}) + { + const fs::path log = dirs.root / "chef.log"; + std::string command = "\"" + std::string(DESTRUM_THECHEF_PATH) + "\" --input \"" + dirs.input.string() + + "\" --output \"" + dirs.output.string() + "\" " + flags + " > \"" + log.string() + + "\" 2>&1"; +#ifdef _WIN32 + // cmd.exe strips the outer quotes, the inner ones keep paths with spaces intact + command = "\"" + command + "\""; +#endif + return std::system(command.c_str()); + } + + void expectChef(const CookDirs& dirs, const std::string& flags, bool succeeds, const char* message) + { + if ((runChef(dirs, flags) == 0) != succeeds) { + std::cerr << readFile(dirs.root / "chef.log") << '\n'; + check(false, message); + } + } + + nlohmann::json readManifest(const CookDirs& dirs) + { + return nlohmann::json::parse(readFile(dirs.output / "manifest.json")); + } + + const nlohmann::json* findEntry(const nlohmann::json& manifest, const std::string& src) + { + for (const auto& entry : manifest["assets"]) { + if (entry["src"] == src) { + return &entry; + } + } + return nullptr; + } + + // Puts the output well before its dependency, so the next cook has to rebuild it without sleeping + fs::file_time_type makeStale(const fs::path& output, const fs::path& dependency) + { + const auto stale = fs::last_write_time(dependency) - std::chrono::hours{1}; + fs::last_write_time(output, stale); + return stale; + } + + bool hasTempFiles(const fs::path& dir) + { + for (const auto& entry : fs::recursive_directory_iterator(dir)) { + if (entry.path().extension() == ".tmp") { + return true; + } + } + return false; + } + + void writeShaders(const CookDirs& dirs) + { + writeText(dirs.input / "shaders" / "include" / "common.glsl", "vec4 tint() { return vec4(1.0); }\n"); + writeText(dirs.input / "shaders" / "plain.frag", + "#version 450\n" + "#extension GL_GOOGLE_include_directive : require\n" + "#include \"common.glsl\"\n" + "layout(location = 0) out vec4 color;\n" + "void main() { color = tint(); }\n"); + } + + void writeModel(const CookDirs& dirs) + { + writeText(dirs.input / "models" / "tri.obj", + "mtllib tri.mtl\n" + "v 0 0 0\nv 1 0 0\nv 0 1 0\n" + "vt 0 0\nvt 1 0\nvt 0 1\n" + "vn 0 0 1\n" + "usemtl red\n" + "f 1/1/1 2/2/1 3/3/1\n"); + writeText(dirs.input / "models" / "tri.mtl", "newmtl red\nKd 1 0 0\n"); + } + + void testCookRecordsEveryOutput() + { + const CookDirs dirs = makeDirs("destrum_cooker_outputs"); + writeShaders(dirs); + writeModel(dirs); + writeTga(dirs.input / "textures" / "red.tga"); + writeText(dirs.input / "data" / "notes.txt", "hello"); + + expectChef(dirs, "", true, "a clean cook must succeed"); + + check(fs::exists(dirs.output / "shaders" / "plain.frag.spv"), "entry shaders compile to SPIR-V"); + check(fs::exists(dirs.output / "models" / "tri.obj.dmodel"), "models cook to .dmodel"); + check(fs::exists(dirs.output / "models" / "tri.obj"), "source models are still copied"); + check(fs::exists(dirs.output / "textures" / "red.tga.dtex"), "LDR images cook to .dtex"); + check(readFile(dirs.output / "data" / "notes.txt") == "hello", "unknown files are copied"); + check(!hasTempFiles(dirs.output), "a finished cook leaves no temporary files"); + + const auto manifest = readManifest(dirs); + const auto* shader = findEntry(manifest, "shaders/plain.frag"); + check(shader && (*shader)["deps"] == nlohmann::json::array({"shaders/include/common.glsl"}), + "shader includes are recorded as dependencies"); + const auto* model = findEntry(manifest, "models/tri.obj"); + check(model && (*model)["out"] == "models/tri.obj.dmodel", "the cooked model is the primary output"); + check(model && (*model)["outputs"].size() == 2, "a model owns its copy and its cooked file"); + check(model && (*model)["deps"] == nlohmann::json::array({"models/tri.mtl"}), + ".obj material libraries are recorded as dependencies"); + check(findEntry(manifest, "shaders/include/common.glsl") != nullptr, "includes are listed too"); + + expectChef(dirs, "--validate --strict", true, "a fresh cook must validate"); + } + + void testUnchangedSourcesAreSkipped() + { + const CookDirs dirs = makeDirs("destrum_cooker_unchanged"); + writeShaders(dirs); + writeModel(dirs); + writeTga(dirs.input / "red.tga"); + expectChef(dirs, "", true, "the first cook must succeed"); + + const std::vector outputs = { + dirs.output / "shaders" / "plain.frag.spv", + dirs.output / "models" / "tri.obj.dmodel", + dirs.output / "red.tga.dtex", + dirs.output / "red.tga", + }; + std::vector times; + for (const auto& output : outputs) { + times.push_back(fs::last_write_time(output)); + } + + expectChef(dirs, "", true, "a second cook must succeed"); + for (std::size_t i = 0; i < outputs.size(); ++i) { + check(fs::last_write_time(outputs[i]) == times[i], "an unchanged source must not be cooked again"); + } + } + + void testDependencyChangesRecook() + { + const CookDirs dirs = makeDirs("destrum_cooker_dependencies"); + writeShaders(dirs); + writeModel(dirs); + expectChef(dirs, "", true, "the first cook must succeed"); + + const fs::path spv = dirs.output / "shaders" / "plain.frag.spv"; + const auto staleSpv = makeStale(spv, dirs.input / "shaders" / "include" / "common.glsl"); + const fs::path model = dirs.output / "models" / "tri.obj.dmodel"; + const auto staleModel = makeStale(model, dirs.input / "models" / "tri.mtl"); + // The entry files themselves stay older than the outputs, only the dependencies are newer + fs::last_write_time(dirs.input / "shaders" / "plain.frag", staleSpv - std::chrono::hours{1}); + fs::last_write_time(dirs.input / "models" / "tri.obj", staleModel - std::chrono::hours{1}); + + expectChef(dirs, "", true, "recooking must succeed"); + check(fs::last_write_time(spv) != staleSpv, "a changed include must recompile the shader"); + check(fs::last_write_time(model) != staleModel, "a changed material library must recook the model"); + } + + void testReplacedSourceIsCopiedAgain() + { + const CookDirs dirs = makeDirs("destrum_cooker_replaced"); + const fs::path source = dirs.input / "notes.txt"; + writeText(source, "new"); + expectChef(dirs, "", true, "the first cook must succeed"); + + // Restoring an older file must still replace the copy, even though it isn't newer + writeText(source, "old!"); + fs::last_write_time(source, fs::last_write_time(dirs.output / "notes.txt") - std::chrono::hours{24}); + expectChef(dirs, "", true, "recooking must succeed"); + check(readFile(dirs.output / "notes.txt") == "old!", "a replaced source must be copied again"); + } + + void testDeletedSourcesAreRemoved() + { + const CookDirs dirs = makeDirs("destrum_cooker_deleted"); + writeTga(dirs.input / "textures" / "old" / "gone.tga"); + writeText(dirs.input / "keep.txt", "keep"); + writeText(dirs.output / "hand_made.txt", "not ours"); + expectChef(dirs, "", true, "the first cook must succeed"); + check(fs::exists(dirs.output / "textures" / "old" / "gone.tga.dtex"), "the texture must be cooked first"); + + fs::remove_all(dirs.input / "textures"); + expectChef(dirs, "", true, "cooking after a delete must succeed"); + + check(!fs::exists(dirs.output / "textures" / "old" / "gone.tga.dtex"), "cooked outputs of deleted sources are removed"); + check(!fs::exists(dirs.output / "textures" / "old" / "gone.tga"), "copies of deleted sources are removed"); + check(!fs::exists(dirs.output / "textures"), "folders emptied by the cleanup are removed"); + check(fs::exists(dirs.output / "keep.txt"), "outputs of existing sources stay"); + check(fs::exists(dirs.output / "hand_made.txt"), "files the cooker never wrote are left alone"); + check(findEntry(readManifest(dirs), "textures/old/gone.tga") == nullptr, "deleted sources leave the manifest"); + + expectChef(dirs, "--validate", false, "validation must report files the cooker didn't write"); + fs::remove(dirs.output / "hand_made.txt"); + expectChef(dirs, "--validate", true, "validation passes once the stray file is gone"); + } + + void testFailedCookKeepsLastGoodOutput() + { + const CookDirs dirs = makeDirs("destrum_cooker_failure"); + writeShaders(dirs); + expectChef(dirs, "", true, "the first cook must succeed"); + + const fs::path spv = dirs.output / "shaders" / "plain.frag.spv"; + const std::string goodSpv = readFile(spv); + + writeText(dirs.input / "shaders" / "plain.frag", "#version 450\nvoid main() { this is not glsl }\n"); + fs::last_write_time(dirs.input / "shaders" / "plain.frag", fs::last_write_time(spv) + std::chrono::hours{1}); + writeText(dirs.input / "added.txt", "added"); + + expectChef(dirs, "", false, "a shader that doesn't compile must fail the cook"); + check(readFile(spv) == goodSpv, "a failed compile must leave the previous SPIR-V untouched"); + check(fs::exists(dirs.output / "added.txt"), "one failing asset must not stop the others"); + check(!hasTempFiles(dirs.output), "a failed cook leaves no temporary files"); + + const auto manifest = readManifest(dirs); + const auto* shader = findEntry(manifest, "shaders/plain.frag"); + check(shader && (*shader)["out"] == "shaders/plain.frag.spv", "the failed shader keeps its previous entry"); + check(findEntry(manifest, "added.txt") != nullptr, "the manifest is still written after a failure"); + + expectChef(dirs, "", false, "the broken shader must be retried, and fail again"); + writeShaders(dirs); + expectChef(dirs, "", true, "fixing the shader must make the cook succeed"); + } + + void testFallbacksAndStrictMode() + { + const CookDirs dirs = makeDirs("destrum_cooker_fallback"); + writeText(dirs.input / "broken.png", "this is not a png"); + + expectChef(dirs, "", true, "a fallback is only a warning by default"); + check(fs::exists(dirs.output / "broken.png"), "the source is used when cooking fails"); + check(!fs::exists(dirs.output / "broken.png.dtex"), "no cooked file is written when cooking fails"); + const auto manifest = readManifest(dirs); + const auto* entry = findEntry(manifest, "broken.png"); + check(entry && entry->contains("fallback"), "the manifest records the fallback"); + + expectChef(dirs, "--strict", false, "strict mode must fail on a remembered fallback"); + expectChef(dirs, "--validate --strict", false, "strict validation must report the fallback"); + + writeText(dirs.input / "chef.json", R"({"allowFallback": ["broken.png"]})"); + expectChef(dirs, "--strict", true, "strict mode must accept allowed fallbacks"); + expectChef(dirs, "--validate --strict", true, "strict validation must accept allowed fallbacks"); + check(!fs::exists(dirs.output / "chef.json"), "the cooker config isn't an asset"); + } + + void testValidateFindsMissingOutputs() + { + const CookDirs dirs = makeDirs("destrum_cooker_validate"); + writeShaders(dirs); + writeText(dirs.input / "notes.txt", "hello"); + expectChef(dirs, "", true, "the first cook must succeed"); + + fs::remove(dirs.output / "shaders" / "plain.frag.spv"); + expectChef(dirs, "--validate", false, "validation must report missing outputs"); + + expectChef(dirs, "", true, "recooking must succeed"); + writeText(dirs.input / "new.txt", "new"); + expectChef(dirs, "--validate", false, "validation must report sources that weren't cooked"); + } +} + +void RunCookerTests() +{ + testCookRecordsEveryOutput(); + testUnchangedSourcesAreSkipped(); + testDependencyChangesRecook(); + testReplacedSourceIsCopiedAgain(); + testDeletedSourcesAreRemoved(); + testFailedCookKeepsLastGoodOutput(); + testFallbacksAndStrictMode(); + testValidateFindsMissingOutputs(); +} diff --git a/tests/main.cpp b/tests/main.cpp index 9d6a421..96006c4 100644 --- a/tests/main.cpp +++ b/tests/main.cpp @@ -12,6 +12,7 @@ void RunGameplayTests(); void RunEditorTests(); void RunAudioTests(); void RunJobsTests(); +void RunCookerTests(); namespace { struct Suite { @@ -30,6 +31,7 @@ namespace { {"editor", RunEditorTests}, {"audio", RunAudioTests}, {"jobs", RunJobsTests}, + {"cooker", RunCookerTests}, }; }