release: 0.1.2 - debug visualization, loco exclusions, tuned defaults

New in 0.1.2:
- Debug color overlay: tint cars by physics state (yellow=frozen,
  cyan=fast-path, magenta=full-accuracy), individual toggles per state
- Exclude locomotives from Physics Optimizer and Auto Freeze
- Debug stats line in overlay showing live counts per state
- Resync quality 1/16 option added

Defaults tuned for new installs:
- Resync quality: 1/4 -> 1/8
- Speed threshold: ~0.7 mph -> 0.2 mph
- Overlay opacity: 100% -> 75%

Rename: Physics Overhaul -> Physics Optimizer throughout
This commit is contained in:
Seton Carmichael 2026-06-16 22:14:34 -04:00
parent 5414fd8979
commit 766822d15a
10 changed files with 252 additions and 14 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ bin/
*.user
.vs/
*.suo
*.zip

124
CarDebugVisualizer.cs Normal file
View file

@ -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<Car, Renderer[]> _rendCache = new();
readonly Dictionary<Car, Color> _curr = new();
readonly Dictionary<Car, Color> _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<Car> 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<Renderer>()
: System.Array.Empty<Renderer>();
// 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<Car>();
foreach (var car in _rendCache.Keys)
if (car == null) dead.Add(car);
foreach (var car in dead) _rendCache.Remove(car);
}
}

View file

@ -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<Car> 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
}
}

View file

@ -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<string> 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<Car> FastPathCars = new();
public static readonly HashSet<Car> 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();

View file

@ -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",

View file

@ -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<PhysicsOverlayGUI>();
overlay.Visible = Settings.ShowOverlay;
overlay.Opacity = Settings.OverlayOpacity;
go.AddComponent<CarDebugVisualizer>();
// UMM settings panel callbacks.
modEntry.OnGUI = entry => SettingsGUI.Draw(entry, Settings);

View file

@ -8,10 +8,27 @@
<OutputPath>..\..\Railroader\Mods\RailroaderPhysicsOverhaul\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<!-- Don't copy game DLLs to output they live in the game folder -->
<!-- Don't copy game DLLs to output - they live in the game folder -->
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<ModVersion>0.1.2</ModVersion>
</PropertyGroup>
<!-- Release build only: package the mod into a zip ready for Forgejo/UMM upload.
Zip structure: RailroaderPhysicsOverhaul/Info.json + .dll
UMM drag-and-drop install expects exactly this layout. -->
<Target Name="PackageRelease" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'">
<PropertyGroup>
<_PkgDir>$(MSBuildProjectDirectory)\obj\pkg\RailroaderPhysicsOverhaul\</_PkgDir>
<_ZipOut>$(MSBuildProjectDirectory)\RailroaderPhysicsOverhaul-$(ModVersion).zip</_ZipOut>
</PropertyGroup>
<RemoveDir Directories="$(MSBuildProjectDirectory)\obj\pkg" />
<MakeDir Directories="$(_PkgDir)" />
<Copy SourceFiles="$(OutputPath)RailroaderPhysicsOverhaul.dll;$(OutputPath)Info.json"
DestinationFolder="$(_PkgDir)" />
<Exec Command="powershell -NoProfile -Command &quot;Compress-Archive -Path '$(MSBuildProjectDirectory)\obj\pkg\RailroaderPhysicsOverhaul' -DestinationPath '$(_ZipOut)' -Force&quot;" />
<Message Text="Release zip ready: $(_ZipOut)" Importance="High" />
</Target>
<ItemGroup>
<!-- Info.json is required by UMM in the output mod folder -->
<None Include="Info.json">

View file

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

View file

@ -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 <subcommand>`:
## 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 |
|---------|---------|-------------|

View file

@ -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("<b>Locomotive Exclusions</b>", 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("<b>Road Number Filter</b>", GUILayout.ExpandWidth(false));
GUILayout.Space(4f);
@ -285,6 +316,23 @@ static class SettingsGUI
changed = true;
}
GUILayout.Space(12f);
// ── Debug Visualization ───────────────────────────────────────────────────
GUILayout.Label("<b>Debug Visualization</b>", 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)