资产浏览器可视化改造 + 材质缩略图 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
+46
View File
@@ -0,0 +1,46 @@
//
// AssetItemView:资产条目的绘制单元(列表行 / 网格瓦片 / 目录 / 拖拽源 /
// tooltip)。Content Browser 与材质纹理选择器共用同一套视觉与交互。
//
#ifndef HELLOVULKAN_ASSETITEMVIEW_H
#define HELLOVULKAN_ASSETITEMVIEW_H
#include "EditorContext.h"
#include <filesystem>
#include <string>
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
+122
View File
@@ -0,0 +1,122 @@
//
// AssetKind.h:资产分类的单一事实来源(Content Browser / 编辑器共用)。
//
// 原先分类逻辑散在 AssetBrowserPanel.cpp 匿名 ns(Kind/KindOf/KindTag),
// 现在上提为头文件:面板、拖拽源、缩略图缓存按同一份扩展名表分派。
//
#ifndef HELLOVULKAN_ASSETKIND_H
#define HELLOVULKAN_ASSETKIND_H
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <string>
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
+19
View File
@@ -12,6 +12,7 @@
// //
#include "Editor.h" #include "Editor.h"
#include "ThumbnailCache.h" // 缩略图缓存(每帧 BeginFrame)
#include "panels/AssetBrowserPanel.h" #include "panels/AssetBrowserPanel.h"
#include "panels/HierarchyPanel.h" #include "panels/HierarchyPanel.h"
#include "panels/InspectorPanel.h" #include "panels/InspectorPanel.h"
@@ -167,6 +168,24 @@ void Editor::TogglePause()
void Editor::Draw() void Editor::Draw()
{ {
// 缩略图缓存推进(解码限量 / 上传就绪 / LRU 淘汰):ImGui::NewFrame
// 已由框架 ImGuiLayer 完成,此处处于面板绘制之前
EditorThumbnailCache::Get().BeginFrame();
// 材质球缩略图烘焙:排空引擎异步回读结果 → 注入缩略图缓存(浏览器
// 材质条目随后从缓存出图,不再占用引擎预览 tile 池)
if (m_Ctx.renderer)
{
Engine::u64 key = 0;
std::vector<unsigned char> pixels;
Engine::u32 w = 0, h = 0;
while (m_Ctx.renderer->PollMaterialPreviewBake(key, pixels, w, h))
{
EditorThumbnailCache::Get().CompleteBake(
key, pixels.data(), static_cast<int>(w), static_cast<int>(h));
}
}
// PIE 镜像同步(面板只读指针消费;权威 = GameFlow,此处适配) // PIE 镜像同步(面板只读指针消费;权威 = GameFlow,此处适配)
if (m_Ctx.flow) if (m_Ctx.flow)
{ {
+75
View File
@@ -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 <imgui.h>
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
+3 -6
View File
@@ -12,6 +12,8 @@
#ifndef HELLOVULKAN_MATERIALASSETUTIL_H #ifndef HELLOVULKAN_MATERIALASSETUTIL_H
#define HELLOVULKAN_MATERIALASSETUTIL_H #define HELLOVULKAN_MATERIALASSETUTIL_H
#include "AssetKind.h" // EditorAsset::IsTexturePath(纹理扩展名单一事实来源)
#include <Engine/Assets/AssetManager.h> #include <Engine/Assets/AssetManager.h>
#include <Engine/Core/Log.h> #include <Engine/Core/Log.h>
#include <Engine/ECS/Components.h> #include <Engine/ECS/Components.h>
@@ -53,12 +55,7 @@ namespace EditorMaterialUtil {
{ {
continue; continue;
} }
std::string ext = entry.path().extension().string(); if (EditorAsset::IsTexturePath(entry.path()))
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")
{ {
s_Files.push_back(entry.path().generic_string()); s_Files.push_back(entry.path().generic_string());
} }
+341
View File
@@ -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 <imgui_internal.h> // RegisterUserTexture / UnregisterUserTexture
#include <stb/stb_image.h>
#include <algorithm>
#include <cstring>
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<size_t>(sy) * sw * 4;
for (int sx = sx0; sx < sx1; sx += sxStep)
{
const unsigned char* p =
row + static_cast<size_t>(sx) * 4;
r += p[0]; g += p[1]; b += p[2]; a += p[3]; ++n;
}
}
unsigned char* q = dst + (static_cast<size_t>(y) * dw + x) * 4;
q[0] = static_cast<unsigned char>(r / n);
q[1] = static_cast<unsigned char>(g / n);
q[2] = static_cast<unsigned char>(b / n);
q[3] = static_cast<unsigned char>(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<size_t>(dw) * dh * 4);
}
else
{
ResampleBoxRGBA8(src, sw, sh,
static_cast<unsigned char*>(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<long long>(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;
}
}
}
+94
View File
@@ -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 <imgui.h> // ImTextureData / ImTextureRef
#include <deque>
#include <string>
#include <unordered_map>
#include <vector>
// 一次请求的结果: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<std::string, Entry> m_Entries;
std::deque<std::string> m_Queue; // 待解码(重复请求命 entries,天然去重)
std::vector<ImTextureData*> m_Retire; // 已标 WantDestroy,等后端释放
unsigned long long m_Frame = 0;
// 材质球烘焙 path ↔ key 映射(幂等分配;回读完成即注销)
unsigned long long m_BakeKeyCounter = 0;
std::unordered_map<std::string, unsigned long long> m_PathToBakeKey;
std::unordered_map<unsigned long long, std::string> m_BakeKeyToPath;
};
#endif //HELLOVULKAN_THUMBNAILCACHE_H
+167 -246
View File
@@ -1,8 +1,15 @@
// //
// AssetBrowserPanel.cpp:项目资产浏览器实现。 // AssetBrowserPanel.cpp:项目资产浏览器实现。
// //
// 条目视觉/拖拽/tooltip 单元住 AssetItemView(列表行与网格瓦片共用同一
// 套类别色 + 缩略图);本文件只负责:根切换 / 过滤 / 目录树 / 内容区
// 铺排 / 选中态 / 双击分派(打开或实例化)。
//
#include "AssetBrowserPanel.h" #include "AssetBrowserPanel.h"
#include "AssetItemView.h" // 列表行 / 网格瓦片 / 目录条目
#include "AssetKind.h" // EditorAsset::AssetKind / KindOfPath
#include "MaterialEditorPanel.h" // kMaterialDragPayload + 双击打开材质编辑器 #include "MaterialEditorPanel.h" // kMaterialDragPayload + 双击打开材质编辑器
#include "ControllerEditorPanel.h" // kControllerDragPayload + 双击打开控制器编辑器 #include "ControllerEditorPanel.h" // kControllerDragPayload + 双击打开控制器编辑器
@@ -13,10 +20,8 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <chrono>
#include <cstdio>
#include <ctime>
#include <filesystem> #include <filesystem>
#include <utility>
#include <vector> #include <vector>
namespace { namespace {
@@ -25,79 +30,26 @@ namespace {
constexpr const char* kRootNames[] = {"Assets", "Scenes"}; constexpr const char* kRootNames[] = {"Assets", "Scenes"};
constexpr const char* kRootPaths[] = {"assets", "scenes"}; constexpr const char* kRootPaths[] = {"assets", "scenes"};
// 类型过滤(下标即身份):0 = All // 类型过滤(下标即身份):0 = All;其余对齐 AssetKind 顺序
constexpr const char* kTypeNames[] = {"All", "Models", constexpr const char* kTypeNames[] = {"All", "Models",
"Textures", "Scenes", "Textures", "Scenes",
"Materials", "Controllers", "Materials", "Controllers",
"Prefabs"}; "Prefabs"};
// 资产类别:按扩展名分派(过滤用) bool PassFilter(int typeFilter, EditorAsset::AssetKind kind)
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)
{ {
switch (typeFilter) switch (typeFilter)
{ {
case 1: return kind == Kind::Model; case 1: return kind == EditorAsset::AssetKind::Model;
case 2: return kind == Kind::Texture; case 2: return kind == EditorAsset::AssetKind::Texture;
case 3: return kind == Kind::Scene; case 3: return kind == EditorAsset::AssetKind::Scene;
case 4: return kind == Kind::Material; case 4: return kind == EditorAsset::AssetKind::Material;
case 5: return kind == Kind::AnimClip; case 5: return kind == EditorAsset::AssetKind::AnimClip;
case 6: return kind == Kind::Prefab; case 6: return kind == EditorAsset::AssetKind::Prefab;
default: return true; 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 风格) // 相对路径规范化为正斜杠(AssetManager 键与场景文件统一 POSIX 风格)
std::string ToAssetPath(const std::filesystem::path& p) std::string ToAssetPath(const std::filesystem::path& p)
{ {
@@ -105,6 +57,50 @@ namespace {
std::replace(s.begin(), s.end(), '\\', '/'); std::replace(s.begin(), s.end(), '\\', '/');
return s; 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 } // namespace
void AssetBrowserPanel::Draw(const EditorContext& ctx) void AssetBrowserPanel::Draw(const EditorContext& ctx)
@@ -152,7 +148,7 @@ void AssetBrowserPanel::Draw(const EditorContext& ctx)
m_CurrentDir = root; m_CurrentDir = root;
} }
// ---- 顶栏下:面包屑(当前目录相对根路径 + 回根按钮)---- // ---- 顶栏下:面包屑(当前目录相对根路径 + 回根按钮)+ 视图切换 ----
if (ImGui::Button("Root")) if (ImGui::Button("Root"))
{ {
m_CurrentDir = root; m_CurrentDir = root;
@@ -162,9 +158,28 @@ void AssetBrowserPanel::Draw(const EditorContext& ctx)
.generic_string(); .generic_string();
ImGui::TextDisabled("%s/%s", kRootNames[m_Root], ImGui::TextDisabled("%s/%s", kRootNames[m_Root],
rel.empty() ? "" : rel.c_str()); 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(); ImGui::Separator();
// ---- 左:目录树 | 右:当前目录文件列表 ---- // ---- 左:目录树 | 右:当前目录内容(列表 / 网格)----
const float treeWidth = 180.0f; const float treeWidth = 180.0f;
ImGui::BeginChild("##tree", ImVec2(treeWidth, 0.0f), ImGui::BeginChild("##tree", ImVec2(treeWidth, 0.0f),
ImGuiChildFlags_ResizeX | ImGuiChildFlags_Borders); ImGuiChildFlags_ResizeX | ImGuiChildFlags_Borders);
@@ -194,7 +209,7 @@ void AssetBrowserPanel::Draw(const EditorContext& ctx)
ImGuiChildFlags_Borders); ImGuiChildFlags_Borders);
if (std::filesystem::exists(m_CurrentDir)) if (std::filesystem::exists(m_CurrentDir))
{ {
DrawFileList(ctx, m_CurrentDir); DrawContent(ctx, m_CurrentDir);
} }
ImGui::EndChild(); ImGui::EndChild();
@@ -227,34 +242,25 @@ void AssetBrowserPanel::DrawDirTree(const EditorContext& ctx,
} }
} }
void AssetBrowserPanel::DrawFileList(const EditorContext& ctx, void AssetBrowserPanel::DrawContent(const EditorContext& ctx,
const std::filesystem::path& dir) const std::filesystem::path& dir)
{ {
// 收集 + 过滤 + 排序(名字序;目录迭代序不稳定) namespace fs = std::filesystem;
struct Item
{ // 收集:目录(不受类型过滤影响)+ 过滤后的文件;目录迭代序不稳定 → 排序
std::filesystem::path path; std::vector<fs::path> dirs;
Kind kind; std::vector<std::pair<fs::path, EditorAsset::AssetKind>> files;
};
std::vector<Item> items;
const std::string filter = m_NameFilter; 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()) if (entry.is_directory())
{ {
// 子目录也进列表:单行点击进入(树/列表双向导航) dirs.push_back(entry.path());
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();
continue; continue;
} }
const Kind kind = KindOf(entry.path()); const EditorAsset::AssetKind kind =
EditorAsset::KindOfPath(entry.path());
if (!PassFilter(m_TypeFilter, kind)) if (!PassFilter(m_TypeFilter, kind))
{ {
continue; continue;
@@ -267,191 +273,106 @@ void AssetBrowserPanel::DrawFileList(const EditorContext& ctx,
continue; continue;
} }
} }
items.push_back({entry.path(), kind}); files.emplace_back(entry.path(), kind);
} }
std::sort(items.begin(), items.end(), std::sort(dirs.begin(), dirs.end(),
[](const Item& a, const Item& b) [](const fs::path& a, const fs::path& b)
{ {
return a.path.filename().string() < return a.filename().string() < b.filename().string();
b.path.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 实例化会改场景,边遍历边打开会让后续条目的项状态错乱)
const std::string name = item.path.filename().string(); bool pendingOpen = false;
const std::string assetPath = ToAssetPath(item.path); std::filesystem::path pendingOpenPath;
ImGui::PushID(name.c_str()); EditorAsset::AssetKind pendingOpenKind = EditorAsset::AssetKind::Other;
ImGui::TextUnformatted(KindTag(item.kind)); if (m_ViewMode == ViewMode::List)
ImGui::SameLine();
ImGui::Selectable(name.c_str());
// .material 拖拽源:拖到 Details 材质槽球上指派(kMaterialDragPayload)
if (item.kind == Kind::Material && ImGui::BeginDragDropSource())
{ {
ImGui::SetDragDropPayload(kMaterialDragPayload, for (const auto& d : dirs)
assetPath.c_str(), assetPath.size() + 1); {
ImGui::TextUnformatted(name.c_str()); if (EditorAssetView::DrawFolderRow(d))
ImGui::EndDragDropSource(); {
m_CurrentDir = d;
m_SelectedPath.clear();
} }
// .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();
} }
for (const auto& [path, kind] : files)
// .prefab 拖拽源:拖到 Outliner(节点=挂其下 / 空白区=挂根)
// 实例化整棵子树(kPrefabDragPayload)
if (item.kind == Kind::Prefab && ImGui::BeginDragDropSource())
{ {
ImGui::SetDragDropPayload(kPrefabDragPayload, const std::string assetPath = ToAssetPath(path);
assetPath.c_str(), assetPath.size() + 1); const auto res = EditorAssetView::DrawRow(
ImGui::TextUnformatted(name.c_str()); ctx, path, assetPath == m_SelectedPath);
ImGui::EndDragDropSource(); if (res.clicked)
{
m_SelectedPath = assetPath;
} }
if (res.doubleClicked)
// 模型拖拽源:拖到 Outliner 落点创建带 MeshRenderer 的实体
//(kModelDragPayload;实体名 = 文件名)
if (item.kind == Kind::Model && ImGui::BeginDragDropSource())
{ {
ImGui::SetDragDropPayload(kModelDragPayload, pendingOpen = true;
assetPath.c_str(), assetPath.size() + 1); pendingOpenPath = path;
ImGui::TextUnformatted(name.c_str()); pendingOpenKind = kind;
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)
{
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);
} }
} }
// hover 信息卡(UE Content Browser 式):类型/大小/修改时间/加载状态
if (ImGui::IsItemHovered())
{
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<int>(item.kind)]);
namespace fs = std::filesystem;
std::error_code ec;
const auto sz = fs::file_size(item.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 else
{ {
std::snprintf(szBuf, sizeof(szBuf), "%.1f KB", // ---- 网格视图:固定列宽表格铺排(列数按可用宽度自适应)----
static_cast<double>(sz) / 1024.0); const float tile = m_TileSize;
} const float cellW = tile + ImGui::GetStyle().CellPadding.x * 2.0f;
ImGui::Text("size: %s", szBuf); int cols = static_cast<int>(ImGui::GetContentRegionAvail().x / cellW);
} if (cols < 1)
const auto ftime = fs::last_write_time(item.path, ec);
if (!ec)
{ {
// file_clock → system_clock 近似转换(C++17 无官方转换) cols = 1;
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 (ImGui::BeginTable("##grid", cols, ImGuiTableFlags_SizingFixedFit))
// Prefab 是实例化模板、Scene 走 File 菜单——显示 "not
// loaded" 会误导(曾让用户当成 bug)
if (ctx.assets)
{ {
if (item.kind == Kind::Model || for (int i = 0; i < cols; ++i)
item.kind == Kind::Texture)
{ {
const bool loaded = ImGui::TableSetupColumn("##c",
(item.kind == Kind::Model) ImGuiTableColumnFlags_WidthFixed,
? ctx.assets->IsModelLoaded(assetPath) cellW);
: ctx.assets->IsEnvironmentLoaded(assetPath);
ImGui::Text("state: %s", loaded ? "loaded"
: "not loaded");
} }
else if (item.kind == Kind::Prefab) for (const auto& d : dirs)
{ {
ImGui::TextDisabled( ImGui::TableNextColumn();
"template — drag into scene / double-click"); const std::string assetPath = ToAssetPath(d);
} if (EditorAssetView::DrawFolderTile(
// 已加载的 model 展开资产数据概览(meshes=0 即纯动画 d, tile, assetPath == m_SelectedPath))
// 容器;未加载不显示——查询不触发加载)
if (item.kind == Kind::Model)
{ {
if (const Engine::Model* m = m_CurrentDir = d;
ctx.assets->GetModelByPath(assetPath); m_SelectedPath.clear();
m != nullptr) }
}
for (const auto& [path, kind] : files)
{ {
ImGui::Text("meshes: %u, bones: %u, clips: %u", ImGui::TableNextColumn();
static_cast<unsigned>(m->GetMeshes().size()), const std::string assetPath = ToAssetPath(path);
m->GetBoneCount(), const auto res = EditorAssetView::DrawTile(
static_cast<unsigned>(m->GetClips().size())); ctx, path, tile, assetPath == m_SelectedPath);
if (res.clicked)
{
m_SelectedPath = assetPath;
}
if (res.doubleClicked)
{
pendingOpen = true;
pendingOpenPath = path;
pendingOpenKind = kind;
} }
} }
} ImGui::EndTable();
ImGui::TextDisabled("%s", assetPath.c_str()); }
ImGui::EndTooltip(); }
}
ImGui::PopID(); if (pendingOpen)
{
DispatchOpen(ctx, pendingOpenPath, pendingOpenKind);
} }
} }
+11 -3
View File
@@ -32,20 +32,28 @@ public:
void Draw(const EditorContext& ctx); void Draw(const EditorContext& ctx);
private: private:
// 目录树节点(递归);右侧列表画 dir 下的文件 // 视图模式:列表(行,含内联小缩略图)/ 网格(缩略图瓦片)
enum class ViewMode { List, Grid };
// 目录树节点(递归);右侧内容区画 dir 下的目录+文件
void DrawDirTree(const EditorContext& ctx, void DrawDirTree(const EditorContext& ctx,
const std::filesystem::path& dir); const std::filesystem::path& dir);
void DrawFileList(const EditorContext& ctx, void DrawContent(const EditorContext& ctx,
const std::filesystem::path& dir); const std::filesystem::path& dir);
// 固定根(下标即身份):项目资产 / 场景文件 // 固定根(下标即身份):项目资产 / 场景文件
int m_Root = 0; int m_Root = 0;
// 当前浏览目录(点目录树切换;列表显示这里的文件) // 当前浏览目录(点目录树切换;内容区显示这里的条目)
std::filesystem::path m_CurrentDir; std::filesystem::path m_CurrentDir;
// 类型过滤(0 = All;其余见 kTypeNames) // 类型过滤(0 = All;其余见 kTypeNames)
int m_TypeFilter = 0; int m_TypeFilter = 0;
// 名字子串过滤(空 = 不过滤) // 名字子串过滤(空 = 不过滤)
char m_NameFilter[64] = ""; char m_NameFilter[64] = "";
// 视图模式 + 网格瓦片尺寸(像素)
ViewMode m_ViewMode = ViewMode::List;
float m_TileSize = 96.0f;
// 当前选中条目(资产相对路径;空 = 无选中)
std::string m_SelectedPath;
}; };
#endif //HELLOVULKAN_ASSETBROWSERPANEL_H #endif //HELLOVULKAN_ASSETBROWSERPANEL_H
+52 -13
View File
@@ -8,6 +8,7 @@
#include "HierarchyPanel.h" #include "HierarchyPanel.h"
#include "AssetBrowserPanel.h" // kPrefabDragPayload(Content Browser 源) #include "AssetBrowserPanel.h" // kPrefabDragPayload(Content Browser 源)
#include "EditorColors.h" // 实体名/组件徽标配色
#include "Engine/ECS/Components.h" #include "Engine/ECS/Components.h"
#include "Engine/ECS/EntityHandle.h" // CreateEntity 返回完整 EntityHandle #include "Engine/ECS/EntityHandle.h" // CreateEntity 返回完整 EntityHandle
#include "Engine/Serialization/SceneSerializer.h" #include "Engine/Serialization/SceneSerializer.h"
@@ -73,6 +74,25 @@ namespace {
return static_cast<entt::entity>(e); return static_cast<entt::entity>(e);
} }
// 主组件 → 实体名颜色(优先级高者胜,用于 Outliner 可读性)
ImVec4 PrimaryColor(const entt::registry& reg, entt::entity e)
{
using namespace Engine::ECS;
if (reg.all_of<CameraComponent>(e)) return EditorColors::kCamera;
if (reg.all_of<LightComponent>(e)) return EditorColors::kLight;
if (reg.all_of<SkyboxComponent>(e) ||
reg.all_of<SkyLightComponent>(e)) return EditorColors::kSky;
if (reg.all_of<MeshRendererComponent>(e)) return EditorColors::kMesh;
if (reg.all_of<AnimationComponent>(e)) return EditorColors::kAnimB;
if (reg.all_of<ScriptComponent>(e)) return EditorColors::kScript;
if (reg.all_of<RigidBodyComponent>(e) ||
reg.all_of<CharacterControllerComponent>(e))
{
return EditorColors::kPhysics;
}
return EditorColors::kEntityNeutral;
}
// 实体名 → 合法文件名(路径分隔符/保留符净化;空名兜底) // 实体名 → 合法文件名(路径分隔符/保留符净化;空名兜底)
std::string PrefabFileName(std::string name) std::string PrefabFileName(std::string name)
{ {
@@ -120,7 +140,10 @@ namespace {
(kids.empty() ? ImGuiTreeNodeFlags_Leaf : 0) | (kids.empty() ? ImGuiTreeNodeFlags_Leaf : 0) |
(selected == e ? ImGuiTreeNodeFlags_Selected : 0); (selected == e ? ImGuiTreeNodeFlags_Selected : 0);
// 实体名按主组件着色(无组件 = 中性色)
ImGui::PushStyleColor(ImGuiCol_Text, PrimaryColor(reg, e));
const bool opened = ImGui::TreeNodeEx(label.c_str(), flags); const bool opened = ImGui::TreeNodeEx(label.c_str(), flags);
ImGui::PopStyleColor();
const bool toggled = ImGui::IsItemToggledOpen(); const bool toggled = ImGui::IsItemToggledOpen();
if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && !toggled) { if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && !toggled) {
selected = e; selected = e;
@@ -208,20 +231,36 @@ namespace {
ImGui::EndPopup(); ImGui::EndPopup();
} }
// ---- 组件徽标(与行内同级显示)---- // ---- 组件徽标(逐组件着色,与行内同级显示)----
std::string badge; bool anyBadge = false;
if (reg.all_of<Engine::ECS::MeshRendererComponent>(e)) badge += "[M]"; auto badge = [&](const char* text, const ImVec4& col)
if (reg.all_of<Engine::ECS::AnimationComponent>(e)) badge += "[A]"; {
if (reg.all_of<Engine::ECS::CameraComponent>(e)) badge += "[C]"; ImGui::SameLine(0.0f, 6.0f);
if (reg.all_of<Engine::ECS::LightComponent>(e)) badge += "[L]"; ImGui::PushStyleColor(ImGuiCol_Text, col);
if (reg.all_of<Engine::ECS::RigidBodyComponent>(e)) badge += "[P]"; ImGui::TextUnformatted(text);
if (!badge.empty()) { ImGui::PopStyleColor();
ImGui::SameLine(); anyBadge = true;
ImGui::TextDisabled("%s", badge.c_str()); };
if (ImGui::IsItemHovered()) { if (reg.all_of<Engine::ECS::MeshRendererComponent>(e))
badge("M", EditorColors::kMesh);
if (reg.all_of<Engine::ECS::AnimationComponent>(e))
badge("A", EditorColors::kAnimB);
if (reg.all_of<Engine::ECS::CameraComponent>(e))
badge("C", EditorColors::kCamera);
if (reg.all_of<Engine::ECS::LightComponent>(e))
badge("L", EditorColors::kLight);
if (reg.all_of<Engine::ECS::RigidBodyComponent>(e) ||
reg.all_of<Engine::ECS::CharacterControllerComponent>(e))
badge("P", EditorColors::kPhysics);
if (reg.all_of<Engine::ECS::ScriptComponent>(e))
badge("S", EditorColors::kScript);
if (reg.all_of<Engine::ECS::SkyboxComponent>(e) ||
reg.all_of<Engine::ECS::SkyLightComponent>(e))
badge("K", EditorColors::kSky);
if (anyBadge && ImGui::IsItemHovered()) {
ImGui::SetTooltip( ImGui::SetTooltip(
"M=Mesh A=Animation C=Camera L=Light P=Physics"); "M=Mesh A=Animation C=Camera L=Light P=Physics "
} "S=Script K=Sky");
} }
if (opened) { if (opened) {
+135 -12
View File
@@ -9,6 +9,9 @@
#include "MaterialEditorPanel.h" #include "MaterialEditorPanel.h"
#include "MaterialAssetUtil.h" #include "MaterialAssetUtil.h"
#include "AssetItemView.h" // 纹理选择器网格(缩略图瓦片)
#include "EditorColors.h"
#include "ThumbnailCache.h"
#include <Engine/ECS/Components.h> #include <Engine/ECS/Components.h>
#include <Engine/Renderer/Model.h> #include <Engine/Renderer/Model.h>
@@ -17,6 +20,7 @@
#include <cstdio> #include <cstdio>
#include <filesystem> #include <filesystem>
#include <string>
namespace { namespace {
@@ -34,6 +38,125 @@ namespace {
} }
constexpr float kPreviewSize = 128.0f; // 编辑器顶部大球(Inspector 槽位为 48²) 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<float>(v.h) / v.w;
else fw = sz * static_cast<float>(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<const char*>(pl->Data));
}
ImGui::EndDragDropTarget();
}
}
// 纹理选择器内容(弹窗内):<none> 清除入口 + 缩略图网格。
// 返回 true = 本次有选择,选中路径写入 out(空串 = <none>)
bool TexturePickerContent(const EditorContext& ctx, std::string& out)
{
bool picked = false;
if (ImGui::Selectable("<none>"))
{
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<int>(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 } // namespace
void MaterialEditorPanel::OpenAsset(const std::string& path) void MaterialEditorPanel::OpenAsset(const std::string& path)
@@ -228,10 +351,17 @@ void MaterialEditorPanel::DrawAssetMode(const EditorContext& ctx)
"Emissive", "Ao", "Roughness"}; "Emissive", "Ao", "Roughness"};
int pendingSlot = -1; int pendingSlot = -1;
std::string pendingPath; std::string pendingPath;
const float thumbSz = ImGui::GetTextLineHeight() * 1.8f;
for (Engine::u32 s = 0; s < Engine::Material::kSlotCount; ++s) for (Engine::u32 s = 0; s < Engine::Material::kSlotCount; ++s)
{ {
std::string tp = asset->GetData().texPaths[s]; std::string tp = asset->GetData().texPaths[s];
ImGui::PushID(static_cast<int>(s)); ImGui::PushID(static_cast<int>(s));
// 缩略图块(落点:拖入纹理资产即指派该槽)
SlotThumb(tp, thumbSz, static_cast<int>(s), pendingSlot, pendingPath);
ImGui::SameLine();
ImGui::SetNextItemWidth(200.0f);
InputTextString(kSlotNames[s], tp); InputTextString(kSlotNames[s], tp);
if (ImGui::IsItemDeactivatedAfterEdit()) if (ImGui::IsItemDeactivatedAfterEdit())
{ {
@@ -245,20 +375,13 @@ void MaterialEditorPanel::DrawAssetMode(const EditorContext& ctx)
} }
if (ImGui::BeginPopup("tex_pick")) if (ImGui::BeginPopup("tex_pick"))
{ {
// 清除入口(Unity/UE 语义):该槽置空 → 回退兜底贴图 // 弹窗内只做选择;pending 统一在循环外落盘(避免 popup 内
if (ImGui::Selectable("<none>")) // 立即重建导致 asset 指针悬空)
std::string chosen;
if (TexturePickerContent(ctx, chosen))
{ {
pendingSlot = static_cast<int>(s); pendingSlot = static_cast<int>(s);
pendingPath.clear(); pendingPath = chosen;
}
ImGui::Separator();
for (const auto& f : EditorMaterialUtil::AssetTextureFiles())
{
if (ImGui::Selectable(f.c_str()))
{
pendingSlot = static_cast<int>(s);
pendingPath = f;
}
} }
ImGui::EndPopup(); ImGui::EndPopup();
} }
+3
View File
@@ -31,6 +31,9 @@
// 材质资产拖拽 payload(Content Browser 拖 .material → Details 槽位) // 材质资产拖拽 payload(Content Browser 拖 .material → Details 槽位)
inline constexpr const char* kMaterialDragPayload = "MAT_PATH"; inline constexpr const char* kMaterialDragPayload = "MAT_PATH";
// 纹理资产拖拽 payload(Content Browser 拖 .png/.jpg/... → 材质纹理槽)
inline constexpr const char* kTextureDragPayload = "TEXTURE_PATH";
class MaterialEditorPanel { class MaterialEditorPanel {
public: public:
// 打开 .material 资产(可编辑模式) // 打开 .material 资产(可编辑模式)
@@ -314,7 +314,10 @@ namespace Engine::RG {
void ComputeLifetimes(); void ComputeLifetimes();
void PrecomputeBarriers(); void PrecomputeBarriers();
void AllocatePhysical(); void AllocatePhysical();
void ReleasePhysical(); // keepFixedSize = true:固定尺寸资源(desc 显式 width/height,与目标
// 渲染尺寸无关)的物理镜像不归还池、保留在 entry 上——重编译后原样
// 沿用同一 VkImage,内容存续(材质球预览 atlas 不因视口 resize 清空)
void ReleasePhysical(bool keepFixedSize);
void PurgePool(vk::Extent2D target); void PurgePool(vk::Extent2D target);
PhysicalImage* AcquirePhysical(vk::Format format, vk::Extent2D extent, PhysicalImage* AcquirePhysical(vk::Format format, vk::Extent2D extent,
vk::ImageUsageFlags usage, vk::ImageUsageFlags usage,
@@ -67,12 +67,10 @@ namespace Engine {
static bool Save(const std::string& path, const MaterialAssetData& data); static bool Save(const std::string& path, const MaterialAssetData& data);
static bool Load(const std::string& path, MaterialAssetData& out); static bool Load(const std::string& path, MaterialAssetData& out);
// GPU 段注入(仅 VulkanAssetUploader 调用):ownedTextures = // GPU 段注入(仅 VulkanAssetUploader 调用):material = 组装好的
// 本资产加载的纹理(独占所有权),material = 组装好的材质 // 材质(空槽已由 uploader 绑共享默认纹理)。纹理所有权归 uploader
//(空槽已由 uploader 绑共享默认纹理) // 的路径缓存(跨材质去重),本资产只持有绑进 Material 的裸指针
MaterialAsset(MaterialAssetData&& data, MaterialAsset(MaterialAssetData&& data, Scope<Material>&& material);
std::array<Scope<Texture>, Material::kSlotCount>&& ownedTextures,
Scope<Material>&& material);
// 析构外置(成员为重资源句柄,销毁序与 GPU 生命周期解耦遵循 // 析构外置(成员为重资源句柄,销毁序与 GPU 生命周期解耦遵循
// Model/Material 既有模式) // Model/Material 既有模式)
~MaterialAsset(); ~MaterialAsset();
@@ -87,8 +85,6 @@ namespace Engine {
private: private:
MaterialAssetData m_Data; MaterialAssetData m_Data;
// 本资产独占的纹理(共享默认纹理不在其中,由 uploader 持有)
std::array<Scope<Texture>, Material::kSlotCount> m_OwnedTextures;
Scope<Material> m_Material; Scope<Material> m_Material;
}; };
@@ -73,6 +73,9 @@ namespace Engine {
inline constexpr u32 kPreviewTilesPerSide = 5; inline constexpr u32 kPreviewTilesPerSide = 5;
inline constexpr u32 kPreviewTileCount = inline constexpr u32 kPreviewTileCount =
kPreviewTilesPerSide * kPreviewTilesPerSide; kPreviewTilesPerSide * kPreviewTilesPerSide;
// 单 tile 回读字节数(SDR atlas 为 RGBA8)
inline constexpr u32 kPreviewTileBytes =
kPreviewTileSize * kPreviewTileSize * 4;
// 本帧待渲染的脏 tile(Material* 来自本帧 UI 请求,活指针) // 本帧待渲染的脏 tile(Material* 来自本帧 UI 请求,活指针)
struct PreviewDirtyTile { struct PreviewDirtyTile {
@@ -80,13 +83,22 @@ namespace Engine {
u32 tile = 0; u32 tile = 0;
}; };
// 本帧待回读烘焙的 tile(key = 调用方令牌,Renderer 不解释)。
// 只收录"本帧确实被渲染"的 tile,故条数 ≤ 渲染预算
struct PreviewBakeTile {
u64 key = 0;
u32 tile = 0;
};
// 编辑器 tile 池(Renderer 持有,UE FAssetThumbnailPool 语义)。 // 编辑器 tile 池(Renderer 持有,UE FAssetThumbnailPool 语义)。
// 缓存键 = Material serial(出生唯一号,防指针地址复用的 ABA) // 缓存键 = Material serial(出生唯一号,防指针地址复用的 ABA)
// + editVersion(参数代数)。游戏构建整体编译裁剪,无运行时成本 // + editVersion(参数代数)。游戏构建整体编译裁剪,无运行时成本
class PreviewTilePool { class PreviewTilePool {
public: public:
// 每帧渲染预算:脏 tile 多时逐帧消化(滚动列表渐进出图) // 每帧渲染预算:脏 tile 多时逐帧消化(浏览器缩略图烘焙 / 滚动
static constexpr u32 kPreviewRendersPerFrame = 2; // 列表渐进出图)。预览球是 128 三角形的 256² 直绘,8 个/帧的
// GPU 成本可忽略;烘焙吞吐 = 预算(回读只收当帧渲染过的 tile)
static constexpr u32 kPreviewRendersPerFrame = 8;
// UI 构建段逐可见行调用(幂等 keepalive,刷新 LRU + 脏检测)。 // UI 构建段逐可见行调用(幂等 keepalive,刷新 LRU + 脏检测)。
// 返回 tile:>=0 = 三槽位镜像内容齐备可显示(含重渲期间的旧 // 返回 tile:>=0 = 三槽位镜像内容齐备可显示(含重渲期间的旧
@@ -145,6 +157,44 @@ namespace Engine {
return displayable ? static_cast<i32>(tileIdx) : -1; return displayable ? static_cast<i32>(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<u32>(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<i32>(tileIdx);
}
// Render 段取本帧预算内的脏 tile(frameSlot = 本帧渲染槽位, // Render 段取本帧预算内的脏 tile(frameSlot = 本帧渲染槽位,
// 该槽位镜像在本帧被写入)。渲染语义(清矩形+画球)由 pass // 该槽位镜像在本帧被写入)。渲染语义(清矩形+画球)由 pass
// 执行后该槽位内容即有效。剩余脏 tile 保持未取——下帧可见行 // 执行后该槽位内容即有效。剩余脏 tile 保持未取——下帧可见行
@@ -215,6 +265,7 @@ namespace Engine {
class MaterialPreviewPass final : public RenderPass { class MaterialPreviewPass final : public RenderPass {
public: public:
// dirtyTiles = Renderer 持有的当帧脏表(Render 段换入,只读) // dirtyTiles = Renderer 持有的当帧脏表(Render 段换入,只读)
// bakeTiles = Renderer 持有的当帧回读表(只读;条数 ≤ 渲染预算)
MaterialPreviewPass(Device& device, MemoryAllocator& allocator, MaterialPreviewPass(Device& device, MemoryAllocator& allocator,
DescriptorManager& descriptors, DescriptorManager& descriptors,
SceneGpu& gpu, FullscreenGpu& fullscreen, SceneGpu& gpu, FullscreenGpu& fullscreen,
@@ -222,8 +273,19 @@ namespace Engine {
RG::TextureHandle velocityAtlas, RG::TextureHandle velocityAtlas,
RG::TextureHandle sdrAtlas, RG::TextureHandle sdrAtlas,
const std::vector<PreviewDirtyTile>& const std::vector<PreviewDirtyTile>&
dirtyTiles); dirtyTiles,
~MaterialPreviewPass() override = default; const std::vector<PreviewBakeTile>&
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 Register(RG::RenderGraph& rg) override;
void OnTargetsRebuilt(RG::RenderGraph& rg) override; void OnTargetsRebuilt(RG::RenderGraph& rg) override;
@@ -246,6 +308,11 @@ namespace Engine {
RG::TextureHandle m_VelocityAtlas; RG::TextureHandle m_VelocityAtlas;
RG::TextureHandle m_SDRAtlas; RG::TextureHandle m_SDRAtlas;
const std::vector<PreviewDirtyTile>& m_DirtyTiles; const std::vector<PreviewDirtyTile>& m_DirtyTiles;
const std::vector<PreviewBakeTile>& m_BakeTiles;
// 回读暂存:每帧槽位一份(容量 = 渲染预算 × 单 tile 字节)。
// 拷贝录制在该槽位命令缓冲,Renderer 在该槽位 fence 通过后读
std::array<Scope<Buffer>, kMaxFramesInFlight> m_BakeStaging;
// ---- 预览专用 set0(与 uboSetLayout 同布局,内容恒定)---- // ---- 预览专用 set0(与 uboSetLayout 同布局,内容恒定)----
// UBO = 固定棚拍相机/布光;SSBO = GPUDriven 契约的最小占位 // UBO = 固定棚拍相机/布光;SSBO = GPUDriven 契约的最小占位
+42
View File
@@ -111,6 +111,17 @@ namespace Engine {
// 脏表引用与槽位——脏 tile 本帧不再解引用已销毁材质(UAF) // 脏表引用与槽位——脏 tile 本帧不再解引用已销毁材质(UAF)
void DropMaterialPreview(const Material* material); void DropMaterialPreview(const Material* material);
void* GetMaterialPreviewTexture() const; 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<u8>& outPixels,
u32& outWidth, u32& outHeight);
#endif #endif
// ---- 后处理参数(ImGui 可调,转发到 pass 共享的 PostProcessParams)---- // ---- 后处理参数(ImGui 可调,转发到 pass 共享的 PostProcessParams)----
@@ -213,6 +224,9 @@ namespace Engine {
void HandleResize(bool recreateSwapchain); void HandleResize(bool recreateSwapchain);
void ReadGpuTimes(u32 frameSlot); void ReadGpuTimes(u32 frameSlot);
void RefreshSceneTextures(); // 物理 view → ImGui 描述符(重建后刷新) void RefreshSceneTextures(); // 物理 view → ImGui 描述符(重建后刷新)
#ifdef HELLO_EDITOR
void CollectBakeResults(u32 frameSlot); // 槽位 fence 后取走回读结果
#endif
// 期望渲染分辨率:Editor 视口 = 面板尺寸(未上报时回退 swapchain); // 期望渲染分辨率:Editor 视口 = 面板尺寸(未上报时回退 swapchain);
// Direct = swapchain 尺寸 // Direct = swapchain 尺寸
vk::Extent2D DesiredRenderExtent() const; vk::Extent2D DesiredRenderExtent() const;
@@ -300,6 +314,34 @@ namespace Engine {
// TakeRenders 取预算内的脏 tile 换入 m_PreviewRenders(pass 只读) // TakeRenders 取预算内的脏 tile 换入 m_PreviewRenders(pass 只读)
Passes::PreviewTilePool m_PreviewPool; Passes::PreviewTilePool m_PreviewPool;
std::vector<Passes::PreviewDirtyTile> m_PreviewRenders; std::vector<Passes::PreviewDirtyTile> 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<PreviewBakeReq> m_BakeReqs; // 跨帧存活至回读完成
u64 m_FrameIndex = 0; // 单调帧号(请求老化判据)
std::vector<Passes::PreviewBakeTile> m_PreviewBakes; // 当帧(pass 只读)
// 每槽位当帧录制的回读 key(帧 N 录,帧 N+2 同槽位 fence 后取走)
std::array<std::vector<u64>, kMaxFramesInFlight> m_BakeRecorded;
struct PreviewBakeResult
{
u64 key = 0;
std::vector<u8> pixels;
u32 width = 0;
u32 height = 0;
};
std::vector<PreviewBakeResult> m_BakeDone;
#endif #endif
// GPU timestamp(pass 级耗时,ImGui 可读) // GPU timestamp(pass 级耗时,ImGui 可读)
vk::QueryPool m_QueryPool; vk::QueryPool m_QueryPool;
@@ -14,6 +14,9 @@
#include <Engine/Assets/IAssetUploader.h> #include <Engine/Assets/IAssetUploader.h>
#include <string>
#include <unordered_map>
namespace Engine { namespace Engine {
class Device; class Device;
@@ -49,7 +52,8 @@ namespace Engine {
~VulkanAssetUploader() override; ~VulkanAssetUploader() override;
// ---- 材质资产(.material 数据段 → GPU 段)---- // ---- 材质资产(.material 数据段 → GPU 段)----
// 每槽路径建独占纹理(空/缺失回退共享默认纹理),再组 Material // 每槽经路径纹理缓存(跨材质去重)取纹理,同批新纹理合并为一次
// submit;空/缺失回退共享默认纹理,再组 Material
Scope<MaterialAsset> CreateMaterial(MaterialAssetData&& data) override; Scope<MaterialAsset> CreateMaterial(MaterialAssetData&& data) override;
// 设备空闲屏障(编辑器资产重建路径:卸载旧 GPU 资源前全停飞) // 设备空闲屏障(编辑器资产重建路径:卸载旧 GPU 资源前全停飞)
void WaitDeviceIdle() override; void WaitDeviceIdle() override;
@@ -60,6 +64,12 @@ namespace Engine {
void InitSharedDefaultTextures(); void InitSharedDefaultTextures();
std::array<Scope<Texture>, Material::kSlotCount> m_DefaultTextures{}; std::array<Scope<Texture>, Material::kSlotCount> m_DefaultTextures{};
// 路径纹理缓存:键 = 归一化路径 + sRGB 标记 → 独占纹理。多个
// 材质引用同一贴图(仓库 4K 贴图被多材质共享)只解码/上传一次。
// 生命周期长于所有 MaterialAsset(uploader 在 assets 之后析构),
// 故 Material 可安全持裸指针。DDS 与 stbi 同缓存
std::unordered_map<std::string, Scope<Texture>> m_TextureCache;
Device& m_Device; Device& m_Device;
MemoryAllocator& m_Allocator; MemoryAllocator& m_Allocator;
DescriptorManager& m_Descriptors; DescriptorManager& m_Descriptors;
+27 -1
View File
@@ -42,6 +42,18 @@ namespace Engine {
Device& device, MemoryAllocator& allocator, Device& device, MemoryAllocator& allocator,
const void* data, u32 bytes, bool srgb); const void* data, u32 bytes, bool srgb);
// 批式上传(一次 submit + 一次 fence 等待服务同批多张贴图,省掉
// 每张各自的命令池创建销毁与阻塞等待):解码 + 建 image/view/
// sampler + 把拷贝/barrier 录制进调用方 cmd(**不**提交)。staging
// 暂存对象内,调用方在批量 submit fence 通过后逐张
// ReleasePendingStaging 释放;析构亦兜底释放。DDS(稀有)仍走
// 自带提交,不参与批量
static Scope<Texture> CreateDeferred(Device& device,
MemoryAllocator& allocator,
const std::string& path, bool srgb,
vk::CommandBuffer cmd);
void ReleasePendingStaging();
~Texture(); ~Texture();
Texture(const Texture&) = delete; Texture(const Texture&) = delete;
@@ -62,9 +74,19 @@ namespace Engine {
const std::array<u8, 4>& rgba, bool srgb); const std::array<u8, 4>& rgba, bool srgb);
// 公共路径:staging → device local image → blit 生成完整 mip 链 → // 公共路径:staging → device local image → blit 生成完整 mip 链 →
// view/sampler(RGBA8,各向异性过滤) // view/sampler(RGBA8,各向异性过滤)。录制进 cmd 后自行提交等待
void InitFromPixels(const void* pixels, u32 width, u32 height, bool srgb); 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 链 // DDS 路径:压缩数据直传(BC 硬件采样解码)+ 文件自带 mip 链
void InitFromDds(const std::string& path, bool srgb); void InitFromDds(const std::string& path, bool srgb);
@@ -83,6 +105,10 @@ namespace Engine {
u64 m_TextureBytes = 0; u64 m_TextureBytes = 0;
// 批式上传暂存的 staging(submit fence 通过前不可释放)
bool m_HasPendingStaging = false;
MemoryAllocator::Allocation m_PendingStaging{};
MemoryAllocator::Allocation m_ImageAllocation; MemoryAllocator::Allocation m_ImageAllocation;
MemoryAllocator& m_Allocator; MemoryAllocator& m_Allocator;
}; };
+33 -5
View File
@@ -28,6 +28,13 @@ namespace Engine::RG {
} }
return false; return false;
} }
// 固定尺寸资源:desc 显式给定 width/height,物理尺寸与目标渲染
// 分辨率无关(如材质球预览 atlas)。这类镜像跨重编译沿用,内容存续
bool IsResolutionIndependent(const TextureEntry& e)
{
return !e.imported && e.desc.width != 0 && e.desc.height != 0;
}
} // namespace } // namespace
// ---------- 构造 / 析构 ---------- // ---------- 构造 / 析构 ----------
@@ -39,7 +46,8 @@ namespace Engine::RG {
RenderGraph::~RenderGraph() RenderGraph::~RenderGraph()
{ {
ReleasePhysical(); // 析构需全量归还(含固定尺寸资源),池随后统一销毁
ReleasePhysical(false);
const auto& vkDevice = m_Device.GetDevice(); const auto& vkDevice = m_Device.GetDevice();
for (auto &images: m_Pool | std::views::values) { for (auto &images: m_Pool | std::views::values) {
@@ -172,7 +180,9 @@ namespace Engine::RG {
ENGINE_FATAL("RenderGraph: Compile before SetTargetExtent"); ENGINE_FATAL("RenderGraph: Compile before SetTargetExtent");
} }
ReleasePhysical(); // 固定尺寸镜像已在 entry 上沿用(SetTargetExtent 只回收尺寸随目标
// 变化的镜像),此处再收一次只对非固定尺寸的残留生效
ReleasePhysical(true);
// 清理上一次编译的中间结果 // 清理上一次编译的中间结果
for (auto& entry : m_Textures) { for (auto& entry : m_Textures) {
@@ -229,11 +239,15 @@ namespace Engine::RG {
if (extent == m_TargetExtent) return; if (extent == m_TargetExtent) return;
// 尺寸变化:调用方必须保证 GPU 空闲(通常在 swapchain recreate 的 waitIdle 之后) // 尺寸变化:调用方必须保证 GPU 空闲(通常在 swapchain recreate 的 waitIdle 之后)
ReleasePhysical(); // 只回收尺寸随目标变化的镜像;固定尺寸资源(预览 atlas)保留在
// entry 上,PurgePool 也看不到它们,内容不会被销毁
ReleasePhysical(true);
PurgePool(extent); PurgePool(extent);
// 物理镜像全部换新,槽位状态清零 // 只有被回收重建的镜像才回到"首次使用"(Undefined 出发);保留的
// 镜像仍在上一轮 resting layout,槽位使用态必须跟着保留
for (auto& entry : m_Textures) { for (auto& entry : m_Textures) {
if (IsResolutionIndependent(entry)) continue;
for (auto& slot : entry.slots) slot.used = false; for (auto& slot : entry.slots) slot.used = false;
} }
@@ -594,6 +608,19 @@ namespace Engine::RG {
}; };
for (u32 slot = 0; slot < m_FramesInFlight; ++slot) { 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<u32>(keep->format),
keep->extent.width, keep->extent.height,
static_cast<u32>(keep->usage)};
m_Pool[key].push_back(Scope<PhysicalImage>(keep));
}
entry.physical[slot] = AcquirePhysical( entry.physical[slot] = AcquirePhysical(
entry.desc.format, extent, entry.usage, entry.aspect); 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) { for (auto& entry : m_Textures) {
if (entry.imported) continue; if (entry.imported) continue;
if (keepFixedSize && IsResolutionIndependent(entry)) continue;
for (u32 slot = 0; slot < entry.physical.size(); ++slot) { for (u32 slot = 0; slot < entry.physical.size(); ++slot) {
auto* img = entry.physical[slot]; auto* img = entry.physical[slot];
if (!img) continue; if (!img) continue;
-2
View File
@@ -174,10 +174,8 @@ namespace Engine
MaterialAsset::MaterialAsset( MaterialAsset::MaterialAsset(
MaterialAssetData&& data, MaterialAssetData&& data,
std::array<Scope<Texture>, Material::kSlotCount>&& ownedTextures,
Scope<Material>&& material) Scope<Material>&& material)
: m_Data(std::move(data)) : m_Data(std::move(data))
, m_OwnedTextures(std::move(ownedTextures))
, m_Material(std::move(material)) , m_Material(std::move(material))
{ {
} }
@@ -12,6 +12,7 @@
#include <Engine/Vulkan/Device.h> #include <Engine/Vulkan/Device.h>
#include <Engine/Vulkan/DescriptorManager.h> #include <Engine/Vulkan/DescriptorManager.h>
#include <Engine/Vulkan/UniformBuffer.h> #include <Engine/Vulkan/UniformBuffer.h>
#include <Engine/Vulkan/Buffer.h>
#include <glm/gtc/matrix_transform.hpp> #include <glm/gtc/matrix_transform.hpp>
@@ -103,7 +104,8 @@ namespace Engine::Passes
DescriptorManager& descriptors, SceneGpu& gpu, DescriptorManager& descriptors, SceneGpu& gpu,
FullscreenGpu& fullscreen, RG::TextureHandle hdrAtlas, FullscreenGpu& fullscreen, RG::TextureHandle hdrAtlas,
RG::TextureHandle velocityAtlas, RG::TextureHandle sdrAtlas, RG::TextureHandle velocityAtlas, RG::TextureHandle sdrAtlas,
const std::vector<PreviewDirtyTile>& dirtyTiles) const std::vector<PreviewDirtyTile>& dirtyTiles,
const std::vector<PreviewBakeTile>& bakeTiles)
: m_Device(device) : m_Device(device)
, m_Allocator(allocator) , m_Allocator(allocator)
, m_Descriptors(descriptors) , m_Descriptors(descriptors)
@@ -113,6 +115,7 @@ namespace Engine::Passes
, m_VelocityAtlas(velocityAtlas) , m_VelocityAtlas(velocityAtlas)
, m_SDRAtlas(sdrAtlas) , m_SDRAtlas(sdrAtlas)
, m_DirtyTiles(dirtyTiles) , m_DirtyTiles(dirtyTiles)
, m_BakeTiles(bakeTiles)
{ {
// ---- set0:棚拍 UBO + GPUDriven 契约占位(内容恒定,创建期 // ---- set0:棚拍 UBO + GPUDriven 契约占位(内容恒定,创建期
// 一次写入;in-flight 帧并发只读同一份,无需 per-slot ring)---- // 一次写入;in-flight 帧并发只读同一份,无需 per-slot ring)----
@@ -190,7 +193,21 @@ namespace Engine::Passes
for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) { for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) {
m_TonemapSets[slot] = fullscreen.AllocateSet(); m_TonemapSets[slot] = fullscreen.AllocateSet();
} }
// ---- 烘焙回读暂存:每槽位一份 host-visible 持久映射缓冲 ----
const vk::DeviceSize bakeBytes =
static_cast<vk::DeviceSize>(PreviewTilePool::kPreviewRendersPerFrame) *
kPreviewTileBytes;
for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) {
m_BakeStaging[slot] = CreateScope<Buffer>(
device, allocator, bakeBytes,
vk::BufferUsageFlagBits::eTransferDst,
vk::MemoryPropertyFlagBits::eHostVisible |
vk::MemoryPropertyFlagBits::eHostCoherent);
} }
}
MaterialPreviewPass::~MaterialPreviewPass() = default;
void MaterialPreviewPass::Register(RG::RenderGraph& rg) void MaterialPreviewPass::Register(RG::RenderGraph& rg)
{ {
@@ -325,6 +342,42 @@ namespace Engine::Passes
.Read(m_HDRAtlas, RG::ResourceUse::ShaderRead, .Read(m_HDRAtlas, RG::ResourceUse::ShaderRead,
vk::ShaderStageFlagBits::eFragment) vk::ShaderStageFlagBits::eFragment)
.Write(m_SDRAtlas, RG::ResourceUse::ColorAttachment); .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<vk::DeviceSize>(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, void MaterialPreviewPass::RecordBall(vk::CommandBuffer cmd, u32 tile,
+113 -6
View File
@@ -43,6 +43,7 @@
#include <Engine/Vulkan/CommandManager.h> #include <Engine/Vulkan/CommandManager.h>
#include <Engine/Vulkan/SyncManager.h> #include <Engine/Vulkan/SyncManager.h>
#include <Engine/Vulkan/UniformBuffer.h> #include <Engine/Vulkan/UniformBuffer.h>
#include <Engine/Vulkan/Buffer.h>
#include <Engine/Core/Window.h> #include <Engine/Core/Window.h>
#include <Engine/Core/Log.h> #include <Engine/Core/Log.h>
#include <Engine/ImGui/ImGuiLayer.h> #include <Engine/ImGui/ImGuiLayer.h>
@@ -563,7 +564,7 @@ namespace Engine
m_Passes.push_back(CreateScope<Passes::MaterialPreviewPass>( m_Passes.push_back(CreateScope<Passes::MaterialPreviewPass>(
*m_Device, *m_Allocator, *m_DescriptorManager, *m_SceneGpu, *m_Device, *m_Allocator, *m_DescriptorManager, *m_SceneGpu,
*m_FullscreenGpu, m_PreviewHDR, m_PreviewVelocity, m_PreviewSDR, *m_FullscreenGpu, m_PreviewHDR, m_PreviewVelocity, m_PreviewSDR,
m_PreviewRenders)); m_PreviewRenders, m_PreviewBakes));
m_PreviewPass = m_PreviewPass =
static_cast<Passes::MaterialPreviewPass*>(m_Passes.back().get()); static_cast<Passes::MaterialPreviewPass*>(m_Passes.back().get());
#endif #endif
@@ -673,6 +674,11 @@ namespace Engine
void Renderer::DropMaterialPreview(const Material* material) void Renderer::DropMaterialPreview(const Material* material)
{ {
m_PreviewPool.DropMaterial(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 void* Renderer::GetMaterialPreviewTexture() const
@@ -684,6 +690,66 @@ namespace Engine
} }
return tex; 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<u8>& 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<u64> 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<const u8*>(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 #endif
// RG 物理镜像按帧槽位各一份:每槽位注册一个 ImGui 描述符。 // RG 物理镜像按帧槽位各一份:每槽位注册一个 ImGui 描述符。
@@ -691,21 +757,27 @@ namespace Engine
void Renderer::RefreshSceneTextures() void Renderer::RefreshSceneTextures()
{ {
if (!m_ImGuiLayer || !m_PresentTarget.Valid()) return; if (!m_ImGuiLayer || !m_PresentTarget.Valid()) return;
bool previewRecreated = false;
for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) { for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) {
m_ImGuiLayer->UpdateSceneTexture( m_ImGuiLayer->UpdateSceneTexture(
slot, m_RenderGraph->GetPhysicalView(m_PresentTarget, slot)); slot, m_RenderGraph->GetPhysicalView(m_PresentTarget, slot));
// 材质球 atlas 同槽位注册(语义与场景纹理一致) // 材质球 atlas 同槽位注册(语义与场景纹理一致)
#ifdef HELLO_EDITOR #ifdef HELLO_EDITOR
if (m_PreviewSDR.Valid()) { if (m_PreviewSDR.Valid()) {
m_ImGuiLayer->UpdatePreviewTexture( const vk::ImageView view =
slot, m_RenderGraph->GetPhysicalView(m_PreviewSDR, slot)); m_RenderGraph->GetPhysicalView(m_PreviewSDR, slot);
// 固定尺寸的预览 atlas 跨视口 resize 沿用同一镜像(内容
// 存续);只有镜像真的换新时才需要让池重画
previewRecreated = previewRecreated || view != m_PreviewViews[slot];
m_PreviewViews[slot] = view;
m_ImGuiLayer->UpdatePreviewTexture(slot, view);
} }
#endif #endif
} }
#ifdef HELLO_EDITOR #ifdef HELLO_EDITOR
// 重编译 = 物理镜像全部换新(三槽位 atlas 内容清零)——池的 // 物理镜像换新 = atlas 内容清零,池的 paintedSlots 记录全部失效,
// paintedSlots 记录全部失效,可见行 keepalive 请求会重新补画 // 可见行 keepalive 请求会重新补画
if (m_PreviewSDR.Valid()) { if (previewRecreated) {
m_PreviewPool.InvalidateAll(); m_PreviewPool.InvalidateAll();
} }
#endif #endif
@@ -756,6 +828,9 @@ namespace Engine
const u32 frameSlot = m_CurrentFrame; const u32 frameSlot = m_CurrentFrame;
const auto& vkDevice = m_Device->GetDevice(); const auto& vkDevice = m_Device->GetDevice();
#ifdef HELLO_EDITOR
++m_FrameIndex; // 单调帧号(烘焙请求老化判据)
#endif
// 1. 场景 → 当帧渲染数据(相机/灯光/双视图剔除;纯 CPU,不碰 GPU) // 1. 场景 → 当帧渲染数据(相机/灯光/双视图剔除;纯 CPU,不碰 GPU)
if (!m_SceneRenderer->BuildFrame(scene, assets, m_Extent, dt)) { if (!m_SceneRenderer->BuildFrame(scene, assets, m_Extent, dt)) {
@@ -791,6 +866,14 @@ namespace Engine
// 2. 等本槽位 fence → 写 UBO/SSBO → reset(写-读竞争关闭) // 2. 等本槽位 fence → 写 UBO/SSBO → reset(写-读竞争关闭)
m_SyncManager->WaitFence(frameSlot); m_SyncManager->WaitFence(frameSlot);
ReadGpuTimes(frameSlot); // fence 已等:上一轮该槽位的查询已就绪 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_UniformBuffer->Update(frameSlot, &frame.ubo, sizeof(frame.ubo));
m_SyncManager->ResetFence(frameSlot); m_SyncManager->ResetFence(frameSlot);
@@ -903,7 +986,31 @@ namespace Engine
// 全部跳帧路径,标记 paintedSlots 的槽位镜像本帧必然被写入)。 // 全部跳帧路径,标记 paintedSlots 的槽位镜像本帧必然被写入)。
// 预算外的脏 tile 保持未取状态,下帧可见行 keepalive 请求重新入队 // 预算外的脏 tile 保持未取状态,下帧可见行 keepalive 请求重新入队
#ifdef HELLO_EDITOR #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); 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<u32>(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 #endif
m_RenderGraph->Execute(frameCtx); m_RenderGraph->Execute(frameCtx);
+61 -10
View File
@@ -11,6 +11,7 @@
#include <algorithm> #include <algorithm>
#include <filesystem> #include <filesystem>
#include <vector>
namespace Engine { namespace Engine {
@@ -31,34 +32,84 @@ namespace Engine {
InitSharedDefaultTextures(); InitSharedDefaultTextures();
// 每槽解析:路径(CWD 相对,反斜杠归一,与 Model::GetOrLoadTexture // 每槽解析:路径(CWD 相对,反斜杠归一,与 Model::GetOrLoadTexture
// 同语义)→ 独占纹理;空/缺失 → 共享默认纹理(非拥有) // 同语义)→ 路径缓存命中的共享纹理 / 本批新建;空/缺失 → 共享
std::array<Scope<Texture>, Material::kSlotCount> owned; // 默认纹理(非拥有)。新建纹理的拷贝/barrier 全部录制进同一 cmd,
// 末尾一次 submit + 一次 fence 等待服务整批——取代过去"每张贴图
// 各自建命令池 + 提交 + 阻塞等待",这是大材质目录首开卡顿的根因
std::array<Texture*, Material::kSlotCount> bound{}; std::array<Texture*, Material::kSlotCount> bound{};
// 槽位 sRGB 语义:颜色图 true,数据图 false(与 Model 同表) // 槽位 sRGB 语义:颜色图 true,数据图 false(与 Model 同表)
const bool slotSrgb[Material::kSlotCount] = {true, false, false, const bool slotSrgb[Material::kSlotCount] = {true, false, false,
true, false, false}; true, false, false};
std::vector<Texture*> created; // 本批新建纹理(fence 后释放 staging)
vk::CommandPool pool = nullptr;
vk::CommandBuffer cmd = nullptr;
for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { for (u32 slot = 0; slot < Material::kSlotCount; ++slot) {
std::string path = data.texPaths[slot]; std::string path = data.texPaths[slot];
std::replace(path.begin(), path.end(), '\\', '/'); std::replace(path.begin(), path.end(), '\\', '/');
if (!path.empty() && std::filesystem::exists(path)) { if (path.empty() || !std::filesystem::exists(path)) {
owned[slot] = CreateScope<Texture>(
m_Device, m_Allocator, path, slotSrgb[slot]);
bound[slot] = owned[slot].get();
}
else {
if (!path.empty()) { if (!path.empty()) {
ENGINE_WARN("MaterialAsset: texture not found '{}', " ENGINE_WARN("MaterialAsset: texture not found '{}', "
"slot {} falls back to default", path, slot); "slot {} falls back to default", path, slot);
} }
bound[slot] = m_DefaultTextures[slot].get(); 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<Material>( auto mat = CreateScope<Material>(
m_Device, m_Allocator, m_Descriptors, m_TextureLayout, m_Device, m_Allocator, m_Descriptors, m_TextureLayout,
bound, data.params, data.transparent); bound, data.params, data.transparent);
return CreateScope<MaterialAsset>(std::move(data), std::move(owned), return CreateScope<MaterialAsset>(std::move(data), std::move(mat));
std::move(mat));
} }
void VulkanAssetUploader::WaitDeviceIdle() void VulkanAssetUploader::WaitDeviceIdle()
+99 -48
View File
@@ -93,7 +93,11 @@ namespace Engine
return tex; 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 // 重入安全:CreateFromEncodedMemory 先经 ProceduralTag ctor 落 1x1
// 占位(只为确定 device/allocator 成员),再调本函数传真实像素。 // 占位(只为确定 device/allocator 成员),再调本函数传真实像素。
@@ -118,15 +122,17 @@ namespace Engine
vk::DeviceSize imageSize = static_cast<vk::DeviceSize>(width) * vk::DeviceSize imageSize = static_cast<vk::DeviceSize>(width) *
static_cast<vk::DeviceSize>(height) * 4; // RGBA8 static_cast<vk::DeviceSize>(height) * 4; // RGBA8
// 2. 创建 Staging Buffer(HOST_VISIBLE,VMA 持久映射) // 2. 创建 Staging Buffer(HOST_VISIBLE,VMA 持久映射)。暂存对象内,
auto stagingAlloc = m_Allocator.CreateBuffer( // 由调用方在提交 fence 通过后 ReleasePendingStaging 释放
m_PendingStaging = m_Allocator.CreateBuffer(
imageSize, imageSize,
vk::BufferUsageFlagBits::eTransferSrc, vk::BufferUsageFlagBits::eTransferSrc,
vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostVisible |
vk::MemoryPropertyFlagBits::eHostCoherent); vk::MemoryPropertyFlagBits::eHostCoherent);
m_HasPendingStaging = true;
// 3. 拷贝像素到 Staging Buffer // 3. 拷贝像素到 Staging Buffer
std::memcpy(stagingAlloc.MappedPtr, pixels, static_cast<size_t>(imageSize)); std::memcpy(m_PendingStaging.MappedPtr, pixels, static_cast<size_t>(imageSize));
// 4. 创建 Device Local Image // 4. 创建 Device Local Image
vk::ImageCreateInfo imageInfo; vk::ImageCreateInfo imageInfo;
@@ -180,25 +186,9 @@ namespace Engine
m_Sampler = m_Device.GetDevice().createSampler(samplerInfo); m_Sampler = m_Device.GetDevice().createSampler(samplerInfo);
// 6. 一次性命令缓冲:Staging → Image // 6. 布局:UNDEFINED → TRANSFER_DST(整条 mip 链一次性转目标布局:
vk::CommandPoolCreateInfo poolInfo; // 后续 blit 的目标 mip 必须已在目标布局;仅转 mip0 会触发
poolInfo.setQueueFamilyIndex(m_Device.GetGraphicsFamily()) // validation 对每个 blit 目标的 UNDEFINED 布局告警)
.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
vk::ImageMemoryBarrier2 toTransfer; vk::ImageMemoryBarrier2 toTransfer;
toTransfer.setSrcStageMask(vk::PipelineStageFlagBits2::eNone) toTransfer.setSrcStageMask(vk::PipelineStageFlagBits2::eNone)
.setSrcAccessMask(vk::AccessFlagBits2::eNone) .setSrcAccessMask(vk::AccessFlagBits2::eNone)
@@ -207,9 +197,6 @@ namespace Engine
.setOldLayout(vk::ImageLayout::eUndefined) .setOldLayout(vk::ImageLayout::eUndefined)
.setNewLayout(vk::ImageLayout::eTransferDstOptimal) .setNewLayout(vk::ImageLayout::eTransferDstOptimal)
.setImage(m_Image) .setImage(m_Image)
// 整条 mip 链一次性 UNDEFINED → TRANSFER_DST:后续 blit
// 的目标 mip 必须已在目标布局(仅转 mip0 会触发 validation
// 对每个 blit 目标的 UNDEFINED 布局告警)
.setSubresourceRange({vk::ImageAspectFlagBits::eColor, .setSubresourceRange({vk::ImageAspectFlagBits::eColor,
0, m_MipLevels, 0, 1}); 0, m_MipLevels, 0, 1});
@@ -226,7 +213,7 @@ namespace Engine
.setImageOffset({0, 0, 0}) .setImageOffset({0, 0, 0})
.setImageExtent({m_Width, m_Height, 1}); .setImageExtent({m_Width, m_Height, 1});
cmd.copyBufferToImage(stagingAlloc.Buffer, m_Image, cmd.copyBufferToImage(m_PendingStaging.Buffer, m_Image,
vk::ImageLayout::eTransferDstOptimal, region); vk::ImageLayout::eTransferDstOptimal, region);
// 6.3 blit 生成 mip 链:逐级半分辨率缩小(硬件线性滤波)。 // 6.3 blit 生成 mip 链:逐级半分辨率缩小(硬件线性滤波)。
@@ -291,27 +278,6 @@ namespace Engine
dep2.setImageMemoryBarriers(toShader); dep2.setImageMemoryBarriers(toShader);
cmd.pipelineBarrier2(dep2); 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 链估算(与分配器台账同一算法) // 显存占用按真实格式 + mip 链估算(与分配器台账同一算法)
if (m_TextureBytes != 0) { if (m_TextureBytes != 0) {
STATS.RemoveTexture(m_TextureBytes); // 重建防双计 STATS.RemoveTexture(m_TextureBytes); // 重建防双计
@@ -321,6 +287,87 @@ namespace Engine
STATS.AddTexture(m_TextureBytes); 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<char>(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<u32>(width),
static_cast<u32>(height), srgb, cmd);
stbi_image_free(pixels);
ENGINE_INFO("Texture loaded (batched): {} ({}x{})", path, m_Width,
m_Height);
}
Scope<Texture> Texture::CreateDeferred(Device& device,
MemoryAllocator& allocator,
const std::string& path, bool srgb,
vk::CommandBuffer cmd)
{
return Scope<Texture>(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 语义由贴图槽位决定(渲染侧职责): // DDS → Vulkan 格式映射。sRGB 语义由贴图槽位决定(渲染侧职责):
// 颜色图(BaseColor/Emissive)→ SRGB 变体 // 颜色图(BaseColor/Emissive)→ SRGB 变体
// 数据图(Normal/Specular 等线性槽位)→ UNORM // 数据图(Normal/Specular 等线性槽位)→ UNORM
@@ -520,6 +567,10 @@ namespace Engine
{ {
const auto& vkDevice = m_Device.GetDevice(); const auto& vkDevice = m_Device.GetDevice();
// 批式上传若在 fence 通过前被析构(如加载中途失败):兜底释放
// staging,避免 VMA 块非空断言崩溃
ReleasePendingStaging();
vkDevice.destroySampler(m_Sampler); vkDevice.destroySampler(m_Sampler);
vkDevice.destroyImageView(m_View); vkDevice.destroyImageView(m_View);