资产浏览器可视化改造 + 材质缩略图 GPU 烘焙 + 纹理上传批式化

- 资产浏览器可视化与配色:面板重构(网格/列表/缩略图)、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 缓存——
  修大材质目录首次打开卡顿
This commit is contained in:
2026-10-18 20:05:29 +08:00
parent 2df6e57b61
commit 84cf547704
25 changed files with 2105 additions and 380 deletions
+505
View File
@@ -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 <Engine/Renderer/Model.h>
#include <Engine/Renderer/MaterialAsset.h>
#include <imgui.h>
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <ctime>
#include <set>
#include <string>
#include <unordered_map>
#include <utility>
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<unsigned char>(out.back()) &
0xC0) == 0x80)
{
out.pop_back(); // 退到 UTF-8 起始字节
}
}
return out + ell;
}
// 已尝试按需加载的材质资产路径:加载失败不再每帧重试(资产缓存接管
// 成功路径,重复调用是缓存命中)
std::set<std::string>& MaterialLoadTried()
{
static std::set<std::string> 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<int>(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<std::string, std::pair<Engine::u64, Engine::u32>>&
BakedIdentity()
{
static std::unordered_map<std::string,
std::pair<Engine::u64, Engine::u32>> 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<float>(v.h) / v.w;
else fw = size * static_cast<float>(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<double,
std::milli>(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<double>(sz) / (1024.0 * 1024.0));
}
else
{
std::snprintf(szBuf, sizeof(szBuf), "%.1f KB",
static_cast<double>(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<unsigned>(m->GetMeshes().size()),
m->GetBoneCount(),
static_cast<unsigned>(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