diff --git a/.gitignore b/.gitignore index 84960d6..a22f938 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ bin/ *.user .vs/ *.suo +*.zip diff --git a/CarDebugVisualizer.cs b/CarDebugVisualizer.cs new file mode 100644 index 0000000..d942cc7 --- /dev/null +++ b/CarDebugVisualizer.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using Model; +using UnityEngine; + +namespace RailroaderPhysicsOverhaul; + +[UnityEngine.DefaultExecutionOrder(10000)] // run after all game LateUpdates so our MPB is last to write +public class CarDebugVisualizer : MonoBehaviour +{ + public static CarDebugVisualizer Instance { get; private set; } + public int TintedCount => _curr.Count; + + // A 1x1 white texture fed via MPB overrides _MainTex so the shader renders + // texture*color = white*color = solid color, regardless of the car's original texture. + Texture2D _whiteTex; + readonly MaterialPropertyBlock _mpb = new(); + + readonly Dictionary _rendCache = new(); + readonly Dictionary _curr = new(); + readonly Dictionary _prev = new(); + + static readonly Color ColFrozen = new(1.00f, 0.90f, 0.00f); // yellow + static readonly Color ColFastPath = new(0.00f, 1.00f, 1.00f); // cyan + static readonly Color ColFullPath = new(1.00f, 0.00f, 1.00f); // magenta + + void Awake() + { + Instance = this; + _whiteTex = new Texture2D(1, 1, TextureFormat.RGBA32, false) + { hideFlags = HideFlags.HideAndDontSave }; + _whiteTex.SetPixel(0, 0, Color.white); + _whiteTex.Apply(); + } + + void OnDestroy() + { + ClearAll(); + if (_whiteTex) Destroy(_whiteTex); + Instance = null; + } + + void LateUpdate() + { + _prev.Clear(); + foreach (var kv in _curr) _prev[kv.Key] = kv.Value; + _curr.Clear(); + + if (Main.Settings.DebugHighlightFrozen) + Collect(ConsistFreezer.FrozenCars, ColFrozen); + if (Main.Settings.DebugHighlightFastPath) + Collect(ConsistLOD.FastPathCars, ColFastPath); + if (Main.Settings.DebugHighlightFullPath) + Collect(ConsistLOD.FullPathCars, ColFullPath); + + foreach (var (car, color) in _curr) + ApplyTint(car, color); + + foreach (var car in _prev.Keys) + if (!_curr.ContainsKey(car)) + ClearTint(car); + + if (Time.frameCount % 300 == 0) + PurgeCache(); + } + + void Collect(HashSet source, Color color) + { + foreach (var car in source) + if (car != null && !_curr.ContainsKey(car)) + _curr[car] = color; + } + + void ApplyTint(Car car, Color color) + { + foreach (var r in GetRenderers(car)) + { + if (r == null) continue; + _mpb.Clear(); + _mpb.SetColor("_Color", color); + _mpb.SetColor("_BaseColor", color); + _mpb.SetTexture("_MainTex", _whiteTex); // built-in RP + _mpb.SetTexture("_BaseMap", _whiteTex); // URP + _mpb.SetTexture("_BaseColorMap", _whiteTex); // HDRP + r.SetPropertyBlock(_mpb); + } + } + + void ClearTint(Car car) + { + foreach (var r in GetRenderers(car)) + if (r != null) r.SetPropertyBlock(null); + } + + void ClearAll() + { + foreach (var car in _curr.Keys) ClearTint(car); + foreach (var car in _prev.Keys) ClearTint(car); + _curr.Clear(); + _prev.Clear(); + _rendCache.Clear(); + } + + Renderer[] GetRenderers(Car car) + { + if (!_rendCache.TryGetValue(car, out var renderers) || renderers.Length == 0) + { + renderers = car.BodyTransform != null + ? car.BodyTransform.GetComponentsInChildren() + : System.Array.Empty(); + // Don't cache empty results — retry next frame until the mesh loads + if (renderers.Length > 0) + _rendCache[car] = renderers; + } + return renderers; + } + + void PurgeCache() + { + var dead = new List(); + foreach (var car in _rendCache.Keys) + if (car == null) dead.Add(car); + foreach (var car in dead) _rendCache.Remove(car); + } +} diff --git a/ConsistFreezer.cs b/ConsistFreezer.cs index 627ce24..1f4cc91 100644 --- a/ConsistFreezer.cs +++ b/ConsistFreezer.cs @@ -18,7 +18,8 @@ public static class ConsistFreezer // Auto-freeze: skip the Verlet tick for consists that are far from the camera // and moving slowly enough that physics quality there doesn't matter. // This is our integrated replacement for the stock optimizer (AllCarsAtRest only). - public static bool AutoFreezeEnabled = true; + public static bool ExcludeLocomotives = true; + public static bool AutoFreezeEnabled = true; public static float AutoFreezeDistance = 200f; // meters public static float AutoFreezeSpeedThreshold = 0.3f; // m/s (~0.7 mph) @@ -31,18 +32,30 @@ public static class ConsistFreezer public static int LastAtRestCars { get; private set; } public static int LastDistanceCars { get; private set; } + // Per-tick set of cars whose Verlet tick is being skipped — for debug visualization. + public static readonly HashSet FrozenCars = new(); + internal static void FlushFrameCounts() { LastAtRestCars = _frameAtRestCars; LastDistanceCars = _frameDistanceCars; _frameAtRestCars = 0; _frameDistanceCars = 0; + FrozenCars.Clear(); } // Called from ShouldSkipTickPatch. Internal so the patch class (same file) can reach it. internal static bool ShouldAutoFreeze(IntegrationSet set) { if (!AutoFreezeEnabled) return false; + // ShouldSkipTick is per-IntegrationSet — we can't freeze freight cars while keeping + // a coupled loco ticking. If ExcludeLocomotives is on, the whole consist stays live + // so AI waypoint queues on the loco don't get interrupted by a physics freeze. + if (ExcludeLocomotives) + { + foreach (Car car in set.Cars) + if (car is BaseLocomotive) return false; + } Camera cam = Camera.main; if (cam == null) return false; Vector3 camPos = cam.transform.position; @@ -79,6 +92,7 @@ static class ShouldSkipTickPatch { if (ConsistFreezer.IsFrozen(__instance.Id)) { + foreach (Car c in __instance.Cars) ConsistFreezer.FrozenCars.Add(c); __result = true; return false; } @@ -93,6 +107,12 @@ static class ShouldSkipTickPatch // the stock optimizer mod installed. if (__instance.AllCarsAtRest()) { + if (ConsistFreezer.ExcludeLocomotives) + { + foreach (Car c in __instance.Cars) + if (c is BaseLocomotive) { __result = false; return false; } + } + foreach (Car c in __instance.Cars) ConsistFreezer.FrozenCars.Add(c); ConsistFreezer._frameAtRestCars += __instance.NumberOfCars; __result = true; return false; @@ -100,7 +120,10 @@ static class ShouldSkipTickPatch __result = ConsistFreezer.ShouldAutoFreeze(__instance); if (__result) + { + foreach (Car c in __instance.Cars) ConsistFreezer.FrozenCars.Add(c); ConsistFreezer._frameDistanceCars += __instance.NumberOfCars; + } return false; // always override — stock getter never runs } } diff --git a/ConsistLOD.cs b/ConsistLOD.cs index 8524909..18775d0 100644 --- a/ConsistLOD.cs +++ b/ConsistLOD.cs @@ -13,11 +13,12 @@ public static class ConsistLOD { public static bool Enabled = true; public static float DistanceThreshold = 30f; - // Must be a power of 2 (1, 2, 4, 8). 1 = full path every tick (no dead-reckoning). + // Must be a power of 2 (1, 2, 4, 8, 16). 1 = full path every tick (no dead-reckoning). public static int ResyncInterval = 4; // Blacklist/whitelist — when enabled, filters which consists get LOD treatment. - public static bool BlacklistEnabled = false; + public static bool ExcludeLocomotives = true; + public static bool BlacklistEnabled = false; public static bool IsBlacklist = true; // true = listed consists skip LOD; false = only listed use LOD public static readonly HashSet RoadNumberList = new(System.StringComparer.OrdinalIgnoreCase); @@ -25,6 +26,10 @@ public static class ConsistLOD public static int LastFastPathCount; public static int LastFullPathCount; + // Per-tick sets for debug visualization — cleared at FixedUpdate start, populated by the patch. + public static readonly HashSet FastPathCars = new(); + public static readonly HashSet FullPathCars = new(); + static float DistanceSq => DistanceThreshold * DistanceThreshold; // Per-consist LOD eligibility cache — rebuilt lazily, flushed periodically and on settings change. @@ -35,6 +40,7 @@ public static class ConsistLOD internal static bool ShouldUseFastPath(Car car) { if (!Enabled) return false; + if (ExcludeLocomotives && car is Model.BaseLocomotive) return false; if (!car.EndGearA.IsCoupled || !car.EndGearB.IsCoupled) return false; if (car.BodyTransform == null) return false; Camera cam = Camera.main; @@ -109,6 +115,7 @@ static class PositionWheelBoundsFrontLODPatch if (!update || !ConsistLOD.ShouldUseFastPath(__instance)) { _fullCount++; + ConsistLOD.FullPathCars.Add(__instance); return true; // run original } @@ -122,10 +129,12 @@ static class PositionWheelBoundsFrontLODPatch if (isResync) { _fullCount++; + ConsistLOD.FastPathCars.Add(__instance); // logically fast-path, just doing a scheduled sync return true; // let original run this tick } _fastCount++; + ConsistLOD.FastPathCars.Add(__instance); // 1. Update track physics bounds — constraint solver and couplers need these. float boundsSpan = __instance.carLength - __instance.wheelInsetF - __instance.wheelInsetR; @@ -182,6 +191,8 @@ static class TrainControllerFixedUpdateCounterPatch static void Prefix() { ConsistLOD._globalTick++; + ConsistLOD.FastPathCars.Clear(); + ConsistLOD.FullPathCars.Clear(); PositionWheelBoundsFrontLODPatch.ResetFrameCounts(); ConsistFreezer.FlushFrameCounts(); diff --git a/Info.json b/Info.json index 3f962c6..e203af5 100644 --- a/Info.json +++ b/Info.json @@ -1,8 +1,8 @@ { "Id": "RailroaderPhysicsOverhaul", - "DisplayName": "Physics Overhaul", + "DisplayName": "Physics Optimizer", "Author": "seton", - "Version": "0.1.1", + "Version": "0.1.2", "ManagerVersion": "0.27.0", "GameVersionPoint": "0", "AssemblyName": "RailroaderPhysicsOverhaul.dll", diff --git a/Main.cs b/Main.cs index d138543..bb21a89 100644 --- a/Main.cs +++ b/Main.cs @@ -25,6 +25,8 @@ public static class Main foreach (string name in Settings.RoadNumberList) ConsistLOD.RoadNumberList.Add(name); + ConsistLOD.ExcludeLocomotives = Settings.ExcludeLocosFromLOD; + ConsistFreezer.ExcludeLocomotives = Settings.ExcludeLocosFromFreeze; ConsistFreezer.AutoFreezeEnabled = Settings.AutoFreezeEnabled; ConsistFreezer.AutoFreezeDistance = Settings.AutoFreezeDistance; ConsistFreezer.AutoFreezeSpeedThreshold = Settings.AutoFreezeSpeedThreshold; @@ -38,6 +40,7 @@ public static class Main var overlay = go.AddComponent(); overlay.Visible = Settings.ShowOverlay; overlay.Opacity = Settings.OverlayOpacity; + go.AddComponent(); // UMM settings panel callbacks. modEntry.OnGUI = entry => SettingsGUI.Draw(entry, Settings); diff --git a/PhysicsOverhaulMod.csproj b/PhysicsOverhaulMod.csproj index 93eaacf..de0ec58 100644 --- a/PhysicsOverhaulMod.csproj +++ b/PhysicsOverhaulMod.csproj @@ -8,10 +8,27 @@ ..\..\Railroader\Mods\RailroaderPhysicsOverhaul\ false false - + false + 0.1.2 + + + + <_PkgDir>$(MSBuildProjectDirectory)\obj\pkg\RailroaderPhysicsOverhaul\ + <_ZipOut>$(MSBuildProjectDirectory)\RailroaderPhysicsOverhaul-$(ModVersion).zip + + + + + + + + diff --git a/PhysicsOverlayGUI.cs b/PhysicsOverlayGUI.cs index 29b83f3..e1157b9 100644 --- a/PhysicsOverlayGUI.cs +++ b/PhysicsOverlayGUI.cs @@ -21,6 +21,7 @@ public class PhysicsOverlayGUI : MonoBehaviour string _lodStatsStr = ""; string _freezeStatsStr = ""; + string _debugStatsStr = ""; int _lodStatsFrame; GUIStyle _windowStyle; @@ -164,10 +165,20 @@ public class PhysicsOverlayGUI : MonoBehaviour int atRest = ConsistFreezer.LastAtRestCars; int byDist = ConsistFreezer.LastDistanceCars; _freezeStatsStr = $"stopped:{atRest} dist/spd:{byDist}"; + + bool anyDbg = Main.Settings.DebugHighlightFrozen || + Main.Settings.DebugHighlightFastPath || + Main.Settings.DebugHighlightFullPath; + _debugStatsStr = anyDbg + ? $"dbg — frozen:{ConsistFreezer.FrozenCars.Count} fast:{ConsistLOD.FastPathCars.Count} full:{ConsistLOD.FullPathCars.Count} tinted:{CarDebugVisualizer.Instance?.TintedCount ?? 0}" + : ""; } GUILayout.Label(_lodStatsStr, _dimLabel); GUILayout.EndHorizontal(); + if (_debugStatsStr.Length > 0) + GUILayout.Label(_debugStatsStr, _dimLabel); + // Auto-freeze row — shows cars skipping the Verlet tick each physics step GUILayout.BeginHorizontal(); GUILayout.Label("Auto Freeze", _dimLabel, GUILayout.Width(130f)); diff --git a/README.md b/README.md index 2db6b50..62a3bde 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Railroader Physics Overhaul +# Railroader Physics Optimizer A [Unity Mod Manager](https://www.nexusmods.com/site/mods/21) mod for [Railroader](https://store.steampowered.com/app/1638770/Railroader/) that reduces CPU time spent on train physics without sacrificing gameplay. @@ -38,7 +38,7 @@ The mod patches `Car.PositionWheelBoundsFront` with a Harmony prefix. When a car 5. **`LocationF`/`LocationR`** are updated each tick so the segment cache never goes stale. ### Auto Freeze detail -`ShouldSkipTick` is replaced. The stock implementation skips the entire solver when all cars are at rest; this mod tracks each car individually and skips cars that are far away and *very* slow, reducing wasteful simulation of parked cars. +`ShouldSkipTick` is replaced. This mod tracks each car individually and skips cars that are far away and *very* slow, reducing wasteful simulation of parked cars. ### Sampling model (the "% of FixedUpdate" number) The profiler measures time with `Stopwatch.GetTimestamp()` (nanosecond resolution, no GC pressure). It accumulates over 60-tick windows. "% of FixedUpdate" is how much of the total physics budget `Tick()` alone consumed that window — a high percentage means Verlet integration dominates; a low percentage means air brakes, coupler forces, or position updates are the bottleneck. @@ -78,7 +78,7 @@ Open the in-game console and type `/rpf `: ## Settings -Open UMM (Ctrl+F10), select **Physics Overhaul**. All settings auto-save on change. +Open UMM (Ctrl+F10), select **Physics Optimizer**. All settings auto-save on change. | Setting | Default | Description | |---------|---------|-------------| diff --git a/Settings.cs b/Settings.cs index b96ecf4..38b4a36 100644 --- a/Settings.cs +++ b/Settings.cs @@ -11,16 +11,25 @@ public class ModSettings : UnityModManager.ModSettings { public bool OptimizerEnabled = true; public float DistanceThreshold = 30f; - public int ResyncInterval = 4; + public int ResyncInterval = 8; public bool ShowOverlay = true; // Auto-freeze: skip the Verlet tick for far+slow consists public bool AutoFreezeEnabled = true; public float AutoFreezeDistance = 200f; // meters - public float AutoFreezeSpeedThreshold = 0.3f; // m/s (~0.7 mph) + public float AutoFreezeSpeedThreshold = 0.09f; // m/s (~0.2 mph) // Road number filter — serialized as a flat string array in Settings.xml. - public float OverlayOpacity = 1.0f; + public float OverlayOpacity = 0.75f; + + // Locomotive exclusions + public bool ExcludeLocosFromLOD = true; + public bool ExcludeLocosFromFreeze = true; + + // Debug visualization — tint cars by their current physics state. + public bool DebugHighlightFrozen = false; + public bool DebugHighlightFastPath = false; + public bool DebugHighlightFullPath = false; public bool BlacklistEnabled = false; public bool IsBlacklist = true; // true = blacklist, false = whitelist @@ -59,8 +68,8 @@ public class ModSettings : UnityModManager.ModSettings // Draws the UMM in-game settings panel. static class SettingsGUI { - static readonly int[] ResyncOptions = { 1, 2, 4, 8 }; - static readonly string[] ResyncLabels = { "1/1 (max quality)", "1/2", "1/4 (default)", "1/8" }; + static readonly int[] ResyncOptions = { 1, 2, 4, 8, 16 }; + static readonly string[] ResyncLabels = { "1/1 (max quality)", "1/2", "1/4", "1/8 (default)", "1/16" }; static string _addInput = ""; static string _toRemove = null; // deferred to avoid mutating list during enumeration @@ -166,6 +175,28 @@ static class SettingsGUI GUILayout.Space(12f); + // ── Locomotive Exclusions ───────────────────────────────────────────────── + GUILayout.Label("Locomotive Exclusions", GUILayout.ExpandWidth(false)); + GUILayout.Space(4f); + + bool newExLOD = GUILayout.Toggle(s.ExcludeLocosFromLOD, " Exclude locomotives from Physics Optimizer"); + if (newExLOD != s.ExcludeLocosFromLOD) + { + s.ExcludeLocosFromLOD = newExLOD; + ConsistLOD.ExcludeLocomotives = newExLOD; + changed = true; + } + + bool newExFreeze = GUILayout.Toggle(s.ExcludeLocosFromFreeze, " Exclude locomotives from Auto Freeze"); + if (newExFreeze != s.ExcludeLocosFromFreeze) + { + s.ExcludeLocosFromFreeze = newExFreeze; + ConsistFreezer.ExcludeLocomotives = newExFreeze; + changed = true; + } + + GUILayout.Space(12f); + // ── Road Number Filter ──────────────────────────────────────────────────── GUILayout.Label("Road Number Filter", GUILayout.ExpandWidth(false)); GUILayout.Space(4f); @@ -285,6 +316,23 @@ static class SettingsGUI changed = true; } + GUILayout.Space(12f); + + // ── Debug Visualization ─────────────────────────────────────────────────── + GUILayout.Label("Debug Visualization", GUILayout.ExpandWidth(false)); + GUILayout.Space(4f); + GUILayout.Label(" Tints car models by physics state. Useful for verifying LOD and freeze behaviour.", GUI.skin.label); + GUILayout.Space(4f); + + bool newHF = GUILayout.Toggle(s.DebugHighlightFrozen, " Highlight frozen cars (yellow)"); + if (newHF != s.DebugHighlightFrozen) { s.DebugHighlightFrozen = newHF; changed = true; } + + bool newHFP = GUILayout.Toggle(s.DebugHighlightFastPath, " Highlight fast-path cars (cyan)"); + if (newHFP != s.DebugHighlightFastPath) { s.DebugHighlightFastPath = newHFP; changed = true; } + + bool newHFU = GUILayout.Toggle(s.DebugHighlightFullPath, " Highlight full-accuracy cars (magenta)"); + if (newHFU != s.DebugHighlightFullPath) { s.DebugHighlightFullPath = newHFU; changed = true; } + GUILayout.EndVertical(); if (changed)