From eb28fe8acaca88e1d89df130656972416792f589 Mon Sep 17 00:00:00 2001 From: adfriz <76892624+adfriz@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:34:11 +0700 Subject: [PATCH 1/2] Optimize csv b3d object parser and object viewer autoreload performance - Implement HashSet face lookup in Plugin.Parser.cs - Add IO caching for File.Exists results during texture loading. - Optimize NumberFormats.TrimInside to reduce allocations. - Implement partial reload in ObjectViewer via UnifiedObject caching. - Add descriptive code comments for better readability. --- source/ObjectViewer/ProgramS.cs | 41 +++++++-- source/OpenBveApi/Math/Math.cs | 17 +++- source/Plugins/Object.CsvB3d/Plugin.Parser.cs | 92 +++++++++++-------- 3 files changed, 98 insertions(+), 52 deletions(-) diff --git a/source/ObjectViewer/ProgramS.cs b/source/ObjectViewer/ProgramS.cs index d87bd6d88..011fbd3f3 100644 --- a/source/ObjectViewer/ProgramS.cs +++ b/source/ObjectViewer/ProgramS.cs @@ -41,6 +41,8 @@ internal static class Program { /// The number of failed auto-reloads internal static int AutoReloadFailureCounter = 0; private static double reloadCheckTimer; + /// The list of loaded objects, cached to allow partial reloads + internal static UnifiedObject[] LoadedObjects = new UnifiedObject[0]; // mouse internal static Vector3 MouseCameraPosition = Vector3.Zero; @@ -162,6 +164,7 @@ internal static void Main(string[] args) if (filesToLoad.Count != 0) { Files = filesToLoad; + LoadedObjects = new UnifiedObject[Files.Count]; } } @@ -311,8 +314,24 @@ internal static void MouseMovement() } } - internal static void RefreshObjects(bool autoReload = false) + internal static void RefreshObjects(int fileIndex = -1) { + // If a specific file index is provided, only reload that file from disk. + // Otherwise (or if the array is out of sync), do a full reload. + if (fileIndex >= 0 && fileIndex < Files.Count && LoadedObjects.Length == Files.Count) { + try { + if (CurrentHost.LoadObject(Files[fileIndex], Encoding.UTF8, out UnifiedObject o)) { + LoadedObjects[fileIndex] = o; + } + } catch { + // If failed, we don't return here but continue to the reset/refresh logic + // which will handle error reporting. + } + } else { + LoadedObjects = new UnifiedObject[Files.Count]; + fileIndex = -1; // Force full load of all objects below + } + LightingRelative = -1.0; Renderer.Reset(); Game.Reset(); @@ -323,6 +342,7 @@ internal static void RefreshObjects(bool autoReload = false) { if(Files[i].EndsWith(".dat", StringComparison.InvariantCultureIgnoreCase) || Files[i].EndsWith(".xml", StringComparison.InvariantCultureIgnoreCase) || Files[i].EndsWith(".cfg", StringComparison.InvariantCultureIgnoreCase) || Files[i].EndsWith(".con", StringComparison.InvariantCultureIgnoreCase)) { + // Trains are still reloaded fully as they are complex collections string currentTrain = Files[i]; if (currentTrain.EndsWith("extensions.cfg", StringComparison.InvariantCultureIgnoreCase)) { @@ -370,19 +390,24 @@ internal static void RefreshObjects(bool autoReload = false) } else { - if (CurrentHost.LoadObject(Files[i], Encoding.UTF8, out UnifiedObject o)) - { - o.CreateObject(Vector3.Zero, 0.0, 0.0, 0.0); + if (fileIndex == -1 || fileIndex == i) { + if (CurrentHost.LoadObject(Files[i], Encoding.UTF8, out UnifiedObject o)) { + LoadedObjects[i] = o; + } + } + if (LoadedObjects[i] != null) { + LoadedObjects[i].CreateObject(Vector3.Zero, 0.0, 0.0, 0.0); } - } } catch (Exception ex) { - if (!autoReload || AutoReloadFailureCounter > 5) + // Check if we are in an auto-reload context (passed from CheckFileChanges) via a flag if needed, + // but here we use fileIndex != -1 as a proxy for auto-reload. + if (fileIndex == -1 || AutoReloadFailureCounter > 5) { - if (autoReload) + if (fileIndex != -1) { // failure counter must be above 5 Interface.CurrentOptions.AutoReloadObjects = false; @@ -450,7 +475,7 @@ internal static void CheckFileChanges(double timeElapsed) } if (System.IO.File.GetLastWriteTimeUtc(Files[i]) > LastReloadTime) { - RefreshObjects(); + RefreshObjects(i); return; } } diff --git a/source/OpenBveApi/Math/Math.cs b/source/OpenBveApi/Math/Math.cs index ffa6f6a0c..05fd2b3a1 100644 --- a/source/OpenBveApi/Math/Math.cs +++ b/source/OpenBveApi/Math/Math.cs @@ -204,11 +204,20 @@ public static double ToRadians(this double degrees) private static string TrimInside(string Expression) { - System.Text.StringBuilder Builder = new System.Text.StringBuilder(Expression.Length); - foreach (char c in Expression.Where(c => !char.IsWhiteSpace(c))) + if (string.IsNullOrEmpty(Expression)) return Expression; + + // Simple loop to remove whitespace without LINQ or StringBuilder overhead + int n = Expression.Length; + char[] result = new char[n]; + int count = 0; + for (int i = 0; i < n; i++) { - Builder.Append(c); - } return Builder.ToString(); + if (!char.IsWhiteSpace(Expression[i])) + { + result[count++] = Expression[i]; + } + } + return count == n ? Expression : new string(result, 0, count); } diff --git a/source/Plugins/Object.CsvB3d/Plugin.Parser.cs b/source/Plugins/Object.CsvB3d/Plugin.Parser.cs index a309a4e51..04343c42f 100644 --- a/source/Plugins/Object.CsvB3d/Plugin.Parser.cs +++ b/source/Plugins/Object.CsvB3d/Plugin.Parser.cs @@ -108,6 +108,7 @@ private static StaticObject ReadObject(string FileName, Encoding Encoding) { } // read lines List Lines = System.IO.File.ReadAllLines(FileName, Encoding).ToList(); + Dictionary fileExistsCache = new Dictionary(StringComparer.OrdinalIgnoreCase); if (enabledHacks.BveTsHacks && multiColumn) { /* @@ -150,6 +151,9 @@ private static StaticObject ReadObject(string FileName, Encoding Encoding) { List Normals = new List(); bool CommentStarted = false; Color24? lastTransparentColor = null; + // HashSet to store face vertex indices to avoid O(N^2) search during Z-fighting check. + // Using a string key of sorted indices since MeshFace is a struct and we need a reliable key. + HashSet faceCache = new HashSet(); for (int i = 0; i < Lines.Count; i++) { { // Strip OpenBVE original standard comments @@ -207,9 +211,10 @@ private static StaticObject ReadObject(string FileName, Encoding Encoding) { } } } - // collect arguments + // collect arguments by splitting the line by commas string[] Arguments = Lines[i].Split(new[] { ',' }, StringSplitOptions.None); for (int j = 0; j < Arguments.Length; j++) { + // Trim each argument to remove leading/trailing whitespace Arguments[j] = Arguments[j].Trim(); } { @@ -291,6 +296,7 @@ private static StaticObject ReadObject(string FileName, Encoding Encoding) { Builder.Apply(ref Object, enabledHacks.BveTsHacks); Builder = new MeshBuilder(currentHost); Normals = new List(); + faceCache.Clear(); // reset face cache for the new mesh builder } break; case B3DCsvCommands.AddVertex: case B3DCsvCommands.Vertex: @@ -519,45 +525,23 @@ private static StaticObject ReadObject(string FileName, Encoding Encoding) { } } - int[] vertexIndices = (int[])a.Clone(); - Array.Sort(vertexIndices); - for (int k = 0; k < Builder.Faces.Count; k++) + /* + * Optimization: Use a HashSet to track existing faces by their vertex indices. + * This avoids the O(N^2) loop through all previous faces in the MeshBuilder. + */ + if (isFace2) { - /* - * Some BVE2 content declares a Face2 twice with the vertices in a differing order, e.g. - * - * Face2 0, 1, 3, 2 - * Face2 1, 0, 2, 3 - * - * Doing this in OpenBVE causes some very funky glitches with Z-fighting, - * as it attempts to render both faces in the same space - * - * With this hack, the lighting may be off but the Z-fighting is gone - * (BVE2 / BVE4 operate in a strict draw-order, so the most recent face is always on top, - * wheras OpenBVE has no fixed draw order) - */ - MeshFace currentFace = Builder.Faces[k]; - if (!isFace2 || (currentFace.Flags & FaceFlags.Face2Mask) == 0) - { - continue; - } - - if (currentFace.Vertices.Length != a.Length) - { - continue; - } - - int[] faceVertexIndices = new int[a.Length]; - for (int v = 0; v < currentFace.Vertices.Length; v++) + int[] vertexIndices = (int[])a.Clone(); + Array.Sort(vertexIndices); + string faceKey = string.Join("|", vertexIndices); + if (faceCache.Contains(faceKey)) { - faceVertexIndices[v] = currentFace.Vertices[v].Index; + q = false; } - Array.Sort(faceVertexIndices); - if (vertexIndices.SequenceEqual(faceVertexIndices)) + else { - q = false; + faceCache.Add(faceKey); } - } } if (q) { @@ -1167,7 +1151,14 @@ private static StaticObject ReadObject(string FileName, Encoding Encoding) { tday = null; } - if (!System.IO.File.Exists(tday)) + // Cache results of File.Exists to avoid hitting the disk for the same file multiple times + bool tdayExists; + if (!fileExistsCache.TryGetValue(tday, out tdayExists)) + { + tdayExists = System.IO.File.Exists(tday); + fileExistsCache[tday] = tdayExists; + } + if (!tdayExists) { bool hackFound = false; if (enabledHacks.BveTsHacks) @@ -1185,7 +1176,12 @@ private static StaticObject ReadObject(string FileName, Encoding Encoding) { { Arguments[0] = Arguments[0].Substring(7); tday = Path.CombineFile(Path.GetDirectoryName(FileName), Arguments[0]); - if (System.IO.File.Exists(tday)) + if (!fileExistsCache.TryGetValue(tday, out tdayExists)) + { + tdayExists = System.IO.File.Exists(tday); + fileExistsCache[tday] = tdayExists; + } + if (tdayExists) { hackFound = true; } @@ -1195,7 +1191,12 @@ private static StaticObject ReadObject(string FileName, Encoding Encoding) { { Arguments[0] = Arguments[0].Substring(4); tday = Path.CombineFile(Path.GetDirectoryName(FileName), Arguments[0]); - if (System.IO.File.Exists(tday)) + if (!fileExistsCache.TryGetValue(tday, out tdayExists)) + { + tdayExists = System.IO.File.Exists(tday); + fileExistsCache[tday] = tdayExists; + } + if (tdayExists) { hackFound = true; } @@ -1205,7 +1206,12 @@ private static StaticObject ReadObject(string FileName, Encoding Encoding) { { Arguments[0] = Arguments[0].Substring(3); tday = Path.CombineFile(Path.GetDirectoryName(FileName), Arguments[0]); - if (System.IO.File.Exists(tday)) + if (!fileExistsCache.TryGetValue(tday, out tdayExists)) + { + tdayExists = System.IO.File.Exists(tday); + fileExistsCache[tday] = tdayExists; + } + if (tdayExists) { hackFound = true; } @@ -1245,7 +1251,13 @@ private static StaticObject ReadObject(string FileName, Encoding Encoding) { } } - if (!System.IO.File.Exists(tnight) && !ignoreAsInvalid) { + bool tnightExists; + if (!fileExistsCache.TryGetValue(tnight, out tnightExists)) + { + tnightExists = System.IO.File.Exists(tnight); + fileExistsCache[tnight] = tnightExists; + } + if (!tnightExists && !ignoreAsInvalid) { currentHost.AddMessage(MessageType.Error, true, "The NighttimeTexture " + tnight + " could not be found in " + cmd + " at line " + (i + 1).ToString(Culture) + " in file " + FileName); tnight = null; } From 787a60915dcbeb081f76134b5121331f7389f2b5 Mon Sep 17 00:00:00 2001 From: adfriz <76892624+adfriz@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:06:52 +0700 Subject: [PATCH 2/2] Put back comments and add more to be clear --- source/Plugins/Object.CsvB3d/Plugin.Parser.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/source/Plugins/Object.CsvB3d/Plugin.Parser.cs b/source/Plugins/Object.CsvB3d/Plugin.Parser.cs index 04343c42f..8c570bc29 100644 --- a/source/Plugins/Object.CsvB3d/Plugin.Parser.cs +++ b/source/Plugins/Object.CsvB3d/Plugin.Parser.cs @@ -108,6 +108,8 @@ private static StaticObject ReadObject(string FileName, Encoding Encoding) { } // read lines List Lines = System.IO.File.ReadAllLines(FileName, Encoding).ToList(); + // Cache results of File.Exists locally during this load operation to avoid redundant disk IO. + // This is created fresh every time the object is loaded/reloaded, so it is safe against file additions/deletions between loads. Dictionary fileExistsCache = new Dictionary(StringComparer.OrdinalIgnoreCase); if (enabledHacks.BveTsHacks && multiColumn) { @@ -525,10 +527,20 @@ private static StaticObject ReadObject(string FileName, Encoding Encoding) { } } - /* - * Optimization: Use a HashSet to track existing faces by their vertex indices. - * This avoids the O(N^2) loop through all previous faces in the MeshBuilder. + /* + * Some BVE2 content declares a Face2 twice with the vertices in a differing order, e.g. + * Face2 0, 1, 3, 2 + * Face2 1, 0, 2, 3 + * Doing this in OpenBVE causes some very funky glitches with Z-fighting, + * as it attempts to render both faces in the same space + * + * With this hack, the lighting may be off but the Z-fighting is gone + * (BVE2 / BVE4 operate in a strict draw-order, so the most recent face is always on top, + * wheras OpenBVE has no fixed draw order) */ + + // Optimization: Use a HashSet to track existing faces by their vertex indices. + // This avoids the O(N^2) loop through all previous faces in the MeshBuilder. if (isFace2) { int[] vertexIndices = (int[])a.Clone();