diff --git a/Nautilus/Initializer.cs b/Nautilus/Initializer.cs index e30cf17a..59a8d544 100644 --- a/Nautilus/Initializer.cs +++ b/Nautilus/Initializer.cs @@ -78,6 +78,7 @@ static Initializer() SurvivalPatcher.Patch(_harmony); CustomSoundPatcher.Patch(_harmony); MaterialUtils.Patch(); + MaterialLibrary.Patch(); FontReferencesPatcher.Patch(_harmony); VehicleUpgradesPatcher.Patch(_harmony); StoryGoalPatcher.Patch(_harmony); diff --git a/Nautilus/Nautilus.csproj b/Nautilus/Nautilus.csproj index 33f66a35..aaea3355 100644 --- a/Nautilus/Nautilus.csproj +++ b/Nautilus/Nautilus.csproj @@ -52,5 +52,12 @@ + + + + + + + \ No newline at end of file diff --git a/Nautilus/Resources/MatFilePathMapBZ.resources b/Nautilus/Resources/MatFilePathMapBZ.resources new file mode 100644 index 00000000..eb868070 Binary files /dev/null and b/Nautilus/Resources/MatFilePathMapBZ.resources differ diff --git a/Nautilus/Resources/MatFilePathMapSN.resources b/Nautilus/Resources/MatFilePathMapSN.resources new file mode 100644 index 00000000..03f5bfa0 Binary files /dev/null and b/Nautilus/Resources/MatFilePathMapSN.resources differ diff --git a/Nautilus/Utility/MaterialLibrary.cs b/Nautilus/Utility/MaterialLibrary.cs new file mode 100644 index 00000000..7f0a9efc --- /dev/null +++ b/Nautilus/Utility/MaterialLibrary.cs @@ -0,0 +1,352 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using BepInEx; +using UnityEngine; +using UWE; +using ResourceManager = System.Resources.ResourceManager; + +namespace Nautilus.Utility; + +/// +/// Allows for quick and simple retrieval of any base-game material throughout both Subnautica, and Subnautica: +/// Below Zero. Materials can either be fetched directly using their names by accessing the +/// method, or applied generically across the entirety of a custom prefab via the method. +/// +public static class MaterialLibrary +{ + /// + /// Handles loading the material filepath maps from the embedded .resources files, and accessing their contents. + /// + private static ResourceManager _resourceManager; + + /// + /// The maximum number of times the method is allowed to retry loading a material + /// from it's designated path. Necessary because .mat files will occasionally fail to load a couple of times before + /// being successfully retrieved. This cap exists only as a failsafe, and should never actually be reached. + /// + private const int MaxFetchAttempts = 1000; + + /// + /// The current amount of entries within the MaterialLibrary. + /// + public static int Size + { + get + { + if (_resourceManager == null) + return 0; + + var resourceSet = _resourceManager.GetResourceSet(CultureInfo.InvariantCulture, true, true); + + if (resourceSet == null) + { + InternalLogger.Error("Failed to get the ResourceSet of the material library."); + return 0; + } + + //Sadly, this is actually the simplest way to get the total number of entries in a .resources file. + int materialEntries = 0; + foreach (var _ in resourceSet) + materialEntries++; + + return materialEntries; + } + } + + internal static void Patch() + { + #if SUBNAUTICA + string resourcePath = "Nautilus.Resources.MatFilePathMapSN"; + #elif BELOWZERO + string resourcePath = "Nautilus.Resources.MatFilePathMapBZ"; + #endif + + _resourceManager = new ResourceManager(resourcePath, Assembly.GetExecutingAssembly()); + } + + /// + /// Iterates over every present on the given and any of its + /// children, and replaces any custom materials it finds that share the exact same name (case-sensitive) of a + /// base-game material with its vanilla counterpart. + /// + /// The custom object you'd like to apply base-game materials to. Children included. + /// + public static IEnumerator ReplaceVanillaMats(GameObject customPrefab) + { + if (customPrefab == null) + { + InternalLogger.Error("Attempted to apply vanilla materials to a null prefab."); + yield break; + } + + var loadedVanillaMats = new List(); + var customMatNames = new List(); + foreach (var renderer in customPrefab.GetAllComponentsInChildren()) + { + var newMatList = renderer.materials; + + for (int i = 0; i < newMatList.Length; i++) + { + if (newMatList[i] == null) + continue; + + var currentMatName = MaterialUtils.RemoveInstanceFromMatName(newMatList[i].name); + + bool skipMat = customMatNames.Contains(currentMatName); + if (!skipMat) + { + foreach (var material in loadedVanillaMats) + { + if (MaterialUtils.RemoveInstanceFromMatName(material.name).Equals(currentMatName)) + { + newMatList[i] = material; + skipMat = true; + break; + } + } + } + + if (skipMat) + continue; + + var taskResult = new TaskResult(); + yield return FetchMaterial(currentMatName, taskResult); + + var foundMaterial = taskResult.value; + + if (foundMaterial == null) + { + customMatNames.Add(currentMatName); + continue; + } + + newMatList[i] = foundMaterial; + loadedVanillaMats.Add(foundMaterial); + } + + renderer.materials = newMatList; + } + } + + /// + /// Searches the library for the provided , and loads it from its path, if an entry for + /// it exists. If the material exists within the library, but fails to load, fetching the asset will be reattempted + /// until the limit is reached. + /// + /// The exact name of the material you wish to retrieve as seen in-game. Case-sensitive! + /// The to load the found material into. Otherwise, has it's value set to null. + /// + public static IEnumerator FetchMaterial(string materialName, IOut foundMaterial) + { + Material matResult = null; + + string filteredMatName = MaterialUtils.RemoveInstanceFromMatName(materialName); + string resourcePath = GetPathToMaterial(filteredMatName); + if (!resourcePath.IsNullOrWhiteSpace()) + { + int fetchAttempts = 0; + do + { + if (fetchAttempts >= MaxFetchAttempts) + { + InternalLogger.Error($"Max retries limit reached when trying to fetch material: {materialName}."); + InternalLogger.Error("Please ensure the material's path is valid, or up the maximum # of retries."); + yield break; + } + + fetchAttempts++; + InternalLogger.Debug($"Attempting to grab material: {materialName}..."); + + var taskResult = new TaskResult(); + + if (resourcePath.EndsWith(".mat")) + yield return GetMaterialFromPath(resourcePath, taskResult); + else if (resourcePath.EndsWith(".prefab")) + yield return GetMaterialFromPrefab(filteredMatName, resourcePath, taskResult); + else if (resourcePath.StartsWith("LightmappedPrefabs/")) + yield return GetMaterialFromScene(filteredMatName, resourcePath.Substring(resourcePath.IndexOf('/') + 1), taskResult); + else + { + InternalLogger.Error($"Invalid path provided for material: {filteredMatName}"); + break; + } + + matResult = taskResult.value; + } while (matResult == null); + } + + foundMaterial.Set(matResult); + } + + /// + /// Loads and returns a material using its path relative to the . + /// NOTE: The provided .mat file will occasionally fail to load, resulting in 's value + /// being null after this method is finished running. It is not currently known what causes this, but it + /// does not happen constantly, and will only occur a handful of times in a row, if it does at all. As such, + /// the best solution for this problem, for the time being, is simply to try calling this method again, until + /// a successful result is retrieved. + /// + /// The path to the .mat file, relative to the . + /// The to load the found material into. Otherwise, has it's value set to null. + /// + private static IEnumerator GetMaterialFromPath(string matPath, IOut matResult) + { + matResult.Set(null); + + if (!matPath.EndsWith(".mat")) + { + InternalLogger.Error($"{matPath} is not a valid path to a material file."); + yield break; + } + + var handle = AddressablesUtility.LoadAsync(matPath); + + yield return handle.Task; + + matResult.Set(handle.Result); + } + + /// + /// Finds and returns a material by first loading an associated Prefab, given that prefab's path, and the desired + /// material's name. Iterates over every on the Prefab's parent object, and any of its + /// children objects, in order to find the material requested. + /// + /// The name of the material to search for. + /// The path to the reference Prefab, for use in the . + /// The to load the found material into. Otherwise, has it's value set to null. + /// + private static IEnumerator GetMaterialFromPrefab(string matName, string prefabPath, IOut matResult) + { + matResult.Set(null); + + if (!prefabPath.EndsWith(".prefab")) + { + InternalLogger.Error($"{prefabPath} is not a valid path to a prefab file."); + yield break; + } + + var task = PrefabDatabase.GetPrefabForFilenameAsync(prefabPath); + yield return task; + + if (!task.TryGetPrefab(out var prefab)) + { + InternalLogger.Error($"Failed to get prefab at path {prefabPath} from PrefabDatabase."); + yield break; + } + + foreach (var renderer in prefab.GetAllComponentsInChildren()) + { + foreach (var material in renderer.materials) + { + if (material == null) + continue; + + if (MaterialUtils.RemoveInstanceFromMatName(material.name).Equals(matName)) + { + matResult.Set(material); + yield break; + } + } + } + + InternalLogger.Error($"Failed to find material: {matName} on prefab at path: {prefabPath}"); + } + + /// + /// Finds and returns a material with the specified , by searching through a scene prefab, + /// loaded using the given . NOTE: This method won't be able to provide a material result + /// until the specified Scene is loaded via the . + /// + /// The name of the material to search the scene prefab for. + /// The name of the additive scene prefab to load and iterate through for the desired material. + /// The to load the found material into. Otherwise, has it's value set to null. + /// + private static IEnumerator GetMaterialFromScene(string matName, string sceneName, IOut matResult) + { + matResult.Set(null); + + if (!AddressablesUtility.IsAddressableScene(sceneName)) + { + InternalLogger.Error($"Attempted to get a material from invalid scene: {sceneName}"); + yield break; + } + + yield return new WaitUntil(() => LightmappedPrefabs.main); + + bool materialSet = false; + bool matCheckFailed = false; + LightmappedPrefabs.main.RequestScenePrefab(sceneName, scenePrefab => + { + foreach (var renderer in scenePrefab.GetAllComponentsInChildren()) + { + foreach (var material in renderer.materials) + { + if (material == null) + continue; + + if (MaterialUtils.RemoveInstanceFromMatName(material.name).Equals(matName)) + { + matResult.Set(material); + materialSet = true; + return; + } + } + } + + matCheckFailed = true; + }); + + yield return new WaitUntil(() => materialSet || matCheckFailed); + } + + /// + /// Uses the to access the MatFilePathMaps, and retrieve the path associated with + /// a specified material using the , so that it may be loaded when requested. + /// + /// The name of the material whose path is being requested. + /// The path to the resource which should be loaded in order to retrieve the specified material. + /// Points to either a mat, prefab, or scene prefab file. Returns an empty string if the provided + /// does not have an entry within the library. + private static string GetPathToMaterial(string materialName) + { + if (_resourceManager == null) + { + InternalLogger.Error("Tried to get material path from library while ResourceManager is null. Please initialize first!"); + return String.Empty; + } + + return _resourceManager.GetString(ConvertNameToKey(materialName)); + } + + /// + /// Converts the name of a material to the MatFilePathMap key equivalent. This approach is necessary because files + /// with the .resources extension do not allow entries with duplicate text to exist, even if the text entries + /// have different capitalization from one another. The MatFilePathMap contains many entries with the same name, + /// however, and the only way to differentiate them from one another is by preserving their casing. To get around + /// this issue, keys within the MatFilePathMap are made to be lowercased versions of the original mat name, with + /// the number of lowercase and uppercase characters in the original name preserved by being appended to the end + /// of the key version of the name. I.e. RawTitanium -> rawtitanium_lc9_uc2 + /// + /// The name of the base-game material being searched for. + /// The MatFilePathMap key version of the given material name. + private static string ConvertNameToKey(string matName) + { + var characters = matName.ToCharArray(); + + int upperCaseLetters = 0; + int lowerCaseLetters = 0; + for (int i = 0; i < characters.Length; i++) + { + if (char.IsUpper(characters[i])) + upperCaseLetters++; + else + lowerCaseLetters++; + } + + return matName.ToLower() + "_lc" + lowerCaseLetters + "_uc" + upperCaseLetters; + } + +} \ No newline at end of file diff --git a/Nautilus/Utility/MaterialUtils.cs b/Nautilus/Utility/MaterialUtils.cs index a870eaf1..bf2943b0 100644 --- a/Nautilus/Utility/MaterialUtils.cs +++ b/Nautilus/Utility/MaterialUtils.cs @@ -333,6 +333,29 @@ public static void SetMaterialCutout(Material material, bool cutout) } } + /// + /// Removes the (Instance) text from the end of a material's name, recursively. Primarily helpful for programmatic + /// material name comparisons. + /// + /// The material name from which to remove the Instance string. + /// The altered material name if any trailing (Instance) strings were found, otherwise the uneffected + /// matName. + public static string RemoveInstanceFromMatName(string matName) + { + string returnValue = matName; + string instanceString = " (Instance)"; + + // We avoid using .Replace() here to account for users possibly including the instance string at the beginning + // Or in the middle of their material names. Not likely to happen, but better safe than sorry. + if (matName.EndsWith(instanceString)) + { + returnValue = matName.Substring(0, matName.Length - instanceString.Length); + return RemoveInstanceFromMatName(returnValue); + } + + return returnValue; + } + private static void OnSceneLoaded(Scene scene, LoadSceneMode mode) { if (scene.name != "MenuEnvironment") return;