EDIT: folder structure refactoring (src/core)
This commit is contained in:
300
src/core/assets/ibl_manager.cpp
Normal file
300
src/core/assets/ibl_manager.cpp
Normal file
@@ -0,0 +1,300 @@
|
||||
#include "ibl_manager.h"
|
||||
#include <core/context.h>
|
||||
#include <core/device/resource.h>
|
||||
#include <core/assets/ktx_loader.h>
|
||||
#include <core/pipeline/sampler.h>
|
||||
#include <core/descriptor/descriptors.h>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <ktx.h>
|
||||
#include <SDL_stdinc.h>
|
||||
|
||||
#include "core/device/device.h"
|
||||
|
||||
bool IBLManager::load(const IBLPaths &paths)
|
||||
{
|
||||
if (_ctx == nullptr || _ctx->getResources() == nullptr) return false;
|
||||
ensureLayout();
|
||||
ResourceManager *rm = _ctx->getResources();
|
||||
|
||||
// Load specular environment: prefer cubemap; fallback to 2D equirect with mips
|
||||
if (!paths.specularCube.empty())
|
||||
{
|
||||
// Try as cubemap first
|
||||
ktxutil::KtxCubemap kcm{};
|
||||
if (ktxutil::load_ktx2_cubemap(paths.specularCube.c_str(), kcm))
|
||||
{
|
||||
_spec = rm->create_image_compressed_layers(
|
||||
kcm.bytes.data(), kcm.bytes.size(),
|
||||
kcm.fmt, kcm.mipLevels, kcm.layers,
|
||||
kcm.copies,
|
||||
VK_IMAGE_USAGE_SAMPLED_BIT,
|
||||
kcm.imgFlags
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
ktxutil::Ktx2D k2d{};
|
||||
if (ktxutil::load_ktx2_2d(paths.specularCube.c_str(), k2d))
|
||||
{
|
||||
std::vector<ResourceManager::MipLevelCopy> lv;
|
||||
lv.reserve(k2d.mipLevels);
|
||||
for (uint32_t mip = 0; mip < k2d.mipLevels; ++mip)
|
||||
{
|
||||
const auto &r = k2d.copies[mip];
|
||||
lv.push_back(ResourceManager::MipLevelCopy{
|
||||
.offset = r.bufferOffset,
|
||||
.length = 0,
|
||||
.width = r.imageExtent.width,
|
||||
.height = r.imageExtent.height,
|
||||
});
|
||||
}
|
||||
_spec = rm->create_image_compressed(k2d.bytes.data(), k2d.bytes.size(), k2d.fmt, lv,
|
||||
VK_IMAGE_USAGE_SAMPLED_BIT);
|
||||
|
||||
ktxTexture2 *ktex = nullptr;
|
||||
if (ktxTexture2_CreateFromNamedFile(paths.specularCube.c_str(), KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT,
|
||||
&ktex) == KTX_SUCCESS && ktex)
|
||||
{
|
||||
const VkFormat fmt = static_cast<VkFormat>(ktex->vkFormat);
|
||||
const bool isFloat16 = fmt == VK_FORMAT_R16G16B16A16_SFLOAT;
|
||||
const bool isFloat32 = fmt == VK_FORMAT_R32G32B32A32_SFLOAT;
|
||||
if (!ktxTexture2_NeedsTranscoding(ktex) && (isFloat16 || isFloat32) && ktex->baseWidth == 2 * ktex->
|
||||
baseHeight)
|
||||
{
|
||||
const uint32_t W = ktex->baseWidth;
|
||||
const uint32_t H = ktex->baseHeight;
|
||||
const uint8_t *dataPtr = reinterpret_cast<const uint8_t *>(
|
||||
ktxTexture_GetData(ktxTexture(ktex)));
|
||||
|
||||
// Compute 9 SH coefficients (irradiance) from equirect HDR
|
||||
struct Vec3
|
||||
{
|
||||
float x, y, z;
|
||||
};
|
||||
auto half_to_float = [](uint16_t h)-> float {
|
||||
uint16_t h_exp = (h & 0x7C00u) >> 10;
|
||||
uint16_t h_sig = h & 0x03FFu;
|
||||
uint32_t sign = (h & 0x8000u) << 16;
|
||||
uint32_t f_e, f_sig;
|
||||
if (h_exp == 0)
|
||||
{
|
||||
if (h_sig == 0)
|
||||
{
|
||||
f_e = 0;
|
||||
f_sig = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// subnormals
|
||||
int e = -1;
|
||||
uint16_t sig = h_sig;
|
||||
while ((sig & 0x0400u) == 0)
|
||||
{
|
||||
sig <<= 1;
|
||||
--e;
|
||||
}
|
||||
sig &= 0x03FFu;
|
||||
f_e = uint32_t(127 - 15 + e) << 23;
|
||||
f_sig = uint32_t(sig) << 13;
|
||||
}
|
||||
}
|
||||
else if (h_exp == 0x1Fu)
|
||||
{
|
||||
f_e = 0xFFu << 23;
|
||||
f_sig = uint32_t(h_sig) << 13;
|
||||
}
|
||||
else
|
||||
{
|
||||
f_e = uint32_t(h_exp - 15 + 127) << 23;
|
||||
f_sig = uint32_t(h_sig) << 13;
|
||||
}
|
||||
uint32_t f = sign | f_e | f_sig;
|
||||
float out;
|
||||
std::memcpy(&out, &f, 4);
|
||||
return out;
|
||||
};
|
||||
|
||||
auto sample_at = [&](uint32_t x, uint32_t y)-> Vec3 {
|
||||
if (isFloat32)
|
||||
{
|
||||
const float *px = reinterpret_cast<const float *>(dataPtr) + 4ull * (y * W + x);
|
||||
return {px[0], px[1], px[2]};
|
||||
}
|
||||
else
|
||||
{
|
||||
const uint16_t *px = reinterpret_cast<const uint16_t *>(dataPtr) + 4ull * (y * W + x);
|
||||
return {half_to_float(px[0]), half_to_float(px[1]), half_to_float(px[2])};
|
||||
}
|
||||
};
|
||||
|
||||
constexpr int L = 2; // 2nd order (9 coeffs)
|
||||
const float dtheta = float(M_PI) / float(H);
|
||||
const float dphi = 2.f * float(M_PI) / float(W);
|
||||
// Accumulate RGB SH coeffs
|
||||
std::array<glm::vec3, 9> c{};
|
||||
for (auto &v: c) v = glm::vec3(0);
|
||||
|
||||
auto sh_basis = [](const glm::vec3 &d)-> std::array<float, 9> {
|
||||
const float x = d.x, y = d.y, z = d.z;
|
||||
// Real SH, unnormalized constants
|
||||
const float c0 = 0.2820947918f;
|
||||
const float c1 = 0.4886025119f;
|
||||
const float c2 = 1.0925484306f;
|
||||
const float c3 = 0.3153915653f;
|
||||
const float c4 = 0.5462742153f;
|
||||
return {
|
||||
c0,
|
||||
c1 * y,
|
||||
c1 * z,
|
||||
c1 * x,
|
||||
c2 * x * y,
|
||||
c2 * y * z,
|
||||
c3 * (3.f * z * z - 1.f),
|
||||
c2 * x * z,
|
||||
c4 * (x * x - y * y)
|
||||
};
|
||||
};
|
||||
|
||||
for (uint32_t y = 0; y < H; ++y)
|
||||
{
|
||||
float theta = (y + 0.5f) * dtheta; // [0,pi]
|
||||
float sinT = std::sin(theta);
|
||||
for (uint32_t x = 0; x < W; ++x)
|
||||
{
|
||||
float phi = (x + 0.5f) * dphi; // [0,2pi]
|
||||
glm::vec3 dir = glm::vec3(std::cos(phi) * sinT, std::cos(theta), std::sin(phi) * sinT);
|
||||
auto Lrgb = sample_at(x, y);
|
||||
glm::vec3 Lvec(Lrgb.x, Lrgb.y, Lrgb.z);
|
||||
auto Y = sh_basis(dir);
|
||||
float dOmega = dphi * dtheta * sinT; // solid angle per pixel
|
||||
for (int i = 0; i < 9; ++i)
|
||||
{
|
||||
c[i] += Lvec * (Y[i] * dOmega);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Convolve with Lambert kernel via per-band scale
|
||||
const float A0 = float(M_PI);
|
||||
const float A1 = 2.f * float(M_PI) / 3.f;
|
||||
const float A2 = float(M_PI) / 4.f;
|
||||
const float Aband[3] = {A0, A1, A2};
|
||||
for (int i = 0; i < 9; ++i)
|
||||
{
|
||||
int band = (i == 0) ? 0 : (i < 4 ? 1 : 2);
|
||||
c[i] *= Aband[band];
|
||||
}
|
||||
|
||||
_shBuffer = rm->create_buffer(sizeof(glm::vec4) * 9, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
|
||||
VMA_MEMORY_USAGE_CPU_TO_GPU);
|
||||
for (int i = 0; i < 9; ++i)
|
||||
{
|
||||
glm::vec4 v(c[i], 0.0f);
|
||||
std::memcpy(reinterpret_cast<char *>(_shBuffer.info.pMappedData) + i * sizeof(glm::vec4),
|
||||
&v, sizeof(glm::vec4));
|
||||
}
|
||||
vmaFlushAllocation(_ctx->getDevice()->allocator(), _shBuffer.allocation, 0,
|
||||
sizeof(glm::vec4) * 9);
|
||||
}
|
||||
ktxTexture_Destroy(ktxTexture(ktex));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Diffuse cubemap (optional; if missing, reuse specular)
|
||||
if (!paths.diffuseCube.empty())
|
||||
{
|
||||
ktxutil::KtxCubemap kcm{};
|
||||
if (ktxutil::load_ktx2_cubemap(paths.diffuseCube.c_str(), kcm))
|
||||
{
|
||||
_diff = rm->create_image_compressed_layers(
|
||||
kcm.bytes.data(), kcm.bytes.size(),
|
||||
kcm.fmt, kcm.mipLevels, kcm.layers,
|
||||
kcm.copies,
|
||||
VK_IMAGE_USAGE_SAMPLED_BIT,
|
||||
kcm.imgFlags
|
||||
);
|
||||
}
|
||||
}
|
||||
if (_diff.image == VK_NULL_HANDLE && _spec.image != VK_NULL_HANDLE)
|
||||
{
|
||||
_diff = _spec;
|
||||
}
|
||||
|
||||
// BRDF LUT
|
||||
if (!paths.brdfLut2D.empty())
|
||||
{
|
||||
ktxutil::Ktx2D lut{};
|
||||
if (ktxutil::load_ktx2_2d(paths.brdfLut2D.c_str(), lut))
|
||||
{
|
||||
std::vector<ResourceManager::MipLevelCopy> lv;
|
||||
lv.reserve(lut.mipLevels);
|
||||
for (uint32_t mip = 0; mip < lut.mipLevels; ++mip)
|
||||
{
|
||||
const auto &r = lut.copies[mip];
|
||||
lv.push_back(ResourceManager::MipLevelCopy{
|
||||
.offset = r.bufferOffset,
|
||||
.length = 0,
|
||||
.width = r.imageExtent.width,
|
||||
.height = r.imageExtent.height,
|
||||
});
|
||||
}
|
||||
_brdf = rm->create_image_compressed(lut.bytes.data(), lut.bytes.size(), lut.fmt, lv,
|
||||
VK_IMAGE_USAGE_SAMPLED_BIT);
|
||||
}
|
||||
}
|
||||
|
||||
return (_spec.image != VK_NULL_HANDLE) && (_diff.image != VK_NULL_HANDLE);
|
||||
}
|
||||
|
||||
void IBLManager::unload()
|
||||
{
|
||||
if (_ctx == nullptr || _ctx->getResources() == nullptr) return;
|
||||
auto *rm = _ctx->getResources();
|
||||
if (_spec.image)
|
||||
{
|
||||
rm->destroy_image(_spec);
|
||||
}
|
||||
// Handle potential aliasing: _diff may have been set to _spec in load().
|
||||
if (_diff.image && _diff.image != _spec.image)
|
||||
{
|
||||
rm->destroy_image(_diff);
|
||||
}
|
||||
if (_brdf.image)
|
||||
{
|
||||
rm->destroy_image(_brdf);
|
||||
}
|
||||
|
||||
_spec = {};
|
||||
_diff = {};
|
||||
_brdf = {};
|
||||
if (_iblSetLayout && _ctx && _ctx->getDevice())
|
||||
{
|
||||
vkDestroyDescriptorSetLayout(_ctx->getDevice()->device(), _iblSetLayout, nullptr);
|
||||
_iblSetLayout = VK_NULL_HANDLE;
|
||||
}
|
||||
if (_shBuffer.buffer)
|
||||
{
|
||||
rm->destroy_buffer(_shBuffer);
|
||||
_shBuffer = {};
|
||||
}
|
||||
}
|
||||
|
||||
bool IBLManager::ensureLayout()
|
||||
{
|
||||
if (_iblSetLayout != VK_NULL_HANDLE) return true;
|
||||
if (!_ctx || !_ctx->getDevice()) return false;
|
||||
|
||||
DescriptorLayoutBuilder builder;
|
||||
// binding 0: environment/specular as 2D equirect with mips
|
||||
builder.add_binding(0, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER);
|
||||
// binding 1: BRDF LUT 2D
|
||||
builder.add_binding(1, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER);
|
||||
// binding 2: SH coefficients UBO (vec4[9])
|
||||
builder.add_binding(2, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER);
|
||||
_iblSetLayout = builder.build(
|
||||
_ctx->getDevice()->device(), VK_SHADER_STAGE_FRAGMENT_BIT,
|
||||
nullptr, VK_DESCRIPTOR_SET_LAYOUT_CREATE_UPDATE_AFTER_BIND_POOL_BIT);
|
||||
return _iblSetLayout != VK_NULL_HANDLE;
|
||||
}
|
||||
47
src/core/assets/ibl_manager.h
Normal file
47
src/core/assets/ibl_manager.h
Normal file
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include <core/types.h>
|
||||
#include <string>
|
||||
|
||||
class EngineContext;
|
||||
|
||||
struct IBLPaths
|
||||
{
|
||||
std::string specularCube; // .ktx2 (GPU-ready BC6H or R16G16B16A16)
|
||||
std::string diffuseCube; // .ktx2
|
||||
std::string brdfLut2D; // .ktx2 (BC5 RG UNORM or similar)
|
||||
};
|
||||
|
||||
class IBLManager
|
||||
{
|
||||
public:
|
||||
void init(EngineContext *ctx) { _ctx = ctx; }
|
||||
|
||||
// Load all three textures. Returns true when specular+diffuse (and optional LUT) are resident.
|
||||
bool load(const IBLPaths &paths);
|
||||
|
||||
// Release GPU memory and patch to fallbacks handled by the caller.
|
||||
void unload();
|
||||
|
||||
bool resident() const { return _spec.image != VK_NULL_HANDLE || _diff.image != VK_NULL_HANDLE; }
|
||||
|
||||
AllocatedImage specular() const { return _spec; }
|
||||
AllocatedImage diffuse() const { return _diff; }
|
||||
AllocatedImage brdf() const { return _brdf; }
|
||||
AllocatedBuffer shBuffer() const { return _shBuffer; }
|
||||
bool hasSH() const { return _shBuffer.buffer != VK_NULL_HANDLE; }
|
||||
|
||||
// Descriptor set layout used by shaders (set=3)
|
||||
VkDescriptorSetLayout descriptorLayout() const { return _iblSetLayout; }
|
||||
|
||||
// Build descriptor set layout without loading images (for early pipeline creation)
|
||||
bool ensureLayout();
|
||||
|
||||
private:
|
||||
EngineContext *_ctx{nullptr};
|
||||
AllocatedImage _spec{};
|
||||
AllocatedImage _diff{};
|
||||
AllocatedImage _brdf{};
|
||||
VkDescriptorSetLayout _iblSetLayout = VK_NULL_HANDLE;
|
||||
AllocatedBuffer _shBuffer{}; // 9*vec4 coefficients (RGB in .xyz)
|
||||
};
|
||||
176
src/core/assets/ktx_loader.cpp
Normal file
176
src/core/assets/ktx_loader.cpp
Normal file
@@ -0,0 +1,176 @@
|
||||
#include "ktx_loader.h"
|
||||
|
||||
#include <ktx.h>
|
||||
#include <ktxvulkan.h>
|
||||
#include <filesystem>
|
||||
|
||||
namespace ktxutil
|
||||
{
|
||||
static inline bool is_bc_format(VkFormat f)
|
||||
{
|
||||
switch (f)
|
||||
{
|
||||
case VK_FORMAT_BC1_RGB_UNORM_BLOCK:
|
||||
case VK_FORMAT_BC1_RGB_SRGB_BLOCK:
|
||||
case VK_FORMAT_BC1_RGBA_UNORM_BLOCK:
|
||||
case VK_FORMAT_BC1_RGBA_SRGB_BLOCK:
|
||||
case VK_FORMAT_BC2_UNORM_BLOCK:
|
||||
case VK_FORMAT_BC2_SRGB_BLOCK:
|
||||
case VK_FORMAT_BC3_UNORM_BLOCK:
|
||||
case VK_FORMAT_BC3_SRGB_BLOCK:
|
||||
case VK_FORMAT_BC4_UNORM_BLOCK:
|
||||
case VK_FORMAT_BC4_SNORM_BLOCK:
|
||||
case VK_FORMAT_BC5_UNORM_BLOCK:
|
||||
case VK_FORMAT_BC5_SNORM_BLOCK:
|
||||
case VK_FORMAT_BC6H_UFLOAT_BLOCK:
|
||||
case VK_FORMAT_BC6H_SFLOAT_BLOCK:
|
||||
case VK_FORMAT_BC7_UNORM_BLOCK:
|
||||
case VK_FORMAT_BC7_SRGB_BLOCK:
|
||||
return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
static inline bool exists_file(const char* path)
|
||||
{
|
||||
std::error_code ec; return std::filesystem::exists(path, ec) && !ec;
|
||||
}
|
||||
|
||||
bool load_ktx2_cubemap(const char* path, KtxCubemap& out)
|
||||
{
|
||||
out = KtxCubemap{};
|
||||
if (path == nullptr || !exists_file(path)) return false;
|
||||
|
||||
ktxTexture2* ktex = nullptr;
|
||||
ktxResult kres = ktxTexture2_CreateFromNamedFile(path, KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT, &ktex);
|
||||
if (kres != KTX_SUCCESS || !ktex) return false;
|
||||
|
||||
// Ensure it is a cubemap or cubemap array
|
||||
if (ktex->numFaces != 6)
|
||||
{
|
||||
ktxTexture_Destroy(ktxTexture(ktex));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Transcoding path: for IBL HDR cubemaps we expect GPU-ready formats (e.g., BC6H or R16G16B16A16).
|
||||
// BasisU does not support BC6H transcoding. If the KTX2 requires transcoding, we bail out here
|
||||
// and expect assets to be pre-encoded to a GPU format.
|
||||
if (ktxTexture2_NeedsTranscoding(ktex))
|
||||
{
|
||||
ktxTexture_Destroy(ktxTexture(ktex));
|
||||
return false;
|
||||
}
|
||||
|
||||
VkFormat vkfmt = static_cast<VkFormat>(ktex->vkFormat);
|
||||
// Accept any GPU format (BC6H preferred). Non-BC formats like R16G16B16A16 are valid too.
|
||||
|
||||
const uint32_t mipLevels = ktex->numLevels;
|
||||
const uint32_t baseW = ktex->baseWidth;
|
||||
const uint32_t baseH = ktex->baseHeight;
|
||||
const uint32_t layers = std::max(1u, ktex->numLayers) * 6u; // arrayLayers = layers × faces
|
||||
|
||||
ktx_size_t totalSize = ktxTexture_GetDataSize(ktxTexture(ktex));
|
||||
const uint8_t* dataPtr = reinterpret_cast<const uint8_t*>(ktxTexture_GetData(ktxTexture(ktex)));
|
||||
|
||||
out.fmt = vkfmt;
|
||||
out.baseW = baseW;
|
||||
out.baseH = baseH;
|
||||
out.mipLevels = mipLevels;
|
||||
out.layers = layers;
|
||||
out.imgFlags = VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT;
|
||||
out.bytes.assign(dataPtr, dataPtr + totalSize);
|
||||
|
||||
out.copies.clear();
|
||||
out.copies.reserve(static_cast<size_t>(mipLevels) * layers);
|
||||
|
||||
for (uint32_t mip = 0; mip < mipLevels; ++mip)
|
||||
{
|
||||
const uint32_t w = std::max(1u, baseW >> mip);
|
||||
const uint32_t h = std::max(1u, baseH >> mip);
|
||||
for (uint32_t layer = 0; layer < std::max(1u, ktex->numLayers); ++layer)
|
||||
{
|
||||
for (uint32_t face = 0; face < 6; ++face)
|
||||
{
|
||||
ktx_size_t off = 0;
|
||||
ktxTexture_GetImageOffset(ktxTexture(ktex), mip, layer, face, &off);
|
||||
|
||||
VkBufferImageCopy r{};
|
||||
r.bufferOffset = static_cast<VkDeviceSize>(off);
|
||||
r.bufferRowLength = 0; // tightly packed
|
||||
r.bufferImageHeight = 0;
|
||||
r.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
|
||||
r.imageSubresource.mipLevel = mip;
|
||||
r.imageSubresource.baseArrayLayer = layer * 6u + face;
|
||||
r.imageSubresource.layerCount = 1;
|
||||
r.imageExtent = { w, h, 1 };
|
||||
out.copies.push_back(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ktxTexture_Destroy(ktxTexture(ktex));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool load_ktx2_2d(const char* path, Ktx2D& out)
|
||||
{
|
||||
out = Ktx2D{};
|
||||
if (path == nullptr || !exists_file(path)) return false;
|
||||
|
||||
ktxTexture2* ktex = nullptr;
|
||||
ktxResult kres = ktxTexture2_CreateFromNamedFile(path, KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT, &ktex);
|
||||
if (kres != KTX_SUCCESS || !ktex) return false;
|
||||
|
||||
if (ktxTexture2_NeedsTranscoding(ktex))
|
||||
{
|
||||
// Common for BRDF LUTs: BC5 RG UNORM
|
||||
kres = ktxTexture2_TranscodeBasis(ktex, KTX_TTF_BC5_RG, 0);
|
||||
if (kres != KTX_SUCCESS)
|
||||
{
|
||||
ktxTexture_Destroy(ktxTexture(ktex));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
VkFormat vkfmt = static_cast<VkFormat>(ktex->vkFormat);
|
||||
if (!is_bc_format(vkfmt))
|
||||
{
|
||||
ktxTexture_Destroy(ktxTexture(ktex));
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint32_t mipLevels = ktex->numLevels;
|
||||
const uint32_t baseW = ktex->baseWidth;
|
||||
const uint32_t baseH = ktex->baseHeight;
|
||||
|
||||
ktx_size_t totalSize = ktxTexture_GetDataSize(ktxTexture(ktex));
|
||||
const uint8_t* dataPtr = reinterpret_cast<const uint8_t*>(ktxTexture_GetData(ktxTexture(ktex)));
|
||||
|
||||
out.fmt = vkfmt;
|
||||
out.baseW = baseW;
|
||||
out.baseH = baseH;
|
||||
out.mipLevels = mipLevels;
|
||||
out.bytes.assign(dataPtr, dataPtr + totalSize);
|
||||
|
||||
out.copies.clear();
|
||||
out.copies.reserve(mipLevels);
|
||||
for (uint32_t mip = 0; mip < mipLevels; ++mip)
|
||||
{
|
||||
ktx_size_t off = 0;
|
||||
ktxTexture_GetImageOffset(ktxTexture(ktex), mip, 0, 0, &off);
|
||||
const uint32_t w = std::max(1u, baseW >> mip);
|
||||
const uint32_t h = std::max(1u, baseH >> mip);
|
||||
VkBufferImageCopy r{};
|
||||
r.bufferOffset = static_cast<VkDeviceSize>(off);
|
||||
r.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
|
||||
r.imageSubresource.mipLevel = mip;
|
||||
r.imageSubresource.baseArrayLayer = 0;
|
||||
r.imageSubresource.layerCount = 1;
|
||||
r.imageExtent = { w, h, 1 };
|
||||
out.copies.push_back(r);
|
||||
}
|
||||
|
||||
ktxTexture_Destroy(ktxTexture(ktex));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
39
src/core/assets/ktx_loader.h
Normal file
39
src/core/assets/ktx_loader.h
Normal file
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <core/types.h>
|
||||
#include <vector>
|
||||
|
||||
// Simple KTX2 helpers focused on IBL assets.
|
||||
// Uses libktx to open and (if needed) transcode to GPU-ready BC formats.
|
||||
namespace ktxutil
|
||||
{
|
||||
struct KtxCubemap
|
||||
{
|
||||
VkFormat fmt{VK_FORMAT_UNDEFINED};
|
||||
uint32_t baseW{0};
|
||||
uint32_t baseH{0};
|
||||
uint32_t mipLevels{0};
|
||||
uint32_t layers{0}; // total array layers in the Vulkan image (faces × layers)
|
||||
std::vector<uint8_t> bytes; // full file data block returned by libktx
|
||||
std::vector<VkBufferImageCopy> copies; // one per (mip × layer)
|
||||
VkImageCreateFlags imgFlags{0}; // e.g., VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT
|
||||
};
|
||||
|
||||
// Loads a .ktx2 cubemap (or cubemap array) and prepares copy regions for upload.
|
||||
// - Prefers BC6H UFLOAT for HDR content; leaves existing GPU BC formats intact.
|
||||
// - Returns true on success and fills 'out'.
|
||||
bool load_ktx2_cubemap(const char* path, KtxCubemap& out);
|
||||
|
||||
// Optional: minimal 2D loader for BRDF LUTs (RG/BC5 etc.). Returns VkFormat and copies per mip.
|
||||
struct Ktx2D
|
||||
{
|
||||
VkFormat fmt{VK_FORMAT_UNDEFINED};
|
||||
uint32_t baseW{0};
|
||||
uint32_t baseH{0};
|
||||
uint32_t mipLevels{0};
|
||||
std::vector<uint8_t> bytes;
|
||||
std::vector<VkBufferImageCopy> copies;
|
||||
};
|
||||
bool load_ktx2_2d(const char* path, Ktx2D& out);
|
||||
}
|
||||
|
||||
125
src/core/assets/locator.cpp
Normal file
125
src/core/assets/locator.cpp
Normal file
@@ -0,0 +1,125 @@
|
||||
#include "locator.h"
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
using std::filesystem::path;
|
||||
|
||||
static path get_env_path(const char *name)
|
||||
{
|
||||
const char *v = std::getenv(name);
|
||||
if (!v || !*v) return {};
|
||||
path p = v;
|
||||
if (std::filesystem::exists(p)) return std::filesystem::canonical(p);
|
||||
return {};
|
||||
}
|
||||
|
||||
static path find_upwards_containing(path start, const std::string &subdir, int maxDepth = 6)
|
||||
{
|
||||
path cur = std::filesystem::weakly_canonical(start);
|
||||
for (int i = 0; i <= maxDepth; i++)
|
||||
{
|
||||
path candidate = cur / subdir;
|
||||
if (std::filesystem::exists(candidate)) return cur;
|
||||
if (!cur.has_parent_path()) break;
|
||||
cur = cur.parent_path();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
AssetPaths AssetPaths::detect(const path &startDir)
|
||||
{
|
||||
AssetPaths out{};
|
||||
|
||||
if (auto root = get_env_path("VKG_ASSET_ROOT"); !root.empty())
|
||||
{
|
||||
out.root = root;
|
||||
if (std::filesystem::exists(root / "assets")) out.assets = root / "assets";
|
||||
if (std::filesystem::exists(root / "shaders")) out.shaders = root / "shaders";
|
||||
return out;
|
||||
}
|
||||
|
||||
if (auto aroot = find_upwards_containing(startDir, "assets"); !aroot.empty())
|
||||
{
|
||||
out.assets = aroot / "assets";
|
||||
out.root = aroot;
|
||||
}
|
||||
if (auto sroot = find_upwards_containing(startDir, "shaders"); !sroot.empty())
|
||||
{
|
||||
out.shaders = sroot / "shaders";
|
||||
if (out.root.empty()) out.root = sroot;
|
||||
}
|
||||
|
||||
if (out.assets.empty())
|
||||
{
|
||||
path p1 = startDir / "assets";
|
||||
path p2 = startDir / ".." / "assets";
|
||||
if (std::filesystem::exists(p1)) out.assets = p1;
|
||||
else if (std::filesystem::exists(p2)) out.assets = std::filesystem::weakly_canonical(p2);
|
||||
}
|
||||
if (out.shaders.empty())
|
||||
{
|
||||
path p1 = startDir / "shaders";
|
||||
path p2 = startDir / ".." / "shaders";
|
||||
if (std::filesystem::exists(p1)) out.shaders = p1;
|
||||
else if (std::filesystem::exists(p2)) out.shaders = std::filesystem::weakly_canonical(p2);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
void AssetLocator::init()
|
||||
{
|
||||
_paths = AssetPaths::detect();
|
||||
}
|
||||
|
||||
bool AssetLocator::file_exists(const path &p)
|
||||
{
|
||||
std::error_code ec;
|
||||
return !p.empty() && std::filesystem::exists(p, ec) && std::filesystem::is_regular_file(p, ec);
|
||||
}
|
||||
|
||||
std::string AssetLocator::resolve_in(const path &base, std::string_view name)
|
||||
{
|
||||
if (name.empty()) return {};
|
||||
path in = base / std::string(name);
|
||||
if (file_exists(in)) return in.string();
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string AssetLocator::shaderPath(std::string_view name) const
|
||||
{
|
||||
if (name.empty()) return {};
|
||||
path np = std::string(name);
|
||||
|
||||
if (np.is_absolute() && file_exists(np)) return np.string();
|
||||
if (file_exists(np)) return np.string();
|
||||
|
||||
if (!_paths.shaders.empty())
|
||||
{
|
||||
if (auto r = resolve_in(_paths.shaders, name); !r.empty()) return r;
|
||||
}
|
||||
|
||||
if (auto r = resolve_in(std::filesystem::current_path() / "shaders", name); !r.empty()) return r;
|
||||
if (auto r = resolve_in(std::filesystem::current_path() / ".." / "shaders", name); !r.empty()) return r;
|
||||
|
||||
return np.string();
|
||||
}
|
||||
|
||||
std::string AssetLocator::assetPath(std::string_view name) const
|
||||
{
|
||||
if (name.empty()) return {};
|
||||
path np = std::string(name);
|
||||
if (np.is_absolute() && file_exists(np)) return np.string();
|
||||
if (file_exists(np)) return np.string();
|
||||
|
||||
if (!_paths.assets.empty())
|
||||
{
|
||||
if (auto r = resolve_in(_paths.assets, name); !r.empty()) return r;
|
||||
}
|
||||
|
||||
if (auto r = resolve_in(std::filesystem::current_path() / "assets", name); !r.empty()) return r;
|
||||
if (auto r = resolve_in(std::filesystem::current_path() / ".." / "assets", name); !r.empty()) return r;
|
||||
|
||||
return np.string();
|
||||
}
|
||||
|
||||
44
src/core/assets/locator.h
Normal file
44
src/core/assets/locator.h
Normal file
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
struct AssetPaths
|
||||
{
|
||||
std::filesystem::path root;
|
||||
std::filesystem::path assets;
|
||||
std::filesystem::path shaders;
|
||||
|
||||
bool valid() const
|
||||
{
|
||||
return (!assets.empty() && std::filesystem::exists(assets)) ||
|
||||
(!shaders.empty() && std::filesystem::exists(shaders));
|
||||
}
|
||||
|
||||
static AssetPaths detect(const std::filesystem::path &startDir = std::filesystem::current_path());
|
||||
};
|
||||
|
||||
class AssetLocator
|
||||
{
|
||||
public:
|
||||
void init();
|
||||
|
||||
const AssetPaths &paths() const { return _paths; }
|
||||
void setPaths(const AssetPaths &p) { _paths = p; }
|
||||
|
||||
std::string shaderPath(std::string_view name) const;
|
||||
|
||||
std::string assetPath(std::string_view name) const;
|
||||
|
||||
std::string modelPath(std::string_view name) const { return assetPath(name); }
|
||||
|
||||
private:
|
||||
static bool file_exists(const std::filesystem::path &p);
|
||||
|
||||
static std::string resolve_in(const std::filesystem::path &base, std::string_view name);
|
||||
|
||||
AssetPaths _paths{};
|
||||
};
|
||||
|
||||
624
src/core/assets/manager.cpp
Normal file
624
src/core/assets/manager.cpp
Normal file
@@ -0,0 +1,624 @@
|
||||
#include "manager.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
|
||||
#include <core/engine.h>
|
||||
#include <core/device/resource.h>
|
||||
#include <render/materials.h>
|
||||
#include <render/primitives.h>
|
||||
#include <scene/tangent_space.h>
|
||||
#include <scene/mesh_bvh.h>
|
||||
#include <stb_image.h>
|
||||
#include "locator.h"
|
||||
#include <core/assets/texture_cache.h>
|
||||
#include <fastgltf/parser.hpp>
|
||||
#include <fastgltf/util.hpp>
|
||||
#include <fastgltf/tools.hpp>
|
||||
#include <fmt/core.h>
|
||||
|
||||
using std::filesystem::path;
|
||||
|
||||
void AssetManager::init(VulkanEngine *engine)
|
||||
{
|
||||
_engine = engine;
|
||||
_locator.init();
|
||||
}
|
||||
|
||||
void AssetManager::cleanup()
|
||||
{
|
||||
if (_engine && _engine->_resourceManager)
|
||||
{
|
||||
for (auto &kv: _meshCache)
|
||||
{
|
||||
if (kv.second)
|
||||
{
|
||||
_engine->_resourceManager->destroy_buffer(kv.second->meshBuffers.indexBuffer);
|
||||
_engine->_resourceManager->destroy_buffer(kv.second->meshBuffers.vertexBuffer);
|
||||
}
|
||||
}
|
||||
for (auto &kv: _meshMaterialBuffers)
|
||||
{
|
||||
_engine->_resourceManager->destroy_buffer(kv.second);
|
||||
}
|
||||
for (auto &kv: _meshOwnedImages)
|
||||
{
|
||||
for (const auto &img: kv.second)
|
||||
{
|
||||
_engine->_resourceManager->destroy_image(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
_meshCache.clear();
|
||||
_meshMaterialBuffers.clear();
|
||||
_meshOwnedImages.clear();
|
||||
_gltfCacheByPath.clear();
|
||||
}
|
||||
|
||||
std::string AssetManager::shaderPath(std::string_view name) const
|
||||
{
|
||||
return _locator.shaderPath(name);
|
||||
}
|
||||
|
||||
std::string AssetManager::assetPath(std::string_view name) const
|
||||
{
|
||||
return _locator.assetPath(name);
|
||||
}
|
||||
|
||||
std::string AssetManager::modelPath(std::string_view name) const
|
||||
{
|
||||
return _locator.modelPath(name);
|
||||
}
|
||||
|
||||
std::optional<std::shared_ptr<LoadedGLTF> > AssetManager::loadGLTF(std::string_view nameOrPath)
|
||||
{
|
||||
if (!_engine) return {};
|
||||
if (nameOrPath.empty()) return {};
|
||||
|
||||
std::string resolved = assetPath(nameOrPath);
|
||||
|
||||
path keyPath = resolved;
|
||||
std::error_code ec;
|
||||
keyPath = std::filesystem::weakly_canonical(keyPath, ec);
|
||||
std::string key = (ec ? resolved : keyPath.string());
|
||||
|
||||
if (auto it = _gltfCacheByPath.find(key); it != _gltfCacheByPath.end())
|
||||
{
|
||||
if (auto sp = it->second.lock())
|
||||
{
|
||||
fmt::println("[AssetManager] loadGLTF cache hit key='{}' path='{}' ptr={}", key, resolved,
|
||||
static_cast<const void *>(sp.get()));
|
||||
return sp;
|
||||
}
|
||||
fmt::println("[AssetManager] loadGLTF cache expired key='{}' path='{}' (reloading)", key, resolved);
|
||||
}
|
||||
|
||||
auto loaded = loadGltf(_engine, resolved);
|
||||
if (!loaded.has_value()) return {};
|
||||
|
||||
if (loaded.value())
|
||||
{
|
||||
fmt::println("[AssetManager] loadGLTF loaded new scene key='{}' path='{}' ptr={}", key, resolved,
|
||||
static_cast<const void *>(loaded.value().get()));
|
||||
}
|
||||
else
|
||||
{
|
||||
fmt::println("[AssetManager] loadGLTF got empty scene for key='{}' path='{}'", key, resolved);
|
||||
}
|
||||
|
||||
_gltfCacheByPath[key] = loaded.value();
|
||||
return loaded;
|
||||
}
|
||||
|
||||
std::shared_ptr<MeshAsset> AssetManager::getPrimitive(std::string_view name) const
|
||||
{
|
||||
if (name.empty()) return {};
|
||||
auto findBy = [&](const std::string &key) -> std::shared_ptr<MeshAsset> {
|
||||
auto it = _meshCache.find(key);
|
||||
return (it != _meshCache.end()) ? it->second : nullptr;
|
||||
};
|
||||
|
||||
if (name == std::string_view("cube") || name == std::string_view("Cube"))
|
||||
{
|
||||
if (auto m = findBy("cube")) return m;
|
||||
if (auto m = findBy("Cube")) return m;
|
||||
return {};
|
||||
}
|
||||
if (name == std::string_view("sphere") || name == std::string_view("Sphere"))
|
||||
{
|
||||
if (auto m = findBy("sphere")) return m;
|
||||
if (auto m = findBy("Sphere")) return m;
|
||||
return {};
|
||||
}
|
||||
if (name == std::string_view("plane") || name == std::string_view("Plane"))
|
||||
{
|
||||
if (auto m = findBy("plane")) return m;
|
||||
if (auto m = findBy("Plane")) return m;
|
||||
return {};
|
||||
}
|
||||
if (name == std::string_view("capsule") || name == std::string_view("Capsule"))
|
||||
{
|
||||
if (auto m = findBy("capsule")) return m;
|
||||
if (auto m = findBy("Capsule")) return m;
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::shared_ptr<MeshAsset> AssetManager::createMesh(const MeshCreateInfo &info)
|
||||
{
|
||||
if (!_engine || !_engine->_resourceManager) return {};
|
||||
if (info.name.empty()) return {};
|
||||
|
||||
if (auto it = _meshCache.find(info.name); it != _meshCache.end())
|
||||
{
|
||||
return it->second;
|
||||
}
|
||||
|
||||
std::vector<Vertex> tmpVerts;
|
||||
std::vector<uint32_t> tmpInds;
|
||||
std::span<Vertex> vertsSpan{};
|
||||
std::span<uint32_t> indsSpan{};
|
||||
|
||||
switch (info.geometry.type)
|
||||
{
|
||||
case MeshGeometryDesc::Type::Provided:
|
||||
vertsSpan = info.geometry.vertices;
|
||||
indsSpan = info.geometry.indices;
|
||||
break;
|
||||
case MeshGeometryDesc::Type::Cube:
|
||||
primitives::buildCube(tmpVerts, tmpInds);
|
||||
vertsSpan = tmpVerts;
|
||||
indsSpan = tmpInds;
|
||||
break;
|
||||
case MeshGeometryDesc::Type::Sphere:
|
||||
primitives::buildSphere(tmpVerts, tmpInds, info.geometry.sectors, info.geometry.stacks);
|
||||
vertsSpan = tmpVerts;
|
||||
indsSpan = tmpInds;
|
||||
break;
|
||||
case MeshGeometryDesc::Type::Plane:
|
||||
primitives::buildPlane(tmpVerts, tmpInds);
|
||||
vertsSpan = tmpVerts;
|
||||
indsSpan = tmpInds;
|
||||
break;
|
||||
case MeshGeometryDesc::Type::Capsule:
|
||||
primitives::buildCapsule(tmpVerts, tmpInds);
|
||||
vertsSpan = tmpVerts;
|
||||
indsSpan = tmpInds;
|
||||
break;
|
||||
}
|
||||
|
||||
// Ensure tangents exist for primitives (and provided geometry if needed)
|
||||
if (!tmpVerts.empty() && !tmpInds.empty())
|
||||
{
|
||||
geom::generate_tangents(tmpVerts, tmpInds);
|
||||
}
|
||||
|
||||
std::shared_ptr<MeshAsset> mesh;
|
||||
|
||||
if (info.material.kind == MeshMaterialDesc::Kind::Default)
|
||||
{
|
||||
mesh = createMesh(info.name, vertsSpan, indsSpan, {});
|
||||
}
|
||||
else
|
||||
{
|
||||
const auto &opt = info.material.options;
|
||||
|
||||
// Fallbacks are bound now; real textures will patch in via TextureCache
|
||||
AllocatedBuffer matBuffer = createMaterialBufferWithConstants(opt.constants);
|
||||
|
||||
GLTFMetallic_Roughness::MaterialResources res{};
|
||||
res.colorImage = _engine->_errorCheckerboardImage; // visible fallback for albedo
|
||||
res.colorSampler = _engine->_samplerManager->defaultLinear();
|
||||
res.metalRoughImage = _engine->_whiteImage;
|
||||
res.metalRoughSampler = _engine->_samplerManager->defaultLinear();
|
||||
res.normalImage = _engine->_flatNormalImage;
|
||||
res.normalSampler = _engine->_samplerManager->defaultLinear();
|
||||
res.dataBuffer = matBuffer.buffer;
|
||||
res.dataBufferOffset = 0;
|
||||
|
||||
auto mat = createMaterial(opt.pass, res);
|
||||
|
||||
// Register dynamic texture bindings using the central TextureCache
|
||||
if (_engine && _engine->_context && _engine->_context->textures)
|
||||
{
|
||||
TextureCache *cache = _engine->_context->textures;
|
||||
auto buildKey = [&](std::string_view path, bool srgb) -> TextureCache::TextureKey {
|
||||
TextureCache::TextureKey k{};
|
||||
if (!path.empty())
|
||||
{
|
||||
k.kind = TextureCache::TextureKey::SourceKind::FilePath;
|
||||
k.path = assetPath(path);
|
||||
k.srgb = srgb;
|
||||
k.mipmapped = true;
|
||||
std::string id = std::string("PRIM:") + k.path + (srgb ? "#sRGB" : "#UNORM");
|
||||
k.hash = texcache::fnv1a64(id);
|
||||
}
|
||||
return k;
|
||||
};
|
||||
|
||||
if (!opt.albedoPath.empty())
|
||||
{
|
||||
auto key = buildKey(opt.albedoPath, opt.albedoSRGB);
|
||||
if (key.hash != 0)
|
||||
{
|
||||
VkSampler samp = _engine->_samplerManager->defaultLinear();
|
||||
auto handle = cache->request(key, samp);
|
||||
cache->watchBinding(handle, mat->data.materialSet, 1u, samp, _engine->_errorCheckerboardImage.imageView);
|
||||
}
|
||||
}
|
||||
if (!opt.metalRoughPath.empty())
|
||||
{
|
||||
auto key = buildKey(opt.metalRoughPath, opt.metalRoughSRGB);
|
||||
if (key.hash != 0)
|
||||
{
|
||||
VkSampler samp = _engine->_samplerManager->defaultLinear();
|
||||
auto handle = cache->request(key, samp);
|
||||
cache->watchBinding(handle, mat->data.materialSet, 2u, samp, _engine->_whiteImage.imageView);
|
||||
}
|
||||
}
|
||||
if (!opt.normalPath.empty())
|
||||
{
|
||||
auto key = buildKey(opt.normalPath, opt.normalSRGB);
|
||||
if (key.hash != 0)
|
||||
{
|
||||
VkSampler samp = _engine->_samplerManager->defaultLinear();
|
||||
auto handle = cache->request(key, samp);
|
||||
cache->watchBinding(handle, mat->data.materialSet, 3u, samp, _engine->_flatNormalImage.imageView);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mesh = createMesh(info.name, vertsSpan, indsSpan, mat);
|
||||
_meshMaterialBuffers.emplace(info.name, matBuffer);
|
||||
}
|
||||
|
||||
if (!mesh)
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
// Tag primitive meshes with more appropriate default bounds types for picking,
|
||||
// then apply any explicit override from MeshCreateInfo.
|
||||
for (auto &surf : mesh->surfaces)
|
||||
{
|
||||
switch (info.geometry.type)
|
||||
{
|
||||
case MeshGeometryDesc::Type::Sphere:
|
||||
surf.bounds.type = BoundsType::Sphere;
|
||||
break;
|
||||
case MeshGeometryDesc::Type::Capsule:
|
||||
surf.bounds.type = BoundsType::Capsule;
|
||||
break;
|
||||
case MeshGeometryDesc::Type::Cube:
|
||||
surf.bounds.type = BoundsType::Box;
|
||||
break;
|
||||
case MeshGeometryDesc::Type::Plane:
|
||||
surf.bounds.type = BoundsType::Box;
|
||||
break;
|
||||
case MeshGeometryDesc::Type::Provided:
|
||||
default:
|
||||
surf.bounds.type = BoundsType::Box;
|
||||
break;
|
||||
}
|
||||
|
||||
if (info.boundsType.has_value())
|
||||
{
|
||||
surf.bounds.type = *info.boundsType;
|
||||
}
|
||||
}
|
||||
|
||||
return mesh;
|
||||
}
|
||||
|
||||
size_t AssetManager::prefetchGLTFTextures(std::string_view nameOrPath)
|
||||
{
|
||||
if (!_engine || !_engine->_context || !_engine->_context->textures) return 0;
|
||||
if (nameOrPath.empty()) return 0;
|
||||
|
||||
std::string resolved = assetPath(nameOrPath);
|
||||
std::filesystem::path path = resolved;
|
||||
|
||||
fastgltf::Parser parser{};
|
||||
constexpr auto gltfOptions = fastgltf::Options::DontRequireValidAssetMember | fastgltf::Options::AllowDouble |
|
||||
fastgltf::Options::LoadGLBBuffers | fastgltf::Options::LoadExternalBuffers;
|
||||
fastgltf::GltfDataBuffer data;
|
||||
if (!data.loadFromFile(path)) return 0;
|
||||
|
||||
fastgltf::Asset gltf;
|
||||
size_t scheduled = 0;
|
||||
|
||||
auto type = fastgltf::determineGltfFileType(&data);
|
||||
if (type == fastgltf::GltfType::glTF)
|
||||
{
|
||||
auto load = parser.loadGLTF(&data, path.parent_path(), gltfOptions);
|
||||
if (load) gltf = std::move(load.get()); else return 0;
|
||||
}
|
||||
else if (type == fastgltf::GltfType::GLB)
|
||||
{
|
||||
auto load = parser.loadBinaryGLTF(&data, path.parent_path(), gltfOptions);
|
||||
if (load) gltf = std::move(load.get()); else return 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
TextureCache *cache = _engine->_context->textures;
|
||||
|
||||
auto enqueueTex = [&](size_t imgIndex, bool srgb)
|
||||
{
|
||||
if (imgIndex >= gltf.images.size()) return;
|
||||
TextureCache::TextureKey key{};
|
||||
key.srgb = srgb;
|
||||
key.mipmapped = true;
|
||||
|
||||
fastgltf::Image &image = gltf.images[imgIndex];
|
||||
std::visit(fastgltf::visitor{
|
||||
[&](fastgltf::sources::URI &filePath)
|
||||
{
|
||||
const std::string p(filePath.uri.path().begin(), filePath.uri.path().end());
|
||||
key.kind = TextureCache::TextureKey::SourceKind::FilePath;
|
||||
key.path = p;
|
||||
std::string id = std::string("GLTF-PREF:") + p + (srgb ? "#sRGB" : "#UNORM");
|
||||
key.hash = texcache::fnv1a64(id);
|
||||
},
|
||||
[&](fastgltf::sources::Vector &vector)
|
||||
{
|
||||
key.kind = TextureCache::TextureKey::SourceKind::Bytes;
|
||||
key.bytes.assign(vector.bytes.begin(), vector.bytes.end());
|
||||
uint64_t h = texcache::fnv1a64(key.bytes.data(), key.bytes.size());
|
||||
key.hash = h ^ (srgb ? 0x9E3779B97F4A7C15ull : 0ull);
|
||||
},
|
||||
[&](fastgltf::sources::BufferView &view)
|
||||
{
|
||||
auto &bufferView = gltf.bufferViews[view.bufferViewIndex];
|
||||
auto &buffer = gltf.buffers[bufferView.bufferIndex];
|
||||
std::visit(fastgltf::visitor{
|
||||
[](auto &arg) {},
|
||||
[&](fastgltf::sources::Vector &vec)
|
||||
{
|
||||
size_t off = bufferView.byteOffset;
|
||||
size_t len = bufferView.byteLength;
|
||||
key.kind = TextureCache::TextureKey::SourceKind::Bytes;
|
||||
key.bytes.assign(vec.bytes.begin() + off, vec.bytes.begin() + off + len);
|
||||
uint64_t h = texcache::fnv1a64(key.bytes.data(), key.bytes.size());
|
||||
key.hash = h ^ (srgb ? 0x9E3779B97F4A7C15ull : 0ull);
|
||||
}
|
||||
}, buffer.data);
|
||||
},
|
||||
[](auto &other) {}
|
||||
}, image.data);
|
||||
|
||||
if (key.hash != 0)
|
||||
{
|
||||
VkSampler samp = _engine->_samplerManager->defaultLinear();
|
||||
cache->request(key, samp);
|
||||
scheduled++;
|
||||
}
|
||||
};
|
||||
|
||||
for (const auto &tex : gltf.textures)
|
||||
{
|
||||
if (tex.imageIndex.has_value())
|
||||
{
|
||||
// For baseColor we prefer sRGB; other maps requested later will reuse entry
|
||||
enqueueTex(tex.imageIndex.value(), true);
|
||||
}
|
||||
}
|
||||
|
||||
// Proactively free big buffer vectors we no longer need.
|
||||
for (auto &buf : gltf.buffers)
|
||||
{
|
||||
std::visit(fastgltf::visitor{
|
||||
[](auto &arg) {},
|
||||
[&](fastgltf::sources::Vector &vec) {
|
||||
std::vector<uint8_t>().swap(vec.bytes);
|
||||
}
|
||||
}, buf.data);
|
||||
}
|
||||
|
||||
return scheduled;
|
||||
}
|
||||
|
||||
static Bounds compute_bounds(std::span<Vertex> vertices)
|
||||
{
|
||||
Bounds b{};
|
||||
if (vertices.empty())
|
||||
{
|
||||
b.origin = glm::vec3(0.0f);
|
||||
b.extents = glm::vec3(0.5f);
|
||||
b.sphereRadius = glm::length(b.extents);
|
||||
b.type = BoundsType::Box;
|
||||
return b;
|
||||
}
|
||||
glm::vec3 minpos = vertices[0].position;
|
||||
glm::vec3 maxpos = vertices[0].position;
|
||||
for (const auto &v: vertices)
|
||||
{
|
||||
minpos = glm::min(minpos, v.position);
|
||||
maxpos = glm::max(maxpos, v.position);
|
||||
}
|
||||
b.origin = (maxpos + minpos) / 2.f;
|
||||
b.extents = (maxpos - minpos) / 2.f;
|
||||
b.sphereRadius = glm::length(b.extents);
|
||||
b.type = BoundsType::Box;
|
||||
return b;
|
||||
}
|
||||
|
||||
AllocatedBuffer AssetManager::createMaterialBufferWithConstants(
|
||||
const GLTFMetallic_Roughness::MaterialConstants &constants) const
|
||||
{
|
||||
AllocatedBuffer matBuffer = _engine->_resourceManager->create_buffer(
|
||||
sizeof(GLTFMetallic_Roughness::MaterialConstants),
|
||||
VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
|
||||
VMA_MEMORY_USAGE_CPU_TO_GPU);
|
||||
|
||||
VmaAllocationInfo allocInfo{};
|
||||
vmaGetAllocationInfo(_engine->_deviceManager->allocator(), matBuffer.allocation, &allocInfo);
|
||||
auto *matConstants = (GLTFMetallic_Roughness::MaterialConstants *) allocInfo.pMappedData;
|
||||
*matConstants = constants;
|
||||
if (matConstants->colorFactors == glm::vec4(0))
|
||||
{
|
||||
matConstants->colorFactors = glm::vec4(1.0f);
|
||||
}
|
||||
if (matConstants->extra[0].x == 0.0f)
|
||||
{
|
||||
matConstants->extra[0].x = 1.0f; // normal scale default
|
||||
}
|
||||
// Ensure writes are visible on non-coherent memory
|
||||
vmaFlushAllocation(_engine->_deviceManager->allocator(), matBuffer.allocation, 0,
|
||||
sizeof(GLTFMetallic_Roughness::MaterialConstants));
|
||||
return matBuffer;
|
||||
}
|
||||
|
||||
std::shared_ptr<GLTFMaterial> AssetManager::createMaterial(
|
||||
MaterialPass pass, const GLTFMetallic_Roughness::MaterialResources &res) const
|
||||
{
|
||||
auto mat = std::make_shared<GLTFMaterial>();
|
||||
mat->data = _engine->metalRoughMaterial.write_material(
|
||||
_engine->_deviceManager->device(), pass, res, *_engine->_context->descriptors);
|
||||
return mat;
|
||||
}
|
||||
|
||||
std::pair<AllocatedImage, bool> AssetManager::loadImageFromAsset(std::string_view imgPath, bool srgb) const
|
||||
{
|
||||
AllocatedImage out{};
|
||||
bool created = false;
|
||||
if (!imgPath.empty())
|
||||
{
|
||||
std::string resolved = assetPath(imgPath);
|
||||
int w = 0, h = 0, comp = 0;
|
||||
stbi_uc *pixels = stbi_load(resolved.c_str(), &w, &h, &comp, 4);
|
||||
if (pixels && w > 0 && h > 0)
|
||||
{
|
||||
VkFormat fmt = srgb ? VK_FORMAT_R8G8B8A8_SRGB : VK_FORMAT_R8G8B8A8_UNORM;
|
||||
out = _engine->_resourceManager->create_image(pixels,
|
||||
VkExtent3D{static_cast<uint32_t>(w), static_cast<uint32_t>(h), 1},
|
||||
fmt,
|
||||
VK_IMAGE_USAGE_SAMPLED_BIT,
|
||||
false);
|
||||
created = true;
|
||||
}
|
||||
if (pixels) stbi_image_free(pixels);
|
||||
}
|
||||
return {out, created};
|
||||
}
|
||||
|
||||
std::shared_ptr<MeshAsset> AssetManager::createMesh(const std::string &name,
|
||||
std::span<Vertex> vertices,
|
||||
std::span<uint32_t> indices,
|
||||
std::shared_ptr<GLTFMaterial> material)
|
||||
{
|
||||
if (!_engine || !_engine->_resourceManager) return {};
|
||||
if (name.empty()) return {};
|
||||
|
||||
auto it = _meshCache.find(name);
|
||||
if (it != _meshCache.end()) return it->second;
|
||||
|
||||
if (!material)
|
||||
{
|
||||
GLTFMetallic_Roughness::MaterialResources matResources{};
|
||||
matResources.colorImage = _engine->_whiteImage;
|
||||
matResources.colorSampler = _engine->_samplerManager->defaultLinear();
|
||||
matResources.metalRoughImage = _engine->_whiteImage;
|
||||
matResources.metalRoughSampler = _engine->_samplerManager->defaultLinear();
|
||||
matResources.normalImage = _engine->_flatNormalImage;
|
||||
matResources.normalSampler = _engine->_samplerManager->defaultLinear();
|
||||
|
||||
AllocatedBuffer matBuffer = createMaterialBufferWithConstants({});
|
||||
matResources.dataBuffer = matBuffer.buffer;
|
||||
matResources.dataBufferOffset = 0;
|
||||
|
||||
material = createMaterial(MaterialPass::MainColor, matResources);
|
||||
_meshMaterialBuffers.emplace(name, matBuffer);
|
||||
}
|
||||
|
||||
auto mesh = std::make_shared<MeshAsset>();
|
||||
mesh->name = name;
|
||||
mesh->meshBuffers = _engine->_resourceManager->uploadMesh(indices, vertices);
|
||||
// BLAS for this mesh is built lazily when TLAS is constructed from the draw
|
||||
// context (RayTracingManager::buildTLASFromDrawContext). This keeps RT work
|
||||
// centralized and avoids redundant builds on load.
|
||||
|
||||
GeoSurface surf{};
|
||||
surf.startIndex = 0;
|
||||
surf.count = (uint32_t) indices.size();
|
||||
surf.material = material;
|
||||
surf.bounds = compute_bounds(vertices);
|
||||
mesh->surfaces.push_back(surf);
|
||||
|
||||
// Build CPU-side BVH for precise ray picking over this mesh.
|
||||
// This uses the same mesh-local vertex/index data as the GPU upload.
|
||||
mesh->bvh = build_mesh_bvh(*mesh, vertices, indices);
|
||||
|
||||
_meshCache.emplace(name, mesh);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
std::shared_ptr<GLTFMaterial> AssetManager::createMaterialFromConstants(
|
||||
const std::string &name,
|
||||
const GLTFMetallic_Roughness::MaterialConstants &constants,
|
||||
MaterialPass pass)
|
||||
{
|
||||
if (!_engine) return {};
|
||||
GLTFMetallic_Roughness::MaterialResources res{};
|
||||
res.colorImage = _engine->_whiteImage;
|
||||
res.colorSampler = _engine->_samplerManager->defaultLinear();
|
||||
res.metalRoughImage = _engine->_whiteImage;
|
||||
res.metalRoughSampler = _engine->_samplerManager->defaultLinear();
|
||||
res.normalImage = _engine->_flatNormalImage;
|
||||
res.normalSampler = _engine->_samplerManager->defaultLinear();
|
||||
|
||||
AllocatedBuffer buf = createMaterialBufferWithConstants(constants);
|
||||
res.dataBuffer = buf.buffer;
|
||||
res.dataBufferOffset = 0;
|
||||
_meshMaterialBuffers[name] = buf;
|
||||
|
||||
return createMaterial(pass, res);
|
||||
}
|
||||
|
||||
std::shared_ptr<MeshAsset> AssetManager::getMesh(const std::string &name) const
|
||||
{
|
||||
auto it = _meshCache.find(name);
|
||||
return (it != _meshCache.end()) ? it->second : nullptr;
|
||||
}
|
||||
|
||||
bool AssetManager::removeMesh(const std::string &name)
|
||||
{
|
||||
auto it = _meshCache.find(name);
|
||||
if (it == _meshCache.end()) return false;
|
||||
if (_engine && _engine->_rayManager)
|
||||
{
|
||||
// Clean up BLAS cached for this mesh (if ray tracing is enabled)
|
||||
_engine->_rayManager->removeBLASForBuffer(it->second->meshBuffers.vertexBuffer.buffer);
|
||||
}
|
||||
if (_engine && _engine->_resourceManager)
|
||||
{
|
||||
_engine->_resourceManager->destroy_buffer(it->second->meshBuffers.indexBuffer);
|
||||
_engine->_resourceManager->destroy_buffer(it->second->meshBuffers.vertexBuffer);
|
||||
}
|
||||
_meshCache.erase(it);
|
||||
auto itb = _meshMaterialBuffers.find(name);
|
||||
if (itb != _meshMaterialBuffers.end())
|
||||
{
|
||||
if (_engine && _engine->_resourceManager)
|
||||
{
|
||||
_engine->_resourceManager->destroy_buffer(itb->second);
|
||||
}
|
||||
_meshMaterialBuffers.erase(itb);
|
||||
}
|
||||
auto iti = _meshOwnedImages.find(name);
|
||||
if (iti != _meshOwnedImages.end())
|
||||
{
|
||||
if (_engine && _engine->_resourceManager)
|
||||
{
|
||||
for (const auto &img: iti->second)
|
||||
{
|
||||
_engine->_resourceManager->destroy_image(img);
|
||||
}
|
||||
}
|
||||
_meshOwnedImages.erase(iti);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
125
src/core/assets/manager.h
Normal file
125
src/core/assets/manager.h
Normal file
@@ -0,0 +1,125 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <filesystem>
|
||||
#include <vector>
|
||||
#include <utility>
|
||||
|
||||
#include <scene/vk_loader.h>
|
||||
#include <core/types.h>
|
||||
|
||||
#include "render/materials.h"
|
||||
#include "locator.h"
|
||||
|
||||
class VulkanEngine;
|
||||
class MeshAsset;
|
||||
|
||||
class AssetManager
|
||||
{
|
||||
public:
|
||||
struct MaterialOptions
|
||||
{
|
||||
std::string albedoPath;
|
||||
std::string metalRoughPath;
|
||||
// Optional tangent-space normal map for PBR (placeholder; not wired yet)
|
||||
// When enabled later, this will be sampled in shaders and requires tangents.
|
||||
std::string normalPath;
|
||||
|
||||
bool albedoSRGB = true;
|
||||
bool metalRoughSRGB = false;
|
||||
bool normalSRGB = false; // normal maps are typically non-sRGB
|
||||
|
||||
GLTFMetallic_Roughness::MaterialConstants constants{};
|
||||
|
||||
MaterialPass pass = MaterialPass::MainColor;
|
||||
};
|
||||
|
||||
struct MeshGeometryDesc
|
||||
{
|
||||
enum class Type { Provided, Cube, Sphere, Plane, Capsule };
|
||||
|
||||
Type type = Type::Provided;
|
||||
std::span<Vertex> vertices{};
|
||||
std::span<uint32_t> indices{};
|
||||
int sectors = 16;
|
||||
int stacks = 16;
|
||||
};
|
||||
|
||||
struct MeshMaterialDesc
|
||||
{
|
||||
enum class Kind { Default, Textured };
|
||||
|
||||
Kind kind = Kind::Default;
|
||||
MaterialOptions options{};
|
||||
};
|
||||
|
||||
struct MeshCreateInfo
|
||||
{
|
||||
std::string name;
|
||||
MeshGeometryDesc geometry;
|
||||
MeshMaterialDesc material;
|
||||
// Optional override for collision / picking bounds type for this mesh.
|
||||
// When unset, a reasonable default is chosen based on geometry.type.
|
||||
std::optional<BoundsType> boundsType;
|
||||
};
|
||||
|
||||
void init(VulkanEngine *engine);
|
||||
|
||||
void cleanup();
|
||||
|
||||
std::string shaderPath(std::string_view name) const;
|
||||
|
||||
std::string modelPath(std::string_view name) const;
|
||||
|
||||
std::string assetPath(std::string_view name) const;
|
||||
|
||||
std::optional<std::shared_ptr<LoadedGLTF> > loadGLTF(std::string_view nameOrPath);
|
||||
|
||||
// Queue texture loads for a glTF file ahead of time. This parses the glTF,
|
||||
// builds TextureCache keys for referenced images (both external URIs and
|
||||
// embedded images in buffers), and issues TextureCache::request() calls.
|
||||
// Actual uploads happen via the normal per-frame pump.
|
||||
// Returns number of textures scheduled.
|
||||
size_t prefetchGLTFTextures(std::string_view nameOrPath);
|
||||
|
||||
std::shared_ptr<MeshAsset> createMesh(const MeshCreateInfo &info);
|
||||
|
||||
std::shared_ptr<MeshAsset> getPrimitive(std::string_view name) const;
|
||||
|
||||
std::shared_ptr<MeshAsset> createMesh(const std::string &name,
|
||||
std::span<Vertex> vertices,
|
||||
std::span<uint32_t> indices,
|
||||
std::shared_ptr<GLTFMaterial> material = {});
|
||||
|
||||
std::shared_ptr<MeshAsset> getMesh(const std::string &name) const;
|
||||
|
||||
bool removeMesh(const std::string &name);
|
||||
|
||||
// Convenience: create a PBR material from constants using engine default textures
|
||||
std::shared_ptr<GLTFMaterial> createMaterialFromConstants(const std::string &name,
|
||||
const GLTFMetallic_Roughness::MaterialConstants &constants,
|
||||
MaterialPass pass = MaterialPass::MainColor);
|
||||
|
||||
const AssetPaths &paths() const { return _locator.paths(); }
|
||||
void setPaths(const AssetPaths &p) { _locator.setPaths(p); }
|
||||
|
||||
private:
|
||||
VulkanEngine *_engine = nullptr;
|
||||
AssetLocator _locator;
|
||||
|
||||
std::unordered_map<std::string, std::weak_ptr<LoadedGLTF> > _gltfCacheByPath;
|
||||
std::unordered_map<std::string, std::shared_ptr<MeshAsset> > _meshCache;
|
||||
std::unordered_map<std::string, AllocatedBuffer> _meshMaterialBuffers;
|
||||
std::unordered_map<std::string, std::vector<AllocatedImage> > _meshOwnedImages;
|
||||
|
||||
AllocatedBuffer createMaterialBufferWithConstants(const GLTFMetallic_Roughness::MaterialConstants &constants) const;
|
||||
|
||||
std::shared_ptr<GLTFMaterial> createMaterial(MaterialPass pass,
|
||||
const GLTFMetallic_Roughness::MaterialResources &res) const;
|
||||
|
||||
std::pair<AllocatedImage, bool> loadImageFromAsset(std::string_view path, bool srgb) const;
|
||||
};
|
||||
943
src/core/assets/texture_cache.cpp
Normal file
943
src/core/assets/texture_cache.cpp
Normal file
@@ -0,0 +1,943 @@
|
||||
#include "texture_cache.h"
|
||||
|
||||
#include <core/context.h>
|
||||
#include <core/device/resource.h>
|
||||
#include <core/descriptor/descriptors.h>
|
||||
#include <core/config.h>
|
||||
#include <algorithm>
|
||||
#include "stb_image.h"
|
||||
#include <ktx.h>
|
||||
#include <ktxvulkan.h>
|
||||
#include <algorithm>
|
||||
#include "core/device/device.h"
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <limits>
|
||||
#include <cmath>
|
||||
|
||||
void TextureCache::init(EngineContext *ctx)
|
||||
{
|
||||
_context = ctx;
|
||||
_running = true;
|
||||
unsigned int threads = std::max(1u, std::min(4u, std::thread::hardware_concurrency()));
|
||||
_decodeThreads.reserve(threads);
|
||||
for (unsigned int i = 0; i < threads; ++i)
|
||||
{
|
||||
_decodeThreads.emplace_back([this]() { worker_loop(); });
|
||||
}
|
||||
}
|
||||
|
||||
void TextureCache::cleanup()
|
||||
{
|
||||
// Stop worker thread first
|
||||
if (_running.exchange(false))
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(_qMutex);
|
||||
}
|
||||
_qCV.notify_all();
|
||||
for (auto &t : _decodeThreads) if (t.joinable()) t.join();
|
||||
_decodeThreads.clear();
|
||||
}
|
||||
if (!_context || !_context->getResources()) return;
|
||||
auto *rm = _context->getResources();
|
||||
for (TextureHandle h = 0; h < _entries.size(); ++h)
|
||||
{
|
||||
auto &e = _entries[h];
|
||||
if (e.state == EntryState::Resident && e.image.image)
|
||||
{
|
||||
fmt::println("[TextureCache] cleanup destroy handle={} path='{}' bytes={}",
|
||||
h,
|
||||
e.path.empty() ? "<bytes>" : e.path,
|
||||
e.sizeBytes);
|
||||
rm->destroy_image(e.image);
|
||||
e.image = {};
|
||||
}
|
||||
e.state = EntryState::Evicted;
|
||||
}
|
||||
_residentBytes = 0;
|
||||
_lookup.clear();
|
||||
_setToHandles.clear();
|
||||
}
|
||||
|
||||
TextureCache::TextureHandle TextureCache::request(const TextureKey &key, VkSampler sampler)
|
||||
{
|
||||
// Ensure we have a valid, stable hash for deduplication.
|
||||
TextureKey normKey = key;
|
||||
if (normKey.hash == 0)
|
||||
{
|
||||
if (normKey.kind == TextureKey::SourceKind::FilePath)
|
||||
{
|
||||
std::string id = std::string("PATH:") + normKey.path + (normKey.srgb ? "#sRGB" : "#UNORM");
|
||||
normKey.hash = texcache::fnv1a64(id);
|
||||
}
|
||||
else if (!normKey.bytes.empty())
|
||||
{
|
||||
uint64_t h = texcache::fnv1a64(normKey.bytes.data(), normKey.bytes.size());
|
||||
normKey.hash = h ^ (normKey.srgb ? 0x9E3779B97F4A7C15ull : 0ull);
|
||||
}
|
||||
}
|
||||
|
||||
auto it = _lookup.find(normKey.hash);
|
||||
if (it != _lookup.end())
|
||||
{
|
||||
TextureHandle h = it->second;
|
||||
// Keep most recent sampler for future patches if provided
|
||||
if (h < _entries.size() && sampler != VK_NULL_HANDLE)
|
||||
{
|
||||
_entries[h].sampler = sampler;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
TextureHandle h = static_cast<TextureHandle>(_entries.size());
|
||||
_lookup.emplace(normKey.hash, h);
|
||||
|
||||
Entry e{};
|
||||
e.key = normKey;
|
||||
e.sampler = sampler;
|
||||
e.state = EntryState::Unloaded;
|
||||
if (normKey.kind == TextureKey::SourceKind::FilePath)
|
||||
{
|
||||
e.path = normKey.path;
|
||||
}
|
||||
else
|
||||
{
|
||||
e.bytes = normKey.bytes;
|
||||
_cpuSourceBytes += e.bytes.size();
|
||||
}
|
||||
fmt::println("[TextureCache] request handle={} kind={} path='{}' srgb={} mipmapped={} hash=0x{:016x}",
|
||||
h,
|
||||
(normKey.kind == TextureKey::SourceKind::FilePath ? "FilePath" : "Bytes"),
|
||||
normKey.kind == TextureKey::SourceKind::FilePath ? normKey.path : "<bytes>",
|
||||
normKey.srgb,
|
||||
normKey.mipmapped,
|
||||
normKey.hash);
|
||||
_entries.push_back(std::move(e));
|
||||
return h;
|
||||
}
|
||||
|
||||
void TextureCache::watchBinding(TextureHandle handle, VkDescriptorSet set, uint32_t binding,
|
||||
VkSampler sampler, VkImageView fallbackView)
|
||||
{
|
||||
if (handle == InvalidHandle) return;
|
||||
if (handle >= _entries.size()) return;
|
||||
Entry &e = _entries[handle];
|
||||
// Track patch
|
||||
Patch p{};
|
||||
p.set = set;
|
||||
p.binding = binding;
|
||||
p.sampler = sampler ? sampler : e.sampler;
|
||||
p.fallbackView = fallbackView;
|
||||
e.patches.push_back(p);
|
||||
|
||||
// Back-reference for fast per-set markUsed
|
||||
_setToHandles[set].push_back(handle);
|
||||
|
||||
// If the texture is already resident, immediately patch the new descriptor
|
||||
// so re-spawned models using cached textures get the correct bindings.
|
||||
if (e.state == EntryState::Resident && e.image.imageView != VK_NULL_HANDLE && set != VK_NULL_HANDLE)
|
||||
{
|
||||
if (!_context || !_context->getDevice()) return;
|
||||
DescriptorWriter writer;
|
||||
writer.write_image(static_cast<int>(binding), e.image.imageView,
|
||||
p.sampler ? p.sampler : e.sampler,
|
||||
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
|
||||
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER);
|
||||
writer.update_set(_context->getDevice()->device(), set);
|
||||
}
|
||||
}
|
||||
|
||||
void TextureCache::unwatchSet(VkDescriptorSet set)
|
||||
{
|
||||
if (set == VK_NULL_HANDLE) return;
|
||||
auto it = _setToHandles.find(set);
|
||||
if (it == _setToHandles.end()) return;
|
||||
|
||||
const auto &handles = it->second;
|
||||
for (TextureHandle h : handles)
|
||||
{
|
||||
if (h >= _entries.size()) continue;
|
||||
auto &patches = _entries[h].patches;
|
||||
patches.erase(std::remove_if(patches.begin(), patches.end(),
|
||||
[&](const Patch &p){ return p.set == set; }),
|
||||
patches.end());
|
||||
}
|
||||
_setToHandles.erase(it);
|
||||
}
|
||||
|
||||
void TextureCache::markUsed(TextureHandle handle, uint32_t frameIndex)
|
||||
{
|
||||
if (handle == InvalidHandle) return;
|
||||
if (handle >= _entries.size()) return;
|
||||
_entries[handle].lastUsedFrame = frameIndex;
|
||||
}
|
||||
|
||||
void TextureCache::markSetUsed(VkDescriptorSet set, uint32_t frameIndex)
|
||||
{
|
||||
auto it = _setToHandles.find(set);
|
||||
if (it == _setToHandles.end()) return;
|
||||
for (TextureHandle h : it->second)
|
||||
{
|
||||
if (h < _entries.size())
|
||||
{
|
||||
_entries[h].lastUsedFrame = frameIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static inline size_t bytes_per_texel(VkFormat fmt)
|
||||
{
|
||||
switch (fmt)
|
||||
{
|
||||
case VK_FORMAT_R8_UNORM:
|
||||
case VK_FORMAT_R8_SRGB:
|
||||
return 1;
|
||||
case VK_FORMAT_R8G8_UNORM:
|
||||
case VK_FORMAT_R8G8_SRGB:
|
||||
return 2;
|
||||
case VK_FORMAT_R8G8B8A8_UNORM:
|
||||
case VK_FORMAT_R8G8B8A8_SRGB:
|
||||
case VK_FORMAT_B8G8R8A8_UNORM:
|
||||
case VK_FORMAT_B8G8R8A8_SRGB:
|
||||
return 4;
|
||||
default:
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
static inline VkFormat to_srgb_variant(VkFormat fmt)
|
||||
{
|
||||
switch (fmt)
|
||||
{
|
||||
case VK_FORMAT_BC1_RGB_UNORM_BLOCK: return VK_FORMAT_BC1_RGB_SRGB_BLOCK;
|
||||
case VK_FORMAT_BC1_RGBA_UNORM_BLOCK: return VK_FORMAT_BC1_RGBA_SRGB_BLOCK;
|
||||
case VK_FORMAT_BC2_UNORM_BLOCK: return VK_FORMAT_BC2_SRGB_BLOCK;
|
||||
case VK_FORMAT_BC3_UNORM_BLOCK: return VK_FORMAT_BC3_SRGB_BLOCK;
|
||||
case VK_FORMAT_BC7_UNORM_BLOCK: return VK_FORMAT_BC7_SRGB_BLOCK;
|
||||
case VK_FORMAT_R8G8B8A8_UNORM: return VK_FORMAT_R8G8B8A8_SRGB;
|
||||
case VK_FORMAT_B8G8R8A8_UNORM: return VK_FORMAT_B8G8R8A8_SRGB;
|
||||
case VK_FORMAT_R8_UNORM: return VK_FORMAT_R8_SRGB;
|
||||
case VK_FORMAT_R8G8_UNORM: return VK_FORMAT_R8G8_SRGB;
|
||||
default: return fmt;
|
||||
}
|
||||
}
|
||||
|
||||
static inline VkFormat to_unorm_variant(VkFormat fmt)
|
||||
{
|
||||
switch (fmt)
|
||||
{
|
||||
case VK_FORMAT_BC1_RGB_SRGB_BLOCK: return VK_FORMAT_BC1_RGB_UNORM_BLOCK;
|
||||
case VK_FORMAT_BC1_RGBA_SRGB_BLOCK: return VK_FORMAT_BC1_RGBA_UNORM_BLOCK;
|
||||
case VK_FORMAT_BC2_SRGB_BLOCK: return VK_FORMAT_BC2_UNORM_BLOCK;
|
||||
case VK_FORMAT_BC3_SRGB_BLOCK: return VK_FORMAT_BC3_UNORM_BLOCK;
|
||||
case VK_FORMAT_BC7_SRGB_BLOCK: return VK_FORMAT_BC7_UNORM_BLOCK;
|
||||
case VK_FORMAT_R8G8B8A8_SRGB: return VK_FORMAT_R8G8B8A8_UNORM;
|
||||
case VK_FORMAT_B8G8R8A8_SRGB: return VK_FORMAT_B8G8R8A8_UNORM;
|
||||
case VK_FORMAT_R8_SRGB: return VK_FORMAT_R8_UNORM;
|
||||
case VK_FORMAT_R8G8_SRGB: return VK_FORMAT_R8G8_UNORM;
|
||||
default: return fmt;
|
||||
}
|
||||
}
|
||||
|
||||
static inline float mip_factor_for_levels(uint32_t levels)
|
||||
{
|
||||
if (levels <= 1) return 1.0f;
|
||||
// Sum of geometric series for area across mips (base * (1 + 1/4 + ...))
|
||||
// factor = (1 - 4^{-L}) / (1 - 1/4) = 4/3 * (1 - 4^{-L})
|
||||
float L = static_cast<float>(levels);
|
||||
return 1.3333333f * (1.0f - std::pow(0.25f, L));
|
||||
}
|
||||
|
||||
static inline VkFormat choose_format(TextureCache::TextureKey::ChannelsHint hint, bool srgb)
|
||||
{
|
||||
using CH = TextureCache::TextureKey::ChannelsHint;
|
||||
switch (hint)
|
||||
{
|
||||
case CH::R: return srgb ? VK_FORMAT_R8_SRGB : VK_FORMAT_R8_UNORM;
|
||||
case CH::RG: return srgb ? VK_FORMAT_R8G8_SRGB : VK_FORMAT_R8G8_UNORM;
|
||||
case CH::RGBA:
|
||||
case CH::Auto:
|
||||
default: return srgb ? VK_FORMAT_R8G8B8A8_SRGB : VK_FORMAT_R8G8B8A8_UNORM;
|
||||
}
|
||||
}
|
||||
|
||||
// Nearest-neighbor downscale-by-2 in-place helper (returns newly allocated buffer)
|
||||
static std::vector<uint8_t> downscale_half(const unsigned char* src, int w, int h, int comps)
|
||||
{
|
||||
int nw = std::max(1, w / 2);
|
||||
int nh = std::max(1, h / 2);
|
||||
std::vector<uint8_t> out(static_cast<size_t>(nw) * nh * comps);
|
||||
for (int y = 0; y < nh; ++y)
|
||||
{
|
||||
for (int x = 0; x < nw; ++x)
|
||||
{
|
||||
int sx = std::min(w - 1, x * 2);
|
||||
int sy = std::min(h - 1, y * 2);
|
||||
const unsigned char* sp = src + (static_cast<size_t>(sy) * w + sx) * comps;
|
||||
unsigned char* dp = out.data() + (static_cast<size_t>(y) * nw + x) * comps;
|
||||
std::memcpy(dp, sp, comps);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void TextureCache::start_load(Entry &e, ResourceManager &rm)
|
||||
{
|
||||
// Legacy synchronous path retained for completeness but not used by pumpLoads now.
|
||||
enqueue_decode(e);
|
||||
}
|
||||
|
||||
void TextureCache::patch_ready_entry(const Entry &e)
|
||||
{
|
||||
if (!_context || !_context->getDevice()) return;
|
||||
if (e.state != EntryState::Resident) return;
|
||||
|
||||
DescriptorWriter writer;
|
||||
for (const Patch &p : e.patches)
|
||||
{
|
||||
if (p.set == VK_NULL_HANDLE) continue;
|
||||
writer.clear();
|
||||
writer.write_image(static_cast<int>(p.binding), e.image.imageView,
|
||||
p.sampler ? p.sampler : e.sampler,
|
||||
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
|
||||
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER);
|
||||
writer.update_set(_context->getDevice()->device(), p.set);
|
||||
}
|
||||
}
|
||||
|
||||
void TextureCache::patch_to_fallback(const Entry &e)
|
||||
{
|
||||
if (!_context || !_context->getDevice()) return;
|
||||
DescriptorWriter writer;
|
||||
for (const Patch &p : e.patches)
|
||||
{
|
||||
if (p.set == VK_NULL_HANDLE || p.fallbackView == VK_NULL_HANDLE) continue;
|
||||
writer.clear();
|
||||
writer.write_image(static_cast<int>(p.binding), p.fallbackView,
|
||||
p.sampler ? p.sampler : e.sampler,
|
||||
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
|
||||
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER);
|
||||
writer.update_set(_context->getDevice()->device(), p.set);
|
||||
}
|
||||
}
|
||||
|
||||
void TextureCache::pumpLoads(ResourceManager &rm, FrameResources &)
|
||||
{
|
||||
// Simple throttle to avoid massive spikes.
|
||||
int started = 0;
|
||||
const uint32_t now = _context ? _context->frameIndex : 0u;
|
||||
// First, drain decoded results with a byte budget.
|
||||
size_t admitted = drain_ready_uploads(rm, _maxBytesPerPump);
|
||||
|
||||
// If we exhausted the budget, avoid scheduling more decodes this frame.
|
||||
bool budgetRemaining = (admitted < _maxBytesPerPump);
|
||||
|
||||
for (auto &e : _entries)
|
||||
{
|
||||
// Allow both Unloaded and Evicted entries to start work if seen again.
|
||||
if (e.state == EntryState::Unloaded || e.state == EntryState::Evicted)
|
||||
{
|
||||
// Visibility-driven residency: only start uploads for textures
|
||||
// that were marked used recently (current or previous frame).
|
||||
// This avoids uploading assets that are not visible.
|
||||
bool recentlyUsed = true;
|
||||
if (_context)
|
||||
{
|
||||
// Schedule when first seen (previous frame) or if seen again.
|
||||
recentlyUsed = (now == 0u) || (now - e.lastUsedFrame <= 1u);
|
||||
}
|
||||
// Gate reload attempts to avoid rapid oscillation right after eviction.
|
||||
bool cooldownPassed = (now >= e.nextAttemptFrame);
|
||||
if (recentlyUsed && cooldownPassed && budgetRemaining)
|
||||
{
|
||||
enqueue_decode(e);
|
||||
if (++started >= _maxLoadsPerPump) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain any remaining decoded results if we still have headroom.
|
||||
if (budgetRemaining)
|
||||
{
|
||||
drain_ready_uploads(rm, _maxBytesPerPump - admitted);
|
||||
}
|
||||
|
||||
// Optionally trim retained compressed sources to CPU budget.
|
||||
evictCpuToBudget();
|
||||
}
|
||||
|
||||
void TextureCache::evictToBudget(size_t budgetBytes)
|
||||
{
|
||||
if (_residentBytes <= budgetBytes) return;
|
||||
|
||||
// Gather candidates
|
||||
std::vector<std::pair<TextureHandle, uint32_t>> order;
|
||||
order.reserve(_entries.size());
|
||||
for (TextureHandle h = 0; h < _entries.size(); ++h)
|
||||
{
|
||||
const auto &e = _entries[h];
|
||||
if (e.state == EntryState::Resident)
|
||||
{
|
||||
order.emplace_back(h, e.lastUsedFrame);
|
||||
}
|
||||
}
|
||||
std::sort(order.begin(), order.end(), [](auto &a, auto &b) { return a.second < b.second; });
|
||||
|
||||
const uint32_t now = _context ? _context->frameIndex : 0u;
|
||||
for (auto &pair : order)
|
||||
{
|
||||
if (_residentBytes <= budgetBytes) break;
|
||||
TextureHandle h = pair.first;
|
||||
Entry &e = _entries[h];
|
||||
if (e.state != EntryState::Resident) continue;
|
||||
// Prefer not to evict textures used this frame unless strictly necessary.
|
||||
if (e.lastUsedFrame == now) continue;
|
||||
|
||||
// Rewrite watchers back to fallback before destroying
|
||||
patch_to_fallback(e);
|
||||
|
||||
fmt::println("[TextureCache] evictToBudget destroy handle={} path='{}' bytes={} residentBytesBefore={}",
|
||||
h,
|
||||
e.path.empty() ? "<bytes>" : e.path,
|
||||
e.sizeBytes,
|
||||
_residentBytes);
|
||||
_context->getResources()->destroy_image(e.image);
|
||||
e.image = {};
|
||||
e.state = EntryState::Evicted;
|
||||
e.lastEvictedFrame = now;
|
||||
e.nextAttemptFrame = std::max(e.nextAttemptFrame, now + _reloadCooldownFrames);
|
||||
if (_residentBytes >= e.sizeBytes) _residentBytes -= e.sizeBytes; else _residentBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void TextureCache::enqueue_decode(Entry &e)
|
||||
{
|
||||
if (e.state != EntryState::Unloaded && e.state != EntryState::Evicted) return;
|
||||
e.state = EntryState::Loading;
|
||||
DecodeRequest rq{};
|
||||
rq.handle = static_cast<TextureHandle>(&e - _entries.data());
|
||||
rq.key = e.key;
|
||||
if (e.key.kind == TextureKey::SourceKind::FilePath) rq.path = e.path; else rq.bytes = e.bytes;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(_qMutex);
|
||||
_queue.push_back(std::move(rq));
|
||||
}
|
||||
_qCV.notify_one();
|
||||
}
|
||||
|
||||
void TextureCache::worker_loop()
|
||||
{
|
||||
while (_running)
|
||||
{
|
||||
DecodeRequest rq{};
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(_qMutex);
|
||||
_qCV.wait(lk, [this]{ return !_running || !_queue.empty(); });
|
||||
if (!_running) break;
|
||||
rq = std::move(_queue.front());
|
||||
_queue.pop_front();
|
||||
}
|
||||
|
||||
DecodedResult out{};
|
||||
out.handle = rq.handle;
|
||||
out.mipmapped = rq.key.mipmapped;
|
||||
out.srgb = rq.key.srgb;
|
||||
out.channels = rq.key.channels;
|
||||
out.mipClampLevels = rq.key.mipClampLevels;
|
||||
|
||||
// 1) Prefer KTX2 when source is a file path and a .ktx2 version exists
|
||||
bool attemptedKTX2 = false;
|
||||
if (rq.key.kind == TextureKey::SourceKind::FilePath)
|
||||
{
|
||||
std::filesystem::path p = rq.path;
|
||||
std::filesystem::path ktxPath;
|
||||
if (p.extension() == ".ktx2")
|
||||
{
|
||||
ktxPath = p;
|
||||
}
|
||||
else
|
||||
{
|
||||
ktxPath = p;
|
||||
ktxPath.replace_extension(".ktx2");
|
||||
}
|
||||
std::error_code ec;
|
||||
bool hasKTX2 = (!ktxPath.empty() && std::filesystem::exists(ktxPath, ec) && !ec);
|
||||
if (hasKTX2)
|
||||
{
|
||||
attemptedKTX2 = true;
|
||||
ktxTexture2* ktex = nullptr;
|
||||
ktxResult kres = ktxTexture2_CreateFromNamedFile(ktxPath.string().c_str(), KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT, &ktex);
|
||||
if (kres != KTX_SUCCESS || !ktex)
|
||||
{
|
||||
fmt::println("[TextureCache] libktx open failed for '{}': {}", ktxPath.string(), ktxErrorString(kres));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ktxTexture2_NeedsTranscoding(ktex))
|
||||
{
|
||||
ktx_transcode_fmt_e target = (rq.key.channels == TextureKey::ChannelsHint::RG) ? KTX_TTF_BC5_RG : KTX_TTF_BC7_RGBA;
|
||||
kres = ktxTexture2_TranscodeBasis(ktex, target, 0);
|
||||
if (kres != KTX_SUCCESS)
|
||||
{
|
||||
fmt::println("[TextureCache] libktx transcode failed for '{}': {}", ktxPath.string(), ktxErrorString(kres));
|
||||
ktxTexture_Destroy(ktxTexture(ktex));
|
||||
ktex = nullptr;
|
||||
}
|
||||
}
|
||||
if (ktex)
|
||||
{
|
||||
VkFormat vkfmt = static_cast<VkFormat>(ktex->vkFormat);
|
||||
uint32_t mipLevels = ktex->numLevels;
|
||||
uint32_t baseW = ktex->baseWidth;
|
||||
uint32_t baseH = ktex->baseHeight;
|
||||
ktx_size_t totalSize = ktxTexture_GetDataSize(ktxTexture(ktex));
|
||||
const uint8_t* dataPtr = reinterpret_cast<const uint8_t*>(ktxTexture_GetData(ktxTexture(ktex)));
|
||||
|
||||
switch (vkfmt)
|
||||
{
|
||||
case VK_FORMAT_BC1_RGB_UNORM_BLOCK:
|
||||
case VK_FORMAT_BC1_RGB_SRGB_BLOCK:
|
||||
case VK_FORMAT_BC1_RGBA_UNORM_BLOCK:
|
||||
case VK_FORMAT_BC1_RGBA_SRGB_BLOCK:
|
||||
case VK_FORMAT_BC2_UNORM_BLOCK:
|
||||
case VK_FORMAT_BC2_SRGB_BLOCK:
|
||||
case VK_FORMAT_BC3_UNORM_BLOCK:
|
||||
case VK_FORMAT_BC3_SRGB_BLOCK:
|
||||
case VK_FORMAT_BC4_UNORM_BLOCK:
|
||||
case VK_FORMAT_BC4_SNORM_BLOCK:
|
||||
case VK_FORMAT_BC5_UNORM_BLOCK:
|
||||
case VK_FORMAT_BC5_SNORM_BLOCK:
|
||||
case VK_FORMAT_BC6H_UFLOAT_BLOCK:
|
||||
case VK_FORMAT_BC6H_SFLOAT_BLOCK:
|
||||
case VK_FORMAT_BC7_UNORM_BLOCK:
|
||||
case VK_FORMAT_BC7_SRGB_BLOCK:
|
||||
break;
|
||||
default:
|
||||
fmt::println("[TextureCache] libktx returned non-BC format {} — skipping KTX2", string_VkFormat(vkfmt));
|
||||
ktxTexture_Destroy(ktxTexture(ktex));
|
||||
ktex = nullptr;
|
||||
break;
|
||||
}
|
||||
|
||||
if (ktex)
|
||||
{
|
||||
out.isKTX2 = true;
|
||||
out.ktxFormat = vkfmt;
|
||||
out.ktxMipLevels = mipLevels;
|
||||
out.ktx.bytes.assign(dataPtr, dataPtr + totalSize);
|
||||
out.ktx.levels.clear();
|
||||
out.ktx.levels.reserve(mipLevels);
|
||||
for (uint32_t mip = 0; mip < mipLevels; ++mip)
|
||||
{
|
||||
ktx_size_t off = 0, len = 0;
|
||||
ktxTexture_GetImageOffset(ktxTexture(ktex), mip, 0, 0, &off);
|
||||
ktxTexture_GetImageSize(ktxTexture(ktex), mip, &len);
|
||||
uint32_t w = std::max(1u, baseW >> mip);
|
||||
uint32_t h = std::max(1u, baseH >> mip);
|
||||
out.ktx.levels.push_back({ static_cast<uint64_t>(off), static_cast<uint64_t>(len), w, h });
|
||||
}
|
||||
out.width = static_cast<int>(baseW);
|
||||
out.height = static_cast<int>(baseH);
|
||||
ktxTexture_Destroy(ktxTexture(ktex));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (p.extension() == ".ktx2")
|
||||
{
|
||||
fmt::println("[TextureCache] Requested .ktx2 '{}' but file not found (ec={})", p.string(), ec.value());
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Raster fallback via stb_image if not KTX2 or unsupported
|
||||
if (!out.isKTX2)
|
||||
{
|
||||
int w = 0, h = 0, comp = 0;
|
||||
unsigned char *data = nullptr;
|
||||
if (rq.key.kind == TextureKey::SourceKind::FilePath)
|
||||
{
|
||||
data = stbi_load(rq.path.c_str(), &w, &h, &comp, 4);
|
||||
}
|
||||
else if (!rq.bytes.empty())
|
||||
{
|
||||
data = stbi_load_from_memory(rq.bytes.data(), static_cast<int>(rq.bytes.size()), &w, &h, &comp, 4);
|
||||
}
|
||||
|
||||
out.width = w;
|
||||
out.height = h;
|
||||
if (data && w > 0 && h > 0)
|
||||
{
|
||||
// Progressive downscale if requested
|
||||
if (_maxUploadDimension > 0 && (w > static_cast<int>(_maxUploadDimension) || h > static_cast<int>(_maxUploadDimension)))
|
||||
{
|
||||
std::vector<uint8_t> scaled;
|
||||
scaled.assign(data, data + static_cast<size_t>(w) * h * 4);
|
||||
int cw = w, ch = h;
|
||||
while (cw > static_cast<int>(_maxUploadDimension) || ch > static_cast<int>(_maxUploadDimension))
|
||||
{
|
||||
auto tmp = downscale_half(scaled.data(), cw, ch, 4);
|
||||
scaled.swap(tmp);
|
||||
cw = std::max(1, cw / 2);
|
||||
ch = std::max(1, ch / 2);
|
||||
}
|
||||
stbi_image_free(data);
|
||||
out.rgba = std::move(scaled);
|
||||
out.width = cw;
|
||||
out.height = ch;
|
||||
}
|
||||
else
|
||||
{
|
||||
out.heap = data;
|
||||
out.heapBytes = static_cast<size_t>(w) * static_cast<size_t>(h) * 4u;
|
||||
}
|
||||
}
|
||||
else if (data)
|
||||
{
|
||||
stbi_image_free(data);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(_readyMutex);
|
||||
_ready.push_back(std::move(out));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
size_t TextureCache::drain_ready_uploads(ResourceManager &rm, size_t budgetBytes)
|
||||
{
|
||||
std::deque<DecodedResult> local;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(_readyMutex);
|
||||
if (_ready.empty()) return 0;
|
||||
local.swap(_ready);
|
||||
}
|
||||
|
||||
size_t admitted = 0;
|
||||
for (auto &res : local)
|
||||
{
|
||||
if (res.handle == InvalidHandle || res.handle >= _entries.size()) continue;
|
||||
Entry &e = _entries[res.handle];
|
||||
if (!res.isKTX2 && ((res.heap == nullptr && res.rgba.empty()) || res.width <= 0 || res.height <= 0))
|
||||
{
|
||||
e.state = EntryState::Evicted; // failed decode; keep fallback
|
||||
continue;
|
||||
}
|
||||
|
||||
const uint32_t now = _context ? _context->frameIndex : 0u;
|
||||
VkExtent3D extent{static_cast<uint32_t>(std::max(0, res.width)), static_cast<uint32_t>(std::max(0, res.height)), 1u};
|
||||
TextureKey::ChannelsHint hint = (e.key.channels == TextureKey::ChannelsHint::Auto)
|
||||
? TextureKey::ChannelsHint::Auto
|
||||
: e.key.channels;
|
||||
|
||||
size_t expectedBytes = 0;
|
||||
VkFormat fmt = VK_FORMAT_UNDEFINED;
|
||||
uint32_t desiredLevels = 1;
|
||||
if (res.isKTX2)
|
||||
{
|
||||
fmt = res.ktxFormat;
|
||||
// Nudge format to sRGB/UNORM variant based on request to avoid gamma mistakes
|
||||
VkFormat reqFmt = e.key.srgb ? to_srgb_variant(fmt) : to_unorm_variant(fmt);
|
||||
if (reqFmt != fmt)
|
||||
{
|
||||
fmt = reqFmt;
|
||||
fmt::println("[TextureCache] Overriding KTX2 format to {} based on request (original {})",
|
||||
string_VkFormat(fmt), string_VkFormat(res.ktxFormat));
|
||||
}
|
||||
desiredLevels = res.ktxMipLevels;
|
||||
for (const auto &lv : res.ktx.levels) expectedBytes += static_cast<size_t>(lv.length);
|
||||
}
|
||||
else
|
||||
{
|
||||
fmt = choose_format(hint, res.srgb);
|
||||
if (res.mipmapped)
|
||||
{
|
||||
if (res.mipClampLevels > 0) desiredLevels = res.mipClampLevels;
|
||||
else desiredLevels = static_cast<uint32_t>(std::floor(std::log2(std::max(extent.width, extent.height)))) + 1u;
|
||||
}
|
||||
const float mipFactor = res.mipmapped ? mip_factor_for_levels(desiredLevels) : 1.0f;
|
||||
expectedBytes = static_cast<size_t>(extent.width) * extent.height * bytes_per_texel(fmt) * mipFactor;
|
||||
}
|
||||
|
||||
// Byte budget for this pump (frame)
|
||||
if (admitted + expectedBytes > budgetBytes)
|
||||
{
|
||||
// push back to be retried next frame/pump
|
||||
std::lock_guard<std::mutex> lk(_readyMutex);
|
||||
_ready.push_front(std::move(res));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_gpuBudgetBytes != std::numeric_limits<size_t>::max())
|
||||
{
|
||||
if (_residentBytes + expectedBytes > _gpuBudgetBytes)
|
||||
{
|
||||
size_t need = (_residentBytes + expectedBytes) - _gpuBudgetBytes;
|
||||
(void)try_make_space(need, now);
|
||||
}
|
||||
if (_residentBytes + expectedBytes > _gpuBudgetBytes)
|
||||
{
|
||||
// Not enough space even after eviction → back off; free decode heap
|
||||
if (res.heap) { stbi_image_free(res.heap); res.heap = nullptr; }
|
||||
e.state = EntryState::Evicted;
|
||||
e.lastEvictedFrame = now;
|
||||
e.nextAttemptFrame = std::max(e.nextAttemptFrame, now + _reloadCooldownFrames);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (res.isKTX2)
|
||||
{
|
||||
// Basic format support check: ensure the GPU can sample this format
|
||||
bool supported = true;
|
||||
if (_context && _context->getDevice())
|
||||
{
|
||||
VkFormatProperties props{};
|
||||
vkGetPhysicalDeviceFormatProperties(_context->getDevice()->physicalDevice(), fmt, &props);
|
||||
supported = (props.optimalTilingFeatures & VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT) != 0;
|
||||
}
|
||||
|
||||
if (!supported)
|
||||
{
|
||||
VkFormatProperties props{};
|
||||
if (_context && _context->getDevice())
|
||||
{
|
||||
vkGetPhysicalDeviceFormatProperties(_context->getDevice()->physicalDevice(), fmt, &props);
|
||||
}
|
||||
fmt::println("[TextureCache] Compressed format unsupported: format={} (optimalFeatures=0x{:08x}) — fallback raster for {}",
|
||||
string_VkFormat(fmt), props.optimalTilingFeatures, e.path);
|
||||
// Fall back to raster path: requeue by synthesizing a non-KTX result
|
||||
// Attempt synchronous fallback decode from file if available.
|
||||
int fw = 0, fh = 0, comp = 0;
|
||||
unsigned char *fdata = nullptr;
|
||||
if (e.key.kind == TextureKey::SourceKind::FilePath)
|
||||
{
|
||||
fdata = stbi_load(e.path.c_str(), &fw, &fh, &comp, 4);
|
||||
}
|
||||
if (!fdata)
|
||||
{
|
||||
e.state = EntryState::Evicted;
|
||||
continue;
|
||||
}
|
||||
VkExtent3D fext{ (uint32_t)fw, (uint32_t)fh, 1 };
|
||||
VkFormat ffmt = choose_format(hint, res.srgb);
|
||||
uint32_t mips = (res.mipmapped) ? static_cast<uint32_t>(std::floor(std::log2(std::max(fext.width, fext.height)))) + 1u : 1u;
|
||||
e.image = rm.create_image(fdata, fext, ffmt, VK_IMAGE_USAGE_SAMPLED_BIT, res.mipmapped, mips);
|
||||
stbi_image_free(fdata);
|
||||
e.sizeBytes = static_cast<size_t>(fext.width) * fext.height * bytes_per_texel(ffmt) * (res.mipmapped ? mip_factor_for_levels(mips) : 1.0f);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Prepare level table for ResourceManager
|
||||
std::vector<ResourceManager::MipLevelCopy> levels;
|
||||
levels.reserve(res.ktx.levels.size());
|
||||
for (const auto &lv : res.ktx.levels)
|
||||
{
|
||||
levels.push_back(ResourceManager::MipLevelCopy{ lv.offset, lv.length, lv.width, lv.height });
|
||||
}
|
||||
fmt::println("[TextureCache] upload KTX2 handle={} fmt={} levels={} size={}x{} srgb={} path='{}'",
|
||||
res.handle,
|
||||
string_VkFormat(fmt),
|
||||
res.ktxMipLevels,
|
||||
extent.width,
|
||||
extent.height,
|
||||
res.srgb,
|
||||
e.path);
|
||||
e.image = rm.create_image_compressed(res.ktx.bytes.data(), res.ktx.bytes.size(), fmt, levels);
|
||||
e.sizeBytes = expectedBytes;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Optionally repack channels to R or RG to save memory
|
||||
std::vector<uint8_t> packed;
|
||||
const void *src = nullptr;
|
||||
if (hint == TextureKey::ChannelsHint::R)
|
||||
{
|
||||
packed.resize(static_cast<size_t>(extent.width) * extent.height);
|
||||
const uint8_t* in = res.heap ? res.heap : res.rgba.data();
|
||||
for (size_t i = 0, px = static_cast<size_t>(extent.width) * extent.height; i < px; ++i)
|
||||
{
|
||||
packed[i] = in[i * 4 + 0];
|
||||
}
|
||||
src = packed.data();
|
||||
}
|
||||
else if (hint == TextureKey::ChannelsHint::RG)
|
||||
{
|
||||
packed.resize(static_cast<size_t>(extent.width) * extent.height * 2);
|
||||
const uint8_t* in = res.heap ? res.heap : res.rgba.data();
|
||||
for (size_t i = 0, px = static_cast<size_t>(extent.width) * extent.height; i < px; ++i)
|
||||
{
|
||||
packed[i * 2 + 0] = in[i * 4 + 0];
|
||||
packed[i * 2 + 1] = in[i * 4 + 1];
|
||||
}
|
||||
src = packed.data();
|
||||
}
|
||||
else
|
||||
{
|
||||
src = res.heap ? static_cast<const void *>(res.heap)
|
||||
: static_cast<const void *>(res.rgba.data());
|
||||
}
|
||||
|
||||
uint32_t mipOverride = (res.mipmapped ? desiredLevels : 1);
|
||||
fmt::println("[TextureCache] upload raster handle={} fmt={} levels={} size={}x{} srgb={} path='{}'",
|
||||
res.handle,
|
||||
string_VkFormat(fmt),
|
||||
mipOverride,
|
||||
extent.width,
|
||||
extent.height,
|
||||
res.srgb,
|
||||
e.path);
|
||||
e.image = rm.create_image(src, extent, fmt, VK_IMAGE_USAGE_SAMPLED_BIT, res.mipmapped, mipOverride);
|
||||
e.sizeBytes = expectedBytes;
|
||||
}
|
||||
|
||||
if (vmaDebugEnabled())
|
||||
{
|
||||
std::string name = e.key.kind == TextureKey::SourceKind::FilePath ? e.path : std::string("tex.bytes");
|
||||
vmaSetAllocationName(_context->getDevice()->allocator(), e.image.allocation, name.c_str());
|
||||
}
|
||||
|
||||
_residentBytes += e.sizeBytes;
|
||||
e.state = EntryState::Resident;
|
||||
e.nextAttemptFrame = 0; // clear backoff after success
|
||||
|
||||
// Drop source bytes if policy says so (only for Bytes-backed keys).
|
||||
if (!_keepSourceBytes && e.key.kind == TextureKey::SourceKind::Bytes)
|
||||
{
|
||||
drop_source_bytes(e);
|
||||
}
|
||||
|
||||
// Free temporary decode heap if present
|
||||
if (res.heap)
|
||||
{
|
||||
stbi_image_free(res.heap);
|
||||
}
|
||||
|
||||
// Patch descriptors now; data becomes valid before sampling due to RG upload pass
|
||||
patch_ready_entry(e);
|
||||
admitted += expectedBytes;
|
||||
}
|
||||
return admitted;
|
||||
}
|
||||
|
||||
void TextureCache::drop_source_bytes(Entry &e)
|
||||
{
|
||||
if (e.bytes.empty()) return;
|
||||
if (e.key.kind != TextureKey::SourceKind::Bytes) return;
|
||||
if (_cpuSourceBytes >= e.bytes.size()) _cpuSourceBytes -= e.bytes.size();
|
||||
e.bytes.clear();
|
||||
e.bytes.shrink_to_fit();
|
||||
e.path.clear();
|
||||
}
|
||||
|
||||
void TextureCache::evictCpuToBudget()
|
||||
{
|
||||
if (_cpuSourceBytes <= _cpuSourceBudget) return;
|
||||
// Collect candidates: Resident entries with retained bytes
|
||||
std::vector<TextureHandle> cands;
|
||||
cands.reserve(_entries.size());
|
||||
for (TextureHandle h = 0; h < _entries.size(); ++h)
|
||||
{
|
||||
const Entry &e = _entries[h];
|
||||
if (e.state == EntryState::Resident && !e.bytes.empty() && e.key.kind == TextureKey::SourceKind::Bytes)
|
||||
{
|
||||
cands.push_back(h);
|
||||
}
|
||||
}
|
||||
// LRU-ish: sort by lastUsed ascending
|
||||
std::sort(cands.begin(), cands.end(), [&](TextureHandle a, TextureHandle b){
|
||||
return _entries[a].lastUsedFrame < _entries[b].lastUsedFrame;
|
||||
});
|
||||
for (TextureHandle h : cands)
|
||||
{
|
||||
if (_cpuSourceBytes <= _cpuSourceBudget) break;
|
||||
drop_source_bytes(_entries[h]);
|
||||
}
|
||||
}
|
||||
|
||||
bool TextureCache::try_make_space(size_t bytesNeeded, uint32_t now)
|
||||
{
|
||||
if (bytesNeeded == 0) return true;
|
||||
if (_residentBytes == 0) return false;
|
||||
|
||||
// Collect candidates that were not used this frame, oldest first
|
||||
std::vector<std::pair<TextureHandle, uint32_t>> order;
|
||||
order.reserve(_entries.size());
|
||||
for (TextureHandle h = 0; h < _entries.size(); ++h)
|
||||
{
|
||||
const auto &e = _entries[h];
|
||||
if (e.state == EntryState::Resident && e.lastUsedFrame != now)
|
||||
{
|
||||
order.emplace_back(h, e.lastUsedFrame);
|
||||
}
|
||||
}
|
||||
std::sort(order.begin(), order.end(), [](auto &a, auto &b) { return a.second < b.second; });
|
||||
|
||||
size_t freed = 0;
|
||||
for (auto &pair : order)
|
||||
{
|
||||
if (freed >= bytesNeeded) break;
|
||||
TextureHandle h = pair.first;
|
||||
Entry &e = _entries[h];
|
||||
if (e.state != EntryState::Resident) continue;
|
||||
|
||||
patch_to_fallback(e);
|
||||
fmt::println("[TextureCache] try_make_space destroy handle={} path='{}' bytes={} residentBytesBefore={}",
|
||||
h,
|
||||
e.path.empty() ? "<bytes>" : e.path,
|
||||
e.sizeBytes,
|
||||
_residentBytes);
|
||||
_context->getResources()->destroy_image(e.image);
|
||||
e.image = {};
|
||||
e.state = EntryState::Evicted;
|
||||
e.lastEvictedFrame = now;
|
||||
e.nextAttemptFrame = std::max(e.nextAttemptFrame, now + _reloadCooldownFrames);
|
||||
if (_residentBytes >= e.sizeBytes) _residentBytes -= e.sizeBytes; else _residentBytes = 0;
|
||||
freed += e.sizeBytes;
|
||||
}
|
||||
return freed >= bytesNeeded;
|
||||
}
|
||||
|
||||
void TextureCache::debug_snapshot(std::vector<DebugRow> &outRows, DebugStats &outStats) const
|
||||
{
|
||||
outRows.clear();
|
||||
outStats = DebugStats{};
|
||||
outStats.residentBytes = _residentBytes;
|
||||
|
||||
auto stateToByteable = [&](const Entry &e) -> bool { return e.state == EntryState::Resident; };
|
||||
|
||||
for (const auto &e : _entries)
|
||||
{
|
||||
switch (e.state)
|
||||
{
|
||||
case EntryState::Resident: outStats.countResident++; break;
|
||||
case EntryState::Evicted: outStats.countEvicted++; break;
|
||||
case EntryState::Unloaded: outStats.countUnloaded++; break;
|
||||
case EntryState::Loading: /* ignore */ break;
|
||||
}
|
||||
|
||||
DebugRow row{};
|
||||
if (e.key.kind == TextureKey::SourceKind::FilePath)
|
||||
{
|
||||
row.name = e.path.empty() ? std::string("<path>") : e.path;
|
||||
}
|
||||
else
|
||||
{
|
||||
row.name = std::string("<bytes> (") + std::to_string(e.bytes.size()) + ")";
|
||||
}
|
||||
if (e.state == EntryState::Resident && e.image.image)
|
||||
{
|
||||
row.name += std::string(" [") + string_VkFormat(e.image.imageFormat) + "]";
|
||||
}
|
||||
row.bytes = e.sizeBytes;
|
||||
row.lastUsed = e.lastUsedFrame;
|
||||
row.state = static_cast<uint8_t>(e.state);
|
||||
outRows.push_back(std::move(row));
|
||||
}
|
||||
std::sort(outRows.begin(), outRows.end(), [](const DebugRow &a, const DebugRow &b) {
|
||||
return a.bytes > b.bytes;
|
||||
});
|
||||
}
|
||||
245
src/core/assets/texture_cache.h
Normal file
245
src/core/assets/texture_cache.h
Normal file
@@ -0,0 +1,245 @@
|
||||
#pragma once
|
||||
|
||||
#include <core/types.h>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <deque>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
#include <condition_variable>
|
||||
#include <atomic>
|
||||
#include <limits>
|
||||
|
||||
class EngineContext;
|
||||
class ResourceManager;
|
||||
struct FrameResources;
|
||||
|
||||
// Lightweight texture streaming cache.
|
||||
// - Requests are deduplicated by a hashable TextureKey.
|
||||
// - Loads happen via ResourceManager (deferred uploads supported).
|
||||
// - Descriptors registered via watchBinding() are patched in-place
|
||||
// when the image becomes Resident, leveraging UPDATE_AFTER_BIND.
|
||||
// - evictToBudget() rewrites watchers to provided fallbacks.
|
||||
class TextureCache
|
||||
{
|
||||
public:
|
||||
struct TextureKey
|
||||
{
|
||||
enum class SourceKind : uint8_t { FilePath, Bytes };
|
||||
enum class ChannelsHint : uint8_t { Auto, R, RG, RGBA };
|
||||
SourceKind kind{SourceKind::FilePath};
|
||||
std::string path; // used when kind==FilePath
|
||||
std::vector<uint8_t> bytes; // used when kind==Bytes
|
||||
bool srgb{false}; // desired sampling format
|
||||
bool mipmapped{true}; // generate full mip chain
|
||||
ChannelsHint channels{ChannelsHint::Auto}; // prefer narrower formats when possible
|
||||
uint32_t mipClampLevels{0}; // 0 = full chain, otherwise limit to N mips
|
||||
uint64_t hash{0}; // stable dedup key
|
||||
};
|
||||
|
||||
using TextureHandle = uint32_t;
|
||||
static constexpr TextureHandle InvalidHandle = 0xFFFFFFFFu;
|
||||
|
||||
void init(EngineContext *ctx);
|
||||
void cleanup();
|
||||
|
||||
// Deduplicated request; returns a stable handle.
|
||||
TextureHandle request(const TextureKey &key, VkSampler sampler);
|
||||
|
||||
// Register a descriptor binding to patch when the texture is ready.
|
||||
void watchBinding(TextureHandle handle, VkDescriptorSet set, uint32_t binding,
|
||||
VkSampler sampler, VkImageView fallbackView);
|
||||
|
||||
// Remove all watches for a descriptor set (call before destroying the
|
||||
// pool that owns the set). Prevents attempts to patch dead sets.
|
||||
void unwatchSet(VkDescriptorSet set);
|
||||
|
||||
// Mark a texture as used this frame (for LRU).
|
||||
void markUsed(TextureHandle handle, uint32_t frameIndex);
|
||||
// Convenience: mark all handles watched by a descriptor set.
|
||||
void markSetUsed(VkDescriptorSet set, uint32_t frameIndex);
|
||||
|
||||
// Schedule pending loads and patch descriptors for newly created images.
|
||||
void pumpLoads(ResourceManager &rm, FrameResources &frame);
|
||||
|
||||
// Evict least-recently-used entries to fit within a budget in bytes.
|
||||
void evictToBudget(size_t budgetBytes);
|
||||
|
||||
// Debug snapshot for UI
|
||||
struct DebugRow
|
||||
{
|
||||
std::string name;
|
||||
size_t bytes{0};
|
||||
uint32_t lastUsed{0};
|
||||
uint8_t state{0}; // cast of EntryState
|
||||
};
|
||||
struct DebugStats
|
||||
{
|
||||
size_t residentBytes{0};
|
||||
size_t countResident{0};
|
||||
size_t countEvicted{0};
|
||||
size_t countUnloaded{0};
|
||||
};
|
||||
void debug_snapshot(std::vector<DebugRow>& outRows, DebugStats& outStats) const;
|
||||
size_t resident_bytes() const { return _residentBytes; }
|
||||
// CPU-side source bytes currently retained (compressed image payloads kept
|
||||
// for potential re-decode). Only applies to entries created with Bytes keys.
|
||||
size_t cpu_source_bytes() const { return _cpuSourceBytes; }
|
||||
|
||||
// Runtime controls
|
||||
void set_max_loads_per_pump(int n) { _maxLoadsPerPump = (n > 0) ? n : 1; }
|
||||
int max_loads_per_pump() const { return _maxLoadsPerPump; }
|
||||
// Limit total bytes admitted for uploads per pump (frame).
|
||||
void set_max_bytes_per_pump(size_t bytes) { _maxBytesPerPump = bytes; }
|
||||
size_t max_bytes_per_pump() const { return _maxBytesPerPump; }
|
||||
// Clamp decoded image dimensions before upload (progressive resolution).
|
||||
// 0 disables clamping. When >0, images larger than this dimension on any axis
|
||||
// are downscaled by powers of 2 on the decode thread until within limit.
|
||||
void set_max_upload_dimension(uint32_t dim) { _maxUploadDimension = dim; }
|
||||
uint32_t max_upload_dimension() const { return _maxUploadDimension; }
|
||||
|
||||
// If false (default), compressed source bytes are dropped once an image is
|
||||
// uploaded to the GPU and descriptors patched. Set true to retain sources
|
||||
// for potential re-decode after eviction.
|
||||
void set_keep_source_bytes(bool keep) { _keepSourceBytes = keep; }
|
||||
bool keep_source_bytes() const { return _keepSourceBytes; }
|
||||
|
||||
// Set a soft CPU budget (in bytes) for retained compressed sources. After
|
||||
// each upload drain, the cache will try to free source bytes for Resident
|
||||
// entries until under budget.
|
||||
void set_cpu_source_budget(size_t bytes) { _cpuSourceBudget = bytes; }
|
||||
size_t cpu_source_budget() const { return _cpuSourceBudget; }
|
||||
|
||||
// Optional GPU residency budget, used to avoid immediate thrashing when
|
||||
// accepting new uploads. The engine should refresh this each frame.
|
||||
void set_gpu_budget_bytes(size_t bytes) { _gpuBudgetBytes = bytes; }
|
||||
size_t gpu_budget_bytes() const { return _gpuBudgetBytes; }
|
||||
|
||||
private:
|
||||
struct Patch
|
||||
{
|
||||
VkDescriptorSet set{VK_NULL_HANDLE};
|
||||
uint32_t binding{0};
|
||||
VkSampler sampler{VK_NULL_HANDLE};
|
||||
VkImageView fallbackView{VK_NULL_HANDLE};
|
||||
};
|
||||
|
||||
enum class EntryState : uint8_t { Unloaded, Loading, Resident, Evicted };
|
||||
|
||||
struct Entry
|
||||
{
|
||||
TextureKey key{};
|
||||
VkSampler sampler{VK_NULL_HANDLE};
|
||||
EntryState state{EntryState::Unloaded};
|
||||
AllocatedImage image{}; // valid when Resident
|
||||
size_t sizeBytes{0}; // approximate VRAM cost
|
||||
uint32_t lastUsedFrame{0};
|
||||
uint32_t lastEvictedFrame{0};
|
||||
uint32_t nextAttemptFrame{0}; // gate reload attempts to reduce churn
|
||||
std::vector<Patch> patches; // descriptor patches to rewrite
|
||||
|
||||
// Source payload for deferred load
|
||||
std::string path; // for FilePath
|
||||
std::vector<uint8_t> bytes; // for Bytes
|
||||
};
|
||||
|
||||
EngineContext *_context{nullptr};
|
||||
std::vector<Entry> _entries;
|
||||
std::unordered_map<uint64_t, TextureHandle> _lookup; // key.hash -> handle
|
||||
std::unordered_map<VkDescriptorSet, std::vector<TextureHandle>> _setToHandles;
|
||||
size_t _residentBytes{0};
|
||||
size_t _cpuSourceBytes{0};
|
||||
|
||||
// Controls
|
||||
int _maxLoadsPerPump{4};
|
||||
bool _keepSourceBytes{false};
|
||||
size_t _cpuSourceBudget{64ull * 1024ull * 1024ull}; // 64 MiB default
|
||||
size_t _gpuBudgetBytes{std::numeric_limits<size_t>::max()}; // unlimited unless set
|
||||
uint32_t _reloadCooldownFrames{2};
|
||||
size_t _maxBytesPerPump{128ull * 1024ull * 1024ull}; // 128 MiB/frame upload budget
|
||||
uint32_t _maxUploadDimension{4096}; // progressive downscale cap
|
||||
|
||||
void start_load(Entry &e, ResourceManager &rm);
|
||||
void patch_ready_entry(const Entry &e);
|
||||
void patch_to_fallback(const Entry &e);
|
||||
|
||||
// --- Async decode backend ---
|
||||
struct DecodeRequest
|
||||
{
|
||||
TextureHandle handle{InvalidHandle};
|
||||
TextureKey key{};
|
||||
std::string path;
|
||||
std::vector<uint8_t> bytes;
|
||||
};
|
||||
struct DecodedResult
|
||||
{
|
||||
TextureHandle handle{InvalidHandle};
|
||||
int width{0};
|
||||
int height{0};
|
||||
// Prefer heap pointer from stb to avoid an extra memcpy into a vector.
|
||||
// If 'heap' is non-null, it must be freed with stbi_image_free() after
|
||||
// the upload has copied the data. 'rgba' remains as a fallback path.
|
||||
unsigned char *heap{nullptr};
|
||||
size_t heapBytes{0};
|
||||
std::vector<uint8_t> rgba;
|
||||
bool mipmapped{true};
|
||||
bool srgb{false};
|
||||
TextureKey::ChannelsHint channels{TextureKey::ChannelsHint::Auto};
|
||||
uint32_t mipClampLevels{0};
|
||||
|
||||
// Compressed path (KTX2 pre-transcoded BCn). When true, 'rgba/heap'
|
||||
// are ignored and the fields below describe the payload.
|
||||
bool isKTX2{false};
|
||||
VkFormat ktxFormat{VK_FORMAT_UNDEFINED};
|
||||
uint32_t ktxMipLevels{0};
|
||||
struct KTXPack {
|
||||
struct L { uint64_t offset{0}, length{0}; uint32_t width{0}, height{0}; };
|
||||
std::vector<uint8_t> bytes; // full file content
|
||||
std::vector<L> levels; // per-mip region description
|
||||
} ktx;
|
||||
};
|
||||
|
||||
void worker_loop();
|
||||
void enqueue_decode(Entry &e);
|
||||
// Returns total resident bytes admitted this pump (after GPU budget gate).
|
||||
size_t drain_ready_uploads(ResourceManager &rm, size_t budgetBytes);
|
||||
void drop_source_bytes(Entry &e);
|
||||
void evictCpuToBudget();
|
||||
|
||||
// Try to free at least 'bytesNeeded' by evicting least-recently-used
|
||||
// Resident entries that were not used in the current frame. Returns true
|
||||
// if enough space was reclaimed. Does not evict textures used this frame.
|
||||
bool try_make_space(size_t bytesNeeded, uint32_t now);
|
||||
|
||||
std::vector<std::thread> _decodeThreads;
|
||||
std::mutex _qMutex;
|
||||
std::condition_variable _qCV;
|
||||
std::deque<DecodeRequest> _queue;
|
||||
std::mutex _readyMutex;
|
||||
std::deque<DecodedResult> _ready;
|
||||
std::atomic<bool> _running{false};
|
||||
};
|
||||
|
||||
// Helpers to build/digest keys
|
||||
namespace texcache
|
||||
{
|
||||
// 64-bit FNV-1a
|
||||
inline uint64_t fnv1a64(std::string_view s)
|
||||
{
|
||||
const uint64_t FNV_OFFSET = 1469598103934665603ull;
|
||||
const uint64_t FNV_PRIME = 1099511628211ull;
|
||||
uint64_t h = FNV_OFFSET;
|
||||
for (unsigned char c : s) { h ^= c; h *= FNV_PRIME; }
|
||||
return h;
|
||||
}
|
||||
inline uint64_t fnv1a64(const uint8_t *data, size_t n)
|
||||
{
|
||||
const uint64_t FNV_OFFSET = 1469598103934665603ull;
|
||||
const uint64_t FNV_PRIME = 1099511628211ull;
|
||||
uint64_t h = FNV_OFFSET;
|
||||
for (size_t i = 0; i < n; ++i) { h ^= data[i]; h *= FNV_PRIME; }
|
||||
return h;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user