diff --git a/src/core/assets/texture_cache.cpp b/src/core/assets/texture_cache.cpp index 2e195db..f29103e 100644 --- a/src/core/assets/texture_cache.cpp +++ b/src/core/assets/texture_cache.cpp @@ -40,6 +40,23 @@ void TextureCache::cleanup() for (auto &t : _decodeThreads) if (t.joinable()) t.join(); _decodeThreads.clear(); } + // Clear any pending decode/upload work (freeing decode heap pointers) + { + std::lock_guard lk(_qMutex); + _queue.clear(); + } + { + std::lock_guard lk(_readyMutex); + for (auto &r : _ready) + { + if (r.heap) + { + stbi_image_free(r.heap); + r.heap = nullptr; + } + } + _ready.clear(); + } if (!_context || !_context->getResources()) return; auto *rm = _context->getResources(); for (TextureHandle h = 0; h < _entries.size(); ++h) @@ -83,10 +100,21 @@ TextureCache::TextureHandle TextureCache::request(const TextureKey &key, VkSampl 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) + if (h < _entries.size()) { - _entries[h].sampler = sampler; + Entry &e = _entries[h]; + // Keep most recent sampler for future patches if provided + if (sampler != VK_NULL_HANDLE) + { + e.sampler = sampler; + } + // Allow re-supplying CPU source bytes for Bytes-backed textures after an unload. + if (normKey.kind == TextureKey::SourceKind::Bytes && !normKey.bytes.empty() && + e.key.kind == TextureKey::SourceKind::Bytes && e.bytes.empty() && e.state != EntryState::Resident) + { + e.bytes = normKey.bytes; + _cpuSourceBytes += e.bytes.size(); + } } return h; } @@ -96,6 +124,10 @@ TextureCache::TextureHandle TextureCache::request(const TextureKey &key, VkSampl Entry e{}; e.key = normKey; + // Keep only metadata in the key to avoid duplicating potentially large payloads. + e.key.path.clear(); + e.key.bytes.clear(); + e.key.bytes.shrink_to_fit(); e.sampler = sampler; e.state = EntryState::Unloaded; if (normKey.kind == TextureKey::SourceKind::FilePath) @@ -187,6 +219,27 @@ void TextureCache::markSetUsed(VkDescriptorSet set, uint32_t frameIndex) } } +void TextureCache::pin(TextureHandle handle) +{ + if (handle == InvalidHandle) return; + if (handle >= _entries.size()) return; + _entries[handle].pinned = true; +} + +void TextureCache::unpin(TextureHandle handle) +{ + if (handle == InvalidHandle) return; + if (handle >= _entries.size()) return; + _entries[handle].pinned = false; +} + +bool TextureCache::is_pinned(TextureHandle handle) const +{ + if (handle == InvalidHandle) return false; + if (handle >= _entries.size()) return false; + return _entries[handle].pinned; +} + static inline size_t bytes_per_texel(VkFormat fmt) { switch (fmt) @@ -378,7 +431,7 @@ void TextureCache::evictToBudget(size_t budgetBytes) for (TextureHandle h = 0; h < _entries.size(); ++h) { const auto &e = _entries[h]; - if (e.state == EntryState::Resident) + if (e.state == EntryState::Resident && !e.pinned) { order.emplace_back(h, e.lastUsedFrame); } @@ -392,6 +445,8 @@ void TextureCache::evictToBudget(size_t budgetBytes) TextureHandle h = pair.first; Entry &e = _entries[h]; if (e.state != EntryState::Resident) continue; + // Never evict pinned textures + if (e.pinned) continue; // Prefer not to evict textures used this frame unless strictly necessary. if (e.lastUsedFrame == now) continue; @@ -412,12 +467,84 @@ void TextureCache::evictToBudget(size_t budgetBytes) } } +bool TextureCache::unload(TextureHandle handle, bool drop_source_bytes) +{ + if (handle == InvalidHandle) return false; + if (handle >= _entries.size()) return false; + + Entry &e = _entries[handle]; + const uint32_t now = _context ? _context->frameIndex : 0u; + + // Invalidate any in-flight decode results for this entry. + e.generation++; + + // Drop queued decode requests for this handle. + { + std::lock_guard lk(_qMutex); + _queue.erase(std::remove_if(_queue.begin(), _queue.end(), + [&](const DecodeRequest &rq) { return rq.handle == handle; }), + _queue.end()); + } + + // Drop already-decoded results waiting for upload for this handle. + { + std::lock_guard lk(_readyMutex); + for (auto it = _ready.begin(); it != _ready.end();) + { + if (it->handle == handle) + { + if (it->heap) + { + stbi_image_free(it->heap); + it->heap = nullptr; + } + it = _ready.erase(it); + } + else + { + ++it; + } + } + } + + // If resident, patch watchers back to fallback and destroy the image. + if (e.state == EntryState::Resident && e.image.image != VK_NULL_HANDLE) + { + patch_to_fallback(e); + + if (_context && _context->getResources()) + { + fmt::println("[TextureCache] unload destroy handle={} path='{}' bytes={} residentBytesBefore={}", + handle, + e.path.empty() ? "" : e.path, + e.sizeBytes, + _residentBytes); + _context->getResources()->destroy_image(e.image); + } + + e.image = {}; + if (_residentBytes >= e.sizeBytes) _residentBytes -= e.sizeBytes; else _residentBytes = 0; + } + + e.state = EntryState::Evicted; + e.lastEvictedFrame = now; + e.nextAttemptFrame = std::max(e.nextAttemptFrame, now + _reloadCooldownFrames); + + if (drop_source_bytes) + { + this->drop_source_bytes(e); + } + + return true; +} + 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(&e - _entries.data()); + rq.generation = e.generation; rq.key = e.key; if (e.key.kind == TextureKey::SourceKind::FilePath) rq.path = e.path; else rq.bytes = e.bytes; { @@ -442,6 +569,7 @@ void TextureCache::worker_loop() DecodedResult out{}; out.handle = rq.handle; + out.generation = rq.generation; out.mipmapped = rq.key.mipmapped; out.srgb = rq.key.srgb; out.channels = rq.key.channels; @@ -618,15 +746,31 @@ size_t TextureCache::drain_ready_uploads(ResourceManager &rm, size_t budgetBytes 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)) + const uint32_t now = _context ? _context->frameIndex : 0u; + if (res.handle == InvalidHandle || res.handle >= _entries.size()) { - e.state = EntryState::Evicted; // failed decode; keep fallback + if (res.heap) { stbi_image_free(res.heap); res.heap = nullptr; } + continue; + } + + Entry &e = _entries[res.handle]; + + // Drop stale results from cancelled/unloaded requests. + if (res.generation != e.generation || e.state != EntryState::Loading) + { + if (res.heap) { stbi_image_free(res.heap); res.heap = nullptr; } + continue; + } + + if (!res.isKTX2 && ((res.heap == nullptr && res.rgba.empty()) || res.width <= 0 || res.height <= 0)) + { + if (res.heap) { stbi_image_free(res.heap); res.heap = nullptr; } + e.state = EntryState::Evicted; // failed decode; keep fallback + e.lastEvictedFrame = now; + e.nextAttemptFrame = std::max(e.nextAttemptFrame, now + _reloadCooldownFrames); continue; } - const uint32_t now = _context ? _context->frameIndex : 0u; VkExtent3D extent{static_cast(std::max(0, res.width)), static_cast(std::max(0, res.height)), 1u}; TextureKey::ChannelsHint hint = (e.key.channels == TextureKey::ChannelsHint::Auto) ? TextureKey::ChannelsHint::Auto @@ -869,7 +1013,7 @@ bool TextureCache::try_make_space(size_t bytesNeeded, uint32_t now) for (TextureHandle h = 0; h < _entries.size(); ++h) { const auto &e = _entries[h]; - if (e.state == EntryState::Resident && e.lastUsedFrame != now) + if (e.state == EntryState::Resident && e.lastUsedFrame != now && !e.pinned) { order.emplace_back(h, e.lastUsedFrame); } @@ -883,6 +1027,8 @@ bool TextureCache::try_make_space(size_t bytesNeeded, uint32_t now) TextureHandle h = pair.first; Entry &e = _entries[h]; if (e.state != EntryState::Resident) continue; + // Never evict pinned textures + if (e.pinned) continue; patch_to_fallback(e); fmt::println("[TextureCache] try_make_space destroy handle={} path='{}' bytes={} residentBytesBefore={}", @@ -948,3 +1094,13 @@ TextureCache::EntryState TextureCache::state(TextureHandle handle) const if (handle >= _entries.size()) return EntryState::Unloaded; return _entries[handle].state; } + +VkImageView TextureCache::image_view(TextureHandle handle) const +{ + if (handle == InvalidHandle) return VK_NULL_HANDLE; + if (handle >= _entries.size()) return VK_NULL_HANDLE; + + const Entry &e = _entries[handle]; + if (e.state != EntryState::Resident) return VK_NULL_HANDLE; + return e.image.imageView; +} diff --git a/src/core/assets/texture_cache.h b/src/core/assets/texture_cache.h index 06bd84f..f03be58 100644 --- a/src/core/assets/texture_cache.h +++ b/src/core/assets/texture_cache.h @@ -63,12 +63,26 @@ public: // Convenience: mark all handles watched by a descriptor set. void markSetUsed(VkDescriptorSet set, uint32_t frameIndex); + // Pin a texture to prevent eviction (useful for UI elements, critical assets). + // Pinned textures are never evicted by LRU or budget constraints. + void pin(TextureHandle handle); + // Unpin a texture, allowing it to be evicted normally. + void unpin(TextureHandle handle); + // Check if a texture is currently pinned. + bool is_pinned(TextureHandle handle) const; + // 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); + // Manually unload a texture. This immediately frees GPU memory (if resident), + // patches watched descriptor bindings back to their fallbacks, and cancels any + // in-flight decode/upload work for the handle. The handle remains valid and + // can be re-requested/reloaded later. + bool unload(TextureHandle handle, bool drop_source_bytes = true); + // Debug snapshot for UI struct DebugRow { @@ -87,6 +101,8 @@ public: void debug_snapshot(std::vector& outRows, DebugStats& outStats) const; // Read-only per-handle state query (main-thread only). EntryState state(TextureHandle handle) const; + // Returns the default image view for a Resident texture, otherwise VK_NULL_HANDLE. + VkImageView image_view(TextureHandle handle) 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. @@ -135,6 +151,8 @@ private: TextureKey key{}; VkSampler sampler{VK_NULL_HANDLE}; EntryState state{EntryState::Unloaded}; + uint32_t generation{1}; // bumps to invalidate in-flight decode results + bool pinned{false}; // if true, never evict (for UI, critical assets) AllocatedImage image{}; // valid when Resident size_t sizeBytes{0}; // approximate VRAM cost uint32_t lastUsedFrame{0}; @@ -171,6 +189,7 @@ private: struct DecodeRequest { TextureHandle handle{InvalidHandle}; + uint32_t generation{0}; TextureKey key{}; std::string path; std::vector bytes; @@ -178,6 +197,7 @@ private: struct DecodedResult { TextureHandle handle{InvalidHandle}; + uint32_t generation{0}; int width{0}; int height{0}; // Prefer heap pointer from stb to avoid an extra memcpy into a vector. diff --git a/src/core/game_api.cpp b/src/core/game_api.cpp index 6e59c0f..9080722 100644 --- a/src/core/game_api.cpp +++ b/src/core/game_api.cpp @@ -3,6 +3,7 @@ #include "context.h" #include "core/assets/texture_cache.h" #include "core/assets/ibl_manager.h" +#include "core/assets/manager.h" #include "core/pipeline/manager.h" #include "core/debug_draw/debug_draw.h" #include "render/passes/tonemap.h" @@ -15,6 +16,8 @@ #include #include +#include +#include namespace GameAPI { @@ -151,6 +154,184 @@ void Engine::evict_textures_to_budget() } } +// ---------------------------------------------------------------------------- +// Texture Loading +// ---------------------------------------------------------------------------- + +TextureHandle Engine::load_texture(const std::string& path, const TextureLoadParams& params) +{ + if (!_engine || !_engine->_textureCache || path.empty()) + { + return InvalidTexture; + } + + // Resolve path relative to assets/textures/ if not absolute + std::string resolvedPath = path; + std::filesystem::path p(path); + if (p.is_relative()) + { + resolvedPath = _engine->_assetManager->assetPath(std::string("textures/") + path); + } + + // Build TextureKey + TextureCache::TextureKey key{}; + key.kind = TextureCache::TextureKey::SourceKind::FilePath; + key.path = resolvedPath; + key.srgb = params.srgb; + key.mipmapped = params.mipmapped; + key.mipClampLevels = params.mipLevels; + + // Map channel hint + switch (params.channels) + { + case TextureChannels::R: + key.channels = TextureCache::TextureKey::ChannelsHint::R; + break; + case TextureChannels::RG: + key.channels = TextureCache::TextureKey::ChannelsHint::RG; + break; + case TextureChannels::RGBA: + key.channels = TextureCache::TextureKey::ChannelsHint::RGBA; + break; + case TextureChannels::Auto: + default: + key.channels = TextureCache::TextureKey::ChannelsHint::Auto; + break; + } + + // Generate hash for deduplication + std::string id = std::string("PATH:") + key.path + (key.srgb ? "#sRGB" : "#UNORM"); + key.hash = texcache::fnv1a64(id); + + // Use default linear sampler + VkSampler sampler = VK_NULL_HANDLE; + if (_engine->_context && _engine->_context->samplers) + { + sampler = _engine->_context->samplers->defaultLinear(); + } + + // Request texture from cache + TextureCache::TextureHandle handle = _engine->_textureCache->request(key, sampler); + return static_cast(handle); +} + +TextureHandle Engine::load_texture_from_memory(const std::vector& data, + const TextureLoadParams& params) +{ + if (!_engine || !_engine->_textureCache || data.empty()) + { + return InvalidTexture; + } + + // Build TextureKey from bytes + TextureCache::TextureKey key{}; + key.kind = TextureCache::TextureKey::SourceKind::Bytes; + key.bytes = data; + key.srgb = params.srgb; + key.mipmapped = params.mipmapped; + key.mipClampLevels = params.mipLevels; + + // Map channel hint + switch (params.channels) + { + case TextureChannels::R: + key.channels = TextureCache::TextureKey::ChannelsHint::R; + break; + case TextureChannels::RG: + key.channels = TextureCache::TextureKey::ChannelsHint::RG; + break; + case TextureChannels::RGBA: + key.channels = TextureCache::TextureKey::ChannelsHint::RGBA; + break; + case TextureChannels::Auto: + default: + key.channels = TextureCache::TextureKey::ChannelsHint::Auto; + break; + } + + // Generate hash for deduplication + uint64_t h = texcache::fnv1a64(key.bytes.data(), key.bytes.size()); + key.hash = h ^ (key.srgb ? 0x9E3779B97F4A7C15ull : 0ull); + + // Use default linear sampler + VkSampler sampler = VK_NULL_HANDLE; + if (_engine->_context && _engine->_context->samplers) + { + sampler = _engine->_context->samplers->defaultLinear(); + } + + // Request texture from cache + TextureCache::TextureHandle handle = _engine->_textureCache->request(key, sampler); + return static_cast(handle); +} + +bool Engine::is_texture_loaded(TextureHandle handle) const +{ + if (!_engine || !_engine->_textureCache) + { + return false; + } + + auto cacheHandle = static_cast(handle); + return _engine->_textureCache->state(cacheHandle) == TextureCache::EntryState::Resident; +} + +void* Engine::get_texture_image_view(TextureHandle handle) const +{ + if (!_engine || !_engine->_textureCache) + { + return nullptr; + } + + auto cacheHandle = static_cast(handle); + VkImageView view = _engine->_textureCache->image_view(cacheHandle); + return reinterpret_cast(view); +} + +void Engine::pin_texture(TextureHandle handle) +{ + if (!_engine || !_engine->_textureCache) + { + return; + } + + auto cacheHandle = static_cast(handle); + _engine->_textureCache->pin(cacheHandle); +} + +void Engine::unpin_texture(TextureHandle handle) +{ + if (!_engine || !_engine->_textureCache) + { + return; + } + + auto cacheHandle = static_cast(handle); + _engine->_textureCache->unpin(cacheHandle); +} + +bool Engine::is_texture_pinned(TextureHandle handle) const +{ + if (!_engine || !_engine->_textureCache) + { + return false; + } + + auto cacheHandle = static_cast(handle); + return _engine->_textureCache->is_pinned(cacheHandle); +} + +void Engine::unload_texture(TextureHandle handle) +{ + if (!_engine || !_engine->_textureCache) + { + return; + } + + auto cacheHandle = static_cast(handle); + _engine->_textureCache->unload(cacheHandle); +} + // ---------------------------------------------------------------------------- // Shadows // ---------------------------------------------------------------------------- diff --git a/src/core/game_api.h b/src/core/game_api.h index b8a92ed..f215984 100644 --- a/src/core/game_api.h +++ b/src/core/game_api.h @@ -19,6 +19,28 @@ namespace GameAPI // Forward declarations and simple POD types // ============================================================================ +// Texture handle (opaque reference to a cached texture) +using TextureHandle = uint32_t; +static constexpr TextureHandle InvalidTexture = 0xFFFFFFFFu; + +// Texture channel hint for memory optimization +enum class TextureChannels : uint8_t +{ + Auto = 0, // Detect from source (default) + R = 1, // Single channel (e.g., occlusion, metallic) + RG = 2, // Two channels (e.g., normal map XY) + RGBA = 3 // Full color +}; + +// Texture loading parameters +struct TextureLoadParams +{ + bool srgb{false}; // Use sRGB color space (true for albedo/emissive) + bool mipmapped{true}; // Generate mipmap chain + TextureChannels channels{TextureChannels::Auto}; // Channel hint + uint32_t mipLevels{0}; // 0 = full chain, otherwise limit to N levels +}; + // Shadow rendering mode enum class ShadowMode : uint32_t { @@ -326,6 +348,41 @@ public: // Force eviction to budget (call after loading large assets) void evict_textures_to_budget(); + // ------------------------------------------------------------------------ + // Texture Loading + // ------------------------------------------------------------------------ + + // Load a texture from file path (relative to assets/textures/ or absolute) + // Returns a handle that can be used to query state or bind to materials + TextureHandle load_texture(const std::string& path, + const TextureLoadParams& params = {}); + + // Load a texture from memory (compressed image data: PNG, JPG, KTX2, etc.) + // Useful for runtime-generated or downloaded textures + TextureHandle load_texture_from_memory(const std::vector& data, + const TextureLoadParams& params = {}); + + // Check if a texture is loaded and resident in VRAM + bool is_texture_loaded(TextureHandle handle) const; + + // Get the internal Vulkan image view for advanced use cases + // Returns VK_NULL_HANDLE if texture is not yet loaded + void* get_texture_image_view(TextureHandle handle) const; // Returns VkImageView + + // Pin a texture to prevent automatic eviction (useful for UI elements, critical assets) + // Pinned textures are never removed from VRAM by LRU or budget constraints + void pin_texture(TextureHandle handle); + + // Unpin a texture, allowing it to be evicted normally + void unpin_texture(TextureHandle handle); + + // Check if a texture is currently pinned + bool is_texture_pinned(TextureHandle handle) const; + + // Unload a texture and free VRAM (textures are ref-counted and auto-evicted by LRU) + // This is optional - the cache manages memory automatically + void unload_texture(TextureHandle handle); + // ------------------------------------------------------------------------ // Shadows // ------------------------------------------------------------------------