Files
XCEngine/editor/src/UI/BuiltInIcons.cpp

1310 lines
43 KiB
C++
Raw Normal View History

#include "BuiltInIcons.h"
#include "ImGuiBackendBridge.h"
#include "Platform/Win32Utf8.h"
#include "StyleTokens.h"
#include <algorithm>
#include <array>
2026-03-30 01:46:49 +08:00
#include <condition_variable>
#include <cstdint>
2026-03-29 01:36:53 +08:00
#include <cwctype>
2026-03-30 01:46:49 +08:00
#include <deque>
#include <cstring>
#include <filesystem>
2026-03-29 01:36:53 +08:00
#include <fstream>
#include <limits>
2026-03-30 01:46:49 +08:00
#include <memory>
#include <string>
2026-03-30 01:46:49 +08:00
#include <thread>
2026-03-29 01:36:53 +08:00
#include <unordered_map>
#include <vector>
#include <wrl/client.h>
#include <stb_image.h>
namespace XCEngine {
namespace Editor {
namespace UI {
namespace {
using Microsoft::WRL::ComPtr;
struct BuiltInTexture {
ImTextureID textureId = {};
D3D12_CPU_DESCRIPTOR_HANDLE cpuHandle = {};
D3D12_GPU_DESCRIPTOR_HANDLE gpuHandle = {};
ComPtr<ID3D12Resource> texture;
int width = 0;
int height = 0;
bool IsValid() const {
return textureId != ImTextureID{} && texture != nullptr && width > 0 && height > 0;
}
};
2026-03-30 01:46:49 +08:00
struct LoadedTexturePixels {
std::vector<stbi_uc> rgbaPixels;
int width = 0;
int height = 0;
};
struct PreviewGpuUpload {
ComPtr<ID3D12Resource> uploadResource;
ComPtr<ID3D12CommandAllocator> commandAllocator;
ComPtr<ID3D12GraphicsCommandList> commandList;
UINT64 fenceValue = 0;
};
struct PreviewDecodeJob {
std::string key;
std::filesystem::path filePath;
std::filesystem::path cacheFilePath;
std::string relativePathKey;
std::uint64_t fileSize = 0;
std::int64_t lastWriteTimeTicks = 0;
bool useDiskCache = false;
2026-03-30 01:46:49 +08:00
};
struct PreviewDecodeResult {
std::string key;
LoadedTexturePixels pixels;
std::uint64_t fileSize = 0;
std::int64_t lastWriteTimeTicks = 0;
2026-03-30 01:46:49 +08:00
bool success = false;
};
struct SourceFileFingerprint {
std::uint64_t fileSize = 0;
std::int64_t lastWriteTimeTicks = 0;
bool IsValid() const {
return fileSize > 0 && lastWriteTimeTicks != 0;
}
};
bool operator==(const SourceFileFingerprint& lhs, const SourceFileFingerprint& rhs) {
return lhs.fileSize == rhs.fileSize && lhs.lastWriteTimeTicks == rhs.lastWriteTimeTicks;
}
bool operator!=(const SourceFileFingerprint& lhs, const SourceFileFingerprint& rhs) {
return !(lhs == rhs);
}
struct PreviewDiskCacheEntry {
std::filesystem::path filePath;
std::string relativePathKey;
};
#pragma pack(push, 1)
struct ThumbnailCacheFileHeader {
char magic[8];
std::uint32_t version = 1;
std::uint64_t fileSize = 0;
std::int64_t lastWriteTimeTicks = 0;
std::uint32_t width = 0;
std::uint32_t height = 0;
std::uint32_t relativePathSize = 0;
std::uint32_t pixelDataSize = 0;
};
#pragma pack(pop)
struct BuiltInIconState {
ImGuiBackendBridge* backend = nullptr;
2026-03-29 01:36:53 +08:00
ID3D12Device* device = nullptr;
ID3D12CommandQueue* commandQueue = nullptr;
BuiltInTexture folder;
BuiltInTexture gameObject;
2026-03-29 01:36:53 +08:00
BuiltInTexture scene;
struct CachedAssetPreview {
BuiltInTexture texture;
2026-03-30 01:46:49 +08:00
std::unique_ptr<LoadedTexturePixels> decodedPixels;
std::unique_ptr<PreviewGpuUpload> pendingUpload;
SourceFileFingerprint sourceFingerprint;
2026-03-30 01:46:49 +08:00
bool decodeQueued = false;
bool hasStaleTexture = false;
2026-03-30 01:46:49 +08:00
bool loadFailed = false;
2026-03-29 01:36:53 +08:00
int lastUsedFrame = -1;
int lastSourceValidationFrame = -1;
2026-03-29 01:36:53 +08:00
};
std::unordered_map<std::string, CachedAssetPreview> assetPreviews;
2026-03-30 01:46:49 +08:00
std::vector<std::unique_ptr<PreviewGpuUpload>> pendingIconUploads;
std::vector<std::thread> previewWorkers;
std::deque<PreviewDecodeJob> previewDecodeQueue;
std::deque<PreviewDecodeResult> previewDecodeResults;
std::mutex previewQueueMutex;
std::condition_variable previewQueueEvent;
bool previewWorkersRunning = false;
size_t pendingPreviewDecodeJobs = 0;
ComPtr<ID3D12Fence> uploadFence;
UINT64 nextUploadFenceValue = 1;
2026-03-29 01:36:53 +08:00
int lastPreviewBudgetFrame = -1;
int previewLoadsThisFrame = 0;
2026-03-30 01:46:49 +08:00
int lastMaintenanceFrame = -1;
};
BuiltInIconState g_icons;
2026-03-29 01:36:53 +08:00
constexpr size_t kMaxCachedAssetPreviews = 40;
2026-03-30 01:46:49 +08:00
constexpr int kMaxPreviewLoadsPerFrame = 64;
constexpr size_t kMaxQueuedPreviewDecodeJobs = 64;
constexpr int kMaxPreviewThumbnailExtent = 192;
constexpr size_t kPreviewWorkerCount = 2;
constexpr int kPreviewSourceValidationIntervalFrames = 30;
constexpr std::uint32_t kThumbnailCacheVersion = 1;
constexpr std::array<char, 8> kThumbnailCacheMagic = { 'X', 'C', 'T', 'H', 'M', 'B', '1', '\0' };
constexpr std::uint32_t kMaxThumbnailCacheRelativePathBytes = 16u * 1024u;
2026-03-29 01:36:53 +08:00
std::filesystem::path ResolveFolderIconPath() {
const std::filesystem::path exeDir(Platform::GetExecutableDirectoryUtf8());
return (exeDir / ".." / ".." / "resources" / "Icons" / "folder_icon.png").lexically_normal();
}
std::filesystem::path ResolveGameObjectIconPath() {
const std::filesystem::path exeDir(Platform::GetExecutableDirectoryUtf8());
return (exeDir / ".." / ".." / "resources" / "Icons" / "gameobject_icon.png").lexically_normal();
}
2026-03-29 01:36:53 +08:00
std::filesystem::path ResolveSceneIconPath() {
const std::filesystem::path exeDir(Platform::GetExecutableDirectoryUtf8());
return (exeDir / ".." / ".." / "resources" / "Icons" / "scene_icon.png").lexically_normal();
}
std::string NormalizePathKey(const std::filesystem::path& path) {
std::wstring key = path.lexically_normal().generic_wstring();
2026-03-29 01:36:53 +08:00
std::transform(key.begin(), key.end(), key.begin(), ::towlower);
return Platform::WideToUtf8(key);
}
std::string MakeAssetPreviewKey(const std::string& filePath) {
return NormalizePathKey(std::filesystem::path(Platform::Utf8ToWide(filePath)));
}
SourceFileFingerprint MakeSourceFingerprint(
std::uint64_t fileSize,
std::int64_t lastWriteTimeTicks) {
SourceFileFingerprint fingerprint;
fingerprint.fileSize = fileSize;
fingerprint.lastWriteTimeTicks = lastWriteTimeTicks;
return fingerprint;
}
bool QuerySourceFileFingerprint(
const std::filesystem::path& filePath,
SourceFileFingerprint& outFingerprint) {
std::error_code ec;
if (!std::filesystem::exists(filePath, ec) || ec) {
return false;
}
ec.clear();
if (!std::filesystem::is_regular_file(filePath, ec) || ec) {
return false;
}
ec.clear();
const auto fileSize = std::filesystem::file_size(filePath, ec);
if (ec || fileSize == static_cast<std::uintmax_t>(-1)) {
return false;
}
ec.clear();
const auto lastWriteTime = std::filesystem::last_write_time(filePath, ec);
if (ec) {
return false;
}
outFingerprint.fileSize = static_cast<std::uint64_t>(fileSize);
outFingerprint.lastWriteTimeTicks = static_cast<std::int64_t>(lastWriteTime.time_since_epoch().count());
return outFingerprint.IsValid();
}
std::uint64_t ComputeFnv1a64(const std::string& value) {
constexpr std::uint64_t kOffsetBasis = 14695981039346656037ull;
constexpr std::uint64_t kPrime = 1099511628211ull;
std::uint64_t hash = kOffsetBasis;
for (unsigned char ch : value) {
hash ^= static_cast<std::uint64_t>(ch);
hash *= kPrime;
}
return hash;
}
std::string FormatHashHex(std::uint64_t value) {
static constexpr char kHexDigits[] = "0123456789abcdef";
std::string result(16, '0');
for (int index = 15; index >= 0; --index) {
result[static_cast<size_t>(index)] = kHexDigits[value & 0xFu];
value >>= 4u;
}
return result;
}
bool IsProjectRelativePath(const std::filesystem::path& relativePath) {
if (relativePath.empty()) {
return false;
}
const std::wstring generic = relativePath.generic_wstring();
return generic != L"." && generic != L".." && generic.rfind(L"../", 0) != 0;
}
bool ResolvePreviewDiskCacheEntry(
const std::string& projectPath,
const std::string& filePath,
PreviewDiskCacheEntry& outEntry) {
if (projectPath.empty() || filePath.empty()) {
return false;
}
std::error_code ec;
const std::filesystem::path rootPath = std::filesystem::weakly_canonical(
std::filesystem::path(Platform::Utf8ToWide(projectPath)),
ec);
if (ec || rootPath.empty()) {
return false;
}
ec.clear();
const std::filesystem::path assetPath = std::filesystem::weakly_canonical(
std::filesystem::path(Platform::Utf8ToWide(filePath)),
ec);
if (ec || assetPath.empty()) {
return false;
}
ec.clear();
std::filesystem::path relativePath = std::filesystem::relative(assetPath, rootPath, ec);
if (ec) {
return false;
}
relativePath = relativePath.lexically_normal();
if (!IsProjectRelativePath(relativePath)) {
return false;
}
outEntry.relativePathKey = NormalizePathKey(relativePath);
const std::string hashFileName = FormatHashHex(ComputeFnv1a64(outEntry.relativePathKey)) + ".thumb";
outEntry.filePath =
(rootPath / L".xceditor" / L"thumbs" / std::filesystem::path(Platform::Utf8ToWide(hashFileName)))
.lexically_normal();
return true;
}
bool ReadThumbnailCacheFile(
const std::filesystem::path& cacheFilePath,
const std::string& relativePathKey,
const SourceFileFingerprint& sourceFingerprint,
LoadedTexturePixels& outTexturePixels) {
std::ifstream stream(cacheFilePath, std::ios::binary | std::ios::in);
if (!stream.is_open()) {
return false;
}
ThumbnailCacheFileHeader header = {};
if (!stream.read(reinterpret_cast<char*>(&header), sizeof(header)).good()) {
return false;
}
if (std::memcmp(header.magic, kThumbnailCacheMagic.data(), kThumbnailCacheMagic.size()) != 0 ||
header.version != kThumbnailCacheVersion ||
header.fileSize != sourceFingerprint.fileSize ||
header.lastWriteTimeTicks != sourceFingerprint.lastWriteTimeTicks ||
header.relativePathSize == 0 ||
header.relativePathSize > kMaxThumbnailCacheRelativePathBytes ||
header.width == 0 ||
header.height == 0 ||
header.width > static_cast<std::uint32_t>(kMaxPreviewThumbnailExtent) ||
header.height > static_cast<std::uint32_t>(kMaxPreviewThumbnailExtent)) {
return false;
}
const std::uint64_t expectedPixelBytes =
static_cast<std::uint64_t>(header.width) * static_cast<std::uint64_t>(header.height) * 4ull;
if (expectedPixelBytes == 0 ||
expectedPixelBytes > static_cast<std::uint64_t>((std::numeric_limits<size_t>::max)()) ||
header.pixelDataSize != expectedPixelBytes) {
return false;
}
std::string cachedRelativePath(header.relativePathSize, '\0');
if (!stream.read(cachedRelativePath.data(), static_cast<std::streamsize>(cachedRelativePath.size())).good()) {
return false;
}
if (cachedRelativePath != relativePathKey) {
return false;
}
outTexturePixels.width = static_cast<int>(header.width);
outTexturePixels.height = static_cast<int>(header.height);
outTexturePixels.rgbaPixels.resize(static_cast<size_t>(header.pixelDataSize));
if (!stream.read(
reinterpret_cast<char*>(outTexturePixels.rgbaPixels.data()),
static_cast<std::streamsize>(outTexturePixels.rgbaPixels.size())).good()) {
outTexturePixels.rgbaPixels.clear();
outTexturePixels.width = 0;
outTexturePixels.height = 0;
return false;
}
return true;
}
void RemoveDiskCacheFile(const std::filesystem::path& cacheFilePath) {
std::error_code ec;
std::filesystem::remove(cacheFilePath, ec);
}
bool WriteThumbnailCacheFile(
const std::filesystem::path& cacheFilePath,
const std::string& relativePathKey,
const SourceFileFingerprint& sourceFingerprint,
const LoadedTexturePixels& texturePixels) {
if (cacheFilePath.empty() ||
relativePathKey.empty() ||
relativePathKey.size() > kMaxThumbnailCacheRelativePathBytes ||
!sourceFingerprint.IsValid() ||
texturePixels.width <= 0 ||
texturePixels.height <= 0 ||
texturePixels.rgbaPixels.empty()) {
return false;
}
const std::uint64_t pixelDataSize = static_cast<std::uint64_t>(texturePixels.rgbaPixels.size());
if (pixelDataSize !=
static_cast<std::uint64_t>(texturePixels.width) * static_cast<std::uint64_t>(texturePixels.height) * 4ull ||
pixelDataSize > static_cast<std::uint64_t>((std::numeric_limits<std::uint32_t>::max)())) {
return false;
}
std::error_code ec;
std::filesystem::create_directories(cacheFilePath.parent_path(), ec);
if (ec) {
return false;
}
const std::filesystem::path tempPath = cacheFilePath.parent_path() / (cacheFilePath.filename().wstring() + L".tmp");
std::ofstream stream(tempPath, std::ios::binary | std::ios::out | std::ios::trunc);
if (!stream.is_open()) {
return false;
}
ThumbnailCacheFileHeader header = {};
std::memcpy(header.magic, kThumbnailCacheMagic.data(), kThumbnailCacheMagic.size());
header.version = kThumbnailCacheVersion;
header.fileSize = sourceFingerprint.fileSize;
header.lastWriteTimeTicks = sourceFingerprint.lastWriteTimeTicks;
header.width = static_cast<std::uint32_t>(texturePixels.width);
header.height = static_cast<std::uint32_t>(texturePixels.height);
header.relativePathSize = static_cast<std::uint32_t>(relativePathKey.size());
header.pixelDataSize = static_cast<std::uint32_t>(pixelDataSize);
const bool writeSucceeded =
stream.write(reinterpret_cast<const char*>(&header), sizeof(header)).good() &&
stream.write(relativePathKey.data(), static_cast<std::streamsize>(relativePathKey.size())).good() &&
stream.write(
reinterpret_cast<const char*>(texturePixels.rgbaPixels.data()),
static_cast<std::streamsize>(texturePixels.rgbaPixels.size())).good();
stream.close();
if (!writeSucceeded) {
RemoveDiskCacheFile(tempPath);
return false;
}
ec.clear();
std::filesystem::remove(cacheFilePath, ec);
ec.clear();
std::filesystem::rename(tempPath, cacheFilePath, ec);
if (ec) {
RemoveDiskCacheFile(tempPath);
return false;
}
return true;
}
2026-03-29 01:36:53 +08:00
bool ReadFileBytes(const std::filesystem::path& filePath, std::vector<stbi_uc>& bytes) {
std::ifstream stream(filePath, std::ios::binary | std::ios::ate);
if (!stream.is_open()) {
return false;
}
const std::streamsize size = stream.tellg();
if (size <= 0) {
return false;
}
bytes.resize(static_cast<size_t>(size));
stream.seekg(0, std::ios::beg);
return stream.read(reinterpret_cast<char*>(bytes.data()), size).good();
}
2026-03-30 01:46:49 +08:00
void ResizeRgbaImageBilinear(
const stbi_uc* srcPixels,
int srcWidth,
int srcHeight,
int dstWidth,
int dstHeight,
std::vector<stbi_uc>& dstPixels) {
dstPixels.resize(static_cast<size_t>(dstWidth) * static_cast<size_t>(dstHeight) * 4u);
if (!srcPixels || srcWidth <= 0 || srcHeight <= 0 || dstWidth <= 0 || dstHeight <= 0) {
return;
}
for (int dstY = 0; dstY < dstHeight; ++dstY) {
const float srcY = dstHeight > 1
? static_cast<float>(dstY) * static_cast<float>(srcHeight - 1) / static_cast<float>(dstHeight - 1)
: 0.0f;
const int y0 = static_cast<int>(srcY);
const int y1 = (std::min)(y0 + 1, srcHeight - 1);
const float ty = srcY - static_cast<float>(y0);
for (int dstX = 0; dstX < dstWidth; ++dstX) {
const float srcX = dstWidth > 1
? static_cast<float>(dstX) * static_cast<float>(srcWidth - 1) / static_cast<float>(dstWidth - 1)
: 0.0f;
const int x0 = static_cast<int>(srcX);
const int x1 = (std::min)(x0 + 1, srcWidth - 1);
const float tx = srcX - static_cast<float>(x0);
const size_t dstOffset =
(static_cast<size_t>(dstY) * static_cast<size_t>(dstWidth) + static_cast<size_t>(dstX)) * 4u;
const size_t srcOffset00 =
(static_cast<size_t>(y0) * static_cast<size_t>(srcWidth) + static_cast<size_t>(x0)) * 4u;
const size_t srcOffset10 =
(static_cast<size_t>(y0) * static_cast<size_t>(srcWidth) + static_cast<size_t>(x1)) * 4u;
const size_t srcOffset01 =
(static_cast<size_t>(y1) * static_cast<size_t>(srcWidth) + static_cast<size_t>(x0)) * 4u;
const size_t srcOffset11 =
(static_cast<size_t>(y1) * static_cast<size_t>(srcWidth) + static_cast<size_t>(x1)) * 4u;
for (size_t channel = 0; channel < 4u; ++channel) {
const float top =
static_cast<float>(srcPixels[srcOffset00 + channel]) * (1.0f - tx) +
static_cast<float>(srcPixels[srcOffset10 + channel]) * tx;
const float bottom =
static_cast<float>(srcPixels[srcOffset01 + channel]) * (1.0f - tx) +
static_cast<float>(srcPixels[srcOffset11 + channel]) * tx;
dstPixels[dstOffset + channel] = static_cast<stbi_uc>(top * (1.0f - ty) + bottom * ty + 0.5f);
}
}
}
}
bool DownscalePreviewTextureIfNeeded(LoadedTexturePixels& texturePixels) {
if (texturePixels.width <= 0 || texturePixels.height <= 0 || texturePixels.rgbaPixels.empty()) {
return false;
}
const int maxExtent = (std::max)(texturePixels.width, texturePixels.height);
if (maxExtent <= kMaxPreviewThumbnailExtent) {
return true;
}
const float scale = static_cast<float>(kMaxPreviewThumbnailExtent) / static_cast<float>(maxExtent);
const int dstWidth = (std::max)(1, static_cast<int>(static_cast<float>(texturePixels.width) * scale + 0.5f));
const int dstHeight = (std::max)(1, static_cast<int>(static_cast<float>(texturePixels.height) * scale + 0.5f));
std::vector<stbi_uc> resizedPixels;
ResizeRgbaImageBilinear(
texturePixels.rgbaPixels.data(),
texturePixels.width,
texturePixels.height,
dstWidth,
dstHeight,
resizedPixels);
if (resizedPixels.empty()) {
return false;
}
texturePixels.rgbaPixels = std::move(resizedPixels);
texturePixels.width = dstWidth;
texturePixels.height = dstHeight;
return true;
}
void ResetTexture(BuiltInTexture& texture);
void InvalidateAssetPreview(BuiltInIconState::CachedAssetPreview& preview) {
if (preview.pendingUpload) {
preview.hasStaleTexture = true;
} else {
ResetTexture(preview.texture);
preview.hasStaleTexture = false;
}
preview.decodedPixels.reset();
preview.decodeQueued = false;
preview.loadFailed = false;
}
bool RefreshAssetPreviewSourceFingerprint(
const std::filesystem::path& filePath,
BuiltInIconState::CachedAssetPreview& preview) {
SourceFileFingerprint currentFingerprint;
if (!QuerySourceFileFingerprint(filePath, currentFingerprint)) {
InvalidateAssetPreview(preview);
preview.sourceFingerprint = {};
preview.loadFailed = true;
return false;
}
if (preview.sourceFingerprint != currentFingerprint) {
InvalidateAssetPreview(preview);
preview.sourceFingerprint = currentFingerprint;
}
return true;
}
void ResetTexture(BuiltInTexture& texture) {
if (g_icons.backend && texture.cpuHandle.ptr != 0) {
g_icons.backend->FreeTextureDescriptor(texture.cpuHandle, texture.gpuHandle);
}
texture.texture.Reset();
texture.textureId = {};
texture.cpuHandle = {};
texture.gpuHandle = {};
texture.width = 0;
texture.height = 0;
}
2026-03-29 01:36:53 +08:00
void ResetAssetPreviewCache() {
for (auto& entry : g_icons.assetPreviews) {
ResetTexture(entry.second.texture);
}
g_icons.assetPreviews.clear();
}
2026-03-30 01:46:49 +08:00
bool EnsureUploadFence(ID3D12Device* device) {
if (g_icons.uploadFence) {
return true;
}
return SUCCEEDED(device->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&g_icons.uploadFence)));
}
bool WaitForQueueIdle(ID3D12Device* device, ID3D12CommandQueue* commandQueue) {
if (!device || !commandQueue) {
return false;
}
ComPtr<ID3D12Fence> fence;
if (FAILED(device->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&fence)))) {
return false;
}
HANDLE eventHandle = CreateEventW(nullptr, FALSE, FALSE, nullptr);
if (!eventHandle) {
return false;
}
constexpr UINT64 kFenceValue = 1;
const HRESULT signalHr = commandQueue->Signal(fence.Get(), kFenceValue);
if (FAILED(signalHr)) {
CloseHandle(eventHandle);
return false;
}
if (fence->GetCompletedValue() < kFenceValue) {
if (FAILED(fence->SetEventOnCompletion(kFenceValue, eventHandle))) {
CloseHandle(eventHandle);
return false;
}
WaitForSingleObject(eventHandle, INFINITE);
}
CloseHandle(eventHandle);
return true;
}
2026-03-30 01:46:49 +08:00
bool DecodeTextureFromFile(
const std::filesystem::path& filePath,
2026-03-30 01:46:49 +08:00
LoadedTexturePixels& outTexturePixels) {
if (!std::filesystem::exists(filePath)) {
return false;
}
2026-03-29 01:36:53 +08:00
std::vector<stbi_uc> fileData;
if (!ReadFileBytes(filePath, fileData)) {
return false;
}
int width = 0;
int height = 0;
int channels = 0;
2026-03-29 01:36:53 +08:00
stbi_uc* pixels = stbi_load_from_memory(
fileData.data(),
static_cast<int>(fileData.size()),
&width,
&height,
&channels,
STBI_rgb_alpha);
if (!pixels || width <= 0 || height <= 0) {
if (pixels) {
stbi_image_free(pixels);
}
return false;
}
2026-03-30 01:46:49 +08:00
outTexturePixels.width = width;
outTexturePixels.height = height;
outTexturePixels.rgbaPixels.assign(
pixels,
pixels + static_cast<size_t>(width) * static_cast<size_t>(height) * 4u);
stbi_image_free(pixels);
return DownscalePreviewTextureIfNeeded(outTexturePixels);
}
bool UploadTexturePixels(
ImGuiBackendBridge& backend,
ID3D12Device* device,
ID3D12CommandQueue* commandQueue,
const LoadedTexturePixels& texturePixels,
BuiltInTexture& outTexture,
std::unique_ptr<PreviewGpuUpload>& outPendingUpload) {
if (!device || !commandQueue || texturePixels.width <= 0 || texturePixels.height <= 0 || texturePixels.rgbaPixels.empty()) {
return false;
}
const UINT srcRowPitch = static_cast<UINT>(texturePixels.width * 4);
D3D12_RESOURCE_DESC textureDesc = {};
textureDesc.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D;
textureDesc.Alignment = 0;
2026-03-30 01:46:49 +08:00
textureDesc.Width = static_cast<UINT64>(texturePixels.width);
textureDesc.Height = static_cast<UINT>(texturePixels.height);
textureDesc.DepthOrArraySize = 1;
textureDesc.MipLevels = 1;
textureDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
textureDesc.SampleDesc.Count = 1;
textureDesc.Layout = D3D12_TEXTURE_LAYOUT_UNKNOWN;
D3D12_HEAP_PROPERTIES defaultHeap = {};
defaultHeap.Type = D3D12_HEAP_TYPE_DEFAULT;
ComPtr<ID3D12Resource> textureResource;
if (FAILED(device->CreateCommittedResource(
&defaultHeap,
D3D12_HEAP_FLAG_NONE,
&textureDesc,
D3D12_RESOURCE_STATE_COPY_DEST,
nullptr,
IID_PPV_ARGS(&textureResource)))) {
return false;
}
D3D12_PLACED_SUBRESOURCE_FOOTPRINT footprint = {};
UINT numRows = 0;
UINT64 rowSizeInBytes = 0;
UINT64 uploadBufferSize = 0;
device->GetCopyableFootprints(&textureDesc, 0, 1, 0, &footprint, &numRows, &rowSizeInBytes, &uploadBufferSize);
D3D12_RESOURCE_DESC uploadDesc = {};
uploadDesc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER;
uploadDesc.Width = uploadBufferSize;
uploadDesc.Height = 1;
uploadDesc.DepthOrArraySize = 1;
uploadDesc.MipLevels = 1;
uploadDesc.SampleDesc.Count = 1;
uploadDesc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR;
D3D12_HEAP_PROPERTIES uploadHeap = {};
uploadHeap.Type = D3D12_HEAP_TYPE_UPLOAD;
ComPtr<ID3D12Resource> uploadResource;
if (FAILED(device->CreateCommittedResource(
&uploadHeap,
D3D12_HEAP_FLAG_NONE,
&uploadDesc,
D3D12_RESOURCE_STATE_GENERIC_READ,
nullptr,
IID_PPV_ARGS(&uploadResource)))) {
return false;
}
std::uint8_t* mappedData = nullptr;
if (FAILED(uploadResource->Map(0, nullptr, reinterpret_cast<void**>(&mappedData)))) {
return false;
}
for (UINT row = 0; row < numRows; ++row) {
std::memcpy(
mappedData + footprint.Offset + static_cast<SIZE_T>(row) * footprint.Footprint.RowPitch,
2026-03-30 01:46:49 +08:00
texturePixels.rgbaPixels.data() + static_cast<size_t>(row) * srcRowPitch,
srcRowPitch);
}
uploadResource->Unmap(0, nullptr);
ComPtr<ID3D12CommandAllocator> commandAllocator;
ComPtr<ID3D12GraphicsCommandList> commandList;
if (FAILED(device->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(&commandAllocator)))) {
return false;
}
if (FAILED(device->CreateCommandList(
0,
D3D12_COMMAND_LIST_TYPE_DIRECT,
commandAllocator.Get(),
nullptr,
IID_PPV_ARGS(&commandList)))) {
return false;
}
D3D12_TEXTURE_COPY_LOCATION dst = {};
dst.pResource = textureResource.Get();
dst.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX;
dst.SubresourceIndex = 0;
D3D12_TEXTURE_COPY_LOCATION src = {};
src.pResource = uploadResource.Get();
src.Type = D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT;
src.PlacedFootprint = footprint;
commandList->CopyTextureRegion(&dst, 0, 0, 0, &src, nullptr);
D3D12_RESOURCE_BARRIER barrier = {};
barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
barrier.Transition.pResource = textureResource.Get();
barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_COPY_DEST;
barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE;
barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
commandList->ResourceBarrier(1, &barrier);
if (FAILED(commandList->Close())) {
return false;
}
2026-03-30 01:46:49 +08:00
if (!EnsureUploadFence(device)) {
return false;
}
2026-03-30 01:46:49 +08:00
ResetTexture(outTexture);
backend.AllocateTextureDescriptor(&outTexture.cpuHandle, &outTexture.gpuHandle);
2026-03-30 01:46:49 +08:00
if (outTexture.cpuHandle.ptr == 0 || outTexture.gpuHandle.ptr == 0) {
ResetTexture(outTexture);
return false;
}
D3D12_SHADER_RESOURCE_VIEW_DESC srvDesc = {};
srvDesc.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
srvDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
srvDesc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D;
srvDesc.Texture2D.MipLevels = 1;
device->CreateShaderResourceView(textureResource.Get(), &srvDesc, outTexture.cpuHandle);
outTexture.texture = textureResource;
outTexture.textureId = (ImTextureID)(static_cast<intptr_t>(outTexture.gpuHandle.ptr));
2026-03-30 01:46:49 +08:00
outTexture.width = texturePixels.width;
outTexture.height = texturePixels.height;
ID3D12CommandList* commandLists[] = { commandList.Get() };
commandQueue->ExecuteCommandLists(1, commandLists);
const UINT64 fenceValue = g_icons.nextUploadFenceValue++;
if (FAILED(commandQueue->Signal(g_icons.uploadFence.Get(), fenceValue))) {
ResetTexture(outTexture);
return false;
}
outPendingUpload = std::make_unique<PreviewGpuUpload>();
outPendingUpload->uploadResource = std::move(uploadResource);
outPendingUpload->commandAllocator = std::move(commandAllocator);
outPendingUpload->commandList = std::move(commandList);
outPendingUpload->fenceValue = fenceValue;
return true;
}
bool LoadTextureFromFile(
ImGuiBackendBridge& backend,
ID3D12Device* device,
ID3D12CommandQueue* commandQueue,
const std::filesystem::path& filePath,
BuiltInTexture& outTexture,
std::unique_ptr<PreviewGpuUpload>& outPendingUpload) {
LoadedTexturePixels texturePixels;
if (!DecodeTextureFromFile(filePath, texturePixels)) {
return false;
}
return UploadTexturePixels(
backend,
device,
commandQueue,
texturePixels,
outTexture,
outPendingUpload);
}
void PreviewWorkerMain() {
while (true) {
PreviewDecodeJob job;
{
std::unique_lock<std::mutex> lock(g_icons.previewQueueMutex);
g_icons.previewQueueEvent.wait(lock, [] {
return !g_icons.previewWorkersRunning || !g_icons.previewDecodeQueue.empty();
});
if (!g_icons.previewWorkersRunning && g_icons.previewDecodeQueue.empty()) {
return;
}
job = std::move(g_icons.previewDecodeQueue.front());
g_icons.previewDecodeQueue.pop_front();
}
PreviewDecodeResult result;
result.key = std::move(job.key);
result.fileSize = job.fileSize;
result.lastWriteTimeTicks = job.lastWriteTimeTicks;
if (job.useDiskCache) {
result.success = ReadThumbnailCacheFile(
job.cacheFilePath,
job.relativePathKey,
MakeSourceFingerprint(job.fileSize, job.lastWriteTimeTicks),
result.pixels);
if (!result.success) {
RemoveDiskCacheFile(job.cacheFilePath);
}
}
if (!result.success) {
result.success = DecodeTextureFromFile(job.filePath, result.pixels);
if (result.success && job.useDiskCache) {
WriteThumbnailCacheFile(
job.cacheFilePath,
job.relativePathKey,
MakeSourceFingerprint(job.fileSize, job.lastWriteTimeTicks),
result.pixels);
}
}
2026-03-30 01:46:49 +08:00
{
std::lock_guard<std::mutex> lock(g_icons.previewQueueMutex);
g_icons.previewDecodeResults.push_back(std::move(result));
}
}
}
void StartPreviewWorkers() {
if (g_icons.previewWorkersRunning) {
return;
}
g_icons.previewWorkersRunning = true;
g_icons.previewWorkers.reserve(kPreviewWorkerCount);
for (size_t workerIndex = 0; workerIndex < kPreviewWorkerCount; ++workerIndex) {
g_icons.previewWorkers.emplace_back(PreviewWorkerMain);
}
}
void StopPreviewWorkers() {
{
std::lock_guard<std::mutex> lock(g_icons.previewQueueMutex);
g_icons.previewWorkersRunning = false;
g_icons.previewDecodeQueue.clear();
g_icons.previewDecodeResults.clear();
g_icons.pendingPreviewDecodeJobs = 0;
}
g_icons.previewQueueEvent.notify_all();
for (std::thread& worker : g_icons.previewWorkers) {
if (worker.joinable()) {
worker.join();
}
}
g_icons.previewWorkers.clear();
}
bool QueuePreviewDecode(
const std::string& key,
const std::filesystem::path& filePath,
const SourceFileFingerprint& sourceFingerprint,
const std::string& projectPath,
const std::string& filePathUtf8) {
PreviewDiskCacheEntry diskCacheEntry;
const bool useDiskCache = ResolvePreviewDiskCacheEntry(projectPath, filePathUtf8, diskCacheEntry);
2026-03-30 01:46:49 +08:00
std::lock_guard<std::mutex> lock(g_icons.previewQueueMutex);
if (!g_icons.previewWorkersRunning || g_icons.pendingPreviewDecodeJobs >= kMaxQueuedPreviewDecodeJobs) {
return false;
}
PreviewDecodeJob job;
job.key = key;
job.filePath = filePath;
job.fileSize = sourceFingerprint.fileSize;
job.lastWriteTimeTicks = sourceFingerprint.lastWriteTimeTicks;
if (useDiskCache) {
job.cacheFilePath = std::move(diskCacheEntry.filePath);
job.relativePathKey = std::move(diskCacheEntry.relativePathKey);
job.useDiskCache = true;
}
g_icons.previewDecodeQueue.push_back(std::move(job));
2026-03-30 01:46:49 +08:00
++g_icons.pendingPreviewDecodeJobs;
g_icons.previewQueueEvent.notify_one();
return true;
}
2026-03-30 01:46:49 +08:00
void DrainPreviewDecodeResults() {
std::deque<PreviewDecodeResult> completedResults;
{
std::lock_guard<std::mutex> lock(g_icons.previewQueueMutex);
completedResults.swap(g_icons.previewDecodeResults);
}
for (PreviewDecodeResult& result : completedResults) {
if (g_icons.pendingPreviewDecodeJobs > 0) {
--g_icons.pendingPreviewDecodeJobs;
}
auto it = g_icons.assetPreviews.find(result.key);
if (it == g_icons.assetPreviews.end()) {
continue;
}
BuiltInIconState::CachedAssetPreview& preview = it->second;
preview.decodeQueued = false;
if (preview.sourceFingerprint != MakeSourceFingerprint(result.fileSize, result.lastWriteTimeTicks)) {
continue;
}
2026-03-30 01:46:49 +08:00
if (!result.success) {
preview.loadFailed = true;
preview.decodedPixels.reset();
continue;
}
preview.loadFailed = false;
preview.decodedPixels = std::make_unique<LoadedTexturePixels>(std::move(result.pixels));
}
}
void PollCompletedUploads() {
if (!g_icons.uploadFence) {
return;
}
const UINT64 completedFenceValue = g_icons.uploadFence->GetCompletedValue();
for (auto& entry : g_icons.assetPreviews) {
auto& preview = entry.second;
if (preview.pendingUpload && completedFenceValue >= preview.pendingUpload->fenceValue) {
preview.pendingUpload.reset();
if (preview.hasStaleTexture) {
ResetTexture(preview.texture);
preview.hasStaleTexture = false;
}
2026-03-30 01:46:49 +08:00
}
}
auto eraseIt = std::remove_if(
g_icons.pendingIconUploads.begin(),
g_icons.pendingIconUploads.end(),
[completedFenceValue](const std::unique_ptr<PreviewGpuUpload>& upload) {
return upload && completedFenceValue >= upload->fenceValue;
});
g_icons.pendingIconUploads.erase(eraseIt, g_icons.pendingIconUploads.end());
}
void MaintainIconRuntimeState() {
const int frame = ImGui::GetFrameCount();
if (g_icons.lastMaintenanceFrame == frame) {
return;
}
g_icons.lastMaintenanceFrame = frame;
DrainPreviewDecodeResults();
PollCompletedUploads();
}
ImVec2 ComputeFittedIconSize(const BuiltInTexture& texture, const ImVec2& min, const ImVec2& max) {
const float availableWidth = max.x - min.x;
const float availableHeight = max.y - min.y;
if (availableWidth <= 0.0f || availableHeight <= 0.0f || texture.width <= 0 || texture.height <= 0) {
return ImVec2(0.0f, 0.0f);
}
const float scale = (std::min)(
availableWidth / static_cast<float>(texture.width),
availableHeight / static_cast<float>(texture.height));
return ImVec2(
static_cast<float>(texture.width) * scale,
static_cast<float>(texture.height) * scale);
}
void DrawTextureIcon(ImDrawList* drawList, const BuiltInTexture& texture, const ImVec2& min, const ImVec2& max) {
if (!drawList || !texture.IsValid()) {
return;
}
const ImVec2 size = ComputeFittedIconSize(texture, min, max);
const float x = min.x + ((max.x - min.x) - size.x) * 0.5f;
const float y = min.y + ((max.y - min.y) - size.y) * 0.5f;
drawList->AddImage(texture.textureId, ImVec2(x, y), ImVec2(x + size.x, y + size.y));
}
void DrawBuiltInFolderFallback(ImDrawList* drawList, const ImVec2& min, const ImVec2& max) {
if (!drawList) {
return;
}
const float width = max.x - min.x;
const float height = max.y - min.y;
if (width <= 0.0f || height <= 0.0f) {
return;
}
const float rounding = (std::max)(1.0f, (std::min)(width, height) * 0.18f);
const ImU32 tabColor = ImGui::GetColorU32(BuiltInFolderIconTabColor());
const ImU32 topColor = ImGui::GetColorU32(BuiltInFolderIconTopColor());
const ImU32 bodyColor = ImGui::GetColorU32(BuiltInFolderIconBodyColor());
const ImVec2 tabMin(min.x + width * 0.08f, min.y + height * 0.14f);
const ImVec2 tabMax(min.x + width * 0.48f, min.y + height * 0.38f);
const ImVec2 topMin(min.x + width * 0.24f, min.y + height * 0.22f);
const ImVec2 topMax(min.x + width * 0.90f, min.y + height * 0.42f);
const ImVec2 bodyMin(min.x + width * 0.06f, min.y + height * 0.32f);
const ImVec2 bodyMax(min.x + width * 0.94f, min.y + height * 0.88f);
drawList->AddRectFilled(tabMin, tabMax, tabColor, rounding);
drawList->AddRectFilled(
topMin,
topMax,
topColor,
rounding,
ImDrawFlags_RoundCornersTopLeft | ImDrawFlags_RoundCornersTopRight);
drawList->AddRectFilled(bodyMin, bodyMax, bodyColor, rounding);
}
void DrawBuiltInFileIcon(ImDrawList* drawList, const ImVec2& min, const ImVec2& max) {
if (!drawList) {
return;
}
const ImU32 fillColor = ImGui::GetColorU32(AssetFileIconFillColor());
const ImU32 lineColor = ImGui::GetColorU32(AssetFileIconLineColor());
const ImVec2 foldA(max.x - 8.0f, min.y);
const ImVec2 foldB(max.x, min.y + 8.0f);
drawList->AddRectFilled(min, max, fillColor, 2.0f);
drawList->AddRect(min, max, lineColor, 2.0f);
drawList->AddTriangleFilled(foldA, ImVec2(max.x, min.y), foldB, ImGui::GetColorU32(AssetFileFoldColor()));
drawList->AddLine(foldA, foldB, lineColor);
}
BuiltInIconState::CachedAssetPreview* GetOrCreateAssetPreview(
const std::string& filePath,
const std::string& projectPath) {
2026-03-29 01:36:53 +08:00
if (!g_icons.backend || !g_icons.device || !g_icons.commandQueue || filePath.empty()) {
return nullptr;
}
2026-03-30 01:46:49 +08:00
MaintainIconRuntimeState();
const std::filesystem::path filePathW(Platform::Utf8ToWide(filePath));
2026-03-29 01:36:53 +08:00
const std::string key = MakeAssetPreviewKey(filePath);
auto [it, inserted] = g_icons.assetPreviews.try_emplace(key);
BuiltInIconState::CachedAssetPreview& preview = it->second;
preview.lastUsedFrame = ImGui::GetFrameCount();
const int frame = ImGui::GetFrameCount();
if (inserted ||
preview.lastSourceValidationFrame < 0 ||
frame - preview.lastSourceValidationFrame >= kPreviewSourceValidationIntervalFrames) {
preview.lastSourceValidationFrame = frame;
if (!RefreshAssetPreviewSourceFingerprint(filePathW, preview)) {
return &preview;
2026-03-30 01:46:49 +08:00
}
}
if (!preview.sourceFingerprint.IsValid()) {
2026-03-29 01:36:53 +08:00
return &preview;
}
if (preview.texture.IsValid() && !preview.hasStaleTexture) {
2026-03-30 01:46:49 +08:00
if (preview.pendingUpload && preview.pendingUpload->fenceValue <= g_icons.uploadFence->GetCompletedValue()) {
preview.pendingUpload.reset();
}
return &preview;
}
if (preview.decodedPixels && !preview.pendingUpload) {
2026-03-30 01:46:49 +08:00
std::unique_ptr<PreviewGpuUpload> pendingUpload;
if (!UploadTexturePixels(
*g_icons.backend,
g_icons.device,
g_icons.commandQueue,
*preview.decodedPixels,
preview.texture,
pendingUpload)) {
preview.decodedPixels.reset();
preview.loadFailed = true;
return &preview;
}
preview.decodedPixels.reset();
preview.loadFailed = false;
preview.pendingUpload = std::move(pendingUpload);
return &preview;
}
if (preview.decodeQueued || preview.loadFailed) {
2026-03-29 01:36:53 +08:00
return &preview;
}
if (g_icons.lastPreviewBudgetFrame != frame) {
g_icons.lastPreviewBudgetFrame = frame;
g_icons.previewLoadsThisFrame = 0;
}
if (g_icons.previewLoadsThisFrame >= kMaxPreviewLoadsPerFrame) {
return &preview;
}
if (QueuePreviewDecode(key, filePathW, preview.sourceFingerprint, projectPath, filePath)) {
2026-03-30 01:46:49 +08:00
preview.decodeQueued = true;
++g_icons.previewLoadsThisFrame;
}
2026-03-29 01:36:53 +08:00
return &preview;
}
void PruneAssetPreviewCache() {
if (g_icons.assetPreviews.size() <= kMaxCachedAssetPreviews) {
return;
}
std::vector<std::pair<std::string, int>> candidates;
candidates.reserve(g_icons.assetPreviews.size());
for (const auto& entry : g_icons.assetPreviews) {
2026-03-30 01:46:49 +08:00
if (entry.second.pendingUpload || entry.second.decodeQueued) {
continue;
}
2026-03-29 01:36:53 +08:00
candidates.emplace_back(entry.first, entry.second.lastUsedFrame);
}
std::sort(
candidates.begin(),
candidates.end(),
[](const auto& lhs, const auto& rhs) {
return lhs.second < rhs.second;
});
size_t removeCount = g_icons.assetPreviews.size() - kMaxCachedAssetPreviews;
for (size_t i = 0; i < removeCount && i < candidates.size(); ++i) {
auto it = g_icons.assetPreviews.find(candidates[i].first);
if (it == g_icons.assetPreviews.end()) {
continue;
}
ResetTexture(it->second.texture);
g_icons.assetPreviews.erase(it);
}
}
} // namespace
void InitializeBuiltInIcons(
ImGuiBackendBridge& backend,
ID3D12Device* device,
ID3D12CommandQueue* commandQueue) {
ShutdownBuiltInIcons();
g_icons.backend = &backend;
2026-03-29 01:36:53 +08:00
g_icons.device = device;
g_icons.commandQueue = commandQueue;
2026-03-30 01:46:49 +08:00
StartPreviewWorkers();
std::unique_ptr<PreviewGpuUpload> pendingUpload;
if (LoadTextureFromFile(backend, device, commandQueue, ResolveFolderIconPath(), g_icons.folder, pendingUpload)) {
g_icons.pendingIconUploads.push_back(std::move(pendingUpload));
}
pendingUpload.reset();
if (LoadTextureFromFile(backend, device, commandQueue, ResolveGameObjectIconPath(), g_icons.gameObject, pendingUpload)) {
g_icons.pendingIconUploads.push_back(std::move(pendingUpload));
}
pendingUpload.reset();
if (LoadTextureFromFile(backend, device, commandQueue, ResolveSceneIconPath(), g_icons.scene, pendingUpload)) {
g_icons.pendingIconUploads.push_back(std::move(pendingUpload));
}
}
void ShutdownBuiltInIcons() {
2026-03-30 01:46:49 +08:00
if (g_icons.device && g_icons.commandQueue) {
WaitForQueueIdle(g_icons.device, g_icons.commandQueue);
}
StopPreviewWorkers();
2026-03-29 01:36:53 +08:00
ResetAssetPreviewCache();
2026-03-30 01:46:49 +08:00
g_icons.pendingIconUploads.clear();
ResetTexture(g_icons.folder);
ResetTexture(g_icons.gameObject);
2026-03-29 01:36:53 +08:00
ResetTexture(g_icons.scene);
g_icons.backend = nullptr;
2026-03-29 01:36:53 +08:00
g_icons.device = nullptr;
g_icons.commandQueue = nullptr;
2026-03-30 01:46:49 +08:00
g_icons.uploadFence.Reset();
g_icons.nextUploadFenceValue = 1;
g_icons.lastMaintenanceFrame = -1;
2026-03-29 01:36:53 +08:00
g_icons.lastPreviewBudgetFrame = -1;
g_icons.previewLoadsThisFrame = 0;
}
void DrawAssetIcon(ImDrawList* drawList, const ImVec2& min, const ImVec2& max, AssetIconKind kind) {
2026-03-30 01:46:49 +08:00
MaintainIconRuntimeState();
if (kind == AssetIconKind::Folder) {
if (g_icons.folder.IsValid()) {
DrawTextureIcon(drawList, g_icons.folder, min, max);
return;
}
DrawBuiltInFolderFallback(drawList, min, max);
return;
}
if (kind == AssetIconKind::GameObject) {
if (g_icons.gameObject.IsValid()) {
DrawTextureIcon(drawList, g_icons.gameObject, min, max);
return;
}
DrawBuiltInFileIcon(drawList, min, max);
return;
}
2026-03-29 01:36:53 +08:00
if (kind == AssetIconKind::Scene) {
if (g_icons.scene.IsValid()) {
DrawTextureIcon(drawList, g_icons.scene, min, max);
return;
}
DrawBuiltInFileIcon(drawList, min, max);
return;
}
DrawBuiltInFileIcon(drawList, min, max);
}
2026-03-29 01:36:53 +08:00
bool DrawTextureAssetPreview(
ImDrawList* drawList,
const ImVec2& min,
const ImVec2& max,
const std::string& filePath,
const std::string& projectPath) {
BuiltInIconState::CachedAssetPreview* preview = GetOrCreateAssetPreview(filePath, projectPath);
if (!preview || !preview->texture.IsValid() || preview->hasStaleTexture) {
2026-03-29 01:36:53 +08:00
return false;
}
DrawTextureIcon(drawList, preview->texture, min, max);
PruneAssetPreviewCache();
return true;
}
} // namespace UI
} // namespace Editor
} // namespace XCEngine