Skip to content

Commit dc6c060

Browse files
authored
perf: Speed up texture replacment (#1)
When I profile my save with HUDReplacer installed (+ZTheme) it takes about 1.5s as part of every scene switch. Most of that time was spent iterating over the `images` dictionary again and again for every loaded texture. This commit gets the overhead of HUDReplacer down to ~130ms (on my save). There are a bunch of linked changes here, most of which are needed in order to make the whole thing work. I'll walk through these one by one. 1. The first thing is an overhaul of how the replacement info is stored. There are now two dictionaries: a global set of replacements, and a set of per-scene replacement dictionaries. These are keyed off of the image name, and have a list of all the available replacements for that image name, ordered by priority. This means that we can parse all the available texture replacements at startup and then not have to worry about needing to parse them again. We also pre-parse the texture filenames into their basename and size, if available. This means that we can actually just look them up by name, instead of having to search everything. As a bonus, I have added a ModuleManagerPostLoad call to load the textures so that it happens during the loading screen, instead of during the black screen when switching to the main screen. 2. Next, I have rewritten ReplaceTextures to take advantage of the new dictionary. There's a bit of extra work, because we need to deal with the fact that the replacements could possibly be either in the global dictionary or the scene-specific one. This all gets wrapped up in the GetMatchingReplacement methods. I have used the priority I saved from the first step to disambiguate. If that doesn't I just pick the scene specific one. This is a small behaviour change but I don't think it will matter in practice. 3. Caching. With the two changes above the runtime goes down to about 300ms - which is split 50/50 between reading textures off disk and loading them into a Texture2D. To speed this up I added a cachedTextureBytes field to ReplacementInfo. This makes it so the disk read can be avoided the next time the replacement is used. I have also made a couple of small changes and/or bugfixes as I was going through: - LoadTextures now emits useful error messages if the config node is invalid or if the format of the file name is wrong, instead of crashing. - ReplaceTextures now picks the texture with the highest priority, instead of the one with the lowest.
1 parent 2561117 commit dc6c060

File tree

1 file changed

+191
-95
lines changed

1 file changed

+191
-95
lines changed

src/HUDReplacer/HUDReplacer.cs

Lines changed: 191 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Linq;
66
using UnityEngine;
77
using UnityEngine.EventSystems;
8+
using UnityEngine.SceneManagement;
89
using UnityEngine.UI;
910

1011
namespace HUDReplacer
@@ -31,27 +32,62 @@ public class HUDReplacerSettings : HUDReplacer
3132
}
3233
public partial class HUDReplacer : MonoBehaviour
3334
{
35+
class ReplacementInfo
36+
{
37+
public List<SizedReplacementInfo> replacements;
38+
39+
public SizedReplacementInfo GetMatchingReplacement(Texture2D tex)
40+
{
41+
foreach (var info in replacements)
42+
{
43+
if (info.width == 0 && info.height == 0)
44+
return info;
45+
46+
if (info.width == tex.width && info.height == tex.height)
47+
return info;
48+
}
49+
50+
return null;
51+
}
52+
}
53+
54+
class SizedReplacementInfo
55+
{
56+
public int priority;
57+
public int width;
58+
public int height;
59+
public string path;
60+
public byte[] cachedTextureBytes;
61+
}
62+
3463
internal static HUDReplacer instance;
3564
internal static bool enableDebug = false;
36-
private static Dictionary<string, string> images;
65+
66+
private static Dictionary<string, ReplacementInfo> Images;
67+
private static Dictionary<GameScenes, Dictionary<string, ReplacementInfo>> SceneImages;
68+
69+
// Empty dictionary to be used when there are no images for a given scene.
70+
private static readonly Dictionary<string, ReplacementInfo> Empty = new Dictionary<string, ReplacementInfo>();
71+
private static readonly string[] CursorNames = new string[] { "basicNeutral", "basicElectricLime", "basicDisabled" };
72+
3773
private static string filePathConfig = "HUDReplacer";
3874
private static string colorPathConfig = "HUDReplacerRecolor";
3975
private TextureCursor[] cursors;
4076
public void Awake()
4177
{
4278
instance = this;
4379
Debug.Log("HUDReplacer: Running scene change. " + HighLogic.LoadedScene);
44-
// No longer cache on first load, as new 'onScene' config option will require a per-scene reload
45-
//if (images == null)
46-
//{
47-
GetTextures();
48-
//}
49-
if (images.Count > 0)
80+
81+
if (Images is null)
82+
LoadTextures();
83+
84+
if (Images.Count != 0 && SceneImages.Count != 0)
5085
{
5186
Debug.Log("HUDReplacer: Replacing textures...");
5287
ReplaceTextures();
5388
Debug.Log("HUDReplacer: Textures have been replaced!");
5489
}
90+
5591
LoadHUDColors();
5692
}
5793

@@ -71,7 +107,7 @@ public void Update()
71107
}
72108
if (Input.GetKeyUp(KeyCode.Q))
73109
{
74-
GetTextures();
110+
LoadTextures();
75111
ReplaceTextures();
76112
LoadHUDColors();
77113
Debug.Log("HUDReplacer: Refreshed.");
@@ -132,135 +168,195 @@ public void Update()
132168

133169
}
134170

171+
// This gets called by ModuleManager once it has finished applying all
172+
// patches. If MM is not installed then we'll call LoadTextures in Awake
173+
// instead.
174+
public static void ModuleManagerPostLoad()
175+
{
176+
LoadTextures();
177+
}
135178

136-
137-
138-
private void GetTextures()
179+
static void LoadTextures()
139180
{
140-
images = new Dictionary<string, string>();
141-
UrlDir.UrlConfig[] configs = GameDatabase.Instance.GetConfigs(filePathConfig);
142-
if(configs.Length <= 0)
181+
Images = new Dictionary<string, ReplacementInfo>();
182+
SceneImages = new Dictionary<GameScenes, Dictionary<string, ReplacementInfo>>();
183+
184+
UrlDir.UrlConfig[] configs = GameDatabase.Instance.GetConfigs(filePathConfig)
185+
.OrderByDescending((configFile) =>
186+
{
187+
int priority = 0;
188+
configFile.config.TryGetValue("priority", ref priority);
189+
return priority;
190+
})
191+
.ToArray();
192+
193+
if (configs.Length == 0)
143194
{
144195
Debug.Log("HUDReplacer: No texture configs found.");
145196
return;
146197
}
147-
148-
Debug.Log("HUDReplacer file paths found:");
149-
configs = configs.OrderByDescending(x => int.Parse(x.config.GetValue("priority"))).ToArray();
150-
foreach(UrlDir.UrlConfig configFile in configs)
198+
199+
foreach (var configFile in configs)
151200
{
152-
string filePath = configFile.config.GetValue("filePath");
153-
string onScene = configFile.config.HasValue("onScene") ? configFile.config.GetValue("onScene") : "";
201+
var config = configFile.config;
202+
var filePath = config.GetValue("filePath");
154203

155-
if(onScene != "")
204+
string onScene = null;
205+
Dictionary<string, ReplacementInfo> replacements = Images;
206+
if (config.TryGetValue("onScene", ref onScene))
156207
{
157-
try
208+
if (!Enum.TryParse(onScene, out GameScenes scene))
158209
{
159-
GameScenes scene = (GameScenes)Enum.Parse(typeof(GameScenes), onScene);
160-
if (HighLogic.LoadedScene != scene) continue;
210+
Debug.LogError($"HUDReplacer: Config {configFile.url} contained invalid onScene value {onScene ?? "<null>"}");
211+
continue;
161212
}
162-
catch (Exception e)
213+
214+
if (!SceneImages.TryGetValue(scene, out replacements))
163215
{
164-
Debug.LogError("HUDReplacer: Error loading onScene variable '" + onScene + "' from filePath: " + filePath);
216+
replacements = new Dictionary<string, ReplacementInfo>();
217+
SceneImages.Add(scene, replacements);
165218
}
166219
}
167-
168-
int priority = int.Parse(configFile.config.GetValue("priority"));
169-
Debug.Log("HUDReplacer: path " + filePath + " - priority: "+priority);
170-
//string[] files = Directory.GetFiles(filePath, "*.png");
220+
221+
int priority = 0;
222+
if (!config.TryGetValue("priority", ref priority))
223+
{
224+
Debug.LogError($"HUDReplacer: config at {configFile.url} is missing a priority key and will not be loaded");
225+
continue;
226+
}
227+
228+
Debug.Log($"HUDReplacer: path {filePath} - priority: {priority}");
171229
string[] files = Directory.GetFiles(KSPUtil.ApplicationRootPath + filePath, "*.png");
172-
foreach (string text in files)
230+
231+
foreach (string filename in files)
173232
{
174-
Debug.Log("HUDReplacer: Found file " + text);
175-
string filename = Path.GetFileNameWithoutExtension(text);
176-
if (!images.ContainsKey(filename))
233+
Debug.Log($"HUDReplacer: Found file {filename}");
234+
235+
int width = 0;
236+
int height = 0;
237+
238+
string basename = Path.GetFileNameWithoutExtension(filename);
239+
int index = basename.LastIndexOf('#');
240+
if (index != -1)
241+
{
242+
string size = basename.Substring(index + 1);
243+
basename = basename.Substring(0, index);
244+
245+
index = size.IndexOf('x');
246+
if (index == -1
247+
|| !int.TryParse(size.Substring(0, index), out width)
248+
|| !int.TryParse(size.Substring(index + 1), out height))
249+
{
250+
Debug.LogError($"HUDReplacer: filename {filename} was not in the expected format. It needs to be either `name.png` or `name#<width>x<height>.png`");
251+
continue;
252+
}
253+
}
254+
255+
SizedReplacementInfo info = new SizedReplacementInfo
177256
{
178-
images.Add(filename, text);
257+
priority = priority,
258+
width = width,
259+
height = height,
260+
path = filename
261+
};
262+
263+
if (!replacements.TryGetValue(basename, out var replacement))
264+
{
265+
replacement = new ReplacementInfo
266+
{
267+
replacements = new List<SizedReplacementInfo>(1)
268+
};
269+
replacements.Add(basename, replacement);
179270
}
180-
271+
272+
replacement.replacements.Add(info);
181273
}
182274
}
183275
}
184276

185277
internal void ReplaceTextures()
186278
{
279+
if (Images.Count == 0 && SceneImages.Count == 0)
280+
return;
281+
187282
Texture2D[] tex_array = (Texture2D[])(object)Resources.FindObjectsOfTypeAll(typeof(Texture2D));
188283
ReplaceTextures(tex_array);
189284
}
190285
internal void ReplaceTextures(Texture2D[] tex_array)
191286
{
192-
if (images.Count == 0) return;
287+
if (Images.Count == 0 && SceneImages.Count == 0)
288+
return;
193289

194-
string[] cursor_names = new string[] { "basicNeutral", "basicElectricLime", "basicDisabled" };
195-
290+
// Get the overloads specific to the current scene but if there are
291+
// then we just use an empty dictionary.
292+
if (!SceneImages.TryGetValue(HighLogic.LoadedScene, out var sceneImages))
293+
sceneImages = Empty;
196294

197295
foreach (Texture2D tex in tex_array)
198296
{
199-
string tex_name_stripped = tex.name;
200-
if(tex_name_stripped.Contains('/')) // weird RP1 case. May also happen with other mods
297+
string name = tex.name;
298+
if (name.Contains("/"))
299+
name = name.Split('/').Last();
300+
301+
if (!Images.TryGetValue(name, out var info))
302+
info = null;
303+
if (!sceneImages.TryGetValue(name, out var sceneInfo))
304+
sceneInfo = null;
305+
306+
var replacement = GetMatchingReplacement(info, sceneInfo, tex);
307+
if (replacement is null)
308+
continue;
309+
310+
// Special handling for the mouse cursor
311+
int cidx = CursorNames.IndexOf(name);
312+
if (cidx != -1)
201313
{
202-
tex_name_stripped = tex_name_stripped.Split('/').Last();
314+
if (cursors is null)
315+
cursors = new TextureCursor[3];
316+
317+
cursors[cidx] = CreateCursor(replacement.path);
318+
continue;
203319
}
204-
foreach (KeyValuePair<string, string> image in images)
320+
321+
// NavBall GaugeGee and GaugeThrottle needs special handling as well
322+
if (name == "GaugeGee")
323+
HarmonyPatches.GaugeGeeFilePath = replacement.path;
324+
else if (name == "GaugeThrottle")
325+
HarmonyPatches.GaugeThrottleFilePath = replacement.path;
326+
else
205327
{
206-
string key_stripped = image.Key;
207-
208-
if (image.Value.Contains("#"))
209-
{
210-
// Some textures have multiple variants in varying sizes. We don't want to overwrite a texture with the wrong dimensions, as it will not render correctly.
211-
// For these special cases, we save the width and height in the filename, appended by a # to tell the program this is a multi-texture.
212-
key_stripped = image.Key.Substring(0, image.Key.IndexOf("#", StringComparison.Ordinal));
213-
}
214-
if(key_stripped == tex_name_stripped)
215-
{
216-
// For the mouse cursor
217-
if (cursor_names.Contains(key_stripped))
218-
{
219-
if (cursors == null)
220-
{
221-
cursors = new TextureCursor[3];
222-
}
223-
cursors[cursor_names.IndexOf(key_stripped)] = CreateCursor(image.Value);
224-
continue;
225-
}
226-
// NavBall GaugeGee and GaugeThrottle needs special handling as well
227-
if(key_stripped == "GaugeGee")
228-
{
229-
HarmonyPatches.GaugeGeeFilePath = image.Value;
230-
continue;
231-
}
232-
if(key_stripped == "GaugeThrottle")
233-
{
234-
HarmonyPatches.GaugeThrottleFilePath = image.Value;
235-
continue;
236-
}
237-
if (key_stripped != image.Key)
238-
{
239-
// Special case texture
240-
string size = image.Key.Substring(image.Key.LastIndexOf("#")+1);
241-
int width = int.Parse(size.Substring(0, size.IndexOf("x")));
242-
int height = int.Parse(size.Substring(size.IndexOf("x")+1));
243-
if(tex.width == width && tex.height == height)
244-
{
245-
//Debug.Log("HUDReplacer: Replacing texture " + image.Value);
246-
ImageConversion.LoadImage(tex, File.ReadAllBytes(image.Value));
247-
continue;
248-
}
249-
}
250-
else
251-
{
252-
// Regular texture
253-
//Debug.Log("HUDReplacer: Replacing texture " + image.Value);
254-
ImageConversion.LoadImage(tex, File.ReadAllBytes(image.Value));
255-
continue;
256-
}
257-
}
328+
if (replacement.cachedTextureBytes is null)
329+
replacement.cachedTextureBytes = File.ReadAllBytes(replacement.path);
330+
331+
tex.LoadImage(replacement.cachedTextureBytes);
258332
}
259333
}
334+
260335
// Need to wait a small amount of time after scene load before you can set the cursor.
261336
this.Invoke(SetCursor, 1f);
262337
}
263338

339+
private static SizedReplacementInfo GetMatchingReplacement(
340+
ReplacementInfo info,
341+
ReplacementInfo sceneInfo,
342+
Texture2D tex
343+
)
344+
{
345+
if (info is null && sceneInfo is null)
346+
return null;
347+
348+
var rep = info?.GetMatchingReplacement(tex);
349+
var sceneRep = sceneInfo?.GetMatchingReplacement(tex);
350+
351+
if (rep != null && sceneRep != null)
352+
{
353+
if (rep.priority < sceneRep.priority)
354+
return sceneRep;
355+
}
356+
357+
return rep ?? sceneRep;
358+
}
359+
264360
internal void LoadHUDColors()
265361
{
266362
UrlDir.UrlConfig[] configs = GameDatabase.Instance.GetConfigs(colorPathConfig);

0 commit comments

Comments
 (0)