diff --git a/GameData/KSPCommunityFixes/Localization/en-us.cfg b/GameData/KSPCommunityFixes/Localization/en-us.cfg index 32ab646c..64f41a17 100644 --- a/GameData/KSPCommunityFixes/Localization/en-us.cfg +++ b/GameData/KSPCommunityFixes/Localization/en-us.cfg @@ -39,6 +39,14 @@ Localization #KSPCF_KSPCFFastLoader_PopupL4 = You can change this setting later in the in-game settings menu #KSPCF_KSPCFFastLoader_PopupL5 = Do you want to enable this optimization ? + // TextureStreaming + + #KSPCF_TextureStreaming_StreamingEnabledTitle = Texture streaming + #KSPCF_TextureStreaming_StreamingEnabledTooltip = Stream part textures from files on disk as they are needed. Ensures VRAM usage for part and IVA textures stays within the budget that you set. + #KSPCF_TextureStreaming_StreamingBudgetTitle = Streaming memory budget (MB) + #KSPCF_TextureStreaming_StreamingBudgetTooltip = The amount of memory that is allocated to store streaming textures. Unity will automatically load and unload parts of textures to stay under this limit. + #KSPCF_TextureStreaming_F_StreamingBudgetValue = <<1>> MB + // LowerMinPhysicsDTPerFrame #KSPCF_LowerMinPhysicsDTPerFrame_SettingsTooltip = How the game handle lag in CPU bound situations.\nMostly relevant with large part count vessels.\n\nLower value :\nHigher and smoother FPS, but game time might advance slower than real time.\n\nHigher value :\nLower and choppier FPS, but game time will advance closer to real time. diff --git a/GameData/KSPCommunityFixes/Settings.cfg b/GameData/KSPCommunityFixes/Settings.cfg index 1fb9b39c..0b1b6a97 100644 --- a/GameData/KSPCommunityFixes/Settings.cfg +++ b/GameData/KSPCommunityFixes/Settings.cfg @@ -558,6 +558,10 @@ KSP_COMMUNITY_FIXES // completes. Significantly reduces stutter when a ship with cargo bays crashes. CargoBayPerf = true + // Stream part and IVA textures from disk on demand using Unity's mipmap streaming, keeping their + // VRAM footprint within a configurable budget. Can be toggled and tuned from the KSPCF in-game settings. + TextureStreaming = true + // ########################## // Modding // ########################## diff --git a/KSPCommunityFixes/Internal/PatchSettings.cs b/KSPCommunityFixes/Internal/PatchSettings.cs index 554625f9..8c18a195 100644 --- a/KSPCommunityFixes/Internal/PatchSettings.cs +++ b/KSPCommunityFixes/Internal/PatchSettings.cs @@ -19,6 +19,7 @@ class PatchSettings : BasePatch private static AltimeterHorizontalPosition altimeterPatch; private static DisableManeuverTool maneuverToolPatch; private static OptionalMakingHistoryDLCFeatures disableMHPatch; + private static TextureStreaming textureStreamingPatch; protected override void ApplyPatches() { @@ -38,6 +39,10 @@ protected override void ApplyPatches() if (disableMHPatch != null) entryCount++; + textureStreamingPatch = KSPCommunityFixes.GetPatchInstance(); + if (textureStreamingPatch != null) + entryCount += 2; + // NoIVA is always enabled entryCount++; } @@ -49,8 +54,10 @@ static void GameplaySettingsScreen_DrawMiniSettings_Postfix(ref DialogGUIBase[] int count = __result.Length; + // +1 for the KSPCF title box; entryCount already accounts for every added row (the streaming + // patch contributes 2, see ApplyPatches). DialogGUIBase[] modifiedResult = new DialogGUIBase[count + entryCount + 1]; - + for (int i = 0; i < count; i++) modifiedResult[i] = __result[i]; @@ -76,7 +83,7 @@ static void GameplaySettingsScreen_DrawMiniSettings_Postfix(ref DialogGUIBase[] if (maneuverToolPatch != null) { DialogGUIToggle toggle = new DialogGUIToggle(DisableManeuverTool.enableManeuverTool, - () => (!DisableManeuverTool.enableManeuverTool) + () => (!DisableManeuverTool.enableManeuverTool) ? Localizer.Format("#autoLOC_6001071") //"Disabled" : Localizer.Format("#autoLOC_6001072"), //"Enabled" DisableManeuverTool.OnToggleApp, 150f); @@ -91,7 +98,7 @@ static void GameplaySettingsScreen_DrawMiniSettings_Postfix(ref DialogGUIBase[] if (altimeterPatch != null) { - DialogGUISlider slider = new DialogGUISlider(() => AltimeterHorizontalPosition.altimeterPosition, 0f, 1f, wholeNumbers: false, 200f, 20f, delegate(float f) + DialogGUISlider slider = new DialogGUISlider(() => AltimeterHorizontalPosition.altimeterPosition, 0f, 1f, wholeNumbers: false, 200f, 20f, delegate (float f) { AltimeterHorizontalPosition.altimeterPosition = f; AltimeterHorizontalPosition.SetTopFramePosition(); @@ -112,6 +119,46 @@ static void GameplaySettingsScreen_DrawMiniSettings_Postfix(ref DialogGUIBase[] new DialogGUILabel(NoIVA.LOC_SettingsTitle, 150f), noIVAslider, valueLabel, new DialogGUIFlexibleSpace()); count++; + if (textureStreamingPatch != null) + { + DialogGUIToggle streamingToggle = new( + TextureStreaming.MipmapStreamingEnabled, + () => (!TextureStreaming.MipmapStreamingEnabled) + ? Localizer.Format("#autoLOC_6001071") //"Disabled" + : Localizer.Format("#autoLOC_6001072"), //"Enabled" + b => TextureStreaming.MipmapStreamingEnabled = b, 150f); + streamingToggle.tooltipText = TextureStreaming.LOC_StreamingEnabledTooltip; + + modifiedResult[count] = new DialogGUIHorizontalLayout( + TextAnchor.MiddleLeft, + new DialogGUILabel(TextureStreaming.LOC_StreamingEnabledTitle, 150f), + streamingToggle, + new DialogGUIFlexibleSpace()); + count++; + + // Streaming memory budget, in MB (0 .. total VRAM). Only interactable while streaming is on. + float budgetMax = Math.Max(1024, SystemInfo.graphicsMemorySize); + DialogGUISlider budgetSlider = new( + () => TextureStreaming.MipmapStreamingBudgetMb, + 0f, + budgetMax, + wholeNumbers: true, + 128f, + 20f, + budget => TextureStreaming.MipmapStreamingBudgetMb = (int)budget); + budgetSlider.tooltipText = TextureStreaming.LOC_StreamingBudgetTooltip; + budgetSlider.OptionInteractableCondition = () => TextureStreaming.MipmapStreamingEnabled; + DialogGUILabel budgetValue = new(() => Localizer.Format(TextureStreaming.LOC_F_StreamingBudgetValue, TextureStreaming.MipmapStreamingBudgetMb)); + + modifiedResult[count] = new DialogGUIHorizontalLayout( + TextAnchor.MiddleLeft, + new DialogGUILabel(TextureStreaming.LOC_StreamingBudgetTitle, 150f), + budgetSlider, + budgetValue, + new DialogGUIFlexibleSpace()); + count++; + } + __result = modifiedResult; } @@ -133,12 +180,20 @@ static void GameplaySettingsScreen_ApplySettings_Postfix() if (altimeterPatch != null) { - ConfigNode node = new ConfigNode(); + ConfigNode node = new(); node.AddValue(nameof(AltimeterHorizontalPosition.altimeterPosition), AltimeterHorizontalPosition.altimeterPosition); SaveData(node); } NoIVA.SaveSettings(); + + if (textureStreamingPatch != null) + { + ConfigNode streamingNode = new(); + streamingNode.AddValue(nameof(TextureStreaming.MipmapStreamingEnabled), TextureStreaming.MipmapStreamingEnabled); + streamingNode.AddValue(nameof(TextureStreaming.MipmapStreamingBudgetMb), TextureStreaming.MipmapStreamingBudgetMb); + SaveData(streamingNode); + } } } } diff --git a/KSPCommunityFixes/Library/Model/ModelInstructions.cs b/KSPCommunityFixes/Library/Model/ModelInstructions.cs index ae098e10..bfa24c7b 100644 --- a/KSPCommunityFixes/Library/Model/ModelInstructions.cs +++ b/KSPCommunityFixes/Library/Model/ModelInstructions.cs @@ -1,5 +1,6 @@ using System; using KSPCommunityFixes.Library; +using KSPCommunityFixes.Performance; using PartToolsLib; using UnityEngine; using UnityEngine.Rendering; @@ -167,7 +168,10 @@ public void Execute(UnityEngine.Object[] locals) if (tex.IsNullOrDestroyed()) Debug.LogError($"Texture '{t.Url}' not found!"); else + { mat.SetTexture(t.Name, tex); + KSPCFFastLoader.ReleaseToStreaming(tex); + } } } } diff --git a/KSPCommunityFixes/Library/TextureBundle/TextureBundleBuilder.cs b/KSPCommunityFixes/Library/TextureBundle/TextureBundleBuilder.cs index 7afc327a..8678a6e9 100644 --- a/KSPCommunityFixes/Library/TextureBundle/TextureBundleBuilder.cs +++ b/KSPCommunityFixes/Library/TextureBundle/TextureBundleBuilder.cs @@ -1,27 +1,14 @@ using System; using System.Collections.Generic; using System.IO; +using UnityEngine; namespace KSPCommunityFixes.Library.TextureBundle { /// - /// Builds a minimal UnityFS bundle wrapping a single streamed Texture2D and the - /// AssetBundle that references it, where the texture's pixel data lives in an existing - /// DDS file on disk. The generated bundle carries only ~1 KB of metadata: the texture's - /// m_StreamData.path is the absolute path of the DDS file and m_StreamData.offset - /// is its data offset, so Unity opens the file and streams the compressed pixels itself. Pure - /// CPU work; safe to call from a background thread. - /// - /// - /// The whole prefix is written into a single : the UnityFS - /// framing (), the serialized-file framing - /// () and the two object bodies written here by hand. The - /// bodies reproduce the exact field order, sizes and alignment padding of Unity 2019.4's own - /// layout — the same layout the embedded type tree encodes. - /// - /// Borrowed from KSPTextureLoader - /// (../KSPTextureLoader/src/KSPTextureLoader/Format/Bundle/TextureBundleBuilder.cs), stripped - /// to the classic Texture2D + external-file path. + /// A builder for UnityFS bundles that contain s. + /// The special bit is that these bundles refer to the actual contents of + /// the dds files on disk instead of storing them directly. /// internal static class TextureBundleBuilder { @@ -67,6 +54,8 @@ public sealed class TextureRequest /// Whether Unity should keep a CPU-side copy of the pixels. public bool Readable; + + public bool StreamingMipmaps; } /// The built bundle prefix plus the name to request from it. @@ -104,6 +93,8 @@ public readonly struct TextureEntry public readonly bool Readable; + public readonly bool StreamingMipmaps; + /// Absolute path of the DDS file the pixels are streamed from. public readonly string ExternalPath; @@ -121,6 +112,7 @@ public TextureEntry( int format, int colorSpace, bool readable, + bool streamingMipmaps, string externalPath, long externalOffset, long pixelsLength) @@ -132,6 +124,7 @@ public TextureEntry( Format = format; ColorSpace = colorSpace; Readable = readable; + StreamingMipmaps = streamingMipmaps; ExternalPath = externalPath; ExternalOffset = externalOffset; PixelsLength = pixelsLength; @@ -271,6 +264,7 @@ public static byte[] BuildMany( Format = e.Format, ColorSpace = e.ColorSpace, Readable = e.Readable, + StreamingMipmaps = e.StreamingMipmaps, }; file.BeginObject(w, ref slots[i + 1]); @@ -331,7 +325,7 @@ long streamSize w.WriteBool(req.Readable); // m_IsReadable w.WriteBool(false); // m_IgnoreMasterTextureLimit w.WriteBool(false); // m_IsPreProcessed - w.WriteBool(false); // m_StreamingMipmaps + w.WriteBool(req.StreamingMipmaps); // m_StreamingMipmaps w.Align(4); w.WriteInt32(0); // m_StreamingMipmapsPriority w.Align(4); diff --git a/KSPCommunityFixes/Performance/FastLoader.cs b/KSPCommunityFixes/Performance/FastLoader.cs index 4d372353..fe16e873 100644 --- a/KSPCommunityFixes/Performance/FastLoader.cs +++ b/KSPCommunityFixes/Performance/FastLoader.cs @@ -234,6 +234,17 @@ private void Awake() assetAndPartLoaderHarmony.Patch(m_PartLoader_StartLoad, null, null, new HarmonyMethod(t_PartLoader_StartLoad)); PatchStartCoroutineInCoroutine(AccessTools.Method(typeof(PartLoader), nameof(PartLoader.CompileParts))); + + // These enable texture streaming for any texture used in a model, + // for a part, or for an IVA. + MethodInfo m_PartLoader_ReplaceTextures = AccessTools.Method(typeof(PartLoader), "ReplaceTextures"); + MethodInfo po_PartLoader_ReplaceTextures = AccessTools.Method(typeof(KSPCFFastLoader), nameof(PartLoader_ReplaceTextures_Postfix)); + assetAndPartLoaderHarmony.Patch(m_PartLoader_ReplaceTextures, null, new HarmonyMethod(po_PartLoader_ReplaceTextures)); + + MethodInfo m_PartLoader_ReplacePartTexture = AccessTools.Method(typeof(PartLoader), "ReplacePartTexture"); + MethodInfo po_PartLoader_ReplacePartTexture = AccessTools.Method(typeof(KSPCFFastLoader), nameof(PartLoader_ReplacePartTexture_Postfix)); + assetAndPartLoaderHarmony.Patch(m_PartLoader_ReplacePartTexture, null, new HarmonyMethod(po_PartLoader_ReplacePartTexture)); + PatchStartCoroutineInCoroutine(AccessTools.Method(typeof(DragCubeSystem), nameof(DragCubeSystem.SetupDragCubeCoroutine), new[] { typeof(Part) })); PatchStartCoroutineInCoroutine(AccessTools.Method(typeof(DragCubeSystem), nameof(DragCubeSystem.RenderDragCubesCoroutine))); @@ -1149,6 +1160,7 @@ private static BundleClassification ClassifyBundleRequest(TextureLoadRequest req req.File.url, hdr.Width, hdr.Height, hdr.MipCount, hdr.ClassicTextureFormat, hdr.ColorSpace, readable: false, + EligibleForStreaming(hdr), sourcePath, hdr.DataOffset, hdr.StreamedSize); return new BundleClassification(entry, new BundleItem { Request = req, IsNormalMap = isNormalMap }); } @@ -1269,6 +1281,11 @@ private static IEnumerator InsertBundledTextures( { req.Result = new TextureInfo(req.File, tex, item.IsNormalMap, isReadable: false, isCompressed: true); req.Status = TextureLoadRequest.State.Ready; + + // Disable mipmap streaming if enabled. We'll re-enable it on a case-by-case + // basis when textures are used in parts or models. + if (tex.streamingMipmaps) + tex.requestedMipmapLevel = 0; } else { @@ -1286,6 +1303,41 @@ private static IEnumerator InsertBundledTextures( } #endregion + #region Mipmap streaming + + /// + /// Enable mipmpa streaming for . + /// + /// + internal static void ReleaseToStreaming(Texture tex) + { + if (tex is Texture2D t2d && t2d.streamingMipmaps) + t2d.ClearRequestedMipmapLevel(); + } + + private static void PartLoader_ReplaceTextures_Postfix(List newTextures) + { + if (newTextures == null) + return; + + for (int i = 0; i < newTextures.Count; i++) + { + TextureInfo ti = newTextures[i]; + if (ti == null) + continue; + ReleaseToStreaming(ti.texture); + ReleaseToStreaming(ti.normalMap); + } + } + + private static void PartLoader_ReplacePartTexture_Postfix(UrlConfig urlConfig, string textureName, bool normalMap) + { + string url = urlConfig.parent.parent.url + "/textures/" + Path.GetFileNameWithoutExtension(textureName); + ReleaseToStreaming(GameDatabase.Instance.GetTexture(url, normalMap)); + } + + #endregion + #region Model bundle loader private readonly struct QueueWriteGuard(BlockingCollection queue) : IDisposable @@ -1472,6 +1524,8 @@ AssetBundleCreateRequest bundleRequest yield break; } + group.Bundle = bundle; + var request = bundle.LoadAllAssetsAsync(); request.priority = -100; yield return request; @@ -2404,6 +2458,19 @@ private static bool IsBlockAligned(GraphicsFormat format, int width, int height) return width % blockWidth == 0 && height % blockHeight == 0; } + // Textures smaller than 4KB are too small to be worth streaming. + private const long MinStreamingBytes = 4 * 1024; + + // Attempting to load a compressed mipmap whose size is not a multiple of the + // DDS block size (4x4) causes unity to crash. To avoid this we need to + // only include textures whose mipmaps are the correct size. + private static bool EligibleForStreaming(in DDSPreparedHeader hdr) => + hdr.StreamedSize >= MinStreamingBytes + && IsBlockAligned( + hdr.Format, + Math.Max(1, hdr.Width >> TextureStreaming.MaxStreamingMipReduction), + Math.Max(1, hdr.Height >> TextureStreaming.MaxStreamingMipReduction)); + // The number of mip levels Unity allocates for a full mip chain. private static int ComputeMipCount(int width, int height) { @@ -3718,10 +3785,14 @@ private static void SetOptIn(bool optIn, ref bool? choosed) loader.userOptInChoiceDone = true; textureCacheEnabled = optIn; choosed = true; + SaveConfig(); + } + private static void SaveConfig() + { ConfigNode config = new ConfigNode(); - config.AddValue(nameof(userOptInChoiceDone), true); - config.AddValue(nameof(textureCacheEnabled), optIn); + config.AddValue(nameof(userOptInChoiceDone), loader.IsNotNullOrDestroyed() && loader.userOptInChoiceDone); + config.AddValue(nameof(textureCacheEnabled), textureCacheEnabled); string pluginDataPath = Path.Combine(ModPath, "PluginData"); if (!Directory.Exists(pluginDataPath)) diff --git a/KSPCommunityFixes/Performance/TextureStreaming.cs b/KSPCommunityFixes/Performance/TextureStreaming.cs new file mode 100644 index 00000000..fb2a766a --- /dev/null +++ b/KSPCommunityFixes/Performance/TextureStreaming.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections; +using UnityEngine; + +namespace KSPCommunityFixes.Performance; + +internal class TextureStreaming : BasePatch +{ + /// + /// Whether mipmap streaming is enabled. + /// + internal static bool MipmapStreamingEnabled = true; + + // The minimum amount of memory reserved for streaming, in megabytes. + internal static int MipmapStreamingBudgetMb = 1024; + + // The maximum number of mipmap levels that we allow streaing to unload. + // + // Note that this also affects which textures are eligible for mipmap streaming. + // Unity will crash if it attempts to load a compressed texture whose size is + // not a multiple of the block size, so the texture loader will disable streaming + // for any texture whose mipmaps match that. + // + // The value of 3 is meant to be a balance between memory improvements (64x less + // memory for a fully streamed out texture) and allowing more textures to be + // streamed. + internal const int MaxStreamingMipReduction = 3; + + internal static string LOC_StreamingEnabledTitle = "Texture streaming"; + internal static string LOC_StreamingEnabledTooltip = + "Stream part textures from files on disk as they are needed. " + + "Ensures VRAM usage for part and IVA textures stays within the " + + "budget that you set."; + internal static string LOC_StreamingBudgetTitle = "Streaming memory budget (MB)"; + internal static string LOC_StreamingBudgetTooltip = + "The amount of memory that is allocated to store streaming textures. " + + "Unity will automatically load and unload parts of textures to stay " + + "under this limit."; + // Format string for the budget slider's value readout; <<1>> is the megabyte value. + internal static string LOC_F_StreamingBudgetValue = "<<1>> MB"; + + protected override Version VersionMin => new(1, 12, 3); + + protected override void ApplyPatches() { } + + protected override void OnLoadData(ConfigNode node) + { + node.TryGetValue(nameof(MipmapStreamingEnabled), ref MipmapStreamingEnabled); + node.TryGetValue(nameof(MipmapStreamingBudgetMb), ref MipmapStreamingBudgetMb); + } + + protected override void OnPatchApplied() + { + if (!KSPCFFastLoader.IsPatchEnabled) + return; + + var go = new GameObject(); + go.AddComponent(); + + QualitySettings.streamingMipmapsActive = MipmapStreamingEnabled; + } + + internal void OnSettingsUpdated() + { + QualitySettings.streamingMipmapsActive = MipmapStreamingEnabled; + } +} + +internal class TextureStreamingController : MonoBehaviour +{ + void Awake() + { + DontDestroyOnLoad(this); + + QualitySettings.streamingMipmapsAddAllCameras = true; + QualitySettings.streamingMipmapsMaxLevelReduction = TextureStreaming.MaxStreamingMipReduction; + } + + void Start() + { + StartCoroutine(UpdateMemoryLimitCoroutine()); + } + + static readonly WaitForSecondsRealtime WaitForHalfSecond = new(0.5f); + static readonly WaitForEndOfFrame WaitForEndOfFrame = new(); + + IEnumerator UpdateMemoryLimitCoroutine() + { + const ulong MB = 1024 * 1024; + + while (true) + { + yield return WaitForHalfSecond; + yield return WaitForEndOfFrame; + + if (QualitySettings.streamingMipmapsActive) + { + ulong totalGraphicsMemory = (ulong)SystemInfo.graphicsMemorySize * 4 / 5; + ulong totalTextureMemory = Texture.nonStreamingTextureMemory; + ulong requestedMemory = totalTextureMemory + (ulong)TextureStreaming.MipmapStreamingBudgetMb * MB; + + // Always request at least the configured budget, but if there is more + // memory available then we might as well allow unity to use it. + // + // We do have to deal with other mods loading large textures on-demand + // so it doesn't really work to just set a fixed budget. + QualitySettings.streamingMipmapsMemoryBudget = Math.Max( + (requestedMemory + (MB - 1)) / MB, + totalGraphicsMemory); + } + else if (QualitySettings.streamingMipmapsMemoryBudget != 0) + { + QualitySettings.streamingMipmapsMemoryBudget = 0; + } + } + } +}