railroader-physics-optimizer/ConsistLOD.cs
Seton Carmichael 766822d15a 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
2026-06-16 22:14:34 -04:00

204 lines
9.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using System.Reflection;
using Helpers;
using HarmonyLib;
using Model;
using Model.Physics;
using Track;
using UnityEngine;
namespace RailroaderPhysicsOverhaul;
public static class ConsistLOD
{
public static bool Enabled = true;
public static float DistanceThreshold = 30f;
// 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 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);
public static int _globalTick = 0;
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.
// IntegrationSet is not a UnityEngine.Object, so we key by reference identity.
static readonly Dictionary<IntegrationSet, bool> _consistCache = new();
public static void InvalidateConsistCache() => _consistCache.Clear();
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;
if (cam == null) return false;
if ((car.BodyTransform.position - cam.transform.position).sqrMagnitude <= DistanceSq)
return false;
// Blacklist/whitelist — cached per IntegrationSet instance ID to avoid per-car scanning.
if (BlacklistEnabled && car.set != null)
{
if (!_consistCache.TryGetValue(car.set, out bool allowed))
{
allowed = IsConsistAllowed(car.set);
_consistCache[car.set] = allowed;
}
if (!allowed) return false;
}
return true;
}
static bool IsConsistAllowed(IntegrationSet set)
{
if (RoadNumberList.Count == 0) return true; // empty list = no filtering
bool hasMatch = false;
foreach (Car c in set.Cars)
{
// Accept a match against the full display name ("UP 1492"), the road number
// alone ("1492"), or the reporting mark alone ("UP") — users naturally type
// whichever they remember, so don't require the exact concatenated string.
if (RoadNumberList.Contains(c.DisplayName) ||
RoadNumberList.Contains(c.Ident.RoadNumber) ||
RoadNumberList.Contains(c.Ident.ReportingMark))
{
hasMatch = true;
break;
}
}
// Blacklist: a match means this consist is NOT allowed to use LOD.
// Whitelist: no match means this consist is NOT allowed to use LOD.
return IsBlacklist ? !hasMatch : hasMatch;
}
}
// Cached accessor for Car._mover (internal field — reflection needed from our assembly).
// CarMover.Move() is public, so once we have the instance we call it directly.
static class CarMoverAccess
{
static readonly FieldInfo Field =
typeof(Car).GetField("_mover", BindingFlags.Instance | BindingFlags.NonPublic);
public static CarMover Get(Car car) => (CarMover)Field.GetValue(car);
}
[HarmonyPatch(typeof(Car), "PositionWheelBoundsFront")]
static class PositionWheelBoundsFrontLODPatch
{
static int _fastCount;
static int _fullCount;
public static void ResetFrameCounts()
{
ConsistLOD.LastFastPathCount = _fastCount;
ConsistLOD.LastFullPathCount = _fullCount;
_fastCount = 0;
_fullCount = 0;
}
static bool Prefix(Car __instance, Location wheelBoundsF, Graph graph, bool update,
ref Location __result)
{
if (!update || !ConsistLOD.ShouldUseFastPath(__instance))
{
_fullCount++;
ConsistLOD.FullPathCars.Add(__instance);
return true; // run original
}
// Fibonacci hash: 4-bit result (0-15) distributes Unity InstanceIDs evenly
// and supports ResyncInterval 1/2/4/8 via modular comparison.
int id = __instance.GetInstanceID();
int bucket = (int)(((uint)id * 2654435761u) >> 28) & 0xF;
bool isResync = ConsistLOD.ResyncInterval <= 1 ||
ConsistLOD._globalTick % ConsistLOD.ResyncInterval == bucket % ConsistLOD.ResyncInterval;
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;
Location wbR = graph.LocationByMoving(wheelBoundsF, -boundsSpan);
__instance.WheelBoundsF = wheelBoundsF;
__instance.WheelBoundsR = wbR;
__result = wbR;
// Keep LogicalEnd locations in sync — used by CarDidPosition's segment-cache
// and coupler placement when the culler transitions distance bands.
__instance.LocationF = graph.LocationByMoving(wheelBoundsF, __instance.wheelInsetF, false, Graph.EndOfTrackHandling.Unclamped);
__instance.LocationR = graph.LocationByMoving(wbR, -__instance.wheelInsetR, false, Graph.EndOfTrackHandling.Unclamped);
// 2. Replicate SetBodyPosition's chord-based rotation: LookRotation between
// the front and rear TRUCK positions, matching the game's exact formula.
// Use the same graph instance and the same PositionAccuracy as PositionWheelBoundsFront
// would choose (IsVisible ? High : Standard) so the position is byte-for-byte identical
// to the full path — preventing a positional jump when the camera closes in.
float halfTruckSpan = (__instance.carLength - __instance.truckSeparation) / 2f;
Location truckFLoc = graph.LocationByMoving(wheelBoundsF, -(halfTruckSpan - __instance.wheelInsetF));
Location truckRLoc = graph.LocationByMoving(truckFLoc, -__instance.truckSeparation);
PositionAccuracy accuracy = __instance.IsVisible ? PositionAccuracy.High : PositionAccuracy.Standard;
var prF = graph.GetPositionRotation(truckFLoc, accuracy);
var prR = graph.GetPositionRotation(truckRLoc, accuracy);
Vector3 avgUp = Vector3.Lerp(prF.Rotation * Vector3.up, prR.Rotation * Vector3.up, 0.5f);
Quaternion bodyRot = Quaternion.LookRotation(prF.Position - prR.Position, avgUp);
Vector3 gameCenter = Vector3.Lerp(prF.Position, prR.Position, 0.5f);
Vector3 worldCenter = WorldTransformer.GameToWorld(gameCenter);
// 3. Drive _mover.Move() exactly as SetBodyPosition does.
// This keeps _moverPosition current every fast-path tick so:
// a) Camera-follow code (which reads _moverPosition) sees correct position.
// b) On the next resync tick, _velocity = Δpos/dt ≈ actual v, not N×v,
// so the rigidbody isn't slammed backward causing a snap.
CarMoverAccess.Get(__instance).Move(worldCenter, bodyRot, immediate: false);
// 4. TagCallout is only updated inside SetBodyPosition; keep it in sync here.
__instance.TagCallout?.SetPosition(worldCenter);
// 5. Fire OnPosition so TrainController.CarDidPosition runs: this updates the
// spatial-hash, car-culler sphere (distance-band gating), and segment cache.
// Without this the culler's bounding sphere stays frozen at the last resync
// position, causing stale IsVisible / IsNearby states on fast-path ticks.
__instance.OnPosition?.Invoke(gameCenter, bodyRot);
return false; // skip original PositionWheelBoundsFront + SetBodyPosition
}
}
[HarmonyPatch(typeof(TrainController), "FixedUpdate")]
static class TrainControllerFixedUpdateCounterPatch
{
static void Prefix()
{
ConsistLOD._globalTick++;
ConsistLOD.FastPathCars.Clear();
ConsistLOD.FullPathCars.Clear();
PositionWheelBoundsFrontLODPatch.ResetFrameCounts();
ConsistFreezer.FlushFrameCounts();
// Periodic consist cache flush — silently handles couple/decouple events
// without needing to patch the coupler system. ~12 s at 50 Hz.
if (ConsistLOD._globalTick % 600 == 0)
ConsistLOD.InvalidateConsistCache();
}
}