mirror of
https://github.com/PCSX2/pcsx2.git
synced 2026-01-31 01:15:24 +01:00
963 lines
32 KiB
C++
963 lines
32 KiB
C++
// SPDX-FileCopyrightText: 2002-2025 PCSX2 Dev Team
|
|
// SPDX-License-Identifier: GPL-3.0+
|
|
|
|
#include "common/AlignedMalloc.h"
|
|
#include "common/Console.h"
|
|
#include "common/HashCombine.h"
|
|
#include "common/FileSystem.h"
|
|
#include "common/Path.h"
|
|
#include "common/StringUtil.h"
|
|
#include "common/ScopedGuard.h"
|
|
#include "common/TextureDecompress.h"
|
|
|
|
#include "Config.h"
|
|
#include "Host.h"
|
|
#include "IconsFontAwesome6.h"
|
|
#include "GS/GSExtra.h"
|
|
#include "GS/GSLocalMemory.h"
|
|
#include "GS/Renderers/HW/GSTextureReplacements.h"
|
|
#include "VMManager.h"
|
|
|
|
#include <cinttypes>
|
|
#include <condition_variable>
|
|
#include <cstring>
|
|
#include <deque>
|
|
#include <functional>
|
|
#include <mutex>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
#include <tuple>
|
|
#include <thread>
|
|
|
|
// this is a #define instead of a variable to avoid warnings from non-literal format strings
|
|
#define TEXTURE_FILENAME_FORMAT_STRING "%" PRIx64 "-%08x"
|
|
#define TEXTURE_FILENAME_CLUT_FORMAT_STRING "%" PRIx64 "-%" PRIx64 "-%08x"
|
|
#define TEXTURE_FILENAME_REGION_FORMAT_STRING "%" PRIx64 "-r%ux%u-%08x"
|
|
#define TEXTURE_FILENAME_REGION_CLUT_FORMAT_STRING "%" PRIx64 "-%" PRIx64 "-r%ux%u-%08x"
|
|
#define TEXTURE_FILENAME_OLD_REGION_FORMAT_STRING "%" PRIx64 "-r%" PRIx64 "-%08x"
|
|
#define TEXTURE_FILENAME_OLD_REGION_CLUT_FORMAT_STRING "%" PRIx64 "-%" PRIx64 "-r%" PRIx64 "-%08x"
|
|
#define TEXTURE_REPLACEMENT_SUBDIRECTORY_NAME "replacements"
|
|
#define TEXTURE_DUMP_SUBDIRECTORY_NAME "dumps"
|
|
|
|
namespace
|
|
{
|
|
struct TextureName // 32 bytes
|
|
{
|
|
u64 TEX0Hash;
|
|
u64 CLUTHash;
|
|
u32 region_width;
|
|
u32 region_height;
|
|
|
|
union
|
|
{
|
|
struct
|
|
{
|
|
u32 TEX0_PSM : 6;
|
|
u32 TEX0_TW : 4;
|
|
u32 TEX0_TH : 4;
|
|
u32 unused0 : 1; // was TCC
|
|
u32 TEXA_TA0 : 8;
|
|
u32 TEXA_AEM : 1;
|
|
u32 TEXA_TA1 : 8;
|
|
};
|
|
u32 bits;
|
|
};
|
|
u32 miplevel;
|
|
|
|
__fi u32 Width() const { return (region_width ? region_width : (1u << TEX0_TW)); }
|
|
__fi u32 Height() const { return (region_height ? region_height : (1u << TEX0_TH)); }
|
|
__fi bool HasPalette() const { return (GSLocalMemory::m_psm[TEX0_PSM].pal > 0); }
|
|
__fi bool HasRegion() const { return (region_width != 0 || region_height != 0); }
|
|
|
|
__fi bool operator==(const TextureName& rhs) const { return BitEqual(*this, rhs); }
|
|
__fi bool operator!=(const TextureName& rhs) const { return !BitEqual(*this, rhs); }
|
|
__fi bool operator<(const TextureName& rhs) const { return (std::memcmp(this, &rhs, sizeof(*this)) < 0); }
|
|
|
|
__fi void RemoveUnusedBits()
|
|
{
|
|
// Remove bits which were previously present, but no longer used.
|
|
unused0 = 0;
|
|
}
|
|
};
|
|
static_assert(sizeof(TextureName) == 32, "ReplacementTextureName is expected size");
|
|
} // namespace
|
|
|
|
namespace std
|
|
{
|
|
template <>
|
|
struct hash<TextureName>
|
|
{
|
|
std::size_t operator()(const TextureName& val) const
|
|
{
|
|
std::size_t h = 0;
|
|
HashCombine(h, val.TEX0Hash, val.CLUTHash,
|
|
static_cast<u64>(val.region_width) | (static_cast<u64>(val.region_height) << 32),
|
|
static_cast<u64>(val.bits) | (static_cast<u64>(val.miplevel) << 32));
|
|
return h;
|
|
}
|
|
};
|
|
} // namespace std
|
|
|
|
namespace GSTextureReplacements
|
|
{
|
|
static TextureName CreateTextureName(const GSTextureCache::HashCacheKey& hash, u32 miplevel);
|
|
static GSTextureCache::HashCacheKey HashCacheKeyFromTextureName(const TextureName& tn);
|
|
static std::optional<TextureName> ParseReplacementName(const std::string& filename);
|
|
static std::string GetGameTextureDirectory();
|
|
static std::string GetDumpFilename(const TextureName& name, u32 level);
|
|
template <GSTexture::Format format>
|
|
std::pair<u8, u8> GetBCAlphaMinMax(ReplacementTexture& rtex);
|
|
static void SetReplacementTextureAlphaMinMax(ReplacementTexture& rtex);
|
|
static std::optional<ReplacementTexture> LoadReplacementTexture(const TextureName& name, const std::string& filename, bool only_base_image);
|
|
static void QueueAsyncReplacementTextureLoad(const TextureName& name, const std::string& filename, bool mipmap, bool cache_only);
|
|
static void PrecacheReplacementTextures();
|
|
static void ClearReplacementTextures();
|
|
|
|
static void StartWorkerThread();
|
|
static void StopWorkerThread();
|
|
static void QueueWorkerThreadItem(std::function<void()> fn, bool high_priority);
|
|
static void WorkerThreadEntryPoint();
|
|
static void SyncWorkerThread();
|
|
static void CancelPendingLoadsAndDumps();
|
|
|
|
static std::string s_current_serial;
|
|
|
|
/// Textures that have been dumped, to save stat() calls.
|
|
static std::unordered_set<TextureName> s_dumped_textures;
|
|
|
|
/// Lookup map of texture names to replacements, if they exist.
|
|
static std::unordered_map<TextureName, std::string> s_replacement_texture_filenames;
|
|
|
|
/// Lookup map of texture names without CLUT hash, to know when we need to disable paltex.
|
|
static std::unordered_set<TextureName> s_replacement_textures_without_clut_hash;
|
|
|
|
/// Lookup map of texture names to replacement data which has been cached.
|
|
static std::unordered_map<TextureName, ReplacementTexture> s_replacement_texture_cache;
|
|
static std::mutex s_replacement_texture_cache_mutex;
|
|
|
|
/// List of textures that are pending asynchronous load. Second element is whether we're only precaching.
|
|
static std::unordered_map<TextureName, bool> s_pending_async_load_textures;
|
|
|
|
/// List of textures that we have asynchronously loaded and can now be injected back into the TC.
|
|
/// Second element is whether the texture should be created with mipmaps.
|
|
static std::vector<std::pair<TextureName, bool>> s_async_loaded_textures;
|
|
|
|
/// Loader/dumper thread.
|
|
static std::thread s_worker_thread;
|
|
static std::mutex s_worker_thread_mutex;
|
|
static std::condition_variable s_worker_thread_cv;
|
|
static std::deque<std::pair<std::function<void()>, bool>> s_worker_thread_queue;
|
|
static bool s_worker_thread_running = false;
|
|
}; // namespace GSTextureReplacements
|
|
|
|
TextureName GSTextureReplacements::CreateTextureName(const GSTextureCache::HashCacheKey& hash, u32 miplevel)
|
|
{
|
|
TextureName name;
|
|
name.bits = 0;
|
|
name.TEX0_PSM = hash.TEX0.PSM;
|
|
name.TEX0_TW = hash.TEX0.TW;
|
|
name.TEX0_TH = hash.TEX0.TH;
|
|
name.TEXA_TA0 = hash.TEXA.TA0;
|
|
name.TEXA_AEM = hash.TEXA.AEM;
|
|
name.TEXA_TA1 = hash.TEXA.TA1;
|
|
name.TEX0Hash = hash.TEX0Hash;
|
|
name.CLUTHash = name.HasPalette() ? hash.CLUTHash : 0;
|
|
name.miplevel = miplevel;
|
|
name.region_width = hash.region_width;
|
|
name.region_height = hash.region_height;
|
|
return name;
|
|
}
|
|
|
|
GSTextureCache::HashCacheKey GSTextureReplacements::HashCacheKeyFromTextureName(const TextureName& tn)
|
|
{
|
|
const GSLocalMemory::psm_t& psm_s = GSLocalMemory::m_psm[tn.TEX0_PSM];
|
|
GSTextureCache::HashCacheKey key = {};
|
|
key.TEX0.PSM = tn.TEX0_PSM;
|
|
key.TEX0.TW = tn.TEX0_TW;
|
|
key.TEX0.TH = tn.TEX0_TH;
|
|
if (psm_s.pal == 0 && psm_s.fmt > 0)
|
|
{
|
|
key.TEXA.TA0 = tn.TEXA_TA0;
|
|
key.TEXA.AEM = tn.TEXA_AEM;
|
|
key.TEXA.TA1 = tn.TEXA_TA1;
|
|
}
|
|
key.TEX0Hash = tn.TEX0Hash;
|
|
key.CLUTHash = tn.HasPalette() ? tn.CLUTHash : 0;
|
|
key.region_width = tn.region_width;
|
|
key.region_height = tn.region_height;
|
|
return key;
|
|
}
|
|
|
|
std::optional<TextureName> GSTextureReplacements::ParseReplacementName(const std::string& filename)
|
|
{
|
|
TextureName ret;
|
|
ret.miplevel = 0;
|
|
|
|
GSTextureCache::SourceRegion full_region;
|
|
|
|
char extension_dot;
|
|
if (std::sscanf(filename.c_str(), TEXTURE_FILENAME_REGION_CLUT_FORMAT_STRING "%c", &ret.TEX0Hash, &ret.CLUTHash,
|
|
&ret.region_width, &ret.region_height, &ret.bits, &extension_dot) == 6 &&
|
|
extension_dot == '.')
|
|
{
|
|
ret.RemoveUnusedBits();
|
|
return ret;
|
|
}
|
|
|
|
if (std::sscanf(filename.c_str(), TEXTURE_FILENAME_REGION_FORMAT_STRING "%c", &ret.TEX0Hash,
|
|
&ret.region_width, &ret.region_height, &ret.bits, &extension_dot) == 5 &&
|
|
extension_dot == '.')
|
|
{
|
|
ret.RemoveUnusedBits();
|
|
ret.CLUTHash = 0;
|
|
return ret;
|
|
}
|
|
|
|
// Allow loading of dumped textures from older versions that included the full region bits.
|
|
if (std::sscanf(filename.c_str(), TEXTURE_FILENAME_OLD_REGION_CLUT_FORMAT_STRING "%c", &ret.TEX0Hash, &ret.CLUTHash,
|
|
&full_region.bits, &ret.bits, &extension_dot) == 5 &&
|
|
extension_dot == '.')
|
|
{
|
|
ret.RemoveUnusedBits();
|
|
ret.region_width = static_cast<u32>(full_region.GetWidth());
|
|
ret.region_height = static_cast<u32>(full_region.GetHeight());
|
|
return ret;
|
|
}
|
|
|
|
if (std::sscanf(filename.c_str(), TEXTURE_FILENAME_OLD_REGION_FORMAT_STRING "%c", &ret.TEX0Hash, &full_region.bits,
|
|
&ret.bits, &extension_dot) == 4 &&
|
|
extension_dot == '.')
|
|
{
|
|
ret.RemoveUnusedBits();
|
|
ret.CLUTHash = 0;
|
|
ret.region_width = static_cast<u32>(full_region.GetWidth());
|
|
ret.region_height = static_cast<u32>(full_region.GetHeight());
|
|
return ret;
|
|
}
|
|
|
|
ret.region_width = 0;
|
|
ret.region_height = 0;
|
|
|
|
if (std::sscanf(filename.c_str(), TEXTURE_FILENAME_CLUT_FORMAT_STRING "%c", &ret.TEX0Hash, &ret.CLUTHash, &ret.bits,
|
|
&extension_dot) == 4 &&
|
|
extension_dot == '.')
|
|
{
|
|
ret.RemoveUnusedBits();
|
|
return ret;
|
|
}
|
|
|
|
if (std::sscanf(filename.c_str(), TEXTURE_FILENAME_FORMAT_STRING "%c", &ret.TEX0Hash, &ret.bits, &extension_dot) ==
|
|
3 &&
|
|
extension_dot == '.')
|
|
{
|
|
ret.RemoveUnusedBits();
|
|
ret.CLUTHash = 0;
|
|
return ret;
|
|
}
|
|
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::string GSTextureReplacements::GetGameTextureDirectory()
|
|
{
|
|
return Path::Combine(EmuFolders::Textures, s_current_serial);
|
|
}
|
|
|
|
std::string GSTextureReplacements::GetDumpFilename(const TextureName& name, u32 level)
|
|
{
|
|
std::string ret;
|
|
if (s_current_serial.empty())
|
|
return ret;
|
|
|
|
const std::string game_dir(GetGameTextureDirectory());
|
|
const std::string game_subdir(Path::Combine(game_dir, TEXTURE_DUMP_SUBDIRECTORY_NAME));
|
|
|
|
if (!FileSystem::DirectoryExists(game_subdir.c_str()))
|
|
{
|
|
// create both dumps and replacements
|
|
if (!FileSystem::CreateDirectoryPath(game_dir.c_str(), false) ||
|
|
!FileSystem::EnsureDirectoryExists(game_subdir.c_str(), false) ||
|
|
!FileSystem::EnsureDirectoryExists(Path::Combine(game_dir, TEXTURE_REPLACEMENT_SUBDIRECTORY_NAME).c_str(), false))
|
|
{
|
|
// if it fails to create, we're not going to be able to use it anyway
|
|
return ret;
|
|
}
|
|
}
|
|
|
|
std::string filename;
|
|
if (name.HasRegion())
|
|
{
|
|
if (name.HasPalette())
|
|
{
|
|
filename = (level > 0)
|
|
? StringUtil::StdStringFromFormat(TEXTURE_FILENAME_REGION_CLUT_FORMAT_STRING "-mip%u.png",
|
|
name.TEX0Hash, name.CLUTHash, name.region_width, name.region_height, name.bits, level)
|
|
: StringUtil::StdStringFromFormat(TEXTURE_FILENAME_REGION_CLUT_FORMAT_STRING ".png",
|
|
name.TEX0Hash, name.CLUTHash, name.region_width, name.region_height, name.bits);
|
|
}
|
|
else
|
|
{
|
|
filename = (level > 0)
|
|
? StringUtil::StdStringFromFormat(TEXTURE_FILENAME_REGION_FORMAT_STRING "-mip%u.png",
|
|
name.TEX0Hash, name.region_width, name.region_height, name.bits, level)
|
|
: StringUtil::StdStringFromFormat(TEXTURE_FILENAME_REGION_FORMAT_STRING ".png",
|
|
name.TEX0Hash, name.region_width, name.region_height, name.bits);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (name.HasPalette())
|
|
{
|
|
filename = (level > 0)
|
|
? StringUtil::StdStringFromFormat(TEXTURE_FILENAME_CLUT_FORMAT_STRING "-mip%u.png",
|
|
name.TEX0Hash, name.CLUTHash, name.bits, level)
|
|
: StringUtil::StdStringFromFormat(TEXTURE_FILENAME_CLUT_FORMAT_STRING ".png",
|
|
name.TEX0Hash, name.CLUTHash, name.bits);
|
|
}
|
|
else
|
|
{
|
|
filename = (level > 0)
|
|
? StringUtil::StdStringFromFormat(TEXTURE_FILENAME_FORMAT_STRING "-mip%u.png",
|
|
name.TEX0Hash, name.bits, level)
|
|
: StringUtil::StdStringFromFormat(TEXTURE_FILENAME_FORMAT_STRING ".png",
|
|
name.TEX0Hash, name.bits);
|
|
}
|
|
}
|
|
|
|
ret = Path::Combine(game_subdir, filename);
|
|
|
|
return ret;
|
|
}
|
|
|
|
void GSTextureReplacements::Initialize()
|
|
{
|
|
s_current_serial = VMManager::GetDiscSerial();
|
|
|
|
if (GSConfig.DumpReplaceableTextures || GSConfig.LoadTextureReplacements)
|
|
StartWorkerThread();
|
|
|
|
ReloadReplacementMap();
|
|
}
|
|
|
|
void GSTextureReplacements::GameChanged()
|
|
{
|
|
std::string new_serial = VMManager::GetDiscSerial();
|
|
if (s_current_serial == new_serial)
|
|
return;
|
|
|
|
s_current_serial = std::move(new_serial);
|
|
ReloadReplacementMap();
|
|
ClearDumpedTextureList();
|
|
}
|
|
|
|
/// If the given file exists in the given directory, but with a different case than the original file, write its path to `*output` and return true.
|
|
static bool GetWrongCasePath(std::string* output, const char* dir, std::string_view file, FileSystem::FindResultsArray* reuseme)
|
|
{
|
|
if (FileSystem::FindFiles(dir, "*", FILESYSTEM_FIND_FOLDERS | FILESYSTEM_FIND_HIDDEN_FILES, reuseme))
|
|
{
|
|
for (const FILESYSTEM_FIND_DATA& fd : *reuseme)
|
|
{
|
|
std::string_view name = Path::GetFileName(fd.FileName);
|
|
if (name.size() != file.size())
|
|
continue;
|
|
if (0 == strncmp(name.data(), file.data(), name.size()))
|
|
continue;
|
|
if (0 == StringUtil::Strncasecmp(name.data(), file.data(), name.size()))
|
|
{
|
|
*output = fd.FileName;
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void GSTextureReplacements::ReloadReplacementMap()
|
|
{
|
|
SyncWorkerThread();
|
|
|
|
// clear out the caches
|
|
{
|
|
s_replacement_texture_filenames.clear();
|
|
s_replacement_textures_without_clut_hash.clear();
|
|
|
|
std::unique_lock<std::mutex> lock(s_replacement_texture_cache_mutex);
|
|
s_replacement_texture_cache.clear();
|
|
s_pending_async_load_textures.clear();
|
|
s_async_loaded_textures.clear();
|
|
}
|
|
|
|
// can't replace bios textures.
|
|
if (s_current_serial.empty() || !GSConfig.LoadTextureReplacements)
|
|
return;
|
|
|
|
const std::string texture_dir = GetGameTextureDirectory();
|
|
const std::string replacement_dir(Path::Combine(texture_dir, TEXTURE_REPLACEMENT_SUBDIRECTORY_NAME));
|
|
|
|
FileSystem::FindResultsArray files;
|
|
|
|
// For some reason texture pack authors think it's a good idea to rename the replacements directory to something with the wrong case...
|
|
std::string wrong_case_path;
|
|
const std::string* right_case_path = nullptr;
|
|
if (GetWrongCasePath(&wrong_case_path, EmuFolders::Textures.c_str(), s_current_serial, &files))
|
|
right_case_path = &texture_dir;
|
|
else if (GetWrongCasePath(&wrong_case_path, texture_dir.c_str(), TEXTURE_REPLACEMENT_SUBDIRECTORY_NAME, &files))
|
|
right_case_path = &replacement_dir;
|
|
if (right_case_path)
|
|
{
|
|
Host::AddKeyedOSDMessage("TextureReplacementDirCaseMismatch",
|
|
fmt::format(TRANSLATE_FS("TextureReplacement", "Texture replacement directory {} will not work on case sensitive filesystems.\n"
|
|
"Rename it to {} to remove this warning."),
|
|
wrong_case_path, *right_case_path),
|
|
Host::OSD_WARNING_DURATION);
|
|
}
|
|
|
|
if (!FileSystem::FindFiles(replacement_dir.c_str(), "*", FILESYSTEM_FIND_FILES | FILESYSTEM_FIND_HIDDEN_FILES | FILESYSTEM_FIND_RECURSIVE, &files))
|
|
return;
|
|
|
|
std::string filename;
|
|
for (FILESYSTEM_FIND_DATA& fd : files)
|
|
{
|
|
// file format we can handle?
|
|
filename = Path::GetFileName(fd.FileName);
|
|
if (!GetLoader(filename))
|
|
continue;
|
|
|
|
// parse the name if it's valid
|
|
std::optional<TextureName> name = ParseReplacementName(filename);
|
|
if (!name.has_value())
|
|
continue;
|
|
|
|
DbgCon.WriteLn("Found %ux%u replacement '%.*s'", name->Width(), name->Height(), static_cast<int>(filename.size()), filename.data());
|
|
s_replacement_texture_filenames.emplace(name.value(), std::move(fd.FileName));
|
|
|
|
// zero out the CLUT hash, because we need this for checking if there's any replacements with this hash when using paltex
|
|
name->CLUTHash = 0;
|
|
s_replacement_textures_without_clut_hash.insert(name.value());
|
|
}
|
|
|
|
if (!s_replacement_texture_filenames.empty())
|
|
{
|
|
if (GSConfig.PrecacheTextureReplacements)
|
|
PrecacheReplacementTextures();
|
|
|
|
// log a warning when paltex is on and preloading is off, since we'll be disabling paltex
|
|
if (GSConfig.GPUPaletteConversion && GSConfig.TexturePreloading != TexturePreloadingLevel::Full)
|
|
{
|
|
Console.Warning("Replacement textures were found, and GPU palette conversion is enabled without full preloading.");
|
|
Console.Warning("Palette textures will be disabled. Please enable full preloading or disable GPU palette conversion.");
|
|
}
|
|
}
|
|
}
|
|
|
|
void GSTextureReplacements::UpdateConfig(Pcsx2Config::GSOptions& old_config)
|
|
{
|
|
// get rid of worker thread if it's no longer needed
|
|
if (s_worker_thread_running && !GSConfig.DumpReplaceableTextures && !GSConfig.LoadTextureReplacements)
|
|
StopWorkerThread();
|
|
if (!s_worker_thread_running && (GSConfig.DumpReplaceableTextures || GSConfig.LoadTextureReplacements))
|
|
StartWorkerThread();
|
|
|
|
if ((!GSConfig.DumpReplaceableTextures && old_config.DumpReplaceableTextures) ||
|
|
(!GSConfig.LoadTextureReplacements && old_config.LoadTextureReplacements))
|
|
{
|
|
CancelPendingLoadsAndDumps();
|
|
}
|
|
|
|
if (GSConfig.LoadTextureReplacements && !old_config.LoadTextureReplacements)
|
|
ReloadReplacementMap();
|
|
else if (!GSConfig.LoadTextureReplacements && old_config.LoadTextureReplacements)
|
|
ClearReplacementTextures();
|
|
|
|
if (!GSConfig.DumpReplaceableTextures && old_config.DumpReplaceableTextures)
|
|
ClearDumpedTextureList();
|
|
|
|
if (GSConfig.LoadTextureReplacements && GSConfig.PrecacheTextureReplacements && !old_config.PrecacheTextureReplacements)
|
|
PrecacheReplacementTextures();
|
|
}
|
|
|
|
void GSTextureReplacements::Shutdown()
|
|
{
|
|
StopWorkerThread();
|
|
|
|
std::string().swap(s_current_serial);
|
|
ClearReplacementTextures();
|
|
ClearDumpedTextureList();
|
|
}
|
|
|
|
u32 GSTextureReplacements::CalcMipmapLevelsForReplacement(u32 width, u32 height)
|
|
{
|
|
return static_cast<u32>(std::log2(std::max(width, height))) + 1u;
|
|
}
|
|
|
|
bool GSTextureReplacements::HasAnyReplacementTextures()
|
|
{
|
|
return !s_replacement_texture_filenames.empty();
|
|
}
|
|
|
|
bool GSTextureReplacements::HasReplacementTextureWithOtherPalette(const GSTextureCache::HashCacheKey& hash)
|
|
{
|
|
const TextureName name(CreateTextureName(hash.WithRemovedCLUTHash(), 0));
|
|
return s_replacement_textures_without_clut_hash.find(name) != s_replacement_textures_without_clut_hash.end();
|
|
}
|
|
|
|
GSTexture* GSTextureReplacements::LookupReplacementTexture(const GSTextureCache::HashCacheKey& hash, bool mipmap,
|
|
bool* pending, std::pair<u8, u8>* alpha_minmax)
|
|
{
|
|
const TextureName name(CreateTextureName(hash, 0));
|
|
*pending = false;
|
|
|
|
// replacement for this name exists?
|
|
auto fnit = s_replacement_texture_filenames.find(name);
|
|
if (fnit == s_replacement_texture_filenames.end())
|
|
return nullptr;
|
|
|
|
// try the full cache first, to avoid reloading from disk
|
|
{
|
|
std::unique_lock<std::mutex> lock(s_replacement_texture_cache_mutex);
|
|
auto it = s_replacement_texture_cache.find(name);
|
|
if (it != s_replacement_texture_cache.end())
|
|
{
|
|
// replacement is cached, can immediately upload to host GPU
|
|
*alpha_minmax = it->second.alpha_minmax;
|
|
return CreateReplacementTexture(it->second, mipmap);
|
|
}
|
|
}
|
|
|
|
// load asynchronously?
|
|
if (GSConfig.LoadTextureReplacementsAsync)
|
|
{
|
|
// replacement will be injected into the TC later on
|
|
std::unique_lock<std::mutex> lock(s_replacement_texture_cache_mutex);
|
|
QueueAsyncReplacementTextureLoad(name, fnit->second, mipmap, false);
|
|
|
|
*pending = true;
|
|
return nullptr;
|
|
}
|
|
else
|
|
{
|
|
// synchronous load
|
|
std::optional<ReplacementTexture> replacement(LoadReplacementTexture(name, fnit->second, !mipmap));
|
|
if (!replacement.has_value())
|
|
return nullptr;
|
|
|
|
// insert into cache
|
|
std::unique_lock<std::mutex> lock(s_replacement_texture_cache_mutex);
|
|
const ReplacementTexture& rtex = s_replacement_texture_cache.emplace(name, std::move(replacement.value())).first->second;
|
|
|
|
// and upload to gpu
|
|
*alpha_minmax = rtex.alpha_minmax;
|
|
return CreateReplacementTexture(rtex, mipmap);
|
|
}
|
|
}
|
|
|
|
template <GSTexture::Format format>
|
|
std::pair<u8, u8> GSTextureReplacements::GetBCAlphaMinMax(ReplacementTexture& rtex)
|
|
{
|
|
constexpr u32 BC_BLOCK_SIZE = 4;
|
|
constexpr u32 BC_BLOCK_BYTES = (format == GSTexture::Format::BC1) ? 8 : 16;
|
|
|
|
const u32 blocks_wide = (rtex.width + (BC_BLOCK_SIZE - 1)) / BC_BLOCK_SIZE;
|
|
const u32 blocks_high = (rtex.height + (BC_BLOCK_SIZE - 1)) / BC_BLOCK_SIZE;
|
|
|
|
GSVector4i minc = GSVector4i::xffffffff();
|
|
GSVector4i maxc = GSVector4i::zero();
|
|
|
|
for (u32 y = 0; y < blocks_high; y++)
|
|
{
|
|
const u8* block_in = rtex.data.data() + y * rtex.pitch;
|
|
alignas(16) u8 block_pixels_out[BC_BLOCK_SIZE * BC_BLOCK_SIZE * sizeof(u32)];
|
|
|
|
for (u32 x = 0; x < blocks_wide; x++, block_in += BC_BLOCK_BYTES)
|
|
{
|
|
switch (format)
|
|
{
|
|
case GSTexture::Format::BC1:
|
|
DecompressBlockBC1(0, 0, sizeof(u32) * BC_BLOCK_SIZE, block_in, block_pixels_out);
|
|
break;
|
|
case GSTexture::Format::BC2:
|
|
DecompressBlockBC2(0, 0, sizeof(u32) * BC_BLOCK_SIZE, block_in, block_pixels_out);
|
|
break;
|
|
case GSTexture::Format::BC3:
|
|
DecompressBlockBC3(0, 0, sizeof(u32) * BC_BLOCK_SIZE, block_in, block_pixels_out);
|
|
break;
|
|
|
|
case GSTexture::Format::BC7:
|
|
bc7decomp::unpack_bc7(block_in, reinterpret_cast<bc7decomp::color_rgba*>(block_pixels_out));
|
|
break;
|
|
}
|
|
|
|
const u8* out_ptr = block_pixels_out;
|
|
for (u32 i = 0; i < ((BC_BLOCK_SIZE * BC_BLOCK_SIZE * sizeof(u32)) / sizeof(GSVector4i)); i++)
|
|
{
|
|
const GSVector4i v = GSVector4i::load<true>(out_ptr);
|
|
out_ptr += sizeof(GSVector4i);
|
|
minc = minc.min_u32(v);
|
|
maxc = maxc.max_u32(v);
|
|
}
|
|
}
|
|
}
|
|
|
|
return std::make_pair<u8, u8>(static_cast<u8>(minc.minv_u32() >> 24), static_cast<u8>(maxc.maxv_u32() >> 24));
|
|
}
|
|
|
|
void GSTextureReplacements::SetReplacementTextureAlphaMinMax(ReplacementTexture& rtex)
|
|
{
|
|
switch (rtex.format)
|
|
{
|
|
case GSTexture::Format::BC1:
|
|
rtex.alpha_minmax = GetBCAlphaMinMax<GSTexture::Format::BC1>(rtex);
|
|
break;
|
|
|
|
case GSTexture::Format::BC2:
|
|
rtex.alpha_minmax = GetBCAlphaMinMax<GSTexture::Format::BC2>(rtex);
|
|
break;
|
|
|
|
case GSTexture::Format::BC3:
|
|
rtex.alpha_minmax = GetBCAlphaMinMax<GSTexture::Format::BC3>(rtex);
|
|
break;
|
|
|
|
case GSTexture::Format::BC7:
|
|
rtex.alpha_minmax = GetBCAlphaMinMax<GSTexture::Format::BC7>(rtex);
|
|
break;
|
|
|
|
default:
|
|
pxAssert(rtex.format == GSTexture::Format::Color);
|
|
rtex.alpha_minmax = GSGetRGBA8AlphaMinMax(rtex.data.data(), rtex.width, rtex.height, rtex.pitch);
|
|
break;
|
|
}
|
|
}
|
|
|
|
std::optional<GSTextureReplacements::ReplacementTexture> GSTextureReplacements::LoadReplacementTexture(const TextureName& name, const std::string& filename, bool only_base_image)
|
|
{
|
|
ReplacementTextureLoader loader = GetLoader(filename);
|
|
if (!loader)
|
|
return std::nullopt;
|
|
|
|
ReplacementTexture rtex;
|
|
if (!loader(filename.c_str(), &rtex, only_base_image))
|
|
{
|
|
Console.Warning("Failed to load replacement texture %s", filename.c_str());
|
|
return std::nullopt;
|
|
}
|
|
|
|
SetReplacementTextureAlphaMinMax(rtex);
|
|
|
|
return rtex;
|
|
}
|
|
|
|
void GSTextureReplacements::QueueAsyncReplacementTextureLoad(const TextureName& name, const std::string& filename, bool mipmap, bool cache_only)
|
|
{
|
|
// check the pending list, so we don't queue it up multiple times
|
|
auto it = s_pending_async_load_textures.find(name);
|
|
if (it != s_pending_async_load_textures.end())
|
|
{
|
|
// remove from queue if it's cache-only, so we bump it to the front of the work items
|
|
if (!cache_only && it->second)
|
|
{
|
|
s_pending_async_load_textures.erase(it);
|
|
}
|
|
else
|
|
{
|
|
it->second &= cache_only;
|
|
return;
|
|
}
|
|
}
|
|
|
|
s_pending_async_load_textures.emplace(name, cache_only);
|
|
QueueWorkerThreadItem([name, filename, mipmap]() {
|
|
// actually load the file, this is what will take the time
|
|
std::optional<ReplacementTexture> replacement(LoadReplacementTexture(name, filename, !mipmap));
|
|
|
|
// check the pending set, there's a race here if we disable replacements while loading otherwise
|
|
// also check the full replacement list, if async loading is off, it might already be in there
|
|
std::unique_lock<std::mutex> lock(s_replacement_texture_cache_mutex);
|
|
auto it = s_pending_async_load_textures.find(name);
|
|
if (it == s_pending_async_load_textures.end() ||
|
|
s_replacement_texture_cache.find(name) != s_replacement_texture_cache.end())
|
|
{
|
|
if (it != s_pending_async_load_textures.end())
|
|
s_pending_async_load_textures.erase(it);
|
|
|
|
return;
|
|
}
|
|
|
|
// insert into the cache and queue for later injection
|
|
if (replacement.has_value())
|
|
{
|
|
s_replacement_texture_cache.emplace(name, std::move(replacement.value()));
|
|
s_async_loaded_textures.emplace_back(name, mipmap);
|
|
}
|
|
else
|
|
{
|
|
// loading failed, so clear it from the pending list
|
|
s_pending_async_load_textures.erase(name);
|
|
}
|
|
}, !cache_only);
|
|
}
|
|
|
|
void GSTextureReplacements::PrecacheReplacementTextures()
|
|
{
|
|
std::unique_lock<std::mutex> lock(s_replacement_texture_cache_mutex);
|
|
|
|
// predict whether the requests will come with mipmaps
|
|
// TODO: This will be wrong for hw mipmap games like Jak.
|
|
const bool mipmap = GSConfig.HWMipmap || GSConfig.TriFilter == TriFiltering::Forced;
|
|
|
|
// pretty simple, just go through the filenames and if any aren't cached, cache them
|
|
for (const auto& it : s_replacement_texture_filenames)
|
|
{
|
|
if (s_replacement_texture_cache.find(it.first) != s_replacement_texture_cache.end())
|
|
continue;
|
|
|
|
// precaching always goes async.. for now
|
|
QueueAsyncReplacementTextureLoad(it.first, it.second, mipmap, true);
|
|
}
|
|
}
|
|
|
|
void GSTextureReplacements::ClearReplacementTextures()
|
|
{
|
|
s_replacement_texture_filenames.clear();
|
|
s_replacement_textures_without_clut_hash.clear();
|
|
|
|
std::unique_lock<std::mutex> lock(s_replacement_texture_cache_mutex);
|
|
s_replacement_texture_cache.clear();
|
|
s_pending_async_load_textures.clear();
|
|
s_async_loaded_textures.clear();
|
|
}
|
|
|
|
GSTexture* GSTextureReplacements::CreateReplacementTexture(const ReplacementTexture& rtex, bool mipmap)
|
|
{
|
|
// can't use generated mipmaps with compressed formats, because they can't be rendered to
|
|
// in the future I guess we could decompress the dds and generate them... but there's no reason that modders can't generate mips in dds
|
|
if (mipmap && GSTexture::IsCompressedFormat(rtex.format) && rtex.mips.empty())
|
|
{
|
|
static bool log_once = false;
|
|
if (!log_once)
|
|
{
|
|
Console.Warning("Disabling autogenerated mipmaps on one or more compressed replacement textures.");
|
|
Host::AddIconOSDMessage("DisablingReplacementAutoGeneratedMipmap", ICON_FA_CIRCLE_EXCLAMATION,
|
|
TRANSLATE_SV("GS", "Disabling autogenerated mipmaps on one or more compressed replacement textures. "
|
|
"Please generate mipmaps when compressing your textures."),
|
|
Host::OSD_WARNING_DURATION);
|
|
log_once = true;
|
|
}
|
|
|
|
mipmap = false;
|
|
}
|
|
|
|
GSTexture* tex = g_gs_device->CreateTexture(rtex.width, rtex.height, static_cast<int>(rtex.mips.size()) + 1, rtex.format);
|
|
if (!tex)
|
|
return nullptr;
|
|
|
|
// upload base level
|
|
tex->Update(GSVector4i(0, 0, rtex.width, rtex.height), rtex.data.data(), rtex.pitch);
|
|
|
|
// and the mips if they're present in the replacement texture
|
|
if (!rtex.mips.empty())
|
|
{
|
|
for (u32 i = 0; i < static_cast<u32>(rtex.mips.size()); i++)
|
|
{
|
|
const ReplacementTexture::MipData& mip = rtex.mips[i];
|
|
tex->Update(GSVector4i(0, 0, static_cast<int>(mip.width), static_cast<int>(mip.height)), mip.data.data(), mip.pitch, i + 1);
|
|
}
|
|
}
|
|
|
|
return tex;
|
|
}
|
|
|
|
void GSTextureReplacements::ProcessAsyncLoadedTextures()
|
|
{
|
|
// this holds the lock while doing the upload, but it should be reasonably quick
|
|
std::unique_lock<std::mutex> lock(s_replacement_texture_cache_mutex);
|
|
for (const auto& [name, mipmap] : s_async_loaded_textures)
|
|
{
|
|
// no longer pending!
|
|
const auto pit = s_pending_async_load_textures.find(name);
|
|
if (pit != s_pending_async_load_textures.end())
|
|
{
|
|
const bool cache_only = pit->second;
|
|
s_pending_async_load_textures.erase(pit);
|
|
|
|
// if we were precaching, don't inject into the TC if we didn't actually get requested
|
|
if (cache_only)
|
|
continue;
|
|
}
|
|
|
|
// we should be in the cache now, lock and loaded
|
|
auto it = s_replacement_texture_cache.find(name);
|
|
if (it == s_replacement_texture_cache.end())
|
|
continue;
|
|
|
|
// upload and inject into TC
|
|
GSTexture* tex = CreateReplacementTexture(it->second, mipmap);
|
|
if (tex)
|
|
g_texture_cache->InjectHashCacheTexture(HashCacheKeyFromTextureName(name), tex, it->second.alpha_minmax);
|
|
}
|
|
s_async_loaded_textures.clear();
|
|
}
|
|
|
|
void GSTextureReplacements::DumpTexture(const GSTextureCache::HashCacheKey& hash, const GIFRegTEX0& TEX0,
|
|
const GIFRegTEXA& TEXA, GSTextureCache::SourceRegion region, GSLocalMemory& mem, u32 level)
|
|
{
|
|
// check if it's been dumped or replaced already
|
|
const TextureName name(CreateTextureName(hash, level));
|
|
if (s_dumped_textures.find(name) != s_dumped_textures.end() || s_replacement_texture_filenames.find(name) != s_replacement_texture_filenames.end())
|
|
return;
|
|
|
|
s_dumped_textures.insert(name);
|
|
|
|
// already exists on disk?
|
|
std::string filename(GetDumpFilename(name, level));
|
|
if (filename.empty() || FileSystem::FileExists(filename.c_str()))
|
|
return;
|
|
|
|
const std::string_view title(Path::GetFileTitle(filename));
|
|
DevCon.WriteLn("Dumping %ux%u texture '%.*s'.", name.Width(), name.Height(), static_cast<int>(title.size()), title.data());
|
|
|
|
// compute width/height
|
|
const GSLocalMemory::psm_t& psm = GSLocalMemory::m_psm[TEX0.PSM];
|
|
const GSVector2i& bs = psm.bs;
|
|
const int tw = region.HasX() ? region.GetWidth() : (1 << TEX0.TW);
|
|
const int th = region.HasY() ? region.GetHeight() : (1 << TEX0.TH);
|
|
const GSVector4i rect(region.GetRect(tw, th));
|
|
const GSVector4i block_rect(rect.ralign<Align_Outside>(bs));
|
|
const int read_width = block_rect.width();
|
|
const int read_height = block_rect.height();
|
|
const u32 pitch = static_cast<u32>(read_width) * sizeof(u32);
|
|
|
|
// use per-texture buffer so we can compress the texture asynchronously and not block the GS thread
|
|
// must be 32 byte aligned for ReadTexture().
|
|
u8* buffer = static_cast<u8*>(_aligned_malloc(pitch * static_cast<u32>(read_height), 32));
|
|
psm.rtx(mem, mem.GetOffset(TEX0.TBP0, TEX0.TBW, TEX0.PSM), block_rect, buffer, pitch, TEXA);
|
|
|
|
// okay, now we can actually dump it
|
|
const u32 buffer_offset = ((rect.top - block_rect.top) * pitch) + ((rect.left - block_rect.left) * sizeof(u32));
|
|
QueueWorkerThreadItem([filename = std::move(filename), tw, th, pitch, buffer, buffer_offset]() {
|
|
if (!SavePNGImage(filename.c_str(), tw, th, buffer + buffer_offset, pitch))
|
|
Console.Error(fmt::format("Failed to dump texture to '{}'.", filename));
|
|
_aligned_free(buffer);
|
|
}, false);
|
|
}
|
|
|
|
void GSTextureReplacements::ClearDumpedTextureList()
|
|
{
|
|
s_dumped_textures.clear();
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
// Worker Thread
|
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
void GSTextureReplacements::StartWorkerThread()
|
|
{
|
|
std::unique_lock<std::mutex> lock(s_worker_thread_mutex);
|
|
|
|
if (s_worker_thread.joinable())
|
|
return;
|
|
|
|
s_worker_thread_running = true;
|
|
s_worker_thread = std::thread(WorkerThreadEntryPoint);
|
|
}
|
|
|
|
void GSTextureReplacements::StopWorkerThread()
|
|
{
|
|
{
|
|
std::unique_lock<std::mutex> lock(s_worker_thread_mutex);
|
|
if (!s_worker_thread.joinable())
|
|
return;
|
|
|
|
s_worker_thread_running = false;
|
|
s_worker_thread_cv.notify_one();
|
|
}
|
|
|
|
s_worker_thread.join();
|
|
|
|
// clear out workery-things too
|
|
CancelPendingLoadsAndDumps();
|
|
}
|
|
|
|
void GSTextureReplacements::QueueWorkerThreadItem(std::function<void()> fn, bool high_priority)
|
|
{
|
|
pxAssert(s_worker_thread.joinable());
|
|
|
|
std::unique_lock<std::mutex> lock(s_worker_thread_mutex);
|
|
if (!high_priority)
|
|
{
|
|
// Low priority => throw on end.
|
|
s_worker_thread_queue.emplace_back(std::move(fn), false);
|
|
}
|
|
else
|
|
{
|
|
auto iter = s_worker_thread_queue.rbegin();
|
|
for (; iter != s_worker_thread_queue.rend(); ++iter)
|
|
{
|
|
// Found our first high priority item?
|
|
if (iter->second)
|
|
{
|
|
// Insert after here!
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (iter != s_worker_thread_queue.rend())
|
|
{
|
|
// Insert after the last high priority item. Remember base() points to the next element.
|
|
s_worker_thread_queue.insert(iter.base(), std::make_pair(std::move(fn), true));
|
|
}
|
|
else
|
|
{
|
|
// All low-priority => insert at beginning.
|
|
s_worker_thread_queue.emplace_front(std::move(fn), true);
|
|
}
|
|
}
|
|
|
|
s_worker_thread_cv.notify_one();
|
|
}
|
|
|
|
void GSTextureReplacements::WorkerThreadEntryPoint()
|
|
{
|
|
std::unique_lock<std::mutex> lock(s_worker_thread_mutex);
|
|
while (s_worker_thread_running)
|
|
{
|
|
if (s_worker_thread_queue.empty())
|
|
{
|
|
s_worker_thread_cv.wait(lock);
|
|
continue;
|
|
}
|
|
|
|
std::function<void()> fn = std::move(s_worker_thread_queue.front().first);
|
|
s_worker_thread_queue.pop_front();
|
|
lock.unlock();
|
|
fn();
|
|
lock.lock();
|
|
}
|
|
}
|
|
|
|
void GSTextureReplacements::SyncWorkerThread()
|
|
{
|
|
std::unique_lock<std::mutex> lock(s_worker_thread_mutex);
|
|
if (!s_worker_thread.joinable())
|
|
return;
|
|
|
|
// not the most efficient by far, but it only gets called on config changes, so whatever
|
|
for (;;)
|
|
{
|
|
if (s_worker_thread_queue.empty())
|
|
break;
|
|
|
|
lock.unlock();
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
|
lock.lock();
|
|
}
|
|
}
|
|
|
|
void GSTextureReplacements::CancelPendingLoadsAndDumps()
|
|
{
|
|
std::unique_lock<std::mutex> lock(s_worker_thread_mutex);
|
|
while (!s_worker_thread_queue.empty())
|
|
s_worker_thread_queue.pop_back();
|
|
s_async_loaded_textures.clear();
|
|
s_pending_async_load_textures.clear();
|
|
}
|