From 84cf5477042e80739f2dd4d445072fb2be3234c1 Mon Sep 17 00:00:00 2001 From: atdunbg Date: Sun, 18 Oct 2026 20:05:29 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B5=84=E4=BA=A7=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E5=8F=AF=E8=A7=86=E5=8C=96=E6=94=B9=E9=80=A0=20+=20=E6=9D=90?= =?UTF-8?q?=E8=B4=A8=E7=BC=A9=E7=95=A5=E5=9B=BE=20GPU=20=E7=83=98=E7=84=99?= =?UTF-8?q?=20+=20=E7=BA=B9=E7=90=86=E4=B8=8A=E4=BC=A0=E6=89=B9=E5=BC=8F?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 资产浏览器可视化与配色:面板重构(网格/列表/缩略图)、AssetKind 分类、 EditorColors 统一配色、AssetItemView 抽出条目绘制;HierarchyPanel/ MaterialEditorPanel 同步调整 - 材质缩略图改走 GPU 回读烘焙:MaterialPreviewPass 新增 bake pass (SDR atlas → host staging copyImageToBuffer)、Renderer 提供 Request/PollMaterialPreviewBake(无 waitIdle,依赖 per-slot fence), Editor 每帧排空后注入 ThumbnailCache——脱离预览 tile 池,消除"滚出视野 即失效/池耗尽"两问题 - 缩略图失效:BakedIdentity(serial, editVersion) 与当前材质身份比对, 换贴图槽(重载新 serial)/调参数(editVersion++)不符即 Invalidate 重烘, 修"修改材质后资产浏览器缩略图不更新" - 材质加载闸门:每帧至多 1 个 + 按实测耗时冷却,替代固定 2/帧 - 纹理上传批式化:VulkanAssetUploader 新增路径纹理缓存(跨材质去重, 同一贴图只解码/上传一次)+ 同批新纹理合并为单 cmd/单 submit/单 fence; Texture 抽出 RecordPixelsUpload/CreateDeferred/ReleasePendingStaging, 析构兜底释放 staging;MaterialAsset 纹理所有权移交 uploader 缓存—— 修大材质目录首次打开卡顿 --- App/editor/AssetItemView.cpp | 505 ++++++++++++++++++ App/editor/AssetItemView.h | 46 ++ App/editor/AssetKind.h | 122 +++++ App/editor/Editor.cpp | 19 + App/editor/EditorColors.h | 75 +++ App/editor/MaterialAssetUtil.h | 9 +- App/editor/ThumbnailCache.cpp | 341 ++++++++++++ App/editor/ThumbnailCache.h | 94 ++++ App/editor/panels/AssetBrowserPanel.cpp | 435 ++++++--------- App/editor/panels/AssetBrowserPanel.h | 16 +- App/editor/panels/HierarchyPanel.cpp | 67 ++- App/editor/panels/MaterialEditorPanel.cpp | 147 ++++- App/editor/panels/MaterialEditorPanel.h | 3 + .../include/Engine/RenderGraph/RenderGraph.h | 5 +- .../include/Engine/Renderer/MaterialAsset.h | 12 +- .../Renderer/Passes/MaterialPreviewPass.h | 75 ++- Engine/include/Engine/Renderer/Renderer.h | 42 ++ .../Engine/Renderer/VulkanAssetUploader.h | 12 +- Engine/include/Engine/Vulkan/Texture.h | 28 +- Engine/src/RenderGraph/RenderGraph.cpp | 38 +- Engine/src/Renderer/MaterialAsset.cpp | 2 - .../Renderer/Passes/MaterialPreviewPass.cpp | 55 +- Engine/src/Renderer/Renderer.cpp | 119 ++++- Engine/src/Renderer/VulkanAssetUploader.cpp | 71 ++- Engine/src/Vulkan/Texture.cpp | 147 +++-- 25 files changed, 2105 insertions(+), 380 deletions(-) create mode 100644 App/editor/AssetItemView.cpp create mode 100644 App/editor/AssetItemView.h create mode 100644 App/editor/AssetKind.h create mode 100644 App/editor/EditorColors.h create mode 100644 App/editor/ThumbnailCache.cpp create mode 100644 App/editor/ThumbnailCache.h diff --git a/App/editor/AssetItemView.cpp b/App/editor/AssetItemView.cpp new file mode 100644 index 0000000..7c2bc3e --- /dev/null +++ b/App/editor/AssetItemView.cpp @@ -0,0 +1,505 @@ +// +// AssetItemView.cpp:资产条目绘制单元实现。 +// + +#include "AssetItemView.h" + +#include "AssetKind.h" +#include "EditorColors.h" +#include "ThumbnailCache.h" +#include "panels/AssetBrowserPanel.h" // kPrefabDragPayload / kModelDragPayload +#include "panels/ControllerEditorPanel.h" // kControllerDragPayload +#include "panels/MaterialEditorPanel.h" // kMaterialDragPayload / kTextureDragPayload + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + + constexpr float kBoxRounding = 3.0f; + + // 类别色压暗背板 + 描边(p0/p1 来自上一个 item rect) + void DrawBoxChrome(const ImVec2& p0, const ImVec2& p1, + const ImVec4& accent) + { + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddRectFilled(p0, p1, + ImGui::GetColorU32(ImVec4(accent.x * 0.25f, + accent.y * 0.25f, + accent.z * 0.25f, 1.0f)), + kBoxRounding); + dl->AddRect(p0, p1, ImGui::GetColorU32(accent), kBoxRounding); + } + + // 名字按可用宽度截断(UTF-8 边界安全) + std::string TruncateToWidth(const std::string& s, float maxW) + { + if (ImGui::CalcTextSize(s.c_str()).x <= maxW) return s; + std::string out = s; + const char* ell = ".."; + while (!out.empty() && + ImGui::CalcTextSize((out + ell).c_str()).x > maxW) + { + out.pop_back(); + while (!out.empty() && (static_cast(out.back()) & + 0xC0) == 0x80) + { + out.pop_back(); // 退到 UTF-8 起始字节 + } + } + return out + ell; + } + + // 已尝试按需加载的材质资产路径:加载失败不再每帧重试(资产缓存接管 + // 成功路径,重复调用是缓存命中) + std::set& MaterialLoadTried() + { + static std::set s; + return s; + } + + // 材质加载闸门:大目录(上百个材质)逐个同步加载(.material 解析 + + // GPU 纹理上传——Texture 构造内含 submit + fence 阻塞等待,单个材质 + // 可含多张大贴图)会顶爆单帧 → "卡成 PPT"。闸门 = 每帧至多 1 个 + + // 按上次实测耗时冷却(1 帧≈16ms;重载 ≈ 冷却相应帧数),把加载占用 + // 压到 ≤ ~50% 帧预算,剩余帧时间还给渲染 + struct MaterialLoadGate + { + bool Allow() + { + const int frame = ImGui::GetFrameCount(); + return frame != m_LastLoadFrame && frame >= m_CooldownUntil; + } + void Charge(double elapsedMs) + { + const int frame = ImGui::GetFrameCount(); + m_LastLoadFrame = frame; + const int cool = static_cast(elapsedMs / 16.0); + m_CooldownUntil = frame + 1 + std::min(cool, 30); + } + private: + int m_LastLoadFrame = -1; + int m_CooldownUntil = 0; + }; + + MaterialLoadGate& MaterialLoad() + { + static MaterialLoadGate s_Gate; + return s_Gate; + } + + // 已烘焙材质缩略图对应的 (serial, editVersion)。资产重载(新 serial) + // 或参数编辑(editVersion++)后与当前不符 = 缩略图过期,须作废重烘 + //(否则浏览器永远停在旧图)——记录时机 = 发起烘焙请求那一帧 + std::unordered_map>& + BakedIdentity() + { + static std::unordered_map> s; + return s; + } + + // 材质球缩略图全部走编辑器缩略图缓存:引擎把预览球渲染一次后 GPU→CPU + // 回读,App 注入缓存(与贴图缩略图同一条出图路径),浏览器不再占用 + // 引擎预览 tile 池——大目录滚动/材质编辑器预览互不饿死 + void DrawCachedThumb(const ThumbView& v, float size, const ImVec2& p0) + { + float fw = size, fh = size; + if (v.w > 0 && v.h > 0) + { + if (v.w >= v.h) fh = size * static_cast(v.h) / v.w; + else fw = size * static_cast(v.w) / v.h; + } + const ImVec2 fp0(p0.x + (size - fw) * 0.5f, p0.y + (size - fh) * 0.5f); + ImGui::GetWindowDrawList()->AddImage( + v.tex, fp0, ImVec2(fp0.x + fw, fp0.y + fh)); + } + + // 缩略图/占位块:一个命中项 + 绘制层。 + // interactive=true → InvisibleButton(带 ID,可承载拖拽源/点击;Dummy + // 无 ID,在其上 BeginDragDropSource 会断言);false → Dummy(行内小图 + // 纯展示,不抢行 Selectable 的点击)。 + // 出图时**不铺任何背板**——透明像素会被类别色染色;背板只在未出图时 + // 作为占位(类别色压暗 + 短标签)。仅可见项才请求缩略图,离屏项不入 + // 解码队列(大目录不再一次性排队,出图延迟可控) + void DrawThumbBox(const EditorContext& ctx, + const std::filesystem::path& path, float size, + EditorAsset::AssetKind kind, bool interactive) + { + const bool visible = ImGui::IsRectVisible(ImVec2(size, size)); + if (interactive) + { + ImGui::InvisibleButton("##thumb", ImVec2(size, size)); + } + else + { + ImGui::Dummy(ImVec2(size, size)); + } + const ImVec2 p0 = ImGui::GetItemRectMin(); + const ImVec2 p1 = ImGui::GetItemRectMax(); + const ImVec4 accent = EditorColors::AssetColor(kind); + + bool drawn = false; + if (visible && kind == EditorAsset::AssetKind::Material && ctx.assets) + { + // 材质资产缩略图 = 引擎烘焙的静态材质球(GPU→CPU 回读 → 缩略图 + // 缓存;与贴图缩略图同一条出图路径) + auto& cache = EditorThumbnailCache::Get(); + const std::string assetPath = path.generic_string(); + + // 当前材质身份:已加载才有;未加载时无法判新旧 + Engine::Material* curMat = nullptr; + if (ctx.assets->IsMaterialLoaded(assetPath)) + { + Engine::MaterialAsset* a = + ctx.assets->FindMaterialAsset(assetPath); + curMat = a ? a->GetMaterial() : nullptr; + } + + const ThumbView v = cache.Peek(assetPath); + if (v.state == ThumbView::State::Ready) + { + // 失效检测:缓存缩略图对应的材质身份 vs 当前身份——资产 + // 重载(新 serial,换贴图槽)/ 参数编辑(editVersion++)后 + // 不符 = 过期,作废缓存让下一帧重烘(否则浏览器永远旧图) + bool stale = false; + if (curMat) + { + auto it = BakedIdentity().find(assetPath); + if (it != BakedIdentity().end() && + (it->second.first != curMat->GetSerial() || + it->second.second != curMat->GetEditVersion())) + { + stale = true; + } + } + if (!stale) + { + DrawCachedThumb(v, size, p0); + drawn = true; + } + else + { + cache.Invalidate(assetPath); + BakedIdentity().erase(assetPath); + } + } + else if (v.state != ThumbView::State::Pending && ctx.renderer) + { + if (curMat) + { + // 未烘焙/已失效:请求烘焙并登记当前身份(key 幂等 + // keepalive,回读完成即进缓存) + ctx.renderer->RequestMaterialPreviewBake( + curMat, cache.BakeKeyForPath(assetPath)); + BakedIdentity()[assetPath] = { + curMat->GetSerial(), curMat->GetEditVersion()}; + } + else if (!MaterialLoadTried().count(assetPath) && + MaterialLoad().Allow()) + { + // 按需加载材质(闸门限流,避免大目录同帧卡顿) + MaterialLoadTried().insert(assetPath); + const auto t0 = std::chrono::steady_clock::now(); + ctx.assets->LoadMaterialAsset(assetPath); + const double ms = std::chrono::duration(std::chrono::steady_clock::now() - t0) + .count(); + MaterialLoad().Charge(ms); + } + } + } + if (!drawn && visible && EditorAsset::IsThumbnailable(path)) + { + const ThumbView v = + EditorThumbnailCache::Get().Request(path.generic_string()); + if (v.state == ThumbView::State::Ready) + { + DrawCachedThumb(v, size, p0); + drawn = true; + } + } + if (!drawn) + { + // 占位:类别色压暗背板 + 描边;大框叠加短标签 + DrawBoxChrome(p0, p1, accent); + if (size >= 28.0f) + { + const char* label = EditorAsset::KindShortLabel(kind); + const ImVec2 ts = ImGui::CalcTextSize(label); + ImGui::GetWindowDrawList()->AddText( + ImVec2(p0.x + (size - ts.x) * 0.5f, + p0.y + (size - ts.y) * 0.5f), + ImGui::GetColorU32(accent), label); + } + } + } + + // 文件夹命中项(黄色)+ 短标签 + void DrawFolderBox(float size, bool interactive) + { + if (interactive) + { + ImGui::InvisibleButton("##dir", ImVec2(size, size)); + } + else + { + ImGui::Dummy(ImVec2(size, size)); + } + const ImVec2 p0 = ImGui::GetItemRectMin(); + const ImVec2 p1 = ImGui::GetItemRectMax(); + DrawBoxChrome(p0, p1, EditorColors::kFolder); + if (size >= 28.0f) + { + const ImVec2 ts = ImGui::CalcTextSize("DIR"); + ImGui::GetWindowDrawList()->AddText( + ImVec2(p0.x + (size - ts.x) * 0.5f, + p0.y + (size - ts.y) * 0.5f), + ImGui::GetColorU32(EditorColors::kFolder), "DIR"); + } + } + +} // namespace + +namespace EditorAssetView { + + void DrawDragSource(const std::filesystem::path& path, + const std::string& name) + { + const EditorAsset::AssetKind kind = EditorAsset::KindOfPath(path); + const char* payload = nullptr; + switch (kind) + { + case EditorAsset::AssetKind::Material: payload = kMaterialDragPayload; break; + case EditorAsset::AssetKind::AnimClip: payload = kControllerDragPayload; break; + case EditorAsset::AssetKind::Prefab: payload = kPrefabDragPayload; break; + case EditorAsset::AssetKind::Model: payload = kModelDragPayload; break; + case EditorAsset::AssetKind::Texture: payload = kTextureDragPayload; break; + default: return; + } + if (ImGui::BeginDragDropSource()) + { + const std::string assetPath = path.generic_string(); + ImGui::SetDragDropPayload(payload, assetPath.c_str(), + assetPath.size() + 1); + ImGui::TextUnformatted(name.c_str()); + ImGui::EndDragDropSource(); + } + } + + void DrawTooltip(const EditorContext& ctx, + const std::filesystem::path& path) + { + namespace fs = std::filesystem; + const std::string name = path.filename().string(); + const std::string assetPath = path.generic_string(); + const EditorAsset::AssetKind kind = EditorAsset::KindOfPath(path); + + ImGui::BeginTooltip(); + ImGui::TextUnformatted(name.c_str()); + ImGui::Separator(); + ImGui::Text("type: %s", EditorAsset::KindLabel(kind)); + + std::error_code ec; + const auto sz = fs::file_size(path, ec); + if (!ec) + { + char szBuf[32]; + if (sz >= 1024ull * 1024ull) + { + std::snprintf(szBuf, sizeof(szBuf), "%.1f MB", + static_cast(sz) / (1024.0 * 1024.0)); + } + else + { + std::snprintf(szBuf, sizeof(szBuf), "%.1f KB", + static_cast(sz) / 1024.0); + } + ImGui::Text("size: %s", szBuf); + } + + const auto ftime = fs::last_write_time(path, ec); + if (!ec) + { + // file_clock → system_clock 近似转换(C++17 无官方转换) + const auto sctp = std::chrono::time_point_cast< + std::chrono::system_clock::duration>( + ftime - fs::file_time_type::clock::now() + + std::chrono::system_clock::now()); + const std::time_t tt = std::chrono::system_clock::to_time_t(sctp); + char tmBuf[32]; + std::strftime(tmBuf, sizeof(tmBuf), "%Y-%m-%d %H:%M", + std::localtime(&tt)); + ImGui::Text("modified: %s", tmBuf); + } + + if (ctx.assets) + { + if (kind == EditorAsset::AssetKind::Model || + kind == EditorAsset::AssetKind::Texture) + { + const bool loaded = + (kind == EditorAsset::AssetKind::Model) + ? ctx.assets->IsModelLoaded(assetPath) + : ctx.assets->IsEnvironmentLoaded(assetPath); + ImGui::Text("state: %s", loaded ? "loaded" : "not loaded"); + } + else if (kind == EditorAsset::AssetKind::Prefab) + { + ImGui::TextDisabled( + "template — drag into scene / double-click"); + } + if (kind == EditorAsset::AssetKind::Model) + { + if (const Engine::Model* m = + ctx.assets->GetModelByPath(assetPath); + m != nullptr) + { + ImGui::Text("meshes: %u, bones: %u, clips: %u", + static_cast(m->GetMeshes().size()), + m->GetBoneCount(), + static_cast(m->GetClips().size())); + } + } + } + ImGui::TextDisabled("%s", assetPath.c_str()); + ImGui::EndTooltip(); + } + + ItemResult DrawRow(const EditorContext& ctx, + const std::filesystem::path& path, bool selected) + { + ItemResult res; + const std::string name = path.filename().string(); + const EditorAsset::AssetKind kind = EditorAsset::KindOfPath(path); + + ImGui::PushID(name.c_str()); + + // 内联小缩略图 / 类别色占位块(非交互:行点击归 Selectable) + DrawThumbBox(ctx, path, ImGui::GetTextLineHeight(), kind, false); + ImGui::SameLine(); + + // 名称(类别色提亮)+ 类别色 hover/选中高亮 + ImGui::PushStyleColor(ImGuiCol_Text, EditorColors::AssetNameColor(kind)); + ImGui::PushStyleColor(ImGuiCol_Header, + EditorColors::AssetHighlightColor(kind)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, + EditorColors::AssetHighlightColor(kind)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, + EditorColors::AssetHighlightColor(kind)); + res.clicked = ImGui::Selectable(name.c_str(), selected, + ImGuiSelectableFlags_AllowDoubleClick); + ImGui::PopStyleColor(4); + if (res.clicked && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) + { + res.doubleClicked = true; + } + + DrawDragSource(path, name); + if (ImGui::IsItemHovered()) + { + DrawTooltip(ctx, path); + } + + ImGui::PopID(); + return res; + } + + ItemResult DrawTile(const EditorContext& ctx, + const std::filesystem::path& path, + float tileSize, bool selected) + { + ItemResult res; + const std::string name = path.filename().string(); + const EditorAsset::AssetKind kind = EditorAsset::KindOfPath(path); + const ImVec4 accent = EditorColors::AssetColor(kind); + + ImGui::PushID(name.c_str()); + ImGui::BeginGroup(); + + DrawThumbBox(ctx, path, tileSize, kind, true); + + // 选中/hover 描边(叠在缩略图上) + const ImVec2 p0 = ImGui::GetItemRectMin(); + const ImVec2 p1 = ImGui::GetItemRectMax(); + const bool hovered = ImGui::IsItemHovered(); + if (selected || hovered) + { + ImGui::GetWindowDrawList()->AddRect( + p0, p1, ImGui::GetColorU32(accent), kBoxRounding, 0, + selected ? 2.0f : 1.0f); + } + res.clicked = ImGui::IsItemClicked(ImGuiMouseButton_Left); + if (hovered && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) + { + res.doubleClicked = true; + } + + DrawDragSource(path, name); + if (ImGui::IsItemHovered()) + { + DrawTooltip(ctx, path); + } + + ImGui::PushStyleColor(ImGuiCol_Text, EditorColors::AssetNameColor(kind)); + ImGui::TextUnformatted(TruncateToWidth(name, tileSize).c_str()); + ImGui::PopStyleColor(); + + ImGui::EndGroup(); + ImGui::PopID(); + return res; + } + + bool DrawFolderRow(const std::filesystem::path& dir) + { + const std::string dname = dir.filename().string(); + ImGui::PushID(dname.c_str()); + DrawFolderBox(ImGui::GetTextLineHeight(), false); + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, EditorColors::kFolder); + const bool clicked = ImGui::Selectable(dname.c_str()); + ImGui::PopStyleColor(); + ImGui::PopID(); + return clicked; + } + + bool DrawFolderTile(const std::filesystem::path& dir, float tileSize, + bool selected) + { + const std::string dname = dir.filename().string(); + ImGui::PushID(dname.c_str()); + ImGui::BeginGroup(); + + DrawFolderBox(tileSize, true); + + const ImVec2 p0 = ImGui::GetItemRectMin(); + const ImVec2 p1 = ImGui::GetItemRectMax(); + if (selected || ImGui::IsItemHovered()) + { + ImGui::GetWindowDrawList()->AddRect( + p0, p1, ImGui::GetColorU32(EditorColors::kFolder), kBoxRounding, + 0, selected ? 2.0f : 1.0f); + } + const bool clicked = ImGui::IsItemClicked(ImGuiMouseButton_Left); + + ImGui::PushStyleColor(ImGuiCol_Text, EditorColors::kFolder); + ImGui::TextUnformatted(TruncateToWidth(dname, tileSize).c_str()); + ImGui::PopStyleColor(); + + ImGui::EndGroup(); + ImGui::PopID(); + return clicked; + } + +} // namespace EditorAssetView diff --git a/App/editor/AssetItemView.h b/App/editor/AssetItemView.h new file mode 100644 index 0000000..b2d6f02 --- /dev/null +++ b/App/editor/AssetItemView.h @@ -0,0 +1,46 @@ +// +// AssetItemView:资产条目的绘制单元(列表行 / 网格瓦片 / 目录 / 拖拽源 / +// tooltip)。Content Browser 与材质纹理选择器共用同一套视觉与交互。 +// + +#ifndef HELLOVULKAN_ASSETITEMVIEW_H +#define HELLOVULKAN_ASSETITEMVIEW_H + +#include "EditorContext.h" + +#include +#include + +namespace EditorAssetView { + + // 单击 / 双击结果(调用方决定选中与打开语义) + struct ItemResult { + bool clicked = false; + bool doubleClicked = false; + }; + + // 拖拽源:按资产类别发出对应 payload(材质/控制器/预制体/模型/纹理) + void DrawDragSource(const std::filesystem::path& path, + const std::string& name); + + // 统一 tooltip(类型/大小/修改时间/加载状态) + void DrawTooltip(const EditorContext& ctx, + const std::filesystem::path& path); + + // 列表行:内联小缩略图 + 彩色短标签 + 类别色名称 + ItemResult DrawRow(const EditorContext& ctx, + const std::filesystem::path& path, bool selected); + + // 网格瓦片:缩略图/类别色占位块 + 名称(截断到瓦片宽) + ItemResult DrawTile(const EditorContext& ctx, + const std::filesystem::path& path, + float tileSize, bool selected); + + // 目录(文件夹色):返回是否被点击(选中/进入) + bool DrawFolderRow(const std::filesystem::path& dir); + bool DrawFolderTile(const std::filesystem::path& dir, float tileSize, + bool selected); + +} // namespace EditorAssetView + +#endif //HELLOVULKAN_ASSETITEMVIEW_H diff --git a/App/editor/AssetKind.h b/App/editor/AssetKind.h new file mode 100644 index 0000000..57db68a --- /dev/null +++ b/App/editor/AssetKind.h @@ -0,0 +1,122 @@ +// +// AssetKind.h:资产分类的单一事实来源(Content Browser / 编辑器共用)。 +// +// 原先分类逻辑散在 AssetBrowserPanel.cpp 匿名 ns(Kind/KindOf/KindTag), +// 现在上提为头文件:面板、拖拽源、缩略图缓存按同一份扩展名表分派。 +// + +#ifndef HELLOVULKAN_ASSETKIND_H +#define HELLOVULKAN_ASSETKIND_H + +#include +#include +#include +#include + +namespace EditorAsset { + + // 资产类别(按扩展名分派;同时是类型过滤的身份) + enum class AssetKind { + Model, Texture, Scene, Material, AnimClip, Prefab, Other + }; + + // 统一小写扩展名(含点,如 ".png";无扩展名 = 空串) + inline std::string LowerExt(const std::filesystem::path& p) + { + std::string ext = p.extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), + [](unsigned char c) { return std::tolower(c); }); + return ext; + } + + // 纹理扩展名表(单一事实来源:分类 / 缩略图 / 材质纹理槽选择器共用)。 + // 口径 = 引擎 Texture 可直接加载者: + // stb_image 8-bit 系 png/jpg/tga/bmp/psd/gif/pnm + // + .hdr(环境贴图,stbi_loadf / stbi_load 走内置 hdr→ldr) + // + .dds(引擎 DdsLoader,BC 压缩直传) + inline bool IsTexturePath(const std::filesystem::path& p) + { + static const char* kExts[] = { + ".png", ".jpg", ".jpeg", ".tga", ".bmp", ".psd", + ".gif", ".ppm", ".pgm", ".pnm", ".hdr", ".dds"}; + const std::string ext = LowerExt(p); + for (const char* e : kExts) + { + if (ext == e) return true; + } + return false; + } + + // 扩展名 → 类别 + inline AssetKind KindOfPath(const std::filesystem::path& p) + { + const std::string ext = LowerExt(p); + if (ext == ".gltf" || ext == ".glb" || ext == ".fbx" || + ext == ".obj") + { + return AssetKind::Model; + } + if (IsTexturePath(p)) + { + return AssetKind::Texture; + } + if (ext == ".scene") + { + return AssetKind::Scene; + } + if (ext == ".material") + { + return AssetKind::Material; + } + if (ext == ".animctrl") + { + return AssetKind::AnimClip; + } + if (ext == ".prefab") + { + return AssetKind::Prefab; + } + return AssetKind::Other; + } + + // 全称标签(tooltip / 网格标题;替代原下标对齐数组) + inline const char* KindLabel(AssetKind kind) + { + switch (kind) + { + case AssetKind::Model: return "Model"; + case AssetKind::Texture: return "Texture/Environment"; + case AssetKind::Scene: return "Scene"; + case AssetKind::Material: return "Material"; + case AssetKind::AnimClip: return "Controller"; + case AssetKind::Prefab: return "Prefab"; + default: return "File"; + } + } + + // 列表类型列的短标签(替代原 "[M]"/"[T]" 前缀) + inline const char* KindShortLabel(AssetKind kind) + { + switch (kind) + { + case AssetKind::Model: return "MDL"; + case AssetKind::Texture: return "TEX"; + case AssetKind::Scene: return "SCN"; + case AssetKind::Material: return "MAT"; + case AssetKind::AnimClip: return "CTRL"; + case AssetKind::Prefab: return "PFB"; + default: return "FILE"; + } + } + + // 可解码为 8-bit 缩略图的扩展名:stbi_load 原生支持者(含 .hdr —— + // stbi_load 内部走 hdr→ldr 色调映射)。仅 .dds 例外(BC 压缩块,需 + // 专用解码器)→ 占位块 + inline bool IsThumbnailable(const std::filesystem::path& p) + { + return IsTexturePath(p) && LowerExt(p) != ".dds"; + } + +} // namespace EditorAsset + +#endif //HELLOVULKAN_ASSETKIND_H diff --git a/App/editor/Editor.cpp b/App/editor/Editor.cpp index 50fd4c6..26d4ec7 100644 --- a/App/editor/Editor.cpp +++ b/App/editor/Editor.cpp @@ -12,6 +12,7 @@ // #include "Editor.h" +#include "ThumbnailCache.h" // 缩略图缓存(每帧 BeginFrame) #include "panels/AssetBrowserPanel.h" #include "panels/HierarchyPanel.h" #include "panels/InspectorPanel.h" @@ -167,6 +168,24 @@ void Editor::TogglePause() void Editor::Draw() { + // 缩略图缓存推进(解码限量 / 上传就绪 / LRU 淘汰):ImGui::NewFrame + // 已由框架 ImGuiLayer 完成,此处处于面板绘制之前 + EditorThumbnailCache::Get().BeginFrame(); + + // 材质球缩略图烘焙:排空引擎异步回读结果 → 注入缩略图缓存(浏览器 + // 材质条目随后从缓存出图,不再占用引擎预览 tile 池) + if (m_Ctx.renderer) + { + Engine::u64 key = 0; + std::vector pixels; + Engine::u32 w = 0, h = 0; + while (m_Ctx.renderer->PollMaterialPreviewBake(key, pixels, w, h)) + { + EditorThumbnailCache::Get().CompleteBake( + key, pixels.data(), static_cast(w), static_cast(h)); + } + } + // PIE 镜像同步(面板只读指针消费;权威 = GameFlow,此处适配) if (m_Ctx.flow) { diff --git a/App/editor/EditorColors.h b/App/editor/EditorColors.h new file mode 100644 index 0000000..af58c2e --- /dev/null +++ b/App/editor/EditorColors.h @@ -0,0 +1,75 @@ +// +// EditorColors.h:编辑器面板配色的单一事实来源。 +// +// 资产浏览器条目、Outliner 实体名/组件徽标的着色集中在此:面板不再 +// 各自散落硬编码 ImVec4。ImVec4 构造为 constexpr(imgui.h),故可用 +// inline constexpr 常量。 +// + +#ifndef HELLOVULKAN_EDITORCOLORS_H +#define HELLOVULKAN_EDITORCOLORS_H + +#include "AssetKind.h" + +#include + +namespace EditorColors { + + // ---- 资产类别色(列表标签 / 名称 / 网格瓦片 / 选中高亮)---- + inline constexpr ImVec4 kModel = ImVec4(0.36f, 0.62f, 0.90f, 1.0f); // 蓝 + inline constexpr ImVec4 kTexture = ImVec4(0.34f, 0.78f, 0.52f, 1.0f); // 绿 + inline constexpr ImVec4 kScene = ImVec4(0.62f, 0.50f, 0.90f, 1.0f); // 紫 + inline constexpr ImVec4 kMaterial = ImVec4(0.92f, 0.60f, 0.25f, 1.0f); // 橙 + inline constexpr ImVec4 kAnim = ImVec4(0.30f, 0.76f, 0.76f, 1.0f); // 青 + inline constexpr ImVec4 kPrefab = ImVec4(0.42f, 0.70f, 0.95f, 1.0f); // 亮蓝 + inline constexpr ImVec4 kOther = ImVec4(0.72f, 0.72f, 0.75f, 1.0f); // 灰 + inline constexpr ImVec4 kFolder = ImVec4(0.88f, 0.74f, 0.34f, 1.0f); // 黄 + + inline ImVec4 AssetColor(EditorAsset::AssetKind kind) + { + switch (kind) + { + case EditorAsset::AssetKind::Model: return kModel; + case EditorAsset::AssetKind::Texture: return kTexture; + case EditorAsset::AssetKind::Scene: return kScene; + case EditorAsset::AssetKind::Material: return kMaterial; + case EditorAsset::AssetKind::AnimClip: return kAnim; + case EditorAsset::AssetKind::Prefab: return kPrefab; + default: return kOther; + } + } + + // 向白提亮(保可读性);t = 0 原色,1 全白 + inline ImVec4 Lighten(const ImVec4& c, float t) + { + return ImVec4(c.x + (1.0f - c.x) * t, c.y + (1.0f - c.y) * t, + c.z + (1.0f - c.z) * t, c.w); + } + + // 名称色:类别色提亮(行/瓦片名用) + inline ImVec4 AssetNameColor(EditorAsset::AssetKind kind) + { + return Lighten(AssetColor(kind), 0.40f); + } + + // 选中/hover 高亮底色:类别色压暗(ImGuiCol_Header/HeaderHovered) + inline ImVec4 AssetHighlightColor(EditorAsset::AssetKind kind) + { + return ImVec4(AssetColor(kind).x * 0.42f, AssetColor(kind).y * 0.42f, + AssetColor(kind).z * 0.42f, 1.0f); + } + + // ---- Outliner 组件徽标色 ---- + inline constexpr ImVec4 kMesh = ImVec4(0.36f, 0.72f, 0.95f, 1.0f); + inline constexpr ImVec4 kAnimB = ImVec4(0.95f, 0.72f, 0.35f, 1.0f); + inline constexpr ImVec4 kCamera = ImVec4(0.70f, 0.55f, 0.95f, 1.0f); + inline constexpr ImVec4 kLight = ImVec4(0.96f, 0.86f, 0.36f, 1.0f); + inline constexpr ImVec4 kPhysics = ImVec4(0.45f, 0.85f, 0.55f, 1.0f); + inline constexpr ImVec4 kScript = ImVec4(0.90f, 0.55f, 0.76f, 1.0f); + inline constexpr ImVec4 kSky = ImVec4(0.46f, 0.80f, 0.92f, 1.0f); + // 无组件/兜底实体名色(近白) + inline constexpr ImVec4 kEntityNeutral = ImVec4(0.86f, 0.86f, 0.90f, 1.0f); + +} // namespace EditorColors + +#endif //HELLOVULKAN_EDITORCOLORS_H diff --git a/App/editor/MaterialAssetUtil.h b/App/editor/MaterialAssetUtil.h index 970c794..4e4a6f5 100644 --- a/App/editor/MaterialAssetUtil.h +++ b/App/editor/MaterialAssetUtil.h @@ -12,6 +12,8 @@ #ifndef HELLOVULKAN_MATERIALASSETUTIL_H #define HELLOVULKAN_MATERIALASSETUTIL_H +#include "AssetKind.h" // EditorAsset::IsTexturePath(纹理扩展名单一事实来源) + #include #include #include @@ -53,12 +55,7 @@ namespace EditorMaterialUtil { { continue; } - std::string ext = entry.path().extension().string(); - std::transform(ext.begin(), ext.end(), ext.begin(), - [](unsigned char c) - { return std::tolower(c); }); - if (ext == ".jpg" || ext == ".jpeg" || ext == ".png" || - ext == ".tga" || ext == ".bmp" || ext == ".hdr") + if (EditorAsset::IsTexturePath(entry.path())) { s_Files.push_back(entry.path().generic_string()); } diff --git a/App/editor/ThumbnailCache.cpp b/App/editor/ThumbnailCache.cpp new file mode 100644 index 0000000..e961c67 --- /dev/null +++ b/App/editor/ThumbnailCache.cpp @@ -0,0 +1,341 @@ +// +// ThumbnailCache.cpp:缩略图解碼 + 降采样 + ImGui 用户纹理上传 + LRU 淘汰。 +// +// 生命周期(依赖 vendored ImGui 1.93.0-WIP 语义,与后端/主循环逐帧对齐): +// 帧 N :Request 入队 → BeginFrame 解码 → ImTextureData::Create + +// RegisterUserTexture(Status=WantCreate,TexID=Invalid) +// 帧 N 末:UpdateTexturesEndFrame 把用户纹理并入 PlatformIO.Textures; +// ImGui_ImplVulkan_RenderDrawData 内 UpdateTexture 建 VkImage + +// 描述符,置 TexID、Status=OK +// 帧 N+1:BeginFrame 见 TexID != Invalid → Ready(当帧注册、次帧出图) +// 淘汰 :WantDestroyNextFrame=true → 后端 UnusedFrames>=ImageCount 时 +// 销毁并清 BackendUserData → 下一帧见 Destroyed/无 BackendUserData +// → UnregisterUserTexture + IM_DELETE +// 关机 :本单例是进程级 function-local static,析构晚于 main 内的 +// SandboxApp(含 ImGuiLayer)——ImGui_ImplVulkan_Shutdown 已对 +// RefCount==1 的用户纹理统一释放 GPU;析构仅 IM_DELETE(只 +// IM_FREE(Pixels),不触任何已销毁的 ImGui 上下文)。 +// + +#include "ThumbnailCache.h" + +#include // RegisterUserTexture / UnregisterUserTexture + +#include + +#include +#include + +namespace { + + constexpr int kThumbMaxDim = 128; // 缩略图最长边 + constexpr size_t kMaxEntries = 256; // 缓存上限(≤ 描述符池) + constexpr int kMaxDecodesPerFrame = 2; // 每帧最多解码张数 + constexpr long long kMaxSourcePixels = 4096ll * 4096ll; // 超大源图跳过 + constexpr int kReadyTimeoutFrames = 3; // 上传超时 → Failed + + // box filter 等比降采样(RGBA8):输出像素在源块内最多取 + // kMaxSamplesPerAxis² 个采样点(按步长抽样)。大图降采样时避免对 + // 整块(如 4096² → 128² 时每像素 1024 次读)全量累加——那是单张 + // 百毫秒级的卡顿源 + void ResampleBoxRGBA8(const unsigned char* src, int sw, int sh, + unsigned char* dst, int dw, int dh) + { + constexpr int kMaxSamplesPerAxis = 8; + for (int y = 0; y < dh; ++y) + { + const int sy0 = y * sh / dh; + int sy1 = (y + 1) * sh / dh; + if (sy1 <= sy0) sy1 = sy0 + 1; + const int syStep = std::max( + 1, (sy1 - sy0 + kMaxSamplesPerAxis - 1) / kMaxSamplesPerAxis); + for (int x = 0; x < dw; ++x) + { + const int sx0 = x * sw / dw; + int sx1 = (x + 1) * sw / dw; + if (sx1 <= sx0) sx1 = sx0 + 1; + const int sxStep = std::max( + 1, + (sx1 - sx0 + kMaxSamplesPerAxis - 1) / kMaxSamplesPerAxis); + unsigned int r = 0, g = 0, b = 0, a = 0, n = 0; + for (int sy = sy0; sy < sy1; sy += syStep) + { + const unsigned char* row = + src + static_cast(sy) * sw * 4; + for (int sx = sx0; sx < sx1; sx += sxStep) + { + const unsigned char* p = + row + static_cast(sx) * 4; + r += p[0]; g += p[1]; b += p[2]; a += p[3]; ++n; + } + } + unsigned char* q = dst + (static_cast(y) * dw + x) * 4; + q[0] = static_cast(r / n); + q[1] = static_cast(g / n); + q[2] = static_cast(b / n); + q[3] = static_cast(a / n); + } + } + } + +} // namespace + +EditorThumbnailCache& EditorThumbnailCache::Get() +{ + static EditorThumbnailCache s_Cache; + return s_Cache; +} + +EditorThumbnailCache::~EditorThumbnailCache() +{ + // 仅回收 CPU 侧(见文件头注释)。勿调 UnregisterUserTexture—— + // 此刻 ImGui 上下文已随 ImGuiLayer 销毁 + for (auto& [path, e] : m_Entries) + { + if (e.tex) IM_DELETE(e.tex); + } + for (ImTextureData* tex : m_Retire) IM_DELETE(tex); +} + +void EditorThumbnailCache::BeginFrame() +{ + ++m_Frame; + + DrainRetired(); + + // 就绪推进:注册后等后端上传(次帧可见) + for (auto& [path, e] : m_Entries) + { + if (e.state != ThumbView::State::Pending || !e.tex) continue; + ++e.pendingFrames; + if (e.tex->TexID != ImTextureID_Invalid) + { + e.state = ThumbView::State::Ready; + } + else if (e.pendingFrames > kReadyTimeoutFrames && + e.tex->Status == ImTextureStatus_Destroyed) + { + e.state = ThumbView::State::Failed; + } + } + + // 限量解码(未解码 = tex 为空) + int decodes = 0; + while (decodes < kMaxDecodesPerFrame && !m_Queue.empty()) + { + const std::string p = m_Queue.front(); + m_Queue.pop_front(); + auto it = m_Entries.find(p); + if (it == m_Entries.end()) continue; // 已被淘汰 + if (it->second.tex) continue; // 已解码(防御) + DecodeOne(p, it->second); + ++decodes; + } + + // 超上限 → 淘汰最久未用 + while (m_Entries.size() > kMaxEntries) + { + if (!RetireOne()) break; // 全在用:本帧放弃 + } +} + +ThumbView EditorThumbnailCache::Request(const std::string& assetPath) +{ + ThumbView v; + if (assetPath.empty()) + { + v.state = ThumbView::State::Failed; + return v; + } + + auto it = m_Entries.find(assetPath); + if (it == m_Entries.end()) + { + Entry e; + e.lastUseFrame = m_Frame; + m_Entries.emplace(assetPath, e); + m_Queue.push_back(assetPath); + return v; // Pending + } + + Entry& e = it->second; + e.lastUseFrame = m_Frame; + v.state = e.state; + v.w = e.w; + v.h = e.h; + if (e.state == ThumbView::State::Ready && e.tex) + { + v.tex = e.tex->GetTexRef(); + } + return v; +} + +void EditorThumbnailCache::UploadRGBA8(const unsigned char* src, int sw, int sh, + Entry& e) +{ + // 等比缩放到最长边 ≤ kThumbMaxDim + int dw = sw, dh = sh; + if (sw >= sh && sw > kThumbMaxDim) + { + dw = kThumbMaxDim; + dh = std::max(1, sh * kThumbMaxDim / sw); + } + else if (sh > sw && sh > kThumbMaxDim) + { + dh = kThumbMaxDim; + dw = std::max(1, sw * kThumbMaxDim / sh); + } + + ImTextureData* tex = IM_NEW(ImTextureData)(); + tex->Create(ImTextureFormat_RGBA32, dw, dh); + if (dw == sw && dh == sh) + { + std::memcpy(tex->GetPixels(), src, static_cast(dw) * dh * 4); + } + else + { + ResampleBoxRGBA8(src, sw, sh, + static_cast(tex->GetPixels()), dw, dh); + } + + ImGui::RegisterUserTexture(tex); + tex->SetStatus(ImTextureStatus_WantCreate); + + e.tex = tex; + e.w = dw; + e.h = dh; + e.state = ThumbView::State::Pending; + e.pendingFrames = 0; +} + +void EditorThumbnailCache::DecodeOne(const std::string& path, Entry& e) +{ + int sw = 0, sh = 0, ch = 0; + if (!stbi_info(path.c_str(), &sw, &sh, &ch) || sw <= 0 || sh <= 0 || + static_cast(sw) * sh > kMaxSourcePixels) + { + e.state = ThumbView::State::Failed; + return; + } + + unsigned char* src = stbi_load(path.c_str(), &sw, &sh, &ch, 4); + if (!src) + { + e.state = ThumbView::State::Failed; + return; + } + + UploadRGBA8(src, sw, sh, e); + stbi_image_free(src); + e.lastUseFrame = m_Frame; +} + +ThumbView EditorThumbnailCache::Peek(const std::string& assetPath) +{ + ThumbView v; + auto it = m_Entries.find(assetPath); + if (it == m_Entries.end()) + { + v.state = ThumbView::State::Failed; // 缺省 = 未注入 + return v; + } + Entry& e = it->second; + e.lastUseFrame = m_Frame; + v.state = e.state; + v.w = e.w; + v.h = e.h; + if (e.state == ThumbView::State::Ready && e.tex) + { + v.tex = e.tex->GetTexRef(); + } + return v; +} + +void EditorThumbnailCache::Invalidate(const std::string& assetPath) +{ + auto it = m_Entries.find(assetPath); + if (it == m_Entries.end()) return; + if (it->second.tex) + { + // 交回回收流程(与 LRU 淘汰同一路径:WantDestroyNextFrame → + // 后端释放 → DrainRetired 注销 + 回收) + it->second.tex->WantDestroyNextFrame = true; + m_Retire.push_back(it->second.tex); + } + m_Entries.erase(it); +} + +unsigned long long EditorThumbnailCache::BakeKeyForPath(const std::string& path) +{ + auto it = m_PathToBakeKey.find(path); + if (it != m_PathToBakeKey.end()) return it->second; + const unsigned long long key = ++m_BakeKeyCounter; + m_PathToBakeKey.emplace(path, key); + m_BakeKeyToPath.emplace(key, path); + return key; +} + +void EditorThumbnailCache::CompleteBake(unsigned long long key, + const unsigned char* rgba, int w, int h) +{ + auto it = m_BakeKeyToPath.find(key); + if (it == m_BakeKeyToPath.end()) return; // 已注销(重复回读) + const std::string path = it->second; + m_BakeKeyToPath.erase(it); + m_PathToBakeKey.erase(path); + InjectRGBA8(path, rgba, w, h); +} + +void EditorThumbnailCache::InjectRGBA8(const std::string& path, + const unsigned char* rgba, int w, int h) +{ + if (!rgba || w <= 0 || h <= 0) return; + Entry& e = m_Entries[path]; // 材质路径不经解码队列,缺省即新建 + if (e.tex) return; // 已有纹理(重复注入):忽略 + UploadRGBA8(rgba, w, h, e); + e.lastUseFrame = m_Frame; +} + +bool EditorThumbnailCache::RetireOne() +{ + auto worst = m_Entries.end(); + for (auto it = m_Entries.begin(); it != m_Entries.end(); ++it) + { + if (it->second.lastUseFrame == m_Frame) continue; // 本帧在用 + if (worst == m_Entries.end() || + it->second.lastUseFrame < worst->second.lastUseFrame) + { + worst = it; + } + } + if (worst == m_Entries.end()) return false; + + if (worst->second.tex) + { + worst->second.tex->WantDestroyNextFrame = true; + m_Retire.push_back(worst->second.tex); + } + m_Entries.erase(worst); + return true; +} + +void EditorThumbnailCache::DrainRetired() +{ + for (size_t i = 0; i < m_Retire.size();) + { + ImTextureData* tex = m_Retire[i]; + // 后端销毁后清 BackendUserData 并置 Destroyed → 可安全回收 + if (tex->BackendUserData == nullptr && + tex->Status == ImTextureStatus_Destroyed) + { + ImGui::UnregisterUserTexture(tex); + IM_DELETE(tex); + m_Retire[i] = m_Retire.back(); + m_Retire.pop_back(); + } + else + { + ++i; + } + } +} diff --git a/App/editor/ThumbnailCache.h b/App/editor/ThumbnailCache.h new file mode 100644 index 0000000..a950d0f --- /dev/null +++ b/App/editor/ThumbnailCache.h @@ -0,0 +1,94 @@ +// +// ThumbnailCache.h:编辑器缩略图缓存(Content Browser / 材质纹理槽共用)。 +// +// 逐帧限量 CPU 解码 + box 降采样 → ImGui 用户纹理(ImTextureData + +// RegisterUserTexture)→ 后端建/传 VkImage。零引擎改动。 +// +// 生命周期(详见 .cpp 注释):进程级静态单例,析构晚于 ImGuiLayer, +// CPU 侧由静态析构回收、GPU 侧由 ImGui_ImplVulkan_Shutdown 回收。 +// + +#ifndef HELLOVULKAN_THUMBNAILCACHE_H +#define HELLOVULKAN_THUMBNAILCACHE_H + +#include // ImTextureData / ImTextureRef + +#include +#include +#include +#include + +// 一次请求的结果:State==Ready 时 tex 可用于 ImGui::Image(tex, size) +struct ThumbView { + enum class State { Pending, Ready, Failed }; + State state = State::Pending; + ImTextureRef tex{}; + int w = 0; // 缩略图原始像素尺寸(按比例显示用) + int h = 0; +}; + +class EditorThumbnailCache { +public: + // 进程级单例(编辑器首帧 BeginFrame 时惰性构造) + static EditorThumbnailCache& Get(); + + // 每帧一次:Editor::Draw 开头(ImGui::NewFrame 之后、面板绘制之前) + void BeginFrame(); + + // 幂等请求:命中且就绪即返回;未命中则入队,本帧返回 Pending + ThumbView Request(const std::string& assetPath); + + // 只读查状态(**不**触发解码入队):缺省 → Failed。材质球烘焙用—— + // 材质路径不走 stbi 解码,注入前必须能区分"未注入"与"上传中" + //(Pending 表示已注入、等后端建纹理) + ThumbView Peek(const std::string& assetPath); + + // 作废某条目的缓存纹理(材质重载/参数编辑后缩略图过期 → 重烘)。 + // 丢弃 CPU 记录、纹理交回回收流程;随后 Peek 缺省 → Failed + void Invalidate(const std::string& assetPath); + + // ---- 材质球烘焙(GPU 回读结果注入)---- + // path ↔ key 幂等映射:Renderer 只接受/回传 u64 令牌,App 侧登记 + unsigned long long BakeKeyForPath(const std::string& path); + // 回读结果注入:RGBA8 像素(w×h)→ 缩略图纹理(内部等比降到最长边 + // ≤ kThumbMaxDim)。key 未登记则忽略 + void CompleteBake(unsigned long long key, const unsigned char* rgba, + int w, int h); + + EditorThumbnailCache(const EditorThumbnailCache&) = delete; + EditorThumbnailCache& operator=(const EditorThumbnailCache&) = delete; + +private: + EditorThumbnailCache() = default; + ~EditorThumbnailCache(); + + struct Entry { + ImTextureData* tex = nullptr; // 非拥有(删由回收流程) + int w = 0; + int h = 0; + ThumbView::State state = ThumbView::State::Pending; + unsigned long long lastUseFrame = 0; + int pendingFrames = 0; // 注册后等待后端上传的帧数 + }; + + void DecodeOne(const std::string& path, Entry& e); // 解码→降采样→注册 + // 源像素(RGBA8,sw×sh)→ 等比降采样 → ImTextureData 注册(解码与 + // 烘焙回读注入共用同一条上传路径) + static void UploadRGBA8(const unsigned char* src, int sw, int sh, Entry& e); + void InjectRGBA8(const std::string& path, const unsigned char* rgba, + int w, int h); + bool RetireOne(); // 淘汰最久未用(返回是否淘汰) + void DrainRetired(); // 后端已释放→回收 + + std::unordered_map m_Entries; + std::deque m_Queue; // 待解码(重复请求命 entries,天然去重) + std::vector m_Retire; // 已标 WantDestroy,等后端释放 + unsigned long long m_Frame = 0; + + // 材质球烘焙 path ↔ key 映射(幂等分配;回读完成即注销) + unsigned long long m_BakeKeyCounter = 0; + std::unordered_map m_PathToBakeKey; + std::unordered_map m_BakeKeyToPath; +}; + +#endif //HELLOVULKAN_THUMBNAILCACHE_H diff --git a/App/editor/panels/AssetBrowserPanel.cpp b/App/editor/panels/AssetBrowserPanel.cpp index ddbaf91..a0cfebc 100644 --- a/App/editor/panels/AssetBrowserPanel.cpp +++ b/App/editor/panels/AssetBrowserPanel.cpp @@ -1,10 +1,17 @@ // // AssetBrowserPanel.cpp:项目资产浏览器实现。 // +// 条目视觉/拖拽/tooltip 单元住 AssetItemView(列表行与网格瓦片共用同一 +// 套类别色 + 缩略图);本文件只负责:根切换 / 过滤 / 目录树 / 内容区 +// 铺排 / 选中态 / 双击分派(打开或实例化)。 +// #include "AssetBrowserPanel.h" -#include "MaterialEditorPanel.h" // kMaterialDragPayload + 双击打开材质编辑器 -#include "ControllerEditorPanel.h" // kControllerDragPayload + 双击打开控制器编辑器 + +#include "AssetItemView.h" // 列表行 / 网格瓦片 / 目录条目 +#include "AssetKind.h" // EditorAsset::AssetKind / KindOfPath +#include "MaterialEditorPanel.h" // kMaterialDragPayload + 双击打开材质编辑器 +#include "ControllerEditorPanel.h" // kControllerDragPayload + 双击打开控制器编辑器 #include #include @@ -13,10 +20,8 @@ #include #include -#include -#include -#include #include +#include #include namespace { @@ -25,79 +30,26 @@ namespace { constexpr const char* kRootNames[] = {"Assets", "Scenes"}; constexpr const char* kRootPaths[] = {"assets", "scenes"}; - // 类型过滤(下标即身份):0 = All + // 类型过滤(下标即身份):0 = All;其余对齐 AssetKind 顺序 constexpr const char* kTypeNames[] = {"All", "Models", "Textures", "Scenes", "Materials", "Controllers", "Prefabs"}; - // 资产类别:按扩展名分派(过滤用) - enum class Kind { Model, Texture, Scene, Material, AnimClip, Prefab, - Other }; - - Kind KindOf(const std::filesystem::path& p) - { - std::string ext = p.extension().string(); - std::transform(ext.begin(), ext.end(), ext.begin(), - [](unsigned char c) { return std::tolower(c); }); - if (ext == ".gltf" || ext == ".glb" || ext == ".fbx" || - ext == ".obj") - { - return Kind::Model; - } - if (ext == ".hdr" || ext == ".png" || ext == ".jpg" || - ext == ".jpeg") - { - return Kind::Texture; - } - if (ext == ".scene") - { - return Kind::Scene; - } - if (ext == ".material") - { - return Kind::Material; - } - if (ext == ".animctrl") - { - return Kind::AnimClip; - } - if (ext == ".prefab") - { - return Kind::Prefab; - } - return Kind::Other; - } - - bool PassFilter(int typeFilter, Kind kind) + bool PassFilter(int typeFilter, EditorAsset::AssetKind kind) { switch (typeFilter) { - case 1: return kind == Kind::Model; - case 2: return kind == Kind::Texture; - case 3: return kind == Kind::Scene; - case 4: return kind == Kind::Material; - case 5: return kind == Kind::AnimClip; - case 6: return kind == Kind::Prefab; + case 1: return kind == EditorAsset::AssetKind::Model; + case 2: return kind == EditorAsset::AssetKind::Texture; + case 3: return kind == EditorAsset::AssetKind::Scene; + case 4: return kind == EditorAsset::AssetKind::Material; + case 5: return kind == EditorAsset::AssetKind::AnimClip; + case 6: return kind == EditorAsset::AssetKind::Prefab; default: return true; } } - // 列表条目前缀符号(ImGui 默认字体无图标字形,用 ASCII 标签) - const char* KindTag(Kind kind) - { - switch (kind) - { - case Kind::Model: return "[M]"; - case Kind::Texture: return "[T]"; - case Kind::Scene: return "[S]"; - case Kind::Material: return "[Ma]"; - case Kind::AnimClip: return "[An]"; - case Kind::Prefab: return "[Pf]"; - default: return "[ ]"; - } - } - // 相对路径规范化为正斜杠(AssetManager 键与场景文件统一 POSIX 风格) std::string ToAssetPath(const std::filesystem::path& p) { @@ -105,6 +57,50 @@ namespace { std::replace(s.begin(), s.end(), '\\', '/'); return s; } + + // 双击分派:材质/控制器 → 打开编辑器;模型/环境 → 缓存预热; + // 场景 → 提示走 File 菜单;预制体 → 快捷实例化(授权位姿落点) + void DispatchOpen(const EditorContext& ctx, + const std::filesystem::path& path, + EditorAsset::AssetKind kind) + { + const std::string assetPath = path.generic_string(); + if (kind == EditorAsset::AssetKind::Material && ctx.materialEditor) + { + ctx.materialEditor->OpenAsset(assetPath); + } + else if (kind == EditorAsset::AssetKind::AnimClip && ctx.controllerEditor) + { + ctx.controllerEditor->Open(assetPath); + } + else if (kind == EditorAsset::AssetKind::Model && ctx.assets) + { + const auto ref = ctx.assets->LoadModel(assetPath); + ENGINE_INFO("AssetBrowser: model '{}' loaded (valid={})", + assetPath, ref.Valid()); + } + else if (kind == EditorAsset::AssetKind::Texture && ctx.assets) + { + const auto ref = ctx.assets->LoadEnvironment(assetPath); + ENGINE_INFO("AssetBrowser: env '{}' loaded (valid={})", + assetPath, ref.Valid()); + } + else if (kind == EditorAsset::AssetKind::Scene) + { + ENGINE_INFO("AssetBrowser: load scenes via File menu ({})", + assetPath); + } + else if (kind == EditorAsset::AssetKind::Prefab && ctx.scene && ctx.assets) + { + // 双击 = 快捷实例化(授权位姿落点,选中交给 Outliner 手动改); + // 拖拽实例化见 Outliner 落点 + const auto root = Engine::Serialization::SceneSerializer:: + InstantiatePrefab(*ctx.scene, *ctx.assets, assetPath); + ENGINE_INFO("AssetBrowser: prefab '{}' instantiated (valid={})", + assetPath, root != entt::null); + } + } + } // namespace void AssetBrowserPanel::Draw(const EditorContext& ctx) @@ -152,7 +148,7 @@ void AssetBrowserPanel::Draw(const EditorContext& ctx) m_CurrentDir = root; } - // ---- 顶栏下:面包屑(当前目录相对根路径 + 回根按钮)---- + // ---- 顶栏下:面包屑(当前目录相对根路径 + 回根按钮)+ 视图切换 ---- if (ImGui::Button("Root")) { m_CurrentDir = root; @@ -162,9 +158,28 @@ void AssetBrowserPanel::Draw(const EditorContext& ctx) .generic_string(); ImGui::TextDisabled("%s/%s", kRootNames[m_Root], rel.empty() ? "" : rel.c_str()); + + // 视图切换(列表 / 网格)+ 网格瓦片尺寸 + ImGui::SameLine(); + const bool listMode = m_ViewMode == ViewMode::List; + if (ImGui::RadioButton("List", listMode)) + { + m_ViewMode = ViewMode::List; + } + ImGui::SameLine(); + if (ImGui::RadioButton("Grid", !listMode)) + { + m_ViewMode = ViewMode::Grid; + } + if (m_ViewMode == ViewMode::Grid) + { + ImGui::SameLine(); + ImGui::SetNextItemWidth(120.0f); + ImGui::SliderFloat("##tile", &m_TileSize, 48.0f, 160.0f, "%.0f px"); + } ImGui::Separator(); - // ---- 左:目录树 | 右:当前目录文件列表 ---- + // ---- 左:目录树 | 右:当前目录内容(列表 / 网格)---- const float treeWidth = 180.0f; ImGui::BeginChild("##tree", ImVec2(treeWidth, 0.0f), ImGuiChildFlags_ResizeX | ImGuiChildFlags_Borders); @@ -194,7 +209,7 @@ void AssetBrowserPanel::Draw(const EditorContext& ctx) ImGuiChildFlags_Borders); if (std::filesystem::exists(m_CurrentDir)) { - DrawFileList(ctx, m_CurrentDir); + DrawContent(ctx, m_CurrentDir); } ImGui::EndChild(); @@ -227,34 +242,25 @@ void AssetBrowserPanel::DrawDirTree(const EditorContext& ctx, } } -void AssetBrowserPanel::DrawFileList(const EditorContext& ctx, - const std::filesystem::path& dir) +void AssetBrowserPanel::DrawContent(const EditorContext& ctx, + const std::filesystem::path& dir) { - // 收集 + 过滤 + 排序(名字序;目录迭代序不稳定) - struct Item - { - std::filesystem::path path; - Kind kind; - }; - std::vector items; + namespace fs = std::filesystem; + + // 收集:目录(不受类型过滤影响)+ 过滤后的文件;目录迭代序不稳定 → 排序 + std::vector dirs; + std::vector> files; const std::string filter = m_NameFilter; - for (const auto& entry : std::filesystem::directory_iterator(dir)) + std::error_code ec; + for (const auto& entry : fs::directory_iterator(dir, ec)) { if (entry.is_directory()) { - // 子目录也进列表:单行点击进入(树/列表双向导航) - const std::string dname = entry.path().filename().string(); - ImGui::PushID(dname.c_str()); - ImGui::TextUnformatted("[D]"); - ImGui::SameLine(); - if (ImGui::Selectable(dname.c_str())) - { - m_CurrentDir = entry.path(); - } - ImGui::PopID(); + dirs.push_back(entry.path()); continue; } - const Kind kind = KindOf(entry.path()); + const EditorAsset::AssetKind kind = + EditorAsset::KindOfPath(entry.path()); if (!PassFilter(m_TypeFilter, kind)) { continue; @@ -267,191 +273,106 @@ void AssetBrowserPanel::DrawFileList(const EditorContext& ctx, continue; } } - items.push_back({entry.path(), kind}); + files.emplace_back(entry.path(), kind); } - std::sort(items.begin(), items.end(), - [](const Item& a, const Item& b) + std::sort(dirs.begin(), dirs.end(), + [](const fs::path& a, const fs::path& b) { - return a.path.filename().string() < - b.path.filename().string(); + return a.filename().string() < b.filename().string(); + }); + std::sort(files.begin(), files.end(), + [](const auto& a, const auto& b) + { + return a.first.filename().string() < + b.first.filename().string(); }); - for (const auto& item : items) + // 双击打开延迟到循环外:Immediate GUI 语义——绘制循环内不改世界状态 + //(prefab 实例化会改场景,边遍历边打开会让后续条目的项状态错乱) + bool pendingOpen = false; + std::filesystem::path pendingOpenPath; + EditorAsset::AssetKind pendingOpenKind = EditorAsset::AssetKind::Other; + + if (m_ViewMode == ViewMode::List) { - const std::string name = item.path.filename().string(); - const std::string assetPath = ToAssetPath(item.path); - ImGui::PushID(name.c_str()); - - ImGui::TextUnformatted(KindTag(item.kind)); - ImGui::SameLine(); - ImGui::Selectable(name.c_str()); - - // .material 拖拽源:拖到 Details 材质槽球上指派(kMaterialDragPayload) - if (item.kind == Kind::Material && ImGui::BeginDragDropSource()) + for (const auto& d : dirs) { - ImGui::SetDragDropPayload(kMaterialDragPayload, - assetPath.c_str(), assetPath.size() + 1); - ImGui::TextUnformatted(name.c_str()); - ImGui::EndDragDropSource(); - } - - // .animctrl 拖拽源:拖到 Details Controller 槽指派 - //(kControllerDragPayload,对齐材质槽交互) - if (item.kind == Kind::AnimClip && ImGui::BeginDragDropSource()) - { - ImGui::SetDragDropPayload(kControllerDragPayload, - assetPath.c_str(), assetPath.size() + 1); - ImGui::TextUnformatted(name.c_str()); - ImGui::EndDragDropSource(); - } - - // .prefab 拖拽源:拖到 Outliner(节点=挂其下 / 空白区=挂根) - // 实例化整棵子树(kPrefabDragPayload) - if (item.kind == Kind::Prefab && ImGui::BeginDragDropSource()) - { - ImGui::SetDragDropPayload(kPrefabDragPayload, - assetPath.c_str(), assetPath.size() + 1); - ImGui::TextUnformatted(name.c_str()); - ImGui::EndDragDropSource(); - } - - // 模型拖拽源:拖到 Outliner 落点创建带 MeshRenderer 的实体 - //(kModelDragPayload;实体名 = 文件名) - if (item.kind == Kind::Model && ImGui::BeginDragDropSource()) - { - ImGui::SetDragDropPayload(kModelDragPayload, - assetPath.c_str(), assetPath.size() + 1); - ImGui::TextUnformatted(name.c_str()); - ImGui::EndDragDropSource(); - } - - // 双击:.material 打开 Material Editor;.animctrl 打开 - // Controller Editor;model/HDR 进缓存预热(路径去重,重复 - // 双击零成本);场景加载含环境恢复/选中清理流程,提示走 - // File 菜单 - if (ImGui::IsItemHovered() && - ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) - { - if (item.kind == Kind::Material && ctx.materialEditor) + if (EditorAssetView::DrawFolderRow(d)) { - ctx.materialEditor->OpenAsset(assetPath); - } - else if (item.kind == Kind::AnimClip && ctx.controllerEditor) - { - ctx.controllerEditor->Open(assetPath); - } - else if (item.kind == Kind::Model && ctx.assets) - { - const auto ref = ctx.assets->LoadModel(assetPath); - ENGINE_INFO("AssetBrowser: model '{}' loaded (valid={})", - assetPath, ref.Valid()); - } - else if (item.kind == Kind::Texture && ctx.assets) - { - const auto ref = ctx.assets->LoadEnvironment(assetPath); - ENGINE_INFO("AssetBrowser: env '{}' loaded (valid={})", - assetPath, ref.Valid()); - } - else if (item.kind == Kind::Scene) - { - ENGINE_INFO("AssetBrowser: load scenes via File menu ({})", - assetPath); - } - else if (item.kind == Kind::Prefab && ctx.scene && ctx.assets) - { - // 双击 = 快捷实例化(授权位姿落点,选中交给 Outliner - // 手动改);拖拽实例化见 Outliner 落点 - const auto root = Engine::Serialization::SceneSerializer:: - InstantiatePrefab(*ctx.scene, *ctx.assets, assetPath); - ENGINE_INFO("AssetBrowser: prefab '{}' instantiated " - "(valid={})", - assetPath, root != entt::null); + m_CurrentDir = d; + m_SelectedPath.clear(); } } - - // hover 信息卡(UE Content Browser 式):类型/大小/修改时间/加载状态 - if (ImGui::IsItemHovered()) + for (const auto& [path, kind] : files) { - ImGui::BeginTooltip(); - ImGui::TextUnformatted(name.c_str()); - ImGui::Separator(); - // 与 Kind 枚举逐位对齐(Kind::Other = 6)——漏项 = tooltip - // 数组越界读 - static const char* kKindNames[] = {"Model", "Texture/Environment", - "Scene", "Material", - "Controller", "Prefab", - "File"}; - ImGui::Text("type: %s", kKindNames[static_cast(item.kind)]); - namespace fs = std::filesystem; - std::error_code ec; - const auto sz = fs::file_size(item.path, ec); - if (!ec) + const std::string assetPath = ToAssetPath(path); + const auto res = EditorAssetView::DrawRow( + ctx, path, assetPath == m_SelectedPath); + if (res.clicked) { - char szBuf[32]; - if (sz >= 1024ull * 1024ull) - { - std::snprintf(szBuf, sizeof(szBuf), "%.1f MB", - static_cast(sz) / (1024.0 * 1024.0)); - } - else - { - std::snprintf(szBuf, sizeof(szBuf), "%.1f KB", - static_cast(sz) / 1024.0); - } - ImGui::Text("size: %s", szBuf); + m_SelectedPath = assetPath; } - const auto ftime = fs::last_write_time(item.path, ec); - if (!ec) + if (res.doubleClicked) { - // file_clock → system_clock 近似转换(C++17 无官方转换) - const auto sctp = std::chrono::time_point_cast< - std::chrono::system_clock::duration>( - ftime - fs::file_time_type::clock::now() + - std::chrono::system_clock::now()); - const std::time_t tt = std::chrono::system_clock::to_time_t(sctp); - char tmBuf[32]; - std::strftime(tmBuf, sizeof(tmBuf), "%Y-%m-%d %H:%M", - std::localtime(&tt)); - ImGui::Text("modified: %s", tmBuf); + pendingOpen = true; + pendingOpenPath = path; + pendingOpenKind = kind; } - // 加载状态:只对"加载"型资产有意义(查询不触发加载)。 - // Prefab 是实例化模板、Scene 走 File 菜单——显示 "not - // loaded" 会误导(曾让用户当成 bug) - if (ctx.assets) + } + } + + else + { + // ---- 网格视图:固定列宽表格铺排(列数按可用宽度自适应)---- + const float tile = m_TileSize; + const float cellW = tile + ImGui::GetStyle().CellPadding.x * 2.0f; + int cols = static_cast(ImGui::GetContentRegionAvail().x / cellW); + if (cols < 1) + { + cols = 1; + } + if (ImGui::BeginTable("##grid", cols, ImGuiTableFlags_SizingFixedFit)) + { + for (int i = 0; i < cols; ++i) { - if (item.kind == Kind::Model || - item.kind == Kind::Texture) + ImGui::TableSetupColumn("##c", + ImGuiTableColumnFlags_WidthFixed, + cellW); + } + for (const auto& d : dirs) + { + ImGui::TableNextColumn(); + const std::string assetPath = ToAssetPath(d); + if (EditorAssetView::DrawFolderTile( + d, tile, assetPath == m_SelectedPath)) { - const bool loaded = - (item.kind == Kind::Model) - ? ctx.assets->IsModelLoaded(assetPath) - : ctx.assets->IsEnvironmentLoaded(assetPath); - ImGui::Text("state: %s", loaded ? "loaded" - : "not loaded"); - } - else if (item.kind == Kind::Prefab) - { - ImGui::TextDisabled( - "template — drag into scene / double-click"); - } - // 已加载的 model 展开资产数据概览(meshes=0 即纯动画 - // 容器;未加载不显示——查询不触发加载) - if (item.kind == Kind::Model) - { - if (const Engine::Model* m = - ctx.assets->GetModelByPath(assetPath); - m != nullptr) - { - ImGui::Text("meshes: %u, bones: %u, clips: %u", - static_cast(m->GetMeshes().size()), - m->GetBoneCount(), - static_cast(m->GetClips().size())); - } + m_CurrentDir = d; + m_SelectedPath.clear(); } } - ImGui::TextDisabled("%s", assetPath.c_str()); - ImGui::EndTooltip(); + for (const auto& [path, kind] : files) + { + ImGui::TableNextColumn(); + const std::string assetPath = ToAssetPath(path); + const auto res = EditorAssetView::DrawTile( + ctx, path, tile, assetPath == m_SelectedPath); + if (res.clicked) + { + m_SelectedPath = assetPath; + } + if (res.doubleClicked) + { + pendingOpen = true; + pendingOpenPath = path; + pendingOpenKind = kind; + } + } + ImGui::EndTable(); } - ImGui::PopID(); + } + + if (pendingOpen) + { + DispatchOpen(ctx, pendingOpenPath, pendingOpenKind); } } diff --git a/App/editor/panels/AssetBrowserPanel.h b/App/editor/panels/AssetBrowserPanel.h index 0a6eae6..ee298aa 100644 --- a/App/editor/panels/AssetBrowserPanel.h +++ b/App/editor/panels/AssetBrowserPanel.h @@ -32,20 +32,28 @@ public: void Draw(const EditorContext& ctx); private: - // 目录树节点(递归);右侧列表画 dir 下的文件 + // 视图模式:列表(行,含内联小缩略图)/ 网格(缩略图瓦片) + enum class ViewMode { List, Grid }; + + // 目录树节点(递归);右侧内容区画 dir 下的目录+文件 void DrawDirTree(const EditorContext& ctx, const std::filesystem::path& dir); - void DrawFileList(const EditorContext& ctx, - const std::filesystem::path& dir); + void DrawContent(const EditorContext& ctx, + const std::filesystem::path& dir); // 固定根(下标即身份):项目资产 / 场景文件 int m_Root = 0; - // 当前浏览目录(点目录树切换;列表显示这里的文件) + // 当前浏览目录(点目录树切换;内容区显示这里的条目) std::filesystem::path m_CurrentDir; // 类型过滤(0 = All;其余见 kTypeNames) int m_TypeFilter = 0; // 名字子串过滤(空 = 不过滤) char m_NameFilter[64] = ""; + // 视图模式 + 网格瓦片尺寸(像素) + ViewMode m_ViewMode = ViewMode::List; + float m_TileSize = 96.0f; + // 当前选中条目(资产相对路径;空 = 无选中) + std::string m_SelectedPath; }; #endif //HELLOVULKAN_ASSETBROWSERPANEL_H diff --git a/App/editor/panels/HierarchyPanel.cpp b/App/editor/panels/HierarchyPanel.cpp index 2012a37..7512ed0 100644 --- a/App/editor/panels/HierarchyPanel.cpp +++ b/App/editor/panels/HierarchyPanel.cpp @@ -8,6 +8,7 @@ #include "HierarchyPanel.h" #include "AssetBrowserPanel.h" // kPrefabDragPayload(Content Browser 源) +#include "EditorColors.h" // 实体名/组件徽标配色 #include "Engine/ECS/Components.h" #include "Engine/ECS/EntityHandle.h" // CreateEntity 返回完整 EntityHandle #include "Engine/Serialization/SceneSerializer.h" @@ -73,6 +74,25 @@ namespace { return static_cast(e); } + // 主组件 → 实体名颜色(优先级高者胜,用于 Outliner 可读性) + ImVec4 PrimaryColor(const entt::registry& reg, entt::entity e) + { + using namespace Engine::ECS; + if (reg.all_of(e)) return EditorColors::kCamera; + if (reg.all_of(e)) return EditorColors::kLight; + if (reg.all_of(e) || + reg.all_of(e)) return EditorColors::kSky; + if (reg.all_of(e)) return EditorColors::kMesh; + if (reg.all_of(e)) return EditorColors::kAnimB; + if (reg.all_of(e)) return EditorColors::kScript; + if (reg.all_of(e) || + reg.all_of(e)) + { + return EditorColors::kPhysics; + } + return EditorColors::kEntityNeutral; + } + // 实体名 → 合法文件名(路径分隔符/保留符净化;空名兜底) std::string PrefabFileName(std::string name) { @@ -120,7 +140,10 @@ namespace { (kids.empty() ? ImGuiTreeNodeFlags_Leaf : 0) | (selected == e ? ImGuiTreeNodeFlags_Selected : 0); + // 实体名按主组件着色(无组件 = 中性色) + ImGui::PushStyleColor(ImGuiCol_Text, PrimaryColor(reg, e)); const bool opened = ImGui::TreeNodeEx(label.c_str(), flags); + ImGui::PopStyleColor(); const bool toggled = ImGui::IsItemToggledOpen(); if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && !toggled) { selected = e; @@ -208,20 +231,36 @@ namespace { ImGui::EndPopup(); } - // ---- 组件徽标(与行内同级显示)---- - std::string badge; - if (reg.all_of(e)) badge += "[M]"; - if (reg.all_of(e)) badge += "[A]"; - if (reg.all_of(e)) badge += "[C]"; - if (reg.all_of(e)) badge += "[L]"; - if (reg.all_of(e)) badge += "[P]"; - if (!badge.empty()) { - ImGui::SameLine(); - ImGui::TextDisabled("%s", badge.c_str()); - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip( - "M=Mesh A=Animation C=Camera L=Light P=Physics"); - } + // ---- 组件徽标(逐组件着色,与行内同级显示)---- + bool anyBadge = false; + auto badge = [&](const char* text, const ImVec4& col) + { + ImGui::SameLine(0.0f, 6.0f); + ImGui::PushStyleColor(ImGuiCol_Text, col); + ImGui::TextUnformatted(text); + ImGui::PopStyleColor(); + anyBadge = true; + }; + if (reg.all_of(e)) + badge("M", EditorColors::kMesh); + if (reg.all_of(e)) + badge("A", EditorColors::kAnimB); + if (reg.all_of(e)) + badge("C", EditorColors::kCamera); + if (reg.all_of(e)) + badge("L", EditorColors::kLight); + if (reg.all_of(e) || + reg.all_of(e)) + badge("P", EditorColors::kPhysics); + if (reg.all_of(e)) + badge("S", EditorColors::kScript); + if (reg.all_of(e) || + reg.all_of(e)) + badge("K", EditorColors::kSky); + if (anyBadge && ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "M=Mesh A=Animation C=Camera L=Light P=Physics " + "S=Script K=Sky"); } if (opened) { diff --git a/App/editor/panels/MaterialEditorPanel.cpp b/App/editor/panels/MaterialEditorPanel.cpp index 67fcbc1..4a7699d 100644 --- a/App/editor/panels/MaterialEditorPanel.cpp +++ b/App/editor/panels/MaterialEditorPanel.cpp @@ -9,6 +9,9 @@ #include "MaterialEditorPanel.h" #include "MaterialAssetUtil.h" +#include "AssetItemView.h" // 纹理选择器网格(缩略图瓦片) +#include "EditorColors.h" +#include "ThumbnailCache.h" #include #include @@ -17,6 +20,7 @@ #include #include +#include namespace { @@ -34,6 +38,125 @@ namespace { } constexpr float kPreviewSize = 128.0f; // 编辑器顶部大球(Inspector 槽位为 48²) + + // 纹理槽缩略图(同时是拖拽落点):Content Browser 拖纹理资产落入即 + // 指派该槽(kTextureDragPayload)。空槽显示 "nil" 占位 + void SlotThumb(const std::string& texPath, float sz, int slotIdx, + int& pendingSlot, std::string& pendingPath) + { + const ImVec4 accent = EditorColors::kTexture; + ImGui::Dummy(ImVec2(sz, sz)); + const ImVec2 p0 = ImGui::GetItemRectMin(); + const ImVec2 p1 = ImGui::GetItemRectMax(); + const bool hovered = ImGui::IsItemHovered(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + + bool drawn = false; + if (!texPath.empty()) + { + const ThumbView v = EditorThumbnailCache::Get().Request(texPath); + if (v.state == ThumbView::State::Ready) + { + float fw = sz, fh = sz; + if (v.w > 0 && v.h > 0) + { + if (v.w >= v.h) fh = sz * static_cast(v.h) / v.w; + else fw = sz * static_cast(v.w) / v.h; + } + const ImVec2 f0(p0.x + (sz - fw) * 0.5f, + p0.y + (sz - fh) * 0.5f); + dl->AddImage(v.tex, f0, ImVec2(f0.x + fw, f0.y + fh)); + drawn = true; + } + } + if (!drawn) + { + // 背板只在未出图时铺:出图时叠加会污染透明像素 + dl->AddRectFilled( + p0, p1, + ImGui::GetColorU32(ImVec4(accent.x * 0.25f, accent.y * 0.25f, + accent.z * 0.25f, 1.0f)), + 3.0f); + const char* lbl = texPath.empty() ? "nil" : "TEX"; + const ImVec2 ts = ImGui::CalcTextSize(lbl); + dl->AddText(ImVec2(p0.x + (sz - ts.x) * 0.5f, + p0.y + (sz - ts.y) * 0.5f), + ImGui::GetColorU32(texPath.empty() + ? EditorColors::kOther + : accent), + lbl); + } + dl->AddRect(p0, p1, + ImGui::GetColorU32(hovered ? EditorColors::Lighten(accent, + 0.3f) + : accent), + 3.0f); + + if (ImGui::BeginDragDropTarget()) + { + if (const ImGuiPayload* pl = + ImGui::AcceptDragDropPayload(kTextureDragPayload)) + { + pendingSlot = slotIdx; + pendingPath = std::string(static_cast(pl->Data)); + } + ImGui::EndDragDropTarget(); + } + } + + // 纹理选择器内容(弹窗内): 清除入口 + 缩略图网格。 + // 返回 true = 本次有选择,选中路径写入 out(空串 = ) + bool TexturePickerContent(const EditorContext& ctx, std::string& out) + { + bool picked = false; + if (ImGui::Selectable("")) + { + out.clear(); + picked = true; + } + ImGui::Separator(); + + const auto& files = EditorMaterialUtil::AssetTextureFiles(); + if (files.empty()) + { + ImGui::TextDisabled("(no textures under assets/)"); + return picked; + } + + constexpr float kTile = 64.0f; + const float cellW = kTile + ImGui::GetStyle().CellPadding.x * 2.0f; + int cols = static_cast(360.0f / cellW); + if (cols < 1) + { + cols = 1; + } + ImGui::BeginChild("##texgrid", ImVec2(360.0f, 260.0f), + ImGuiChildFlags_Borders); + if (ImGui::BeginTable("##grid", cols, ImGuiTableFlags_SizingFixedFit)) + { + for (int i = 0; i < cols; ++i) + { + ImGui::TableSetupColumn("##c", + ImGuiTableColumnFlags_WidthFixed, + cellW); + } + for (const auto& f : files) + { + ImGui::TableNextColumn(); + const auto r = + EditorAssetView::DrawTile(ctx, f, kTile, false); + if (r.clicked) + { + out = f; + picked = true; + } + } + ImGui::EndTable(); + } + ImGui::EndChild(); + return picked; + } + } // namespace void MaterialEditorPanel::OpenAsset(const std::string& path) @@ -228,10 +351,17 @@ void MaterialEditorPanel::DrawAssetMode(const EditorContext& ctx) "Emissive", "Ao", "Roughness"}; int pendingSlot = -1; std::string pendingPath; + const float thumbSz = ImGui::GetTextLineHeight() * 1.8f; for (Engine::u32 s = 0; s < Engine::Material::kSlotCount; ++s) { std::string tp = asset->GetData().texPaths[s]; ImGui::PushID(static_cast(s)); + + // 缩略图块(落点:拖入纹理资产即指派该槽) + SlotThumb(tp, thumbSz, static_cast(s), pendingSlot, pendingPath); + ImGui::SameLine(); + + ImGui::SetNextItemWidth(200.0f); InputTextString(kSlotNames[s], tp); if (ImGui::IsItemDeactivatedAfterEdit()) { @@ -245,20 +375,13 @@ void MaterialEditorPanel::DrawAssetMode(const EditorContext& ctx) } if (ImGui::BeginPopup("tex_pick")) { - // 清除入口(Unity/UE 语义):该槽置空 → 回退兜底贴图 - if (ImGui::Selectable("")) + // 弹窗内只做选择;pending 统一在循环外落盘(避免 popup 内 + // 立即重建导致 asset 指针悬空) + std::string chosen; + if (TexturePickerContent(ctx, chosen)) { pendingSlot = static_cast(s); - pendingPath.clear(); - } - ImGui::Separator(); - for (const auto& f : EditorMaterialUtil::AssetTextureFiles()) - { - if (ImGui::Selectable(f.c_str())) - { - pendingSlot = static_cast(s); - pendingPath = f; - } + pendingPath = chosen; } ImGui::EndPopup(); } diff --git a/App/editor/panels/MaterialEditorPanel.h b/App/editor/panels/MaterialEditorPanel.h index 88ac0e5..fda418d 100644 --- a/App/editor/panels/MaterialEditorPanel.h +++ b/App/editor/panels/MaterialEditorPanel.h @@ -31,6 +31,9 @@ // 材质资产拖拽 payload(Content Browser 拖 .material → Details 槽位) inline constexpr const char* kMaterialDragPayload = "MAT_PATH"; +// 纹理资产拖拽 payload(Content Browser 拖 .png/.jpg/... → 材质纹理槽) +inline constexpr const char* kTextureDragPayload = "TEXTURE_PATH"; + class MaterialEditorPanel { public: // 打开 .material 资产(可编辑模式) diff --git a/Engine/include/Engine/RenderGraph/RenderGraph.h b/Engine/include/Engine/RenderGraph/RenderGraph.h index 1447002..1a872ac 100644 --- a/Engine/include/Engine/RenderGraph/RenderGraph.h +++ b/Engine/include/Engine/RenderGraph/RenderGraph.h @@ -314,7 +314,10 @@ namespace Engine::RG { void ComputeLifetimes(); void PrecomputeBarriers(); void AllocatePhysical(); - void ReleasePhysical(); + // keepFixedSize = true:固定尺寸资源(desc 显式 width/height,与目标 + // 渲染尺寸无关)的物理镜像不归还池、保留在 entry 上——重编译后原样 + // 沿用同一 VkImage,内容存续(材质球预览 atlas 不因视口 resize 清空) + void ReleasePhysical(bool keepFixedSize); void PurgePool(vk::Extent2D target); PhysicalImage* AcquirePhysical(vk::Format format, vk::Extent2D extent, vk::ImageUsageFlags usage, diff --git a/Engine/include/Engine/Renderer/MaterialAsset.h b/Engine/include/Engine/Renderer/MaterialAsset.h index 160b3c6..886eae9 100644 --- a/Engine/include/Engine/Renderer/MaterialAsset.h +++ b/Engine/include/Engine/Renderer/MaterialAsset.h @@ -67,12 +67,10 @@ namespace Engine { static bool Save(const std::string& path, const MaterialAssetData& data); static bool Load(const std::string& path, MaterialAssetData& out); - // GPU 段注入(仅 VulkanAssetUploader 调用):ownedTextures = - // 本资产加载的纹理(独占所有权),material = 组装好的材质 - //(空槽已由 uploader 绑共享默认纹理) - MaterialAsset(MaterialAssetData&& data, - std::array, Material::kSlotCount>&& ownedTextures, - Scope&& material); + // GPU 段注入(仅 VulkanAssetUploader 调用):material = 组装好的 + // 材质(空槽已由 uploader 绑共享默认纹理)。纹理所有权归 uploader + // 的路径缓存(跨材质去重),本资产只持有绑进 Material 的裸指针 + MaterialAsset(MaterialAssetData&& data, Scope&& material); // 析构外置(成员为重资源句柄,销毁序与 GPU 生命周期解耦遵循 // Model/Material 既有模式) ~MaterialAsset(); @@ -87,8 +85,6 @@ namespace Engine { private: MaterialAssetData m_Data; - // 本资产独占的纹理(共享默认纹理不在其中,由 uploader 持有) - std::array, Material::kSlotCount> m_OwnedTextures; Scope m_Material; }; diff --git a/Engine/include/Engine/Renderer/Passes/MaterialPreviewPass.h b/Engine/include/Engine/Renderer/Passes/MaterialPreviewPass.h index dd13982..cfba26f 100644 --- a/Engine/include/Engine/Renderer/Passes/MaterialPreviewPass.h +++ b/Engine/include/Engine/Renderer/Passes/MaterialPreviewPass.h @@ -73,6 +73,9 @@ namespace Engine { inline constexpr u32 kPreviewTilesPerSide = 5; inline constexpr u32 kPreviewTileCount = kPreviewTilesPerSide * kPreviewTilesPerSide; + // 单 tile 回读字节数(SDR atlas 为 RGBA8) + inline constexpr u32 kPreviewTileBytes = + kPreviewTileSize * kPreviewTileSize * 4; // 本帧待渲染的脏 tile(Material* 来自本帧 UI 请求,活指针) struct PreviewDirtyTile { @@ -80,13 +83,22 @@ namespace Engine { u32 tile = 0; }; + // 本帧待回读烘焙的 tile(key = 调用方令牌,Renderer 不解释)。 + // 只收录"本帧确实被渲染"的 tile,故条数 ≤ 渲染预算 + struct PreviewBakeTile { + u64 key = 0; + u32 tile = 0; + }; + // 编辑器 tile 池(Renderer 持有,UE FAssetThumbnailPool 语义)。 // 缓存键 = Material serial(出生唯一号,防指针地址复用的 ABA) // + editVersion(参数代数)。游戏构建整体编译裁剪,无运行时成本 class PreviewTilePool { public: - // 每帧渲染预算:脏 tile 多时逐帧消化(滚动列表渐进出图) - static constexpr u32 kPreviewRendersPerFrame = 2; + // 每帧渲染预算:脏 tile 多时逐帧消化(浏览器缩略图烘焙 / 滚动 + // 列表渐进出图)。预览球是 128 三角形的 256² 直绘,8 个/帧的 + // GPU 成本可忽略;烘焙吞吐 = 预算(回读只收当帧渲染过的 tile) + static constexpr u32 kPreviewRendersPerFrame = 8; // UI 构建段逐可见行调用(幂等 keepalive,刷新 LRU + 脏检测)。 // 返回 tile:>=0 = 三槽位镜像内容齐备可显示(含重渲期间的旧 @@ -145,6 +157,44 @@ namespace Engine { return displayable ? static_cast(tileIdx) : -1; } + // 烘焙请求(浏览器缩略图回读):强制标脏——即使 tile 已 + // displayable 也重渲一次,保证"当帧渲染 → 当帧拷贝回读"闭环 + // (回读只收当帧渲染过的 tile)。返回槽位 tile 索引;-1 = + // 池满无槽(调用方下帧重试)。不参与 paintedSlots 记账 + i32 RequestBake(Material* material) + { + if (!material) return -1; + const u64 serial = material->GetSerial(); + Entry* slot = nullptr; + for (auto& e : m_Entries) { + if (e.serial == serial) { slot = &e; break; } + } + if (!slot) { + u64 oldest = ~0ull; + for (auto& e : m_Entries) { + if (e.serial == 0) { slot = &e; break; } + if (e.lastUseFrame < m_Frame && + e.lastUseFrame < oldest) { + oldest = e.lastUseFrame; + slot = &e; + } + } + if (!slot) return -1; + slot->serial = serial; + slot->paintedSlots = 0; + slot->trackedVersion = 0; + } + slot->lastUseFrame = m_Frame; + const u32 tileIdx = + static_cast(slot - m_Entries.data()); + bool queued = false; + for (auto& d : m_FrameDirty) { + if (d.tile == tileIdx) { d.material = material; queued = true; break; } + } + if (!queued) m_FrameDirty.push_back({material, tileIdx}); + return static_cast(tileIdx); + } + // Render 段取本帧预算内的脏 tile(frameSlot = 本帧渲染槽位, // 该槽位镜像在本帧被写入)。渲染语义(清矩形+画球)由 pass // 执行后该槽位内容即有效。剩余脏 tile 保持未取——下帧可见行 @@ -215,6 +265,7 @@ namespace Engine { class MaterialPreviewPass final : public RenderPass { public: // dirtyTiles = Renderer 持有的当帧脏表(Render 段换入,只读) + // bakeTiles = Renderer 持有的当帧回读表(只读;条数 ≤ 渲染预算) MaterialPreviewPass(Device& device, MemoryAllocator& allocator, DescriptorManager& descriptors, SceneGpu& gpu, FullscreenGpu& fullscreen, @@ -222,8 +273,19 @@ namespace Engine { RG::TextureHandle velocityAtlas, RG::TextureHandle sdrAtlas, const std::vector& - dirtyTiles); - ~MaterialPreviewPass() override = default; + dirtyTiles, + const std::vector& + bakeTiles); + ~MaterialPreviewPass() override; + + // 本槽位的回读暂存(host-visible,持久映射):Renderer 在该 + // 槽位 fence 通过后 memcpy 取走烘焙像素 + Buffer* BakeStaging(u32 frameSlot) const + { + return frameSlot < kMaxFramesInFlight + ? m_BakeStaging[frameSlot].get() + : nullptr; + } void Register(RG::RenderGraph& rg) override; void OnTargetsRebuilt(RG::RenderGraph& rg) override; @@ -246,6 +308,11 @@ namespace Engine { RG::TextureHandle m_VelocityAtlas; RG::TextureHandle m_SDRAtlas; const std::vector& m_DirtyTiles; + const std::vector& m_BakeTiles; + + // 回读暂存:每帧槽位一份(容量 = 渲染预算 × 单 tile 字节)。 + // 拷贝录制在该槽位命令缓冲,Renderer 在该槽位 fence 通过后读 + std::array, kMaxFramesInFlight> m_BakeStaging; // ---- 预览专用 set0(与 uboSetLayout 同布局,内容恒定)---- // UBO = 固定棚拍相机/布光;SSBO = GPUDriven 契约的最小占位 diff --git a/Engine/include/Engine/Renderer/Renderer.h b/Engine/include/Engine/Renderer/Renderer.h index 2397d93..df43640 100644 --- a/Engine/include/Engine/Renderer/Renderer.h +++ b/Engine/include/Engine/Renderer/Renderer.h @@ -111,6 +111,17 @@ namespace Engine { // 脏表引用与槽位——脏 tile 本帧不再解引用已销毁材质(UAF) void DropMaterialPreview(const Material* material); void* GetMaterialPreviewTexture() const; + + // ---- 材质球缩略图烘焙(GPU→CPU 异步回读,无 waitIdle)---- + // UI 段对"需要静态缩略图"的材质逐帧幂等请求(userKey 由调用方 + // 定义,Renderer 不解释、原样回传;同 Material serial 去重)。渲染 + // 段把该材质渲染进预览 tile 后当帧拷进 host 暂存,待该帧槽位 + // fence 通过(两帧后同槽位再入图)再取走——不阻塞主线程 + void RequestMaterialPreviewBake(Material* material, u64 userKey); + // UI 段轮询:有完成的烘焙则取走(一次性)。像素 = RGBA8, + // kPreviewTileSize²(tonemap 后的 SDR,可直接当缩略图) + bool PollMaterialPreviewBake(u64& outKey, std::vector& outPixels, + u32& outWidth, u32& outHeight); #endif // ---- 后处理参数(ImGui 可调,转发到 pass 共享的 PostProcessParams)---- @@ -213,6 +224,9 @@ namespace Engine { void HandleResize(bool recreateSwapchain); void ReadGpuTimes(u32 frameSlot); void RefreshSceneTextures(); // 物理 view → ImGui 描述符(重建后刷新) +#ifdef HELLO_EDITOR + void CollectBakeResults(u32 frameSlot); // 槽位 fence 后取走回读结果 +#endif // 期望渲染分辨率:Editor 视口 = 面板尺寸(未上报时回退 swapchain); // Direct = swapchain 尺寸 vk::Extent2D DesiredRenderExtent() const; @@ -300,6 +314,34 @@ namespace Engine { // TakeRenders 取预算内的脏 tile 换入 m_PreviewRenders(pass 只读) Passes::PreviewTilePool m_PreviewPool; std::vector m_PreviewRenders; + // 上一轮注册到 ImGui 的预览 atlas 物理 view:重编译后若镜像真的 + // 换新(固定尺寸资源跨视口 resize 会保留同一镜像)才需让池重画, + // 否则预览球内容存续、不闪烁 + vk::ImageView m_PreviewViews[kMaxFramesInFlight]{}; + + // ---- 材质球缩略图烘焙(GPU→CPU 异步回读)---- + struct PreviewBakeReq + { + u64 key = 0; // 调用方令牌(App 侧 path → key) + u64 serial = 0; // Material serial(去重/失效判据) + Material* material = nullptr; // 仅 !copied 期间被解引用 + i32 frameTile = -1; // 本帧 RequestBake 返回的槽位 + bool copied = false; // 拷贝已录制,等待回读 + u64 lastRequestFrame = 0; // UI keepalive 帧号(老化丢弃判据) + }; + std::vector m_BakeReqs; // 跨帧存活至回读完成 + u64 m_FrameIndex = 0; // 单调帧号(请求老化判据) + std::vector m_PreviewBakes; // 当帧(pass 只读) + // 每槽位当帧录制的回读 key(帧 N 录,帧 N+2 同槽位 fence 后取走) + std::array, kMaxFramesInFlight> m_BakeRecorded; + struct PreviewBakeResult + { + u64 key = 0; + std::vector pixels; + u32 width = 0; + u32 height = 0; + }; + std::vector m_BakeDone; #endif // GPU timestamp(pass 级耗时,ImGui 可读) vk::QueryPool m_QueryPool; diff --git a/Engine/include/Engine/Renderer/VulkanAssetUploader.h b/Engine/include/Engine/Renderer/VulkanAssetUploader.h index b26f5cb..74f9036 100644 --- a/Engine/include/Engine/Renderer/VulkanAssetUploader.h +++ b/Engine/include/Engine/Renderer/VulkanAssetUploader.h @@ -14,6 +14,9 @@ #include +#include +#include + namespace Engine { class Device; @@ -49,7 +52,8 @@ namespace Engine { ~VulkanAssetUploader() override; // ---- 材质资产(.material 数据段 → GPU 段)---- - // 每槽路径建独占纹理(空/缺失回退共享默认纹理),再组 Material + // 每槽经路径纹理缓存(跨材质去重)取纹理,同批新纹理合并为一次 + // submit;空/缺失回退共享默认纹理,再组 Material Scope CreateMaterial(MaterialAssetData&& data) override; // 设备空闲屏障(编辑器资产重建路径:卸载旧 GPU 资源前全停飞) void WaitDeviceIdle() override; @@ -60,6 +64,12 @@ namespace Engine { void InitSharedDefaultTextures(); std::array, Material::kSlotCount> m_DefaultTextures{}; + // 路径纹理缓存:键 = 归一化路径 + sRGB 标记 → 独占纹理。多个 + // 材质引用同一贴图(仓库 4K 贴图被多材质共享)只解码/上传一次。 + // 生命周期长于所有 MaterialAsset(uploader 在 assets 之后析构), + // 故 Material 可安全持裸指针。DDS 与 stbi 同缓存 + std::unordered_map> m_TextureCache; + Device& m_Device; MemoryAllocator& m_Allocator; DescriptorManager& m_Descriptors; diff --git a/Engine/include/Engine/Vulkan/Texture.h b/Engine/include/Engine/Vulkan/Texture.h index 25d03e2..b88d3a5 100644 --- a/Engine/include/Engine/Vulkan/Texture.h +++ b/Engine/include/Engine/Vulkan/Texture.h @@ -42,6 +42,18 @@ namespace Engine { Device& device, MemoryAllocator& allocator, const void* data, u32 bytes, bool srgb); + // 批式上传(一次 submit + 一次 fence 等待服务同批多张贴图,省掉 + // 每张各自的命令池创建销毁与阻塞等待):解码 + 建 image/view/ + // sampler + 把拷贝/barrier 录制进调用方 cmd(**不**提交)。staging + // 暂存对象内,调用方在批量 submit fence 通过后逐张 + // ReleasePendingStaging 释放;析构亦兜底释放。DDS(稀有)仍走 + // 自带提交,不参与批量 + static Scope CreateDeferred(Device& device, + MemoryAllocator& allocator, + const std::string& path, bool srgb, + vk::CommandBuffer cmd); + void ReleasePendingStaging(); + ~Texture(); Texture(const Texture&) = delete; @@ -62,9 +74,19 @@ namespace Engine { const std::array& rgba, bool srgb); // 公共路径:staging → device local image → blit 生成完整 mip 链 → - // view/sampler(RGBA8,各向异性过滤) + // view/sampler(RGBA8,各向异性过滤)。录制进 cmd 后自行提交等待 void InitFromPixels(const void* pixels, u32 width, u32 height, bool srgb); + // 批式路径构造(cmd 由调用方提供,不在本构造内提交) + Texture(Device& device, MemoryAllocator& allocator, + const std::string& path, bool srgb, vk::CommandBuffer cmd); + + // 公共上传路径核心:建 staging/image/view/sampler + 逐 mip + // barrier/copy/blit 录制进 cmd(不提交、不销毁 staging,staging 存 + // m_PendingStaging)。InitFromPixels 与批式路径共用 + void RecordPixelsUpload(const void* pixels, u32 width, u32 height, + bool srgb, vk::CommandBuffer cmd); + // DDS 路径:压缩数据直传(BC 硬件采样解码)+ 文件自带 mip 链 void InitFromDds(const std::string& path, bool srgb); @@ -83,6 +105,10 @@ namespace Engine { u64 m_TextureBytes = 0; + // 批式上传暂存的 staging(submit fence 通过前不可释放) + bool m_HasPendingStaging = false; + MemoryAllocator::Allocation m_PendingStaging{}; + MemoryAllocator::Allocation m_ImageAllocation; MemoryAllocator& m_Allocator; }; diff --git a/Engine/src/RenderGraph/RenderGraph.cpp b/Engine/src/RenderGraph/RenderGraph.cpp index 405961f..204b5ab 100644 --- a/Engine/src/RenderGraph/RenderGraph.cpp +++ b/Engine/src/RenderGraph/RenderGraph.cpp @@ -28,6 +28,13 @@ namespace Engine::RG { } return false; } + + // 固定尺寸资源:desc 显式给定 width/height,物理尺寸与目标渲染 + // 分辨率无关(如材质球预览 atlas)。这类镜像跨重编译沿用,内容存续 + bool IsResolutionIndependent(const TextureEntry& e) + { + return !e.imported && e.desc.width != 0 && e.desc.height != 0; + } } // namespace // ---------- 构造 / 析构 ---------- @@ -39,7 +46,8 @@ namespace Engine::RG { RenderGraph::~RenderGraph() { - ReleasePhysical(); + // 析构需全量归还(含固定尺寸资源),池随后统一销毁 + ReleasePhysical(false); const auto& vkDevice = m_Device.GetDevice(); for (auto &images: m_Pool | std::views::values) { @@ -172,7 +180,9 @@ namespace Engine::RG { ENGINE_FATAL("RenderGraph: Compile before SetTargetExtent"); } - ReleasePhysical(); + // 固定尺寸镜像已在 entry 上沿用(SetTargetExtent 只回收尺寸随目标 + // 变化的镜像),此处再收一次只对非固定尺寸的残留生效 + ReleasePhysical(true); // 清理上一次编译的中间结果 for (auto& entry : m_Textures) { @@ -229,11 +239,15 @@ namespace Engine::RG { if (extent == m_TargetExtent) return; // 尺寸变化:调用方必须保证 GPU 空闲(通常在 swapchain recreate 的 waitIdle 之后) - ReleasePhysical(); + // 只回收尺寸随目标变化的镜像;固定尺寸资源(预览 atlas)保留在 + // entry 上,PurgePool 也看不到它们,内容不会被销毁 + ReleasePhysical(true); PurgePool(extent); - // 物理镜像全部换新,槽位状态清零 + // 只有被回收重建的镜像才回到"首次使用"(Undefined 出发);保留的 + // 镜像仍在上一轮 resting layout,槽位使用态必须跟着保留 for (auto& entry : m_Textures) { + if (IsResolutionIndependent(entry)) continue; for (auto& slot : entry.slots) slot.used = false; } @@ -594,6 +608,19 @@ namespace Engine::RG { }; for (u32 slot = 0; slot < m_FramesInFlight; ++slot) { + // 跨重编译保留的固定尺寸镜像:形态与本次一致则原样沿用 + //(同一 VkImage → 内容存续);不一致则归还池再重分配 + PhysicalImage* keep = entry.physical[slot]; + if (keep) { + if (keep->format == entry.desc.format && + keep->extent == extent && keep->usage == entry.usage) { + continue; + } + PoolKey key{static_cast(keep->format), + keep->extent.width, keep->extent.height, + static_cast(keep->usage)}; + m_Pool[key].push_back(Scope(keep)); + } entry.physical[slot] = AcquirePhysical( entry.desc.format, extent, entry.usage, entry.aspect); } @@ -601,10 +628,11 @@ namespace Engine::RG { } } - void RenderGraph::ReleasePhysical() + void RenderGraph::ReleasePhysical(bool keepFixedSize) { for (auto& entry : m_Textures) { if (entry.imported) continue; + if (keepFixedSize && IsResolutionIndependent(entry)) continue; for (u32 slot = 0; slot < entry.physical.size(); ++slot) { auto* img = entry.physical[slot]; if (!img) continue; diff --git a/Engine/src/Renderer/MaterialAsset.cpp b/Engine/src/Renderer/MaterialAsset.cpp index 4b17e37..9da664b 100644 --- a/Engine/src/Renderer/MaterialAsset.cpp +++ b/Engine/src/Renderer/MaterialAsset.cpp @@ -174,10 +174,8 @@ namespace Engine MaterialAsset::MaterialAsset( MaterialAssetData&& data, - std::array, Material::kSlotCount>&& ownedTextures, Scope&& material) : m_Data(std::move(data)) - , m_OwnedTextures(std::move(ownedTextures)) , m_Material(std::move(material)) { } diff --git a/Engine/src/Renderer/Passes/MaterialPreviewPass.cpp b/Engine/src/Renderer/Passes/MaterialPreviewPass.cpp index c3033ae..00e0b18 100644 --- a/Engine/src/Renderer/Passes/MaterialPreviewPass.cpp +++ b/Engine/src/Renderer/Passes/MaterialPreviewPass.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include @@ -103,7 +104,8 @@ namespace Engine::Passes DescriptorManager& descriptors, SceneGpu& gpu, FullscreenGpu& fullscreen, RG::TextureHandle hdrAtlas, RG::TextureHandle velocityAtlas, RG::TextureHandle sdrAtlas, - const std::vector& dirtyTiles) + const std::vector& dirtyTiles, + const std::vector& bakeTiles) : m_Device(device) , m_Allocator(allocator) , m_Descriptors(descriptors) @@ -113,6 +115,7 @@ namespace Engine::Passes , m_VelocityAtlas(velocityAtlas) , m_SDRAtlas(sdrAtlas) , m_DirtyTiles(dirtyTiles) + , m_BakeTiles(bakeTiles) { // ---- set0:棚拍 UBO + GPUDriven 契约占位(内容恒定,创建期 // 一次写入;in-flight 帧并发只读同一份,无需 per-slot ring)---- @@ -190,8 +193,22 @@ namespace Engine::Passes for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) { m_TonemapSets[slot] = fullscreen.AllocateSet(); } + + // ---- 烘焙回读暂存:每槽位一份 host-visible 持久映射缓冲 ---- + const vk::DeviceSize bakeBytes = + static_cast(PreviewTilePool::kPreviewRendersPerFrame) * + kPreviewTileBytes; + for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) { + m_BakeStaging[slot] = CreateScope( + device, allocator, bakeBytes, + vk::BufferUsageFlagBits::eTransferDst, + vk::MemoryPropertyFlagBits::eHostVisible | + vk::MemoryPropertyFlagBits::eHostCoherent); + } } + MaterialPreviewPass::~MaterialPreviewPass() = default; + void MaterialPreviewPass::Register(RG::RenderGraph& rg) { // ---- 球体渲染:HDR atlas(loadOp=Load 保留缓存内容,逐脏 tile @@ -325,6 +342,42 @@ namespace Engine::Passes .Read(m_HDRAtlas, RG::ResourceUse::ShaderRead, vk::ShaderStageFlagBits::eFragment) .Write(m_SDRAtlas, RG::ResourceUse::ColorAttachment); + + // ---- 烘焙回读:当帧渲染过的 tile 从 SDR atlas 拷进 host 暂存 ---- + // TransferSrc use 让 RG 自动完成 eColorAttachmentOptimal → + // eTransferSrcOptimal 迁移(ImGui 随后仍以 ShaderRead 采样同一图, + // RG 按 read-read layout 变化再迁回)。SideEffect 保证空表帧不被 + // 剔除(barrier 照常插入,无拷贝则 GPU 零实际工作) + rg.AddPass("MaterialPreviewBake", + [this](RG::RenderGraphContext& ctx) { + if (m_BakeTiles.empty()) return; + Buffer* staging = m_BakeStaging[ctx.frame.frameSlot].get(); + if (!staging) return; + const vk::Image src = ctx.Image(m_SDRAtlas); + for (size_t i = 0; i < m_BakeTiles.size(); ++i) { + const u32 tile = m_BakeTiles[i].tile; + if (tile >= kPreviewTileCount) continue; + const u32 col = tile % kPreviewTilesPerSide; + const u32 row = tile / kPreviewTilesPerSide; + vk::BufferImageCopy region; + region.setBufferOffset(static_cast(i) * + kPreviewTileBytes) + .setBufferRowLength(0) + .setBufferImageHeight(0) + .setImageSubresource(vk::ImageSubresourceLayers( + vk::ImageAspectFlagBits::eColor, 0, 0, 1)) + .setImageOffset(vk::Offset3D{ + i32(col * kPreviewTileSize), + i32(row * kPreviewTileSize), 0}) + .setImageExtent(vk::Extent3D{ + kPreviewTileSize, kPreviewTileSize, 1}); + ctx.cmd.copyImageToBuffer( + src, vk::ImageLayout::eTransferSrcOptimal, + staging->Get(), region); + } + }) + .Read(m_SDRAtlas, RG::ResourceUse::TransferSrc) + .SideEffect(); } void MaterialPreviewPass::RecordBall(vk::CommandBuffer cmd, u32 tile, diff --git a/Engine/src/Renderer/Renderer.cpp b/Engine/src/Renderer/Renderer.cpp index e96d06e..d3a546a 100644 --- a/Engine/src/Renderer/Renderer.cpp +++ b/Engine/src/Renderer/Renderer.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -563,7 +564,7 @@ namespace Engine m_Passes.push_back(CreateScope( *m_Device, *m_Allocator, *m_DescriptorManager, *m_SceneGpu, *m_FullscreenGpu, m_PreviewHDR, m_PreviewVelocity, m_PreviewSDR, - m_PreviewRenders)); + m_PreviewRenders, m_PreviewBakes)); m_PreviewPass = static_cast(m_Passes.back().get()); #endif @@ -673,6 +674,11 @@ namespace Engine void Renderer::DropMaterialPreview(const Material* material) { m_PreviewPool.DropMaterial(material); + if (material) { + const u64 serial = material->GetSerial(); + std::erase_if(m_BakeReqs, [serial](const PreviewBakeReq& r) + { return r.serial == serial; }); + } } void* Renderer::GetMaterialPreviewTexture() const @@ -684,6 +690,66 @@ namespace Engine } return tex; } + + void Renderer::RequestMaterialPreviewBake(Material* material, u64 userKey) + { + if (!material) return; + const u64 serial = material->GetSerial(); + // 同 serial 去重(逐帧 keepalive);同 key 换 serial(资产重载 → + // 旧实例销毁)时替换,避免残留悬垂 Material* + std::erase_if(m_BakeReqs, [&](const PreviewBakeReq& r) + { return r.key == userKey && r.serial != serial; }); + for (auto& r : m_BakeReqs) { + if (r.serial == serial) { + r.material = material; r.key = userKey; + r.lastRequestFrame = m_FrameIndex; + return; + } + } + PreviewBakeReq req; + req.key = userKey; + req.serial = serial; + req.material = material; + req.lastRequestFrame = m_FrameIndex; + m_BakeReqs.push_back(req); + } + + bool Renderer::PollMaterialPreviewBake(u64& outKey, std::vector& outPixels, + u32& outWidth, u32& outHeight) + { + if (m_BakeDone.empty()) return false; + PreviewBakeResult res = std::move(m_BakeDone.front()); + m_BakeDone.erase(m_BakeDone.begin()); + outKey = res.key; + outPixels = std::move(res.pixels); + outWidth = res.width; + outHeight = res.height; + return true; + } + + // 槽位 fence 已通过 → 帧 N-2 在该槽位录制的拷贝已完成,取走并出队 + void Renderer::CollectBakeResults(u32 frameSlot) + { + std::vector recorded = std::move(m_BakeRecorded[frameSlot]); + m_BakeRecorded[frameSlot].clear(); + if (recorded.empty()) return; + if (!m_PreviewPass) return; + Buffer* staging = m_PreviewPass->BakeStaging(frameSlot); + const auto* base = + static_cast(staging ? staging->MappedPtr() : nullptr); + if (!base) return; + for (size_t i = 0; i < recorded.size(); ++i) { + PreviewBakeResult res; + res.key = recorded[i]; + res.width = Passes::kPreviewTileSize; + res.height = Passes::kPreviewTileSize; + const u8* src = base + i * Passes::kPreviewTileBytes; + res.pixels.assign(src, src + Passes::kPreviewTileBytes); + m_BakeDone.push_back(std::move(res)); + std::erase_if(m_BakeReqs, [&](const PreviewBakeReq& r) + { return r.key == recorded[i]; }); + } + } #endif // RG 物理镜像按帧槽位各一份:每槽位注册一个 ImGui 描述符。 @@ -691,21 +757,27 @@ namespace Engine void Renderer::RefreshSceneTextures() { if (!m_ImGuiLayer || !m_PresentTarget.Valid()) return; + bool previewRecreated = false; for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) { m_ImGuiLayer->UpdateSceneTexture( slot, m_RenderGraph->GetPhysicalView(m_PresentTarget, slot)); // 材质球 atlas 同槽位注册(语义与场景纹理一致) #ifdef HELLO_EDITOR if (m_PreviewSDR.Valid()) { - m_ImGuiLayer->UpdatePreviewTexture( - slot, m_RenderGraph->GetPhysicalView(m_PreviewSDR, slot)); + const vk::ImageView view = + m_RenderGraph->GetPhysicalView(m_PreviewSDR, slot); + // 固定尺寸的预览 atlas 跨视口 resize 沿用同一镜像(内容 + // 存续);只有镜像真的换新时才需要让池重画 + previewRecreated = previewRecreated || view != m_PreviewViews[slot]; + m_PreviewViews[slot] = view; + m_ImGuiLayer->UpdatePreviewTexture(slot, view); } #endif } #ifdef HELLO_EDITOR - // 重编译 = 物理镜像全部换新(三槽位 atlas 内容清零)——池的 - // paintedSlots 记录全部失效,可见行 keepalive 请求会重新补画 - if (m_PreviewSDR.Valid()) { + // 物理镜像换新 = atlas 内容清零,池的 paintedSlots 记录全部失效, + // 可见行 keepalive 请求会重新补画 + if (previewRecreated) { m_PreviewPool.InvalidateAll(); } #endif @@ -756,6 +828,9 @@ namespace Engine const u32 frameSlot = m_CurrentFrame; const auto& vkDevice = m_Device->GetDevice(); +#ifdef HELLO_EDITOR + ++m_FrameIndex; // 单调帧号(烘焙请求老化判据) +#endif // 1. 场景 → 当帧渲染数据(相机/灯光/双视图剔除;纯 CPU,不碰 GPU) if (!m_SceneRenderer->BuildFrame(scene, assets, m_Extent, dt)) { @@ -791,6 +866,14 @@ namespace Engine // 2. 等本槽位 fence → 写 UBO/SSBO → reset(写-读竞争关闭) m_SyncManager->WaitFence(frameSlot); ReadGpuTimes(frameSlot); // fence 已等:上一轮该槽位的查询已就绪 +#ifdef HELLO_EDITOR + CollectBakeResults(frameSlot); // 该槽位上一轮拷贝已完成 → 取走 + // 老化丢弃:UI 已不再 keepalive 的未完成烘焙请求(离屏/换目录), + // 避免其持续占用渲染预算 + std::erase_if(m_BakeReqs, [&](const PreviewBakeReq& r) + { return !r.copied && + m_FrameIndex - r.lastRequestFrame > 4; }); +#endif m_UniformBuffer->Update(frameSlot, &frame.ubo, sizeof(frame.ubo)); m_SyncManager->ResetFence(frameSlot); @@ -903,7 +986,31 @@ namespace Engine // 全部跳帧路径,标记 paintedSlots 的槽位镜像本帧必然被写入)。 // 预算外的脏 tile 保持未取状态,下帧可见行 keepalive 请求重新入队 #ifdef HELLO_EDITOR + // 烘焙准备:UI keepalive 请求的材质逐个 RequestBake(强制标脏, + // 使其进入本帧脏表;池满无槽者 -1 下帧重试) + for (auto& r : m_BakeReqs) { + if (r.copied) continue; + r.frameTile = m_PreviewPool.RequestBake(r.material); + } m_PreviewRenders = m_PreviewPool.TakeRenders(frameSlot); + // 回读表只收"本帧确实渲染过"的 tile(条数 ≤ 渲染预算)——内容 + // 当帧新鲜,拷贝可靠;未渲染的请求留待下帧 + m_PreviewBakes.clear(); + u32 renderedTiles = 0; + for (const auto& d : m_PreviewRenders) { + renderedTiles |= (1u << d.tile); + } + for (auto& r : m_BakeReqs) { + if (r.copied || r.frameTile < 0) continue; + if (!(renderedTiles & (1u << r.frameTile))) continue; + m_PreviewBakes.push_back( + Passes::PreviewBakeTile{r.key, static_cast(r.frameTile)}); + r.copied = true; + } + // 每槽位记录当帧回读 key(帧 N 录 → N+2 同槽位 fence 后取走) + auto& recorded = m_BakeRecorded[frameSlot]; + recorded.clear(); + for (const auto& b : m_PreviewBakes) recorded.push_back(b.key); #endif m_RenderGraph->Execute(frameCtx); diff --git a/Engine/src/Renderer/VulkanAssetUploader.cpp b/Engine/src/Renderer/VulkanAssetUploader.cpp index 23a8aba..3e19c51 100644 --- a/Engine/src/Renderer/VulkanAssetUploader.cpp +++ b/Engine/src/Renderer/VulkanAssetUploader.cpp @@ -11,6 +11,7 @@ #include #include +#include namespace Engine { @@ -31,34 +32,84 @@ namespace Engine { InitSharedDefaultTextures(); // 每槽解析:路径(CWD 相对,反斜杠归一,与 Model::GetOrLoadTexture - // 同语义)→ 独占纹理;空/缺失 → 共享默认纹理(非拥有) - std::array, Material::kSlotCount> owned; + // 同语义)→ 路径缓存命中的共享纹理 / 本批新建;空/缺失 → 共享 + // 默认纹理(非拥有)。新建纹理的拷贝/barrier 全部录制进同一 cmd, + // 末尾一次 submit + 一次 fence 等待服务整批——取代过去"每张贴图 + // 各自建命令池 + 提交 + 阻塞等待",这是大材质目录首开卡顿的根因 std::array bound{}; // 槽位 sRGB 语义:颜色图 true,数据图 false(与 Model 同表) const bool slotSrgb[Material::kSlotCount] = {true, false, false, true, false, false}; + + std::vector created; // 本批新建纹理(fence 后释放 staging) + vk::CommandPool pool = nullptr; + vk::CommandBuffer cmd = nullptr; + for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { std::string path = data.texPaths[slot]; std::replace(path.begin(), path.end(), '\\', '/'); - if (!path.empty() && std::filesystem::exists(path)) { - owned[slot] = CreateScope( - m_Device, m_Allocator, path, slotSrgb[slot]); - bound[slot] = owned[slot].get(); - } - else { + if (path.empty() || !std::filesystem::exists(path)) { if (!path.empty()) { ENGINE_WARN("MaterialAsset: texture not found '{}', " "slot {} falls back to default", path, slot); } bound[slot] = m_DefaultTextures[slot].get(); + continue; } + + // 缓存键:路径 + sRGB 标记(同一文件按颜色/数据两种语义加载时 + // 格式不同,必须分开缓存) + std::string key = path; + key += slotSrgb[slot] ? "|s" : "|l"; + if (auto it = m_TextureCache.find(key); it != m_TextureCache.end()) { + bound[slot] = it->second.get(); + continue; + } + + // 未命中:惰性建批式命令缓冲,解码 + 录制上传(不提交) + if (!cmd) { + vk::CommandPoolCreateInfo poolInfo; + poolInfo.setQueueFamilyIndex(m_Device.GetGraphicsFamily()) + .setFlags(vk::CommandPoolCreateFlagBits::eTransient); + pool = m_Device.GetDevice().createCommandPool(poolInfo); + + vk::CommandBufferAllocateInfo allocInfo; + allocInfo.setCommandPool(pool) + .setLevel(vk::CommandBufferLevel::ePrimary) + .setCommandBufferCount(1); + cmd = m_Device.GetDevice().allocateCommandBuffers(allocInfo)[0]; + + cmd.begin(vk::CommandBufferBeginInfo{ + vk::CommandBufferUsageFlagBits::eOneTimeSubmit}); + } + + auto tex = Texture::CreateDeferred(m_Device, m_Allocator, path, + slotSrgb[slot], cmd); + Texture* raw = tex.get(); + bound[slot] = raw; + created.push_back(raw); + m_TextureCache.emplace(std::move(key), std::move(tex)); + } + + // 整批一次提交 + 一次等待,随后释放各新建纹理的 staging + if (cmd) { + cmd.end(); + vk::Fence fence = m_Device.GetDevice().createFence({}); + m_Device.GetGraphicsQueue().submit( + vk::SubmitInfo{}.setCommandBuffers(cmd), fence); + if (m_Device.GetDevice().waitForFences(fence, VK_TRUE, UINT64_MAX) + != vk::Result::eSuccess) { + ENGINE_ERROR("Material texture batch upload: waitForFences failed"); + } + m_Device.GetDevice().destroyFence(fence); + m_Device.GetDevice().destroyCommandPool(pool); + for (Texture* t : created) t->ReleasePendingStaging(); } auto mat = CreateScope( m_Device, m_Allocator, m_Descriptors, m_TextureLayout, bound, data.params, data.transparent); - return CreateScope(std::move(data), std::move(owned), - std::move(mat)); + return CreateScope(std::move(data), std::move(mat)); } void VulkanAssetUploader::WaitDeviceIdle() diff --git a/Engine/src/Vulkan/Texture.cpp b/Engine/src/Vulkan/Texture.cpp index 6f93cd8..b9d3df6 100644 --- a/Engine/src/Vulkan/Texture.cpp +++ b/Engine/src/Vulkan/Texture.cpp @@ -93,7 +93,11 @@ namespace Engine return tex; } - void Texture::InitFromPixels(const void* pixels, u32 width, u32 height, bool srgb) + // 公共上传路径核心:建 staging/image/view/sampler + 逐 mip + // barrier/copy/blit 录制进 cmd(**不**提交)。staging 存 m_PendingStaging, + // 由调用方在提交 fence 通过后 ReleasePendingStaging 释放 + void Texture::RecordPixelsUpload(const void* pixels, u32 width, u32 height, + bool srgb, vk::CommandBuffer cmd) { // 重入安全:CreateFromEncodedMemory 先经 ProceduralTag ctor 落 1x1 // 占位(只为确定 device/allocator 成员),再调本函数传真实像素。 @@ -118,15 +122,17 @@ namespace Engine vk::DeviceSize imageSize = static_cast(width) * static_cast(height) * 4; // RGBA8 - // 2. 创建 Staging Buffer(HOST_VISIBLE,VMA 持久映射) - auto stagingAlloc = m_Allocator.CreateBuffer( + // 2. 创建 Staging Buffer(HOST_VISIBLE,VMA 持久映射)。暂存对象内, + // 由调用方在提交 fence 通过后 ReleasePendingStaging 释放 + m_PendingStaging = m_Allocator.CreateBuffer( imageSize, vk::BufferUsageFlagBits::eTransferSrc, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent); + m_HasPendingStaging = true; // 3. 拷贝像素到 Staging Buffer - std::memcpy(stagingAlloc.MappedPtr, pixels, static_cast(imageSize)); + std::memcpy(m_PendingStaging.MappedPtr, pixels, static_cast(imageSize)); // 4. 创建 Device Local Image vk::ImageCreateInfo imageInfo; @@ -180,25 +186,9 @@ namespace Engine m_Sampler = m_Device.GetDevice().createSampler(samplerInfo); - // 6. 一次性命令缓冲:Staging → Image - vk::CommandPoolCreateInfo poolInfo; - poolInfo.setQueueFamilyIndex(m_Device.GetGraphicsFamily()) - .setFlags(vk::CommandPoolCreateFlagBits::eTransient); - - vk::CommandPool pool = m_Device.GetDevice().createCommandPool(poolInfo); - - vk::CommandBufferAllocateInfo allocInfo; - allocInfo.setCommandPool(pool) - .setLevel(vk::CommandBufferLevel::ePrimary) - .setCommandBufferCount(1); - - vk::CommandBuffer cmd = m_Device.GetDevice().allocateCommandBuffers(allocInfo)[0]; - - vk::CommandBufferBeginInfo beginInfo; - beginInfo.setFlags(vk::CommandBufferUsageFlagBits::eOneTimeSubmit); - cmd.begin(beginInfo); - - // 6.1 布局:UNDEFINED → TRANSFER_DST + // 6. 布局:UNDEFINED → TRANSFER_DST(整条 mip 链一次性转目标布局: + // 后续 blit 的目标 mip 必须已在目标布局;仅转 mip0 会触发 + // validation 对每个 blit 目标的 UNDEFINED 布局告警) vk::ImageMemoryBarrier2 toTransfer; toTransfer.setSrcStageMask(vk::PipelineStageFlagBits2::eNone) .setSrcAccessMask(vk::AccessFlagBits2::eNone) @@ -207,9 +197,6 @@ namespace Engine .setOldLayout(vk::ImageLayout::eUndefined) .setNewLayout(vk::ImageLayout::eTransferDstOptimal) .setImage(m_Image) - // 整条 mip 链一次性 UNDEFINED → TRANSFER_DST:后续 blit - // 的目标 mip 必须已在目标布局(仅转 mip0 会触发 validation - // 对每个 blit 目标的 UNDEFINED 布局告警) .setSubresourceRange({vk::ImageAspectFlagBits::eColor, 0, m_MipLevels, 0, 1}); @@ -226,7 +213,7 @@ namespace Engine .setImageOffset({0, 0, 0}) .setImageExtent({m_Width, m_Height, 1}); - cmd.copyBufferToImage(stagingAlloc.Buffer, m_Image, + cmd.copyBufferToImage(m_PendingStaging.Buffer, m_Image, vk::ImageLayout::eTransferDstOptimal, region); // 6.3 blit 生成 mip 链:逐级半分辨率缩小(硬件线性滤波)。 @@ -291,27 +278,6 @@ namespace Engine dep2.setImageMemoryBarriers(toShader); cmd.pipelineBarrier2(dep2); - cmd.end(); - - // 7. 提交并等待 - vk::SubmitInfo submit; - submit.setCommandBuffers(cmd); - - vk::FenceCreateInfo fenceInfo; - vk::Fence fence = m_Device.GetDevice().createFence(fenceInfo); - - m_Device.GetGraphicsQueue().submit(submit, fence); - auto waitResult = m_Device.GetDevice().waitForFences(fence, VK_TRUE, UINT64_MAX); - if (waitResult != vk::Result::eSuccess) - { - ENGINE_ERROR("Texture upload: waitForFences failed"); - } - - // 8. 清理临时对象(staging 资源与分配经 VMA 成对释放) - m_Device.GetDevice().destroyFence(fence); - m_Device.GetDevice().destroyCommandPool(pool); - m_Allocator.Destroy(stagingAlloc); - // 显存占用按真实格式 + mip 链估算(与分配器台账同一算法) if (m_TextureBytes != 0) { STATS.RemoveTexture(m_TextureBytes); // 重建防双计 @@ -321,6 +287,87 @@ namespace Engine STATS.AddTexture(m_TextureBytes); } + // 单张自提交路径(程序化/CreateFromEncodedMemory/非批式下载):录制 + // 完自行建一次性命令池 + 提交 + fence 等待 + 释放 staging + void Texture::InitFromPixels(const void* pixels, u32 width, u32 height, + bool srgb) + { + vk::CommandPoolCreateInfo poolInfo; + poolInfo.setQueueFamilyIndex(m_Device.GetGraphicsFamily()) + .setFlags(vk::CommandPoolCreateFlagBits::eTransient); + vk::CommandPool pool = m_Device.GetDevice().createCommandPool(poolInfo); + + vk::CommandBufferAllocateInfo allocInfo; + allocInfo.setCommandPool(pool) + .setLevel(vk::CommandBufferLevel::ePrimary) + .setCommandBufferCount(1); + vk::CommandBuffer cmd = + m_Device.GetDevice().allocateCommandBuffers(allocInfo)[0]; + + cmd.begin(vk::CommandBufferBeginInfo{ + vk::CommandBufferUsageFlagBits::eOneTimeSubmit}); + RecordPixelsUpload(pixels, width, height, srgb, cmd); + cmd.end(); + + vk::Fence fence = m_Device.GetDevice().createFence({}); + m_Device.GetGraphicsQueue().submit( + vk::SubmitInfo{}.setCommandBuffers(cmd), fence); + if (m_Device.GetDevice().waitForFences(fence, VK_TRUE, UINT64_MAX) != + vk::Result::eSuccess) { + ENGINE_ERROR("Texture upload: waitForFences failed"); + } + + m_Device.GetDevice().destroyFence(fence); + m_Device.GetDevice().destroyCommandPool(pool); + ReleasePendingStaging(); + } + + // 批式路径构造:复用 stbi 解码 + RecordPixelsUpload(录制进外部 cmd, + // 不提交;staging 交由调用方在批量 fence 后释放)。DDS 稀有路径退回 + // 自带提交的 InitFromDds + Texture::Texture(Device& device, MemoryAllocator& allocator, + const std::string& path, bool srgb, vk::CommandBuffer cmd) + : m_Device(device), m_Allocator(allocator) + { + const std::string ext = path.size() >= 4 + ? path.substr(path.size() - 4) : ""; + std::string lower; + for (char c : ext) lower += static_cast(std::tolower(c)); + + if (lower == ".dds") { + InitFromDds(path, srgb); + return; + } + + int width = 0, height = 0, channels = 0; + stbi_uc* pixels = stbi_load(path.c_str(), &width, &height, &channels, + STBI_rgb_alpha); + if (!pixels) { + ENGINE_ERROR("Failed to load texture: {}", path); + return; + } + RecordPixelsUpload(pixels, static_cast(width), + static_cast(height), srgb, cmd); + stbi_image_free(pixels); + ENGINE_INFO("Texture loaded (batched): {} ({}x{})", path, m_Width, + m_Height); + } + + Scope Texture::CreateDeferred(Device& device, + MemoryAllocator& allocator, + const std::string& path, bool srgb, + vk::CommandBuffer cmd) + { + return Scope(new Texture(device, allocator, path, srgb, cmd)); + } + + void Texture::ReleasePendingStaging() + { + if (!m_HasPendingStaging) return; + m_Allocator.Destroy(m_PendingStaging); + m_HasPendingStaging = false; + } + // DDS → Vulkan 格式映射。sRGB 语义由贴图槽位决定(渲染侧职责): // 颜色图(BaseColor/Emissive)→ SRGB 变体 // 数据图(Normal/Specular 等线性槽位)→ UNORM @@ -520,6 +567,10 @@ namespace Engine { const auto& vkDevice = m_Device.GetDevice(); + // 批式上传若在 fence 通过前被析构(如加载中途失败):兜底释放 + // staging,避免 VMA 块非空断言崩溃 + ReleasePendingStaging(); + vkDevice.destroySampler(m_Sampler); vkDevice.destroyImageView(m_View);