diff --git a/Source/Core/DolphinLib.props b/Source/Core/DolphinLib.props index 737a2a16d6..b08aa2042b 100644 --- a/Source/Core/DolphinLib.props +++ b/Source/Core/DolphinLib.props @@ -669,6 +669,8 @@ + + @@ -1323,6 +1325,8 @@ + + diff --git a/Source/Core/VideoCommon/Assets/CustomAssetLoader.cpp b/Source/Core/VideoCommon/Assets/CustomAssetLoader.cpp new file mode 100644 index 0000000000..2ca227546c --- /dev/null +++ b/Source/Core/VideoCommon/Assets/CustomAssetLoader.cpp @@ -0,0 +1,157 @@ +// Copyright 2025 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "VideoCommon/Assets/CustomAssetLoader.h" + +#include + +#include "Common/Logging/Log.h" +#include "Common/Thread.h" + +#include "UICommon/UICommon.h" + +namespace VideoCommon +{ +void CustomAssetLoader::Initialize() +{ + ResizeWorkerThreads(2); +} + +void CustomAssetLoader::Shutdown() +{ + Reset(false); +} + +bool CustomAssetLoader::StartWorkerThreads(u32 num_worker_threads) +{ + for (u32 i = 0; i < num_worker_threads; i++) + { + m_worker_threads.emplace_back(&CustomAssetLoader::WorkerThreadRun, this, i); + } + + return HasWorkerThreads(); +} + +bool CustomAssetLoader::ResizeWorkerThreads(u32 num_worker_threads) +{ + if (m_worker_threads.size() == num_worker_threads) + return true; + + StopWorkerThreads(); + return StartWorkerThreads(num_worker_threads); +} + +bool CustomAssetLoader::HasWorkerThreads() const +{ + return !m_worker_threads.empty(); +} + +void CustomAssetLoader::StopWorkerThreads() +{ + if (!HasWorkerThreads()) + return; + + // Signal worker threads to stop, and wake all of them. + { + std::lock_guard guard(m_assets_to_load_lock); + m_exit_flag.Set(); + m_worker_thread_wake.notify_all(); + } + + // Wait for worker threads to exit. + for (std::thread& thr : m_worker_threads) + thr.join(); + m_worker_threads.clear(); + m_exit_flag.Clear(); +} + +void CustomAssetLoader::WorkerThreadRun(u32 thread_index) +{ + Common::SetCurrentThreadName(fmt::format("Asset Loader {}", thread_index).c_str()); + + std::unique_lock load_lock(m_assets_to_load_lock); + while (true) + { + m_worker_thread_wake.wait(load_lock, + [&] { return !m_assets_to_load.empty() || m_exit_flag.IsSet(); }); + + if (m_exit_flag.IsSet()) + return; + + // If more memory than allowed has already been loaded, we will load nothing more + // until the next ScheduleAssetsToLoad from Manager. + if (m_change_in_memory > m_allowed_memory) + { + m_assets_to_load.clear(); + continue; + } + + auto* const item = m_assets_to_load.front(); + m_assets_to_load.pop_front(); + + // Make sure another thread isn't loading this handle. + if (!m_handles_in_progress.insert(item->GetHandle()).second) + continue; + + load_lock.unlock(); + + // Unload previously loaded asset. + m_change_in_memory -= item->Unload(); + + const std::size_t bytes_loaded = item->Load(); + m_change_in_memory += s64(bytes_loaded); + + load_lock.lock(); + + { + INFO_LOG_FMT(VIDEO, "CustomAssetLoader thread {} loaded: {} ({})", thread_index, + item->GetAssetId(), UICommon::FormatSize(bytes_loaded)); + + std::lock_guard lk{m_assets_loaded_lock}; + m_asset_handles_loaded.emplace_back(item->GetHandle(), bytes_loaded > 0); + + // Make sure no other threads try to re-process this item. + // Manager will take the handles and re-ScheduleAssetsToLoad based on timestamps if needed. + std::erase(m_assets_to_load, item); + } + + m_handles_in_progress.erase(item->GetHandle()); + } +} + +auto CustomAssetLoader::TakeLoadResults() -> LoadResults +{ + std::lock_guard guard(m_assets_loaded_lock); + return {std::move(m_asset_handles_loaded), m_change_in_memory.exchange(0)}; +} + +void CustomAssetLoader::ScheduleAssetsToLoad(std::list assets_to_load, + u64 allowed_memory) +{ + if (assets_to_load.empty()) [[unlikely]] + return; + + // There's new assets to process, notify worker threads + std::lock_guard guard(m_assets_to_load_lock); + m_allowed_memory = allowed_memory; + m_assets_to_load = std::move(assets_to_load); + m_worker_thread_wake.notify_all(); +} + +void CustomAssetLoader::Reset(bool restart_worker_threads) +{ + const std::size_t worker_thread_count = m_worker_threads.size(); + StopWorkerThreads(); + + m_assets_to_load.clear(); + m_asset_handles_loaded.clear(); + m_allowed_memory = 0; + m_change_in_memory = 0; + + if (restart_worker_threads) + { + StartWorkerThreads(static_cast(worker_thread_count)); + } +} + +} // namespace VideoCommon diff --git a/Source/Core/VideoCommon/Assets/CustomAssetLoader.h b/Source/Core/VideoCommon/Assets/CustomAssetLoader.h new file mode 100644 index 0000000000..8b67da65a9 --- /dev/null +++ b/Source/Core/VideoCommon/Assets/CustomAssetLoader.h @@ -0,0 +1,82 @@ +// Copyright 2025 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "Common/Flag.h" +#include "VideoCommon/Assets/CustomAsset.h" + +namespace VideoCommon +{ +// This class takes any number of assets +// and loads them across a configurable +// thread pool +class CustomAssetLoader +{ +public: + CustomAssetLoader() = default; + ~CustomAssetLoader() = default; + CustomAssetLoader(const CustomAssetLoader&) = delete; + CustomAssetLoader(CustomAssetLoader&&) = delete; + CustomAssetLoader& operator=(const CustomAssetLoader&) = delete; + CustomAssetLoader& operator=(CustomAssetLoader&&) = delete; + + void Initialize(); + void Shutdown(); + + using AssetHandle = std::pair; + struct LoadResults + + { + std::vector asset_handles; + s64 change_in_memory; + }; + + // Returns a vector of loaded asset handle / loaded result pairs + // and the change in memory. + LoadResults TakeLoadResults(); + + // Schedule assets to load on the worker threads + // and set how much memory is available for loading these additional assets. + void ScheduleAssetsToLoad(std::list assets_to_load, u64 allowed_memory); + + void Reset(bool restart_worker_threads = true); + +private: + bool StartWorkerThreads(u32 num_worker_threads); + bool ResizeWorkerThreads(u32 num_worker_threads); + bool HasWorkerThreads() const; + void StopWorkerThreads(); + + void WorkerThreadRun(u32 thread_index); + + Common::Flag m_exit_flag; + + std::vector m_worker_threads; + + std::mutex m_assets_to_load_lock; + std::list m_assets_to_load; + + std::condition_variable m_worker_thread_wake; + + std::vector m_asset_handles_loaded; + + // Memory available to load new assets. + s64 m_allowed_memory = 0; + + // Change in memory from just-loaded/unloaded asset results yet to be taken by the Manager. + std::atomic m_change_in_memory = 0; + + std::mutex m_assets_loaded_lock; + + std::set m_handles_in_progress; +}; +} // namespace VideoCommon diff --git a/Source/Core/VideoCommon/Assets/CustomResourceManager.cpp b/Source/Core/VideoCommon/Assets/CustomResourceManager.cpp new file mode 100644 index 0000000000..f2af4ccbea --- /dev/null +++ b/Source/Core/VideoCommon/Assets/CustomResourceManager.cpp @@ -0,0 +1,229 @@ +// Copyright 2025 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "VideoCommon/Assets/CustomResourceManager.h" + +#include "Common/Logging/Log.h" +#include "Common/MemoryUtil.h" + +#include "UICommon/UICommon.h" + +#include "VideoCommon/Assets/CustomAsset.h" +#include "VideoCommon/Assets/TextureAsset.h" +#include "VideoCommon/VideoEvents.h" + +namespace VideoCommon +{ +void CustomResourceManager::Initialize() +{ + // Use half of available system memory but leave at least 2GiB unused for system stability. + constexpr size_t must_keep_unused = 2 * size_t(1024 * 1024 * 1024); + + const size_t sys_mem = Common::MemPhysical(); + const size_t keep_unused_mem = std::max(sys_mem / 2, std::min(sys_mem, must_keep_unused)); + + m_max_ram_available = sys_mem - keep_unused_mem; + + if (m_max_ram_available == 0) + ERROR_LOG_FMT(VIDEO, "Not enough system memory for custom resources."); + + m_asset_loader.Initialize(); + + m_xfb_event = + AfterFrameEvent::Register([this](Core::System&) { XFBTriggered(); }, "CustomResourceManager"); +} + +void CustomResourceManager::Shutdown() +{ + Reset(); + + m_asset_loader.Shutdown(); +} + +void CustomResourceManager::Reset() +{ + m_asset_loader.Reset(true); + + m_active_assets = {}; + m_pending_assets = {}; + m_asset_handle_to_data.clear(); + m_asset_id_to_handle.clear(); + m_texture_data_asset_cache.clear(); + m_dirty_assets.clear(); + m_ram_used = 0; +} + +void CustomResourceManager::MarkAssetDirty(const CustomAssetLibrary::AssetID& asset_id) +{ + std::lock_guard guard(m_dirty_mutex); + m_dirty_assets.insert(asset_id); +} + +CustomResourceManager::TextureTimePair CustomResourceManager::GetTextureDataFromAsset( + const CustomAssetLibrary::AssetID& asset_id, + std::shared_ptr library) +{ + auto& resource = m_texture_data_asset_cache[asset_id]; + if (resource.asset_data != nullptr && + resource.asset_data->load_status == AssetData::LoadStatus::ResourceDataAvailable) + { + m_active_assets.MakeAssetHighestPriority(resource.asset->GetHandle(), resource.asset); + return {resource.texture_data, resource.asset->GetLastLoadedTime()}; + } + + // If there is an error, don't try and load again until the error is fixed + if (resource.asset_data != nullptr && resource.asset_data->has_load_error) + return {}; + + LoadTextureDataAsset(asset_id, std::move(library), &resource); + m_active_assets.MakeAssetHighestPriority(resource.asset->GetHandle(), resource.asset); + + return {}; +} + +void CustomResourceManager::LoadTextureDataAsset( + const CustomAssetLibrary::AssetID& asset_id, + std::shared_ptr library, InternalTextureDataResource* resource) +{ + if (!resource->asset) + { + resource->asset = + CreateAsset(asset_id, AssetData::AssetType::TextureData, std::move(library)); + resource->asset_data = &m_asset_handle_to_data[resource->asset->GetHandle()]; + } + + auto texture_data = resource->asset->GetData(); + if (!texture_data || resource->asset_data->load_status == AssetData::LoadStatus::PendingReload) + { + // Tell the system we are still interested in loading this asset + const auto asset_handle = resource->asset->GetHandle(); + m_pending_assets.MakeAssetHighestPriority(asset_handle, + m_asset_handle_to_data[asset_handle].asset.get()); + } + else if (resource->asset_data->load_status == AssetData::LoadStatus::LoadFinished) + { + resource->texture_data = std::move(texture_data); + resource->asset_data->load_status = AssetData::LoadStatus::ResourceDataAvailable; + } +} + +void CustomResourceManager::XFBTriggered() +{ + ProcessDirtyAssets(); + ProcessLoadedAssets(); + + if (m_ram_used > m_max_ram_available) + { + RemoveAssetsUntilBelowMemoryLimit(); + } + + if (m_pending_assets.IsEmpty()) + return; + + if (m_ram_used > m_max_ram_available) + return; + + const u64 allowed_memory = m_max_ram_available - m_ram_used; + m_asset_loader.ScheduleAssetsToLoad(m_pending_assets.Elements(), allowed_memory); +} + +void CustomResourceManager::ProcessDirtyAssets() +{ + decltype(m_dirty_assets) dirty_assets; + + if (const auto lk = std::unique_lock{m_dirty_mutex, std::try_to_lock}) + std::swap(dirty_assets, m_dirty_assets); + + const auto now = CustomAsset::ClockType::now(); + for (const auto& asset_id : dirty_assets) + { + if (const auto it = m_asset_id_to_handle.find(asset_id); it != m_asset_id_to_handle.end()) + { + const auto asset_handle = it->second; + AssetData& asset_data = m_asset_handle_to_data[asset_handle]; + asset_data.load_status = AssetData::LoadStatus::PendingReload; + asset_data.load_request_time = now; + + // Asset was reloaded, clear any errors we might have + asset_data.has_load_error = false; + + m_pending_assets.InsertAsset(it->second, asset_data.asset.get()); + + DEBUG_LOG_FMT(VIDEO, "Dirty asset pending reload: {}", asset_data.asset->GetAssetId()); + } + } +} + +void CustomResourceManager::ProcessLoadedAssets() +{ + const auto load_results = m_asset_loader.TakeLoadResults(); + + // Update the ram with the change in memory from the loader + // + // Note: Assets with outstanding reload requests will have + // two copies in memory temporarily (the old data stored in + // the asset shared_ptr that the resource manager owns, and + // the new data loaded from the loader in the asset's shared_ptr) + // This temporary duplication will not be reflected in the + // resource manager's ram used + m_ram_used += load_results.change_in_memory; + + for (const auto& [handle, load_successful] : load_results.asset_handles) + { + AssetData& asset_data = m_asset_handle_to_data[handle]; + + // If we have a reload request that is newer than our loaded time + // we need to wait for another reload. + if (asset_data.load_request_time > asset_data.asset->GetLastLoadedTime()) + continue; + + m_pending_assets.RemoveAsset(handle); + + asset_data.load_request_time = {}; + if (!load_successful) + { + asset_data.has_load_error = true; + } + else + { + m_active_assets.InsertAsset(handle, asset_data.asset.get()); + asset_data.load_status = AssetData::LoadStatus::LoadFinished; + } + } +} + +void CustomResourceManager::RemoveAssetsUntilBelowMemoryLimit() +{ + const u64 threshold_ram = m_max_ram_available * 8 / 10; + + if (m_ram_used > threshold_ram) + { + INFO_LOG_FMT(VIDEO, "Memory usage over threshold: {}", UICommon::FormatSize(m_ram_used)); + } + + // Clear out least recently used resources until + // we get safely in our threshold + while (m_ram_used > threshold_ram && m_active_assets.Size() > 0) + { + auto* const asset = m_active_assets.RemoveLowestPriorityAsset(); + + AssetData& asset_data = m_asset_handle_to_data[asset->GetHandle()]; + + // Remove the resource manager's cached entry with its asset data + if (asset_data.type == AssetData::AssetType::TextureData) + { + m_texture_data_asset_cache.erase(asset->GetAssetId()); + } + // Remove the asset's copy + const std::size_t bytes_unloaded = asset_data.asset->Unload(); + m_ram_used -= bytes_unloaded; + + asset_data.load_status = AssetData::LoadStatus::Unloaded; + asset_data.load_request_time = {}; + + INFO_LOG_FMT(VIDEO, "Unloading asset: {} ({})", asset_data.asset->GetAssetId(), + UICommon::FormatSize(bytes_unloaded)); + } +} + +} // namespace VideoCommon diff --git a/Source/Core/VideoCommon/Assets/CustomResourceManager.h b/Source/Core/VideoCommon/Assets/CustomResourceManager.h new file mode 100644 index 0000000000..6a2b5cbbe3 --- /dev/null +++ b/Source/Core/VideoCommon/Assets/CustomResourceManager.h @@ -0,0 +1,215 @@ +// Copyright 2025 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +#include "Common/CommonTypes.h" +#include "Common/HookableEvent.h" + +#include "VideoCommon/Assets/CustomAsset.h" +#include "VideoCommon/Assets/CustomAssetLibrary.h" +#include "VideoCommon/Assets/CustomAssetLoader.h" +#include "VideoCommon/Assets/CustomTextureData.h" + +namespace VideoCommon +{ +class TextureAsset; + +// The resource manager manages custom resources (textures, shaders, meshes) +// called assets. These assets are loaded using a priority system, +// where assets requested more often gets loaded first. This system +// also tracks memory usage and if memory usage goes over a calculated limit, +// then assets will be purged with older assets being targeted first. +class CustomResourceManager +{ +public: + void Initialize(); + void Shutdown(); + + void Reset(); + + // Request that an asset be reloaded + void MarkAssetDirty(const CustomAssetLibrary::AssetID& asset_id); + + void XFBTriggered(); + + using TextureTimePair = std::pair, CustomAsset::TimeType>; + + // Returns a pair with the custom texture data and the time it was last loaded + // Callees are not expected to hold onto the shared_ptr as that will prevent + // the resource manager from being able to properly release data + TextureTimePair GetTextureDataFromAsset(const CustomAssetLibrary::AssetID& asset_id, + std::shared_ptr library); + +private: + // A generic interface to describe an assets' type + // and load state + struct AssetData + { + std::unique_ptr asset; + CustomAsset::TimeType load_request_time = {}; + bool has_load_error = false; + + enum class AssetType + { + TextureData + }; + AssetType type; + + enum class LoadStatus + { + PendingReload, + LoadFinished, + ResourceDataAvailable, + Unloaded, + }; + LoadStatus load_status = LoadStatus::PendingReload; + }; + + // A structure to represent some raw texture data + // (this data hasn't hit the GPU yet, used for custom textures) + struct InternalTextureDataResource + { + AssetData* asset_data = nullptr; + VideoCommon::TextureAsset* asset = nullptr; + std::shared_ptr texture_data; + }; + + void LoadTextureDataAsset(const CustomAssetLibrary::AssetID& asset_id, + std::shared_ptr library, + InternalTextureDataResource* resource); + + void ProcessDirtyAssets(); + void ProcessLoadedAssets(); + void RemoveAssetsUntilBelowMemoryLimit(); + + template + T* CreateAsset(const CustomAssetLibrary::AssetID& asset_id, AssetData::AssetType asset_type, + std::shared_ptr library) + { + const auto [it, added] = + m_asset_id_to_handle.try_emplace(asset_id, m_asset_handle_to_data.size()); + + if (added) + { + AssetData asset_data; + asset_data.asset = std::make_unique(library, asset_id, it->second); + asset_data.type = asset_type; + asset_data.load_request_time = {}; + asset_data.has_load_error = false; + + m_asset_handle_to_data.insert_or_assign(it->second, std::move(asset_data)); + } + auto& asset_data_from_handle = m_asset_handle_to_data[it->second]; + asset_data_from_handle.load_status = AssetData::LoadStatus::PendingReload; + + return static_cast(asset_data_from_handle.asset.get()); + } + + // Maintains a priority-sorted list of assets. + // Used to figure out which assets to load or unload first. + // Most recently used assets get marked with highest priority. + class AssetPriorityQueue + { + public: + const auto& Elements() const { return m_assets; } + + // Inserts or moves the asset to the top of the queue. + void MakeAssetHighestPriority(u64 asset_handle, CustomAsset* asset) + { + RemoveAsset(asset_handle); + m_assets.push_front(asset); + + // See CreateAsset for how a handle gets defined + if (asset_handle >= m_iterator_lookup.size()) + m_iterator_lookup.resize(asset_handle + 1, m_assets.end()); + + m_iterator_lookup[asset_handle] = m_assets.begin(); + } + + // Inserts an asset at lowest priority or + // does nothing if asset is already in the queue. + void InsertAsset(u64 asset_handle, CustomAsset* asset) + { + if (asset_handle >= m_iterator_lookup.size()) + m_iterator_lookup.resize(asset_handle + 1, m_assets.end()); + + if (m_iterator_lookup[asset_handle] == m_assets.end()) + { + m_assets.push_back(asset); + m_iterator_lookup[asset_handle] = std::prev(m_assets.end()); + } + } + + CustomAsset* RemoveLowestPriorityAsset() + { + if (m_assets.empty()) [[unlikely]] + return nullptr; + auto* const ret = m_assets.back(); + if (ret != nullptr) + { + m_iterator_lookup[ret->GetHandle()] = m_assets.end(); + } + m_assets.pop_back(); + return ret; + } + + void RemoveAsset(u64 asset_handle) + { + if (asset_handle >= m_iterator_lookup.size()) + return; + + const auto iter = m_iterator_lookup[asset_handle]; + if (iter != m_assets.end()) + { + m_assets.erase(iter); + m_iterator_lookup[asset_handle] = m_assets.end(); + } + } + + bool IsEmpty() const { return m_assets.empty(); } + + std::size_t Size() const { return m_assets.size(); } + + private: + std::list m_assets; + + // Handle-to-iterator lookup for fast access. + // Grows as needed on insert. + std::vector m_iterator_lookup; + }; + + // Assets that are currently active in memory, in order of most recently used by the game. + AssetPriorityQueue m_active_assets; + + // Assets that need to be loaded. + // e.g. Because the game tried to use them or because they changed on disk. + // Ordered by most recently used. + AssetPriorityQueue m_pending_assets; + + std::map m_asset_handle_to_data; + std::map m_asset_id_to_handle; + + // Memory used by currently "loaded" assets. + u64 m_ram_used = 0; + + // A calculated amount of memory to avoid exceeding. + u64 m_max_ram_available = 0; + + std::map m_texture_data_asset_cache; + + std::mutex m_dirty_mutex; + std::set m_dirty_assets; + + CustomAssetLoader m_asset_loader; + + Common::EventHook m_xfb_event; +}; + +} // namespace VideoCommon diff --git a/Source/Core/VideoCommon/CMakeLists.txt b/Source/Core/VideoCommon/CMakeLists.txt index b712599ebc..0ee9722c42 100644 --- a/Source/Core/VideoCommon/CMakeLists.txt +++ b/Source/Core/VideoCommon/CMakeLists.txt @@ -13,6 +13,8 @@ add_library(videocommon Assets/CustomAssetLibrary.h Assets/CustomAssetLoader.cpp Assets/CustomAssetLoader.h + Assets/CustomResourceManager.cpp + Assets/CustomResourceManager.h Assets/CustomTextureData.cpp Assets/CustomTextureData.h Assets/DirectFilesystemAssetLibrary.cpp