railroader-physics-optimizer/PhysicsOverlayGUI.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

275 lines
12 KiB
C#
Raw Permalink 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 UnityEngine;
namespace RailroaderPhysicsOverhaul;
public class PhysicsOverlayGUI : MonoBehaviour
{
public static PhysicsOverlayGUI Instance { get; private set; }
public bool Visible = true;
public float Opacity = 1.0f;
Rect _windowRect = new(10f, 10f, 420f, 10f);
GUIStyle _blueLabel;
GUIStyle _greenLabel;
GUIStyle _yellowLabel;
GUIStyle _orangeLabel;
GUIStyle _dimLabel;
GUIStyle _fps60Label;
GUIStyle _fps30Label;
GUIStyle _onStyle; // bold green, no button background
GUIStyle _offStyle; // bold red, no button background
string _lodStatsStr = "";
string _freezeStatsStr = "";
int _lodStatsFrame;
GUIStyle _windowStyle;
Texture2D _solidTex; // 1×1 white — lets GUI.backgroundColor.a control opacity 0100%
// Graph rendered as a texture — avoids all GL coordinate-space issues.
// Unity textures are Y-up (row 0 = bottom pixel), IMGUI is Y-down,
// but GUI.DrawTexture just stretches the texture into the target rect,
// so we flip Y in our write formula and the result renders correctly.
Texture2D _graphTex;
Color32[] _graphPixels;
const int TexW = 300; // matches RingSize exactly — 1 pixel column per sample
const int TexH = 90;
static readonly Color32 ColBg = new(16, 16, 16, 220);
static readonly Color32 Col60 = new(210, 210, 210, 255); // 60fps reference line
static readonly Color32 Col30 = new(210, 210, 210, 255); // 30fps reference line
static readonly Color32 ColRender = new(50, 140, 215, 255); // render frame time (blue)
static readonly Color32 ColFrame = new(70, 200, 70, 255); // FixedUpdate total (green)
static readonly Color32 ColTick = new(210, 185, 50, 255); // Tick() only (yellow)
static readonly Color32 ColPosCars = new(220, 110, 30, 255); // PositionCars (orange)
static readonly int WindowId = "RPFOverlay".GetHashCode();
const float RefFps60Ms = 16.7f;
const float RefFps30Ms = 33.3f;
void Awake()
{
Instance = this;
_graphTex = new Texture2D(TexW, TexH, TextureFormat.RGBA32, false)
{ filterMode = FilterMode.Point, hideFlags = HideFlags.HideAndDontSave };
_graphPixels = new Color32[TexW * TexH];
_solidTex = new Texture2D(1, 1, TextureFormat.RGBA32, false)
{ hideFlags = HideFlags.HideAndDontSave };
_solidTex.SetPixel(0, 0, Color.white);
_solidTex.Apply();
}
void LateUpdate()
{
// Record render frame time each rendered frame (not each physics tick).
// Time.unscaledDeltaTime = wall-clock ms since last render frame.
PhysicsTimer.RecordRenderFrame(Time.unscaledDeltaTime * 1000f);
}
void OnDestroy()
{
if (_graphTex != null) Destroy(_graphTex);
if (_solidTex != null) Destroy(_solidTex);
Instance = null;
}
void OnGUI()
{
if (!Visible) return;
EnsureStyles();
// Tint the solid-white window background to dark gray at the chosen opacity.
// Using a custom style (solid texture) means Opacity=1 → fully opaque, not
// capped by whatever alpha the default IMGUI skin has baked in.
Color prevBg = GUI.backgroundColor;
GUI.backgroundColor = new Color(0.063f, 0.063f, 0.063f, Opacity);
_windowRect = GUILayout.Window(WindowId, _windowRect, DrawWindow,
"Physics Profiler", _windowStyle, GUILayout.MinWidth(420f));
GUI.backgroundColor = prevBg;
}
void DrawWindow(int _)
{
// The window background was already drawn with the opacity tint — restore
// backgroundColor so buttons and labels inside use their normal skin colors.
GUI.backgroundColor = Color.white;
GUILayout.Label(PhysicsTimer.GetReport());
// Reserve space for graph in window-local coords.
Rect localRect = GUILayoutUtility.GetRect(
GUIContent.none, GUIStyle.none,
GUILayout.Height(TexH), GUILayout.ExpandWidth(true));
if (Event.current.type == EventType.Repaint)
{
UpdateGraphTexture();
// GUI.DrawTexture uses window-local coords — no coordinate conversion needed.
GUI.DrawTexture(localRect, _graphTex, ScaleMode.StretchToFill);
// 10% headroom above the 30fps line so it's never squashed against the top pixel row.
float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f);
// Reference-line labels: centered vertically on the line, anchored to right edge.
float y60Gui = LocalGaugeY(localRect, maxMs, RefFps60Ms);
GUI.Label(new Rect(localRect.xMax - 54f, y60Gui - 9f, 52f, 18f), "60 fps", _fps60Label);
float y30Gui = LocalGaugeY(localRect, maxMs, RefFps30Ms);
GUI.Label(new Rect(localRect.xMax - 54f, y30Gui - 9f, 52f, 18f), "30 fps", _fps30Label);
// Current sample readout (top-left of graph).
int prev = (PhysicsTimer.RingWrite - 1 + PhysicsTimer.RingSize) % PhysicsTimer.RingSize;
GUI.Label(new Rect(localRect.x + 4f, localRect.y + 2f, 340f, 20f),
$"render:{PhysicsTimer.RingRender[prev]:F2}ms phys:{PhysicsTimer.RingFrame[prev]:F2}ms tick:{PhysicsTimer.RingTick[prev]:F2}ms",
_dimLabel);
}
// Legend row
GUILayout.BeginHorizontal();
GUILayout.Label(" ■ Render", _blueLabel);
GUILayout.Label(" ■ FixedUpdate", _greenLabel);
GUILayout.Label(" ■ Tick()", _yellowLabel);
if (PhysicsTimer.HasPosCarsData)
GUILayout.Label(" ■ PosCars", _orangeLabel);
GUILayout.Label($" [{PhysicsTimer.RingSize} frames]", _dimLabel);
GUILayout.EndHorizontal();
// Physics Optimizer row: plain-text toggle (no button background), then slow-updating stats
GUILayout.BeginHorizontal();
GUILayout.Label("Physics Optimizer", _dimLabel, GUILayout.Width(130f));
if (GUILayout.Button(ConsistLOD.Enabled ? "ON" : "OFF",
ConsistLOD.Enabled ? _onStyle : _offStyle, GUILayout.Width(34f)))
{
ConsistLOD.Enabled = !ConsistLOD.Enabled;
// Persist toggle state so it survives a game restart.
Main.Settings.OptimizerEnabled = ConsistLOD.Enabled;
Main.Settings.Save(Main.ModEntry);
}
// Stats refresh once per second — readable, not flickering
if (Time.frameCount != _lodStatsFrame && Time.frameCount % 60 == 0)
{
_lodStatsFrame = Time.frameCount;
if (ConsistLOD.Enabled)
{
int fast = ConsistLOD.LastFastPathCount;
int full = ConsistLOD.LastFullPathCount;
int total = fast + full;
_lodStatsStr = $"fast:{fast}/{total} full:{full}/{total} dist:{ConsistLOD.DistanceThreshold:F0}m resync:1/{ConsistLOD.ResyncInterval}";
}
else
{
_lodStatsStr = "";
}
int atRest = ConsistFreezer.LastAtRestCars;
int byDist = ConsistFreezer.LastDistanceCars;
_freezeStatsStr = $"stopped:{atRest} dist/spd:{byDist}";
}
GUILayout.Label(_lodStatsStr, _dimLabel);
GUILayout.EndHorizontal();
// Auto-freeze row — shows cars skipping the Verlet tick each physics step
GUILayout.BeginHorizontal();
GUILayout.Label("Auto Freeze", _dimLabel, GUILayout.Width(130f));
if (GUILayout.Button(ConsistFreezer.AutoFreezeEnabled ? "ON" : "OFF",
ConsistFreezer.AutoFreezeEnabled ? _onStyle : _offStyle, GUILayout.Width(34f)))
{
ConsistFreezer.AutoFreezeEnabled = !ConsistFreezer.AutoFreezeEnabled;
Main.Settings.AutoFreezeEnabled = ConsistFreezer.AutoFreezeEnabled;
Main.Settings.Save(Main.ModEntry);
}
GUILayout.Label(_freezeStatsStr, _dimLabel);
GUILayout.EndHorizontal();
GUI.DragWindow(new Rect(0f, 0f, _windowRect.width, 20f));
}
// Y in IMGUI window-local space: top of rect = maxMs, bottom = 0ms.
static float LocalGaugeY(Rect r, float maxMs, float ms) =>
r.yMax - Mathf.Clamp01(ms / maxMs) * r.height;
void UpdateGraphTexture()
{
float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f);
int write = PhysicsTimer.RingWrite;
int n = PhysicsTimer.RingSize;
float[] render = PhysicsTimer.RingRender;
float[] frame = PhysicsTimer.RingFrame;
float[] tick = PhysicsTimer.RingTick;
float[] posCars = PhysicsTimer.RingPosCars;
// Background fill.
for (int i = 0; i < _graphPixels.Length; i++)
_graphPixels[i] = ColBg;
// Stacked filled areas — drawn back-to-front so later layers paint over earlier ones.
//
// Layer 1 (bottom): render frame time.
DrawFilledArea(render, null, n, write, maxMs, ColRender);
// Layer 2: FixedUpdate total, stacked on top of render.
DrawFilledArea(frame, render, n, write, maxMs, ColFrame);
// Layer 3: Tick(), painted over the lower part of the FixedUpdate band (same baseline).
DrawFilledArea(tick, render, n, write, maxMs, ColTick);
// Layer 4: PosCars, painted over the lower part of the Tick band (same baseline).
if (PhysicsTimer.HasPosCarsData)
DrawFilledArea(posCars, render, n, write, maxMs, ColPosCars);
// Reference lines on top of all data.
DrawHLine(maxMs, RefFps60Ms, Col60);
DrawHLine(maxMs, RefFps30Ms, Col30);
_graphTex.SetPixels32(_graphPixels);
_graphTex.Apply(false);
}
// Pixel row for a given ms value: row 0 = bottom (0ms), row TexH-1 = top (maxMs).
static int MsToRow(float ms, float maxMs) =>
Mathf.Clamp((int)(ms / maxMs * TexH), 0, TexH - 1);
void DrawHLine(float maxMs, float ms, Color32 color)
{
int row = MsToRow(ms, maxMs);
int offset = row * TexW;
for (int x = 0; x < TexW; x++)
_graphPixels[offset + x] = color;
}
// Fills a solid area from [baselines[i]] to [baselines[i] + values[i]] for each sample column.
// baselines == null means 0 (fill from the bottom of the chart).
void DrawFilledArea(float[] values, float[] baselines, int n, int write, float maxMs, Color32 color)
{
for (int x = 0; x < TexW; x++)
{
int si = (write + (int)((float)x / TexW * n)) % n;
float bot = baselines != null ? baselines[si] : 0f;
int rowBot = MsToRow(bot, maxMs);
int rowTop = MsToRow(bot + values[si], maxMs);
int lo = Mathf.Min(rowBot, rowTop);
int hi = Mathf.Max(rowBot, rowTop);
for (int r = lo; r <= hi; r++)
_graphPixels[r * TexW + x] = color;
}
}
void EnsureStyles()
{
if (_blueLabel != null) return;
_blueLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.2f, 0.55f, 0.85f) } };
_greenLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.4f, 1f, 0.4f) } };
_yellowLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(1f, 0.85f, 0.2f) } };
_orangeLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.9f, 0.45f, 0.1f) } };
_dimLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.55f, 0.55f, 0.55f) } };
_fps60Label = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.82f, 0.82f, 0.82f) } };
_fps30Label = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.82f, 0.82f, 0.82f) } };
_onStyle = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.4f, 1f, 0.4f) }, hover = { textColor = new Color(0.7f, 1f, 0.7f) } };
_offStyle = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(1f, 0.35f, 0.3f) }, hover = { textColor = new Color(1f, 0.6f, 0.55f) } };
// Custom window style: solid white background texture so GUI.backgroundColor.a
// gives true 0100% opacity rather than being capped by the skin's baked-in alpha.
_windowStyle = new GUIStyle(GUI.skin.window);
_windowStyle.normal.background = _solidTex;
_windowStyle.onNormal.background = _solidTex;
_windowStyle.focused.background = _solidTex;
_windowStyle.onFocused.background = _solidTex;
}
}