资产浏览器可视化改造 + 材质缩略图 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
@@ -314,7 +314,10 @@ namespace Engine::RG {
void ComputeLifetimes();
void PrecomputeBarriers();
void AllocatePhysical();
void ReleasePhysical();
// keepFixedSize = true:固定尺寸资源(desc 显式 width/height,与目标
// 渲染尺寸无关)的物理镜像不归还池、保留在 entry 上——重编译后原样
// 沿用同一 VkImage,内容存续(材质球预览 atlas 不因视口 resize 清空)
void ReleasePhysical(bool keepFixedSize);
void PurgePool(vk::Extent2D target);
PhysicalImage* AcquirePhysical(vk::Format format, vk::Extent2D extent,
vk::ImageUsageFlags usage,
@@ -67,12 +67,10 @@ namespace Engine {
static bool Save(const std::string& path, const MaterialAssetData& data);
static bool Load(const std::string& path, MaterialAssetData& out);
// GPU 段注入(仅 VulkanAssetUploader 调用):ownedTextures =
// 本资产加载的纹理(独占所有权),material = 组装好的材质
//(空槽已由 uploader 绑共享默认纹理)
MaterialAsset(MaterialAssetData&& data,
std::array<Scope<Texture>, Material::kSlotCount>&& ownedTextures,
Scope<Material>&& material);
// GPU 段注入(仅 VulkanAssetUploader 调用):material = 组装好的
// 材质(空槽已由 uploader 绑共享默认纹理)。纹理所有权归 uploader
// 的路径缓存(跨材质去重),本资产只持有绑进 Material 的裸指针
MaterialAsset(MaterialAssetData&& data, Scope<Material>&& material);
// 析构外置(成员为重资源句柄,销毁序与 GPU 生命周期解耦遵循
// Model/Material 既有模式)
~MaterialAsset();
@@ -87,8 +85,6 @@ namespace Engine {
private:
MaterialAssetData m_Data;
// 本资产独占的纹理(共享默认纹理不在其中,由 uploader 持有)
std::array<Scope<Texture>, Material::kSlotCount> m_OwnedTextures;
Scope<Material> m_Material;
};
@@ -73,6 +73,9 @@ namespace Engine {
inline constexpr u32 kPreviewTilesPerSide = 5;
inline constexpr u32 kPreviewTileCount =
kPreviewTilesPerSide * kPreviewTilesPerSide;
// 单 tile 回读字节数(SDR atlas 为 RGBA8)
inline constexpr u32 kPreviewTileBytes =
kPreviewTileSize * kPreviewTileSize * 4;
// 本帧待渲染的脏 tile(Material* 来自本帧 UI 请求,活指针)
struct PreviewDirtyTile {
@@ -80,13 +83,22 @@ namespace Engine {
u32 tile = 0;
};
// 本帧待回读烘焙的 tile(key = 调用方令牌,Renderer 不解释)。
// 只收录"本帧确实被渲染"的 tile,故条数 ≤ 渲染预算
struct PreviewBakeTile {
u64 key = 0;
u32 tile = 0;
};
// 编辑器 tile 池(Renderer 持有,UE FAssetThumbnailPool 语义)。
// 缓存键 = Material serial(出生唯一号,防指针地址复用的 ABA)
// + editVersion(参数代数)。游戏构建整体编译裁剪,无运行时成本
class PreviewTilePool {
public:
// 每帧渲染预算:脏 tile 多时逐帧消化(滚动列表渐进出图)
static constexpr u32 kPreviewRendersPerFrame = 2;
// 每帧渲染预算:脏 tile 多时逐帧消化(浏览器缩略图烘焙 / 滚动
// 列表渐进出图)。预览球是 128 三角形的 256² 直绘,8 个/帧的
// GPU 成本可忽略;烘焙吞吐 = 预算(回读只收当帧渲染过的 tile)
static constexpr u32 kPreviewRendersPerFrame = 8;
// UI 构建段逐可见行调用(幂等 keepalive,刷新 LRU + 脏检测)。
// 返回 tile:>=0 = 三槽位镜像内容齐备可显示(含重渲期间的旧
@@ -145,6 +157,44 @@ namespace Engine {
return displayable ? static_cast<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 = 本帧渲染槽位,
// 该槽位镜像在本帧被写入)。渲染语义(清矩形+画球)由 pass
// 执行后该槽位内容即有效。剩余脏 tile 保持未取——下帧可见行
@@ -215,6 +265,7 @@ namespace Engine {
class MaterialPreviewPass final : public RenderPass {
public:
// dirtyTiles = Renderer 持有的当帧脏表(Render 段换入,只读)
// bakeTiles = Renderer 持有的当帧回读表(只读;条数 ≤ 渲染预算)
MaterialPreviewPass(Device& device, MemoryAllocator& allocator,
DescriptorManager& descriptors,
SceneGpu& gpu, FullscreenGpu& fullscreen,
@@ -222,8 +273,19 @@ namespace Engine {
RG::TextureHandle velocityAtlas,
RG::TextureHandle sdrAtlas,
const std::vector<PreviewDirtyTile>&
dirtyTiles);
~MaterialPreviewPass() override = default;
dirtyTiles,
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 OnTargetsRebuilt(RG::RenderGraph& rg) override;
@@ -246,6 +308,11 @@ namespace Engine {
RG::TextureHandle m_VelocityAtlas;
RG::TextureHandle m_SDRAtlas;
const std::vector<PreviewDirtyTile>& m_DirtyTiles;
const std::vector<PreviewBakeTile>& m_BakeTiles;
// 回读暂存:每帧槽位一份(容量 = 渲染预算 × 单 tile 字节)。
// 拷贝录制在该槽位命令缓冲,Renderer 在该槽位 fence 通过后读
std::array<Scope<Buffer>, kMaxFramesInFlight> m_BakeStaging;
// ---- 预览专用 set0(与 uboSetLayout 同布局,内容恒定)----
// UBO = 固定棚拍相机/布光;SSBO = GPUDriven 契约的最小占位
+42
View File
@@ -111,6 +111,17 @@ namespace Engine {
// 脏表引用与槽位——脏 tile 本帧不再解引用已销毁材质(UAF)
void DropMaterialPreview(const Material* material);
void* GetMaterialPreviewTexture() const;
// ---- 材质球缩略图烘焙(GPU→CPU 异步回读,无 waitIdle)----
// UI 段对"需要静态缩略图"的材质逐帧幂等请求(userKey 由调用方
// 定义,Renderer 不解释、原样回传;同 Material serial 去重)。渲染
// 段把该材质渲染进预览 tile 后当帧拷进 host 暂存,待该帧槽位
// fence 通过(两帧后同槽位再入图)再取走——不阻塞主线程
void RequestMaterialPreviewBake(Material* material, u64 userKey);
// UI 段轮询:有完成的烘焙则取走(一次性)。像素 = RGBA8,
// kPreviewTileSize²(tonemap 后的 SDR,可直接当缩略图)
bool PollMaterialPreviewBake(u64& outKey, std::vector<u8>& outPixels,
u32& outWidth, u32& outHeight);
#endif
// ---- 后处理参数(ImGui 可调,转发到 pass 共享的 PostProcessParams)----
@@ -213,6 +224,9 @@ namespace Engine {
void HandleResize(bool recreateSwapchain);
void ReadGpuTimes(u32 frameSlot);
void RefreshSceneTextures(); // 物理 view → ImGui 描述符(重建后刷新)
#ifdef HELLO_EDITOR
void CollectBakeResults(u32 frameSlot); // 槽位 fence 后取走回读结果
#endif
// 期望渲染分辨率:Editor 视口 = 面板尺寸(未上报时回退 swapchain);
// Direct = swapchain 尺寸
vk::Extent2D DesiredRenderExtent() const;
@@ -300,6 +314,34 @@ namespace Engine {
// TakeRenders 取预算内的脏 tile 换入 m_PreviewRenders(pass 只读)
Passes::PreviewTilePool m_PreviewPool;
std::vector<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
// GPU timestamp(pass 级耗时,ImGui 可读)
vk::QueryPool m_QueryPool;
@@ -14,6 +14,9 @@
#include <Engine/Assets/IAssetUploader.h>
#include <string>
#include <unordered_map>
namespace Engine {
class Device;
@@ -49,7 +52,8 @@ namespace Engine {
~VulkanAssetUploader() override;
// ---- 材质资产(.material 数据段 → GPU 段)----
// 每槽路径建独占纹理(空/缺失回退共享默认纹理),再组 Material
// 每槽经路径纹理缓存(跨材质去重)取纹理,同批新纹理合并为一次
// submit;空/缺失回退共享默认纹理,再组 Material
Scope<MaterialAsset> CreateMaterial(MaterialAssetData&& data) override;
// 设备空闲屏障(编辑器资产重建路径:卸载旧 GPU 资源前全停飞)
void WaitDeviceIdle() override;
@@ -60,6 +64,12 @@ namespace Engine {
void InitSharedDefaultTextures();
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;
MemoryAllocator& m_Allocator;
DescriptorManager& m_Descriptors;
+27 -1
View File
@@ -42,6 +42,18 @@ namespace Engine {
Device& device, MemoryAllocator& allocator,
const void* data, u32 bytes, bool srgb);
// 批式上传(一次 submit + 一次 fence 等待服务同批多张贴图,省掉
// 每张各自的命令池创建销毁与阻塞等待):解码 + 建 image/view/
// sampler + 把拷贝/barrier 录制进调用方 cmd(**不**提交)。staging
// 暂存对象内,调用方在批量 submit fence 通过后逐张
// ReleasePendingStaging 释放;析构亦兜底释放。DDS(稀有)仍走
// 自带提交,不参与批量
static Scope<Texture> CreateDeferred(Device& device,
MemoryAllocator& allocator,
const std::string& path, bool srgb,
vk::CommandBuffer cmd);
void ReleasePendingStaging();
~Texture();
Texture(const Texture&) = delete;
@@ -62,9 +74,19 @@ namespace Engine {
const std::array<u8, 4>& rgba, bool srgb);
// 公共路径:staging → device local image → blit 生成完整 mip 链 →
// view/sampler(RGBA8,各向异性过滤)
// view/sampler(RGBA8,各向异性过滤)。录制进 cmd 后自行提交等待
void InitFromPixels(const void* pixels, u32 width, u32 height, bool srgb);
// 批式路径构造(cmd 由调用方提供,不在本构造内提交)
Texture(Device& device, MemoryAllocator& allocator,
const std::string& path, bool srgb, vk::CommandBuffer cmd);
// 公共上传路径核心:建 staging/image/view/sampler + 逐 mip
// barrier/copy/blit 录制进 cmd(不提交、不销毁 staging,staging 存
// m_PendingStaging)。InitFromPixels 与批式路径共用
void RecordPixelsUpload(const void* pixels, u32 width, u32 height,
bool srgb, vk::CommandBuffer cmd);
// DDS 路径:压缩数据直传(BC 硬件采样解码)+ 文件自带 mip 链
void InitFromDds(const std::string& path, bool srgb);
@@ -83,6 +105,10 @@ namespace Engine {
u64 m_TextureBytes = 0;
// 批式上传暂存的 staging(submit fence 通过前不可释放)
bool m_HasPendingStaging = false;
MemoryAllocator::Allocation m_PendingStaging{};
MemoryAllocator::Allocation m_ImageAllocation;
MemoryAllocator& m_Allocator;
};