From c8252605a847a782ebfaf192dc97c463f4c0dcbe Mon Sep 17 00:00:00 2001 From: Phantomical Date: Wed, 15 Jul 2026 14:32:37 -0700 Subject: [PATCH 1/6] Add mipmap streaming for part textures Mipmap streaming (safety valve): bake m_StreamingMipmaps into eligible bundle textures, enable QualitySettings.streamingMipmaps* sized to a fraction of VRAM, pin every streaming texture full-res on load, then release mesh/part textures at their bind sites (ModelInstructions plus two PartLoader postfixes) so only renderer-owned textures drop mips under real pressure. Eligibility gates on >=4KB mip chain and mip-level block alignment under the deepest reduction. --- .../Library/Model/ModelInstructions.cs | 6 + .../TextureBundle/TextureBundleBuilder.cs | 19 ++- KSPCommunityFixes/Performance/FastLoader.cs | 124 ++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) diff --git a/KSPCommunityFixes/Library/Model/ModelInstructions.cs b/KSPCommunityFixes/Library/Model/ModelInstructions.cs index ae098e1..f5b0f57 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,12 @@ public void Execute(UnityEngine.Object[] locals) if (tex.IsNullOrDestroyed()) Debug.LogError($"Texture '{t.Url}' not found!"); else + { mat.SetTexture(t.Name, tex); + // This texture is used by rendered geometry: release it from the load-time full-res + // pin so Unity's mipmap streaming can manage its residency (no-op when disabled). + KSPCFFastLoader.ReleaseToStreaming(tex); + } } } } diff --git a/KSPCommunityFixes/Library/TextureBundle/TextureBundleBuilder.cs b/KSPCommunityFixes/Library/TextureBundle/TextureBundleBuilder.cs index 7afc327..e9d2f97 100644 --- a/KSPCommunityFixes/Library/TextureBundle/TextureBundleBuilder.cs +++ b/KSPCommunityFixes/Library/TextureBundle/TextureBundleBuilder.cs @@ -67,6 +67,11 @@ public sealed class TextureRequest /// Whether Unity should keep a CPU-side copy of the pixels. public bool Readable; + + /// Serialized m_StreamingMipmaps. When true, the texture joins Unity's + /// mipmap streaming system. The caller decides eligibility (only set it when the mip + /// levels stay block aligned under the deepest streaming reduction). + public bool StreamingMipmaps; } /// The built bundle prefix plus the name to request from it. @@ -104,6 +109,10 @@ public readonly struct TextureEntry public readonly bool Readable; + /// Serialized m_StreamingMipmaps: whether this texture joins Unity's + /// mipmap streaming system. + public readonly bool StreamingMipmaps; + /// Absolute path of the DDS file the pixels are streamed from. public readonly string ExternalPath; @@ -121,6 +130,7 @@ public TextureEntry( int format, int colorSpace, bool readable, + bool streamingMipmaps, string externalPath, long externalOffset, long pixelsLength) @@ -132,6 +142,7 @@ public TextureEntry( Format = format; ColorSpace = colorSpace; Readable = readable; + StreamingMipmaps = streamingMipmaps; ExternalPath = externalPath; ExternalOffset = externalOffset; PixelsLength = pixelsLength; @@ -271,6 +282,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 +343,12 @@ long streamSize w.WriteBool(req.Readable); // m_IsReadable w.WriteBool(false); // m_IgnoreMasterTextureLimit w.WriteBool(false); // m_IsPreProcessed - w.WriteBool(false); // m_StreamingMipmaps + // Opt this texture into Unity's mipmap streaming manager only when the caller marked it + // eligible (its mip levels stay block aligned under the deepest streaming reduction, so the + // reduced base mip can still be uploaded). The feature is also gated globally by + // QualitySettings.streamingMipmapsActive (see KSPCFFastLoader.ApplyStreamingQualitySettings), + // and each realized streaming texture is pinned full-res until a model/part binds it. + 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 4d37235..a591d3b 100644 --- a/KSPCommunityFixes/Performance/FastLoader.cs +++ b/KSPCommunityFixes/Performance/FastLoader.cs @@ -176,6 +176,16 @@ internal class KSPCFFastLoader : MonoBehaviour // Vestigial: kept so the popup can persist its choice across launches once it is repurposed. private static bool textureCacheEnabled; + // Mipmap streaming for part/mesh textures. Bundle textures are baked with m_StreamingMipmaps=true + // (see TextureBundleBuilder), pinned full-res on load, then released to the streaming manager at + // the point a model material or a part texture-replacement binds them. Gated globally by this + // toggle (read early from PluginData config in Awake, before KSPCommunityFixes.SettingsNode exists). + internal static bool mipmapStreamingEnabled = true; + + // Fraction of total VRAM handed to the streaming memory budget. Leaves headroom for framebuffers + // and other VRAM consumers (Deferred, Scatterer, EVE) so streaming only drops mips under real pressure. + private const float StreamingBudgetFraction = 0.8f; + private static string ModPath => Path.GetDirectoryName(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)); private static string ConfigPath => Path.Combine(ModPath, "PluginData", "PNGTextureCache.cfg"); @@ -234,6 +244,18 @@ private void Awake() assetAndPartLoaderHarmony.Patch(m_PartLoader_StartLoad, null, null, new HarmonyMethod(t_PartLoader_StartLoad)); PatchStartCoroutineInCoroutine(AccessTools.Method(typeof(PartLoader), nameof(PartLoader.CompileParts))); + + // Release part-compilation texture replacements to the mipmap streaming manager at their bind + // sites (gated at call time on mipmapStreamingEnabled). These are the only two texture-assignment + // points in stock part compilation. + 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))); @@ -260,7 +282,17 @@ private void Awake() if (!config.TryGetValue(nameof(textureCacheEnabled), ref textureCacheEnabled)) userOptInChoiceDone = false; + + // Optional opt-out for mipmap streaming (default ON). Read here — the load-phase streaming + // setup runs long before KSPCommunityFixes.SettingsNode is populated. + config.TryGetValue(nameof(mipmapStreamingEnabled), ref mipmapStreamingEnabled); } + + // TEMPORARY: force the texture cache on and skip the opt-in popup while iterating on the + // mipmap-streaming work (DeployToKSP wipes the cfg each build, so the popup blocks unattended + // loads). Revert to the config-driven / popup behavior before shipping. + userOptInChoiceDone = true; + textureCacheEnabled = true; } /// @@ -563,6 +595,11 @@ static IEnumerator FastAssetLoader(List configFileTypes) // Tune the AUP for much better throughput QualitySettings.asyncUploadTimeSlice = 25; QualitySettings.asyncUploadBufferSize = 256; + QualitySettings.streamingMipmapsMaxLevelReduction = MaxStreamingMipReduction; + + // Enable mipmap streaming before the bundle textures are realized so they register with the + // streaming manager on load. + ApplyStreamingQualitySettings(); int textureCount = bundleRequests.Count + textureQueue.Count; @@ -1149,6 +1186,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 +1307,12 @@ private static IEnumerator InsertBundledTextures( { req.Result = new TextureInfo(req.File, tex, item.IsNormalMap, isReadable: false, isCompressed: true); req.Status = TextureLoadRequest.State.Ready; + + // Pin every streaming texture full-res on load. It stays pinned (never streamed down) + // until a model material or a part texture-replacement releases it via ReleaseToStreaming; + // textures with no owning mesh (UI, icons, ...) therefore never blur. + if (mipmapStreamingEnabled && tex.streamingMipmaps) + tex.requestedMipmapLevel = 0; } else { @@ -1286,6 +1330,64 @@ private static IEnumerator InsertBundledTextures( } #endregion + #region Mipmap streaming + + // Enable Unity's mipmap streaming and size its budget to a fraction of total VRAM. These setters + // write to the currently-active quality level (GameSettings.QUALITY_PRESET). Called once, early in + // the asset-load phase, before the bundle textures are realized. If in-game testing shows KSP's + // SetQualityLevel wipes these, a fallback postfix on GameSettings.ApplySettings would re-apply them. + internal static void ApplyStreamingQualitySettings() + { + if (!mipmapStreamingEnabled) + return; + + QualitySettings.streamingMipmapsActive = true; + QualitySettings.streamingMipmapsAddAllCameras = true; + QualitySettings.streamingMipmapsMemoryBudget = SystemInfo.graphicsMemorySize * StreamingBudgetFraction; + } + + // Release a just-bound mesh/part texture back to automatic streaming, undoing the load-time full-res + // pin from InsertBundledTextures. Idempotent (ClearRequestedMipmapLevel is a no-op the second time and + // on non-streaming textures), so the shared database Texture2D can be released by whichever model or + // part binds it first, with no dedup bookkeeping. + internal static void ReleaseToStreaming(Texture tex) + { + if (mipmapStreamingEnabled && tex is Texture2D t2d && t2d.streamingMipmaps) + t2d.ClearRequestedMipmapLevel(); + } + + // Postfix on PartLoader.ReplaceTextures (MODEL-node "texture = orig, new" swaps): release each + // config-declared replacement texture. Releasing a declared-but-unmatched entry is harmless (idempotent + // + a texture with no owning renderer would only stream down when it is genuinely unused). + private static void PartLoader_ReplaceTextures_Postfix(List newTextures) + { + if (!mipmapStreamingEnabled || 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); + } + } + + // Postfix on PartLoader.ReplacePartTexture (part-level "texture"/"bump" field): the resolved texture is + // a local in the stock method, so recompute the URL from the parameters exactly as the method does and + // release the shared database texture (GetTexture is a dictionary lookup, fast-pathed by KSPCF). + private static void PartLoader_ReplacePartTexture_Postfix(UrlConfig urlConfig, string textureName, bool normalMap) + { + if (!mipmapStreamingEnabled) + return; + + 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 +1574,8 @@ AssetBundleCreateRequest bundleRequest yield break; } + group.Bundle = bundle; + var request = bundle.LoadAllAssetsAsync(); request.priority = -100; yield return request; @@ -2404,6 +2508,26 @@ private static bool IsBlockAligned(GraphicsFormat format, int width, int height) return width % blockWidth == 0 && height % blockHeight == 0; } + // Mipmap streaming reduces a texture's resident base mip by up to + // QualitySettings.streamingMipmapsMaxLevelReduction levels (we set that to this value; Unity's + // default is 2). The reduced mip becomes the texture's uploaded base level, and a block-compressed + // upload only works when that base is a whole number of blocks. So a texture may only join the + // streaming system when its mip level MaxStreamingMipReduction is still block aligned; otherwise the + // deepest reduction would upload a fractional-block base and corrupt. Uncompressed formats have + // a 1x1 block and always qualify. + private const int MaxStreamingMipReduction = 3; + + // Textures smaller than this (whole mip chain) aren't worth streaming: the VRAM they could + // free is negligible next to the per-texture bookkeeping the streaming manager keeps for them. + private const long MinStreamingBytes = 4 * 1024; + + private static bool EligibleForStreaming(in DDSPreparedHeader hdr) => + hdr.StreamedSize >= MinStreamingBytes + && IsBlockAligned( + hdr.Format, + Math.Max(1, hdr.Width >> MaxStreamingMipReduction), + Math.Max(1, hdr.Height >> MaxStreamingMipReduction)); + // The number of mip levels Unity allocates for a full mip chain. private static int ComputeMipCount(int width, int height) { From 9d7893421f56ec4c16e50fb644051c1cc31f564b Mon Sep 17 00:00:00 2001 From: Phantomical Date: Wed, 15 Jul 2026 23:15:32 -0700 Subject: [PATCH 2/6] Add appropriate settings for texture streaming --- .../KSPCommunityFixes/Localization/en-us.cfg | 8 ++ GameData/KSPCommunityFixes/Settings.cfg | 4 + KSPCommunityFixes/Internal/PatchSettings.cs | 55 +++++++- .../TextureBundle/TextureBundleBuilder.cs | 2 +- KSPCommunityFixes/Performance/FastLoader.cs | 79 ++++-------- .../Performance/TextureStreaming.cs | 117 ++++++++++++++++++ 6 files changed, 209 insertions(+), 56 deletions(-) create mode 100644 KSPCommunityFixes/Performance/TextureStreaming.cs diff --git a/GameData/KSPCommunityFixes/Localization/en-us.cfg b/GameData/KSPCommunityFixes/Localization/en-us.cfg index 32ab646..64f41a1 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 1fb9b39..0b1b6a9 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 554625f..7e4fd47 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,6 +54,8 @@ 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++) @@ -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,18 @@ 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(); + + ConfigNode streamingNode = new(); + streamingNode.AddValue(nameof(TextureStreaming.MipmapStreamingEnabled), TextureStreaming.MipmapStreamingEnabled); + streamingNode.AddValue(nameof(TextureStreaming.MipmapStreamingBudgetMb), TextureStreaming.MipmapStreamingBudgetMb); + SaveData(streamingNode); + } } } diff --git a/KSPCommunityFixes/Library/TextureBundle/TextureBundleBuilder.cs b/KSPCommunityFixes/Library/TextureBundle/TextureBundleBuilder.cs index e9d2f97..47874ad 100644 --- a/KSPCommunityFixes/Library/TextureBundle/TextureBundleBuilder.cs +++ b/KSPCommunityFixes/Library/TextureBundle/TextureBundleBuilder.cs @@ -346,7 +346,7 @@ long streamSize // Opt this texture into Unity's mipmap streaming manager only when the caller marked it // eligible (its mip levels stay block aligned under the deepest streaming reduction, so the // reduced base mip can still be uploaded). The feature is also gated globally by - // QualitySettings.streamingMipmapsActive (see KSPCFFastLoader.ApplyStreamingQualitySettings), + // QualitySettings.streamingMipmapsActive (see TextureStreaming.ApplyStreamingQualitySettings), // and each realized streaming texture is pinned full-res until a model/part binds it. w.WriteBool(req.StreamingMipmaps); // m_StreamingMipmaps w.Align(4); diff --git a/KSPCommunityFixes/Performance/FastLoader.cs b/KSPCommunityFixes/Performance/FastLoader.cs index a591d3b..a08b204 100644 --- a/KSPCommunityFixes/Performance/FastLoader.cs +++ b/KSPCommunityFixes/Performance/FastLoader.cs @@ -176,15 +176,9 @@ internal class KSPCFFastLoader : MonoBehaviour // Vestigial: kept so the popup can persist its choice across launches once it is repurposed. private static bool textureCacheEnabled; - // Mipmap streaming for part/mesh textures. Bundle textures are baked with m_StreamingMipmaps=true - // (see TextureBundleBuilder), pinned full-res on load, then released to the streaming manager at - // the point a model material or a part texture-replacement binds them. Gated globally by this - // toggle (read early from PluginData config in Awake, before KSPCommunityFixes.SettingsNode exists). - internal static bool mipmapStreamingEnabled = true; - - // Fraction of total VRAM handed to the streaming memory budget. Leaves headroom for framebuffers - // and other VRAM consumers (Deferred, Scatterer, EVE) so streaming only drops mips under real pressure. - private const float StreamingBudgetFraction = 0.8f; + // Mipmap streaming for part/mesh textures. The load-time plumbing lives here (bake eligibility, the + // full-res pin, and the two PartLoader release postfixes registered in Awake); the runtime settings and + // QualitySettings application live in TextureStreaming. private static string ModPath => Path.GetDirectoryName(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)); private static string ConfigPath => Path.Combine(ModPath, "PluginData", "PNGTextureCache.cfg"); @@ -246,8 +240,7 @@ private void Awake() PatchStartCoroutineInCoroutine(AccessTools.Method(typeof(PartLoader), nameof(PartLoader.CompileParts))); // Release part-compilation texture replacements to the mipmap streaming manager at their bind - // sites (gated at call time on mipmapStreamingEnabled). These are the only two texture-assignment - // points in stock part compilation. + // sites. These are the only two texture-assignment points in stock part compilation. 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)); @@ -282,10 +275,6 @@ private void Awake() if (!config.TryGetValue(nameof(textureCacheEnabled), ref textureCacheEnabled)) userOptInChoiceDone = false; - - // Optional opt-out for mipmap streaming (default ON). Read here — the load-phase streaming - // setup runs long before KSPCommunityFixes.SettingsNode is populated. - config.TryGetValue(nameof(mipmapStreamingEnabled), ref mipmapStreamingEnabled); } // TEMPORARY: force the texture cache on and skip the opt-in popup while iterating on the @@ -595,11 +584,6 @@ static IEnumerator FastAssetLoader(List configFileTypes) // Tune the AUP for much better throughput QualitySettings.asyncUploadTimeSlice = 25; QualitySettings.asyncUploadBufferSize = 256; - QualitySettings.streamingMipmapsMaxLevelReduction = MaxStreamingMipReduction; - - // Enable mipmap streaming before the bundle textures are realized so they register with the - // streaming manager on load. - ApplyStreamingQualitySettings(); int textureCount = bundleRequests.Count + textureQueue.Count; @@ -1310,8 +1294,9 @@ private static IEnumerator InsertBundledTextures( // Pin every streaming texture full-res on load. It stays pinned (never streamed down) // until a model material or a part texture-replacement releases it via ReleaseToStreaming; - // textures with no owning mesh (UI, icons, ...) therefore never blur. - if (mipmapStreamingEnabled && tex.streamingMipmaps) + // textures with no owning mesh (UI, icons, ...) therefore never blur. The pin is latent + // until streamingMipmapsActive is turned on (after MM patching, see PatchSettings). + if (tex.streamingMipmaps) tex.requestedMipmapLevel = 0; } else @@ -1332,27 +1317,13 @@ private static IEnumerator InsertBundledTextures( #region Mipmap streaming - // Enable Unity's mipmap streaming and size its budget to a fraction of total VRAM. These setters - // write to the currently-active quality level (GameSettings.QUALITY_PRESET). Called once, early in - // the asset-load phase, before the bundle textures are realized. If in-game testing shows KSP's - // SetQualityLevel wipes these, a fallback postfix on GameSettings.ApplySettings would re-apply them. - internal static void ApplyStreamingQualitySettings() - { - if (!mipmapStreamingEnabled) - return; - - QualitySettings.streamingMipmapsActive = true; - QualitySettings.streamingMipmapsAddAllCameras = true; - QualitySettings.streamingMipmapsMemoryBudget = SystemInfo.graphicsMemorySize * StreamingBudgetFraction; - } - // Release a just-bound mesh/part texture back to automatic streaming, undoing the load-time full-res // pin from InsertBundledTextures. Idempotent (ClearRequestedMipmapLevel is a no-op the second time and // on non-streaming textures), so the shared database Texture2D can be released by whichever model or // part binds it first, with no dedup bookkeeping. internal static void ReleaseToStreaming(Texture tex) { - if (mipmapStreamingEnabled && tex is Texture2D t2d && t2d.streamingMipmaps) + if (tex is Texture2D t2d && t2d.streamingMipmaps) t2d.ClearRequestedMipmapLevel(); } @@ -1361,7 +1332,7 @@ internal static void ReleaseToStreaming(Texture tex) // + a texture with no owning renderer would only stream down when it is genuinely unused). private static void PartLoader_ReplaceTextures_Postfix(List newTextures) { - if (!mipmapStreamingEnabled || newTextures == null) + if (newTextures == null) return; for (int i = 0; i < newTextures.Count; i++) @@ -1379,9 +1350,6 @@ private static void PartLoader_ReplaceTextures_Postfix(List newText // release the shared database texture (GetTexture is a dictionary lookup, fast-pathed by KSPCF). private static void PartLoader_ReplacePartTexture_Postfix(UrlConfig urlConfig, string textureName, bool normalMap) { - if (!mipmapStreamingEnabled) - return; - string url = urlConfig.parent.parent.url + "/textures/" + Path.GetFileNameWithoutExtension(textureName); ReleaseToStreaming(GameDatabase.Instance.GetTexture(url, normalMap)); } @@ -2508,25 +2476,21 @@ private static bool IsBlockAligned(GraphicsFormat format, int width, int height) return width % blockWidth == 0 && height % blockHeight == 0; } - // Mipmap streaming reduces a texture's resident base mip by up to - // QualitySettings.streamingMipmapsMaxLevelReduction levels (we set that to this value; Unity's - // default is 2). The reduced mip becomes the texture's uploaded base level, and a block-compressed - // upload only works when that base is a whole number of blocks. So a texture may only join the - // streaming system when its mip level MaxStreamingMipReduction is still block aligned; otherwise the - // deepest reduction would upload a fractional-block base and corrupt. Uncompressed formats have - // a 1x1 block and always qualify. - private const int MaxStreamingMipReduction = 3; - // Textures smaller than this (whole mip chain) aren't worth streaming: the VRAM they could // free is negligible next to the per-texture bookkeeping the streaming manager keeps for them. private const long MinStreamingBytes = 4 * 1024; + // A texture is baked into the streaming system (m_StreamingMipmaps) only if it's large enough to bother + // with and still block aligned at TextureStreaming.MaxStreamingMipReduction: streaming reduces the + // resident base mip by up to that many levels and re-uploads the reduced mip as the new base, and a + // block-compressed base upload only works when its dims are a whole number of blocks. Uncompressed + // formats have a 1x1 block and always qualify. private static bool EligibleForStreaming(in DDSPreparedHeader hdr) => hdr.StreamedSize >= MinStreamingBytes && IsBlockAligned( hdr.Format, - Math.Max(1, hdr.Width >> MaxStreamingMipReduction), - Math.Max(1, hdr.Height >> MaxStreamingMipReduction)); + 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) @@ -3842,10 +3806,17 @@ private static void SetOptIn(bool optIn, ref bool? choosed) loader.userOptInChoiceDone = true; textureCacheEnabled = optIn; choosed = true; + SaveConfig(); + } + // Rewrite the fast-loader PluginData config from current in-memory state. This file holds only the + // opt-in choice (the mipmap-streaming settings live in KSPCF's own settings, see PatchSettings), so a + // full rewrite is safe. + 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 0000000..b20c567 --- /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 = false; + + // 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; + } + } + } +} From eb00d1b62b8fdc52f61b91472a92152039d0fb82 Mon Sep 17 00:00:00 2001 From: Phantomical Date: Wed, 15 Jul 2026 23:34:33 -0700 Subject: [PATCH 3/6] Avoid saving settings if texture streaming is disabled --- KSPCommunityFixes/Internal/PatchSettings.cs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/KSPCommunityFixes/Internal/PatchSettings.cs b/KSPCommunityFixes/Internal/PatchSettings.cs index 7e4fd47..8c18a19 100644 --- a/KSPCommunityFixes/Internal/PatchSettings.cs +++ b/KSPCommunityFixes/Internal/PatchSettings.cs @@ -57,7 +57,7 @@ static void GameplaySettingsScreen_DrawMiniSettings_Postfix(ref DialogGUIBase[] // +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]; @@ -83,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); @@ -98,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(); @@ -187,11 +187,13 @@ static void GameplaySettingsScreen_ApplySettings_Postfix() NoIVA.SaveSettings(); - ConfigNode streamingNode = new(); - streamingNode.AddValue(nameof(TextureStreaming.MipmapStreamingEnabled), TextureStreaming.MipmapStreamingEnabled); - streamingNode.AddValue(nameof(TextureStreaming.MipmapStreamingBudgetMb), TextureStreaming.MipmapStreamingBudgetMb); - SaveData(streamingNode); - + if (textureStreamingPatch != null) + { + ConfigNode streamingNode = new(); + streamingNode.AddValue(nameof(TextureStreaming.MipmapStreamingEnabled), TextureStreaming.MipmapStreamingEnabled); + streamingNode.AddValue(nameof(TextureStreaming.MipmapStreamingBudgetMb), TextureStreaming.MipmapStreamingBudgetMb); + SaveData(streamingNode); + } } } } From 6f388b3b12acd9485e0725006a681286fd8c9056 Mon Sep 17 00:00:00 2001 From: Phantomical Date: Thu, 16 Jul 2026 00:18:53 -0700 Subject: [PATCH 4/6] Enable mipmap streaming by default --- KSPCommunityFixes/Performance/TextureStreaming.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KSPCommunityFixes/Performance/TextureStreaming.cs b/KSPCommunityFixes/Performance/TextureStreaming.cs index b20c567..fb2a766 100644 --- a/KSPCommunityFixes/Performance/TextureStreaming.cs +++ b/KSPCommunityFixes/Performance/TextureStreaming.cs @@ -9,7 +9,7 @@ internal class TextureStreaming : BasePatch /// /// Whether mipmap streaming is enabled. /// - internal static bool MipmapStreamingEnabled = false; + internal static bool MipmapStreamingEnabled = true; // The minimum amount of memory reserved for streaming, in megabytes. internal static int MipmapStreamingBudgetMb = 1024; From 582df8e83a5a0d8f01859673108eacae00078707 Mon Sep 17 00:00:00 2001 From: Phantomical Date: Mon, 27 Jul 2026 19:18:53 -0700 Subject: [PATCH 5/6] Clean up temporary force-enable of the texture cache --- KSPCommunityFixes/Performance/FastLoader.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/KSPCommunityFixes/Performance/FastLoader.cs b/KSPCommunityFixes/Performance/FastLoader.cs index a08b204..a3a3f38 100644 --- a/KSPCommunityFixes/Performance/FastLoader.cs +++ b/KSPCommunityFixes/Performance/FastLoader.cs @@ -276,12 +276,6 @@ private void Awake() if (!config.TryGetValue(nameof(textureCacheEnabled), ref textureCacheEnabled)) userOptInChoiceDone = false; } - - // TEMPORARY: force the texture cache on and skip the opt-in popup while iterating on the - // mipmap-streaming work (DeployToKSP wipes the cfg each build, so the popup blocks unattended - // loads). Revert to the config-driven / popup behavior before shipping. - userOptInChoiceDone = true; - textureCacheEnabled = true; } /// From b3cce6b231ef212048d024a7767a6e04aa1ec34d Mon Sep 17 00:00:00 2001 From: Phantomical Date: Mon, 27 Jul 2026 19:47:45 -0700 Subject: [PATCH 6/6] Clean up comments --- .../Library/Model/ModelInstructions.cs | 2 - .../TextureBundle/TextureBundleBuilder.cs | 31 ++------------ KSPCommunityFixes/Performance/FastLoader.cs | 42 ++++++------------- 3 files changed, 16 insertions(+), 59 deletions(-) diff --git a/KSPCommunityFixes/Library/Model/ModelInstructions.cs b/KSPCommunityFixes/Library/Model/ModelInstructions.cs index f5b0f57..bfa24c7 100644 --- a/KSPCommunityFixes/Library/Model/ModelInstructions.cs +++ b/KSPCommunityFixes/Library/Model/ModelInstructions.cs @@ -170,8 +170,6 @@ public void Execute(UnityEngine.Object[] locals) else { mat.SetTexture(t.Name, tex); - // This texture is used by rendered geometry: release it from the load-time full-res - // pin so Unity's mipmap streaming can manage its residency (no-op when disabled). KSPCFFastLoader.ReleaseToStreaming(tex); } } diff --git a/KSPCommunityFixes/Library/TextureBundle/TextureBundleBuilder.cs b/KSPCommunityFixes/Library/TextureBundle/TextureBundleBuilder.cs index 47874ad..8678a6e 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 { @@ -68,9 +55,6 @@ public sealed class TextureRequest /// Whether Unity should keep a CPU-side copy of the pixels. public bool Readable; - /// Serialized m_StreamingMipmaps. When true, the texture joins Unity's - /// mipmap streaming system. The caller decides eligibility (only set it when the mip - /// levels stay block aligned under the deepest streaming reduction). public bool StreamingMipmaps; } @@ -109,8 +93,6 @@ public readonly struct TextureEntry public readonly bool Readable; - /// Serialized m_StreamingMipmaps: whether this texture joins Unity's - /// mipmap streaming system. public readonly bool StreamingMipmaps; /// Absolute path of the DDS file the pixels are streamed from. @@ -343,11 +325,6 @@ long streamSize w.WriteBool(req.Readable); // m_IsReadable w.WriteBool(false); // m_IgnoreMasterTextureLimit w.WriteBool(false); // m_IsPreProcessed - // Opt this texture into Unity's mipmap streaming manager only when the caller marked it - // eligible (its mip levels stay block aligned under the deepest streaming reduction, so the - // reduced base mip can still be uploaded). The feature is also gated globally by - // QualitySettings.streamingMipmapsActive (see TextureStreaming.ApplyStreamingQualitySettings), - // and each realized streaming texture is pinned full-res until a model/part binds it. w.WriteBool(req.StreamingMipmaps); // m_StreamingMipmaps w.Align(4); w.WriteInt32(0); // m_StreamingMipmapsPriority diff --git a/KSPCommunityFixes/Performance/FastLoader.cs b/KSPCommunityFixes/Performance/FastLoader.cs index a3a3f38..fe16e87 100644 --- a/KSPCommunityFixes/Performance/FastLoader.cs +++ b/KSPCommunityFixes/Performance/FastLoader.cs @@ -176,10 +176,6 @@ internal class KSPCFFastLoader : MonoBehaviour // Vestigial: kept so the popup can persist its choice across launches once it is repurposed. private static bool textureCacheEnabled; - // Mipmap streaming for part/mesh textures. The load-time plumbing lives here (bake eligibility, the - // full-res pin, and the two PartLoader release postfixes registered in Awake); the runtime settings and - // QualitySettings application live in TextureStreaming. - private static string ModPath => Path.GetDirectoryName(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)); private static string ConfigPath => Path.Combine(ModPath, "PluginData", "PNGTextureCache.cfg"); @@ -239,8 +235,8 @@ private void Awake() PatchStartCoroutineInCoroutine(AccessTools.Method(typeof(PartLoader), nameof(PartLoader.CompileParts))); - // Release part-compilation texture replacements to the mipmap streaming manager at their bind - // sites. These are the only two texture-assignment points in stock part compilation. + // 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)); @@ -1286,10 +1282,8 @@ private static IEnumerator InsertBundledTextures( req.Result = new TextureInfo(req.File, tex, item.IsNormalMap, isReadable: false, isCompressed: true); req.Status = TextureLoadRequest.State.Ready; - // Pin every streaming texture full-res on load. It stays pinned (never streamed down) - // until a model material or a part texture-replacement releases it via ReleaseToStreaming; - // textures with no owning mesh (UI, icons, ...) therefore never blur. The pin is latent - // until streamingMipmapsActive is turned on (after MM patching, see PatchSettings). + // 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; } @@ -1311,19 +1305,16 @@ private static IEnumerator InsertBundledTextures( #region Mipmap streaming - // Release a just-bound mesh/part texture back to automatic streaming, undoing the load-time full-res - // pin from InsertBundledTextures. Idempotent (ClearRequestedMipmapLevel is a no-op the second time and - // on non-streaming textures), so the shared database Texture2D can be released by whichever model or - // part binds it first, with no dedup bookkeeping. + /// + /// Enable mipmpa streaming for . + /// + /// internal static void ReleaseToStreaming(Texture tex) { if (tex is Texture2D t2d && t2d.streamingMipmaps) t2d.ClearRequestedMipmapLevel(); } - // Postfix on PartLoader.ReplaceTextures (MODEL-node "texture = orig, new" swaps): release each - // config-declared replacement texture. Releasing a declared-but-unmatched entry is harmless (idempotent - // + a texture with no owning renderer would only stream down when it is genuinely unused). private static void PartLoader_ReplaceTextures_Postfix(List newTextures) { if (newTextures == null) @@ -1339,9 +1330,6 @@ private static void PartLoader_ReplaceTextures_Postfix(List newText } } - // Postfix on PartLoader.ReplacePartTexture (part-level "texture"/"bump" field): the resolved texture is - // a local in the stock method, so recompute the URL from the parameters exactly as the method does and - // release the shared database texture (GetTexture is a dictionary lookup, fast-pathed by KSPCF). private static void PartLoader_ReplacePartTexture_Postfix(UrlConfig urlConfig, string textureName, bool normalMap) { string url = urlConfig.parent.parent.url + "/textures/" + Path.GetFileNameWithoutExtension(textureName); @@ -2470,15 +2458,12 @@ private static bool IsBlockAligned(GraphicsFormat format, int width, int height) return width % blockWidth == 0 && height % blockHeight == 0; } - // Textures smaller than this (whole mip chain) aren't worth streaming: the VRAM they could - // free is negligible next to the per-texture bookkeeping the streaming manager keeps for them. + // Textures smaller than 4KB are too small to be worth streaming. private const long MinStreamingBytes = 4 * 1024; - // A texture is baked into the streaming system (m_StreamingMipmaps) only if it's large enough to bother - // with and still block aligned at TextureStreaming.MaxStreamingMipReduction: streaming reduces the - // resident base mip by up to that many levels and re-uploads the reduced mip as the new base, and a - // block-compressed base upload only works when its dims are a whole number of blocks. Uncompressed - // formats have a 1x1 block and always qualify. + // 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( @@ -3803,9 +3788,6 @@ private static void SetOptIn(bool optIn, ref bool? choosed) SaveConfig(); } - // Rewrite the fast-loader PluginData config from current in-memory state. This file holds only the - // opt-in choice (the mipmap-streaming settings live in KSPCF's own settings, see PatchSettings), so a - // full rewrite is safe. private static void SaveConfig() { ConfigNode config = new ConfigNode();