railroader-physics-optimizer/ConsistLOD.cs
Seton Carmichael 5414fd8979 Initial commit - Physics Overhaul v0.1.1
Features:
- ConsistLOD: fast-path Bezier position update for distant cars (PositionAccuracy
  matched, LocationF/R synced, OnPosition fired, culler sphere kept current)
- ConsistFreezer: per-consist auto-freeze replacing stock all-or-nothing optimizer
- PhysicsTimer: stacked ring buffers (render, FixedUpdate, Tick, PosCars) + report
- PhysicsOverlayGUI: draggable stacked-area chart, reference lines always visible,
  ON/OFF toggles with persistence, opacity control
- Settings: JSON persistence, full UMM settings panel with auto-save
- ConsoleCommands: /rpf with freeze/unfreeze/dump/timing/overlay/forceactive/lod
2026-06-16 12:24:56 -04:00

193 lines
8.4 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). 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 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;
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 (!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++;
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++;
return true; // let original run this tick
}
_fastCount++;
// 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++;
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();
}
}