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
204 lines
9.1 KiB
C#
204 lines
9.1 KiB
C#
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();
|
||
}
|
||
}
|