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();