Persistent UI Replacement, and Optimizations#16
Persistent UI Replacement, and Optimizations#16Aeurias wants to merge 1 commit intoKSPModStewards:masterfrom
Conversation
- Persistent singleton architecture, ensure that UI monitoring and texture replacement remain active across all game scenes without needing to be re-instantiated.
- Reactive Harmony patches on `UnityEngine.UI.Graphic.OnEnable` and `KSP.UI.UISkinManager.GetSkin`. For texture replacement the moment a UI component is activated or a skin is retrieved.
Performance:
- O(1) Lookup Cache: Implemented an `idReplacementMap (Dictionary)` and `replacedTextureIds (HashSet)` to track processed textures by their `InstanceID`. This bypasses expensive string-based matching and name resolution for previously encountered textures.
- Raw `byte[]` data and `basename` strings for replacement textures are now pre-cached during the initial `LoadTextures()` phase, eliminating main-thread disk I/O bottlenecks during runtime replacement.
- Optimized string manipulation within the replacement loop, favoring index-based substring operations over `String.Split('/')` to minimize heap allocations.
Skinning:
- uGUI: Reflection-based skinning for `UISkinDef` templates. Discover `UIStyle` and `UIStyleState` fields to apply ZTheme assets.
- IMGUI: Added support for intercepted skinning of the global `HighLogic.Skin` (GUISkin) to ensure appearance for legacy windows.
Scene Management:
- Integrated with `GameEvents.onLevelWasLoadedGUIReady` to prevent the default KSP UI from being visible during scene loads.
- Main Menu refresh upon entering the Main Menu to capture late-loading UI elements that are often reset by KSP's internal initialization.
Stability:
- Debounced Cursor Updates, state tracking and a delayed `Invoke` mechanism for `SetCursor` calls to prevent redundant updates and console spam when multiple cursor textures are replaced in a single frame.
- Replacement logic with additional null checks and graceful handling of empty asset dictionaries to ensure mod stability even when no texture packs are present.
Phantomical
left a comment
There was a problem hiding this comment.
So I liked the general idea of your approach, but there was a bunch of stuff I wasn't quite happy about how it worked so I ended up rewriting almost all of HUDReplacer on top of your branch.
So here's the things that I think were good ideas and I ended up keeping:
- Comparing texture instance ids to see if the texture has been replaced. This is definitely way faster than doing a bunch of string ops and then calling
LoadImage. - Moving the texture replace to
onLevelWasLoadedGUIReady. This definitely makes more sense then doing it inHUDReplacer.Awake - Loading all the file data up-front.
- Explicitly adding the textures from
HighLogic.Skin. Turns out these are lazily loaded the first time the fields for the individual textures are accessed. This was the one thing missing to actually replace the textures for imgui UIs. I ended up doing it lazily the first timeHighLogic.Skinis called in a scene for performance reasons, but the approach makes sense.
I have left comments on the remaining things I am confused about. You don't need to fix them or anything, I have already rebuilt HUDReplacer on top of your code :). I just figure doing a proper code review would help.
| [HarmonyPatch(typeof(KSP.UI.UIMasterController), "Awake")] | ||
| class Patch_UIMasterController_Awake | ||
| { | ||
| static void Postfix() | ||
| { | ||
| if (HUDReplacer.Instance != null) | ||
| { | ||
| HUDReplacer.Instance.RefreshAll(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| [HarmonyPatch(typeof(KSP.UI.Screens.ApplicationLauncher), "Awake")] | ||
| class Patch_ApplicationLauncher_Awake | ||
| { | ||
| static void Postfix() | ||
| { | ||
| if (HUDReplacer.Instance != null) | ||
| { | ||
| HUDReplacer.Instance.RefreshAll(); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
What's the point of adding these? Both of them run before OnLevelGUIReady gets called and I'm pretty sure that KSP won't show any UI until after OnLevelGUIReady is called. This seems like it would result in extra work for no visible difference.
| public void Awake() | ||
| { | ||
| instance = this; | ||
| Debug.Log("HUDReplacer: Running scene change. " + HighLogic.LoadedScene); | ||
| if (Instance != null) | ||
| { | ||
| Destroy(gameObject); | ||
| return; | ||
| } | ||
| Instance = this; | ||
| DontDestroyOnLoad(gameObject); | ||
|
|
||
| Debug.Log("HUDReplacer: Initializing persistent instance."); | ||
|
|
||
| if (Images is null) | ||
| if (Images == null || Images.Count == 0) | ||
| LoadTextures(); | ||
|
|
||
| Debug.Log("HUDReplacer: Replacing textures..."); | ||
| ReplaceTextures(); | ||
| Debug.Log("HUDReplacer: Textures have been replaced!"); | ||
| Debug.Log("HUDReplacer: Initial texture replacement..."); | ||
| RefreshAll(); | ||
|
|
||
| GameEvents.onLevelWasLoadedGUIReady.Add(OnLevelGUIReady); | ||
| } |
There was a problem hiding this comment.
This runs before module manager patching finishes, so it would be wrong to do so. The correct place would be to have the addon started either in the main menu or PSystemSetup.
| public void RunMainMenuRefreshSequence() | ||
| { | ||
| float[] delays = { 0.5f, 1.2f, 2.0f }; | ||
| foreach (float delay in delays) | ||
| { | ||
| this.Invoke( | ||
| () => | ||
| { | ||
| Debug.Log($"HUDReplacer: Performing Main Menu refresh ({delay}s)."); | ||
| replacedTextureIds.Clear(); | ||
| idReplacementMap.Clear(); | ||
| RefreshAll(); | ||
| }, | ||
| delay | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
What's the point in this? What UIs elements are you finding aren't getting replaced?
| } | ||
| } | ||
|
|
||
| public void ApplyUISkinDef(object uiSkinDef) |
There was a problem hiding this comment.
Reflection tends to be very slow. It's also not necessary in this case because all the relevant fields here are public (and in general, you can use Krafs.Publicizer to access private fields directly).
| [HarmonyPatch(typeof(Graphic), "OnEnable")] | ||
| class Patch_Graphic_OnEnable | ||
| { | ||
| static void Postfix(Graphic __instance) | ||
| { | ||
| if (HUDReplacer.Instance == null) | ||
| return; | ||
|
|
||
| if (__instance is Image img) | ||
| { | ||
| Texture2D tex = null; | ||
| if (img.sprite != null && img.sprite.texture != null) | ||
| tex = img.sprite.texture; | ||
| else if (img.mainTexture is Texture2D mTex) | ||
| tex = mTex; | ||
|
|
||
| if (tex != null) | ||
| { | ||
| HUDReplacer.Instance.ReplaceTextures(new Texture2D[] { tex }); | ||
| } | ||
| } | ||
| else if (__instance is RawImage rawImg) | ||
| { | ||
| if (rawImg.texture is Texture2D tex) | ||
| { | ||
| HUDReplacer.Instance.ReplaceTextures(new Texture2D[] { tex }); | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
I'm struggling to think of cases where this patch will replace something that is not covered by the textures getting replaced in OnLevelGUIReady. Have you run into cases where mods are loading GUI textures in the middle of a scene?
| [HarmonyPatch(typeof(UISkinManager), nameof(UISkinManager.GetSkin), new Type[] { typeof(string) })] | ||
| class Patch_UISkinManager_GetSkin | ||
| { | ||
| static void Postfix(UISkinDef __result) | ||
| { | ||
| if (HUDReplacer.Instance != null && __result != null) | ||
| { | ||
| HUDReplacer.Instance.ApplyUISkinDef(__result); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
It turns out that this isn't necessary because all the builtin UISkinDef instances are instantiated right away when the game is loaded.
|
Like I said above, this got merged in c6b9084 so I'll be closing it. Thanks for the PR! |
UnityEngine.UI.Graphic.OnEnableandKSP.UI.UISkinManager.GetSkin. For texture replacement the moment a UI component is activated or a skin is retrieved.Performance:
idReplacementMap(Dictionary) andreplacedTextureIds(HashSet) to track processed textures by theirInstanceID. This bypasses expensive string-based matching and name resolution for previously encountered textures.byte[]data andbasenamestrings for replacement textures are now pre-cached during the initialLoadTextures()phase, eliminating main-thread disk I/O bottlenecks during runtime replacement.String.Split('/')to minimize heap allocations.Skinning:
UISkinDeftemplates. DiscoverUIStyleandUIStyleStatefields to apply ZTheme assets.HighLogic.Skin(GUISkin) to ensure appearance for legacy windows.Scene Management:
GameEvents.onLevelWasLoadedGUIReadyto prevent the default KSP UI from being visible during scene loads.Stability:
Invokemechanism forSetCursorcalls to prevent redundant updates and console spam when multiple cursor textures are replaced in a single frame.