Compare commits

...

10 commits
0.2.5 ... main

Author SHA1 Message Date
aec4d2ecc4 Bump version to 0.2.7
Skip 0.2.6: this rolls up two releases' worth of work (the Mesh LOD and
Profiler modules and the map labels). Add 0.2.7 to the update manifest and
drop the oldest entry to keep the last three releases listed.
2026-06-25 20:15:02 -04:00
92649801c2 Document Mesh LOD, Profiler, and map labels
Add README sections for the two new modules (Mesh LOD and Profiler) and the
track and industry label feature, with settings screenshots, the unified
overlay shot, the three-level LOD comparison, and the label demo video. Update
the module table and remove the profiler-overlay mention from Physics
Optimizer now that the overlay lives in its own module.
2026-06-25 20:14:57 -04:00
eb6699f23f Fix proxy box color, mesh leak, and stale comment
The Mesh LOD proxy box rendered flat gray instead of the car's color. It was
being given a shared material tinted via Material.color (the legacy _Color
property), which URP ignores in favor of _BaseColor, and a single shared
material cannot match each car anyway. Reuse the car's own material so the box
matches its color again.

Also destroy the proxy box mesh when tearing down or refreshing a car's LOD
(the GameObject was destroyed but the Mesh leaked until scene unload), and
prune dead weak-refs from the tracked-car list periodically so it cannot grow
unbounded across a long session.

Fix a stale comment in PhysicsTimer that still referenced PhysicsOverlayGUI
after the overlay moved to the Profiler module.
2026-06-25 20:14:52 -04:00
4853015eff Profiler: new unified overlay module; absorb PhysicsOverlayGUI
Add a standalone Profiler module (S3.profiler.json, disabled by default)
that hosts the in-game frame-time overlay previously owned by Physics
Optimizer.  The overlay now adapts to whichever modules are enabled:

- Always shows: render + physics frame-time graph, timing report.
- Physics Optimizer section (if enabled): LOD fast-path and auto-freeze
  quick-toggles with live stats, debug car count line.
- Mesh LOD section (if enabled): total tracked cars, loco/freight split,
  per-LOD-level counts refreshed once per second.

PhysicsOptimizerModule retains only the Harmony patches and
CarDebugVisualizer; ShowOverlay/OverlayOpacity removed from PhysicsSettings.
MeshLodInjector gains GetLodStats() and GetLodLevel() for the overlay.
BBox material shader search now tries URP/Lit before Standard.
/rpf overlay toggle redirected to ProfilerOverlayGUI.Instance.
2026-06-25 19:06:46 -04:00
1b4ab97be5 MeshLOD: polish settings fields, bbox material, tracked car count
- Rename all MeshLodSettings public fields to camelCase (consistency
  with every other settings class in the project).
- Replace the proxy-box renderer's borrowed car material with a shared
  matte Standard material tinted to the car body albedo; cheaper at
  LOD3 distances and responds correctly to scene lighting at night.
- Expose GetTrackedCounts() and display loco/freight breakdown next to
  the Refresh Now button in the settings panel.
2026-06-25 18:57:50 -04:00
fcfdc6fba0 Map: fix T key teleport in overlay and popout; pass M through when module disabled
Overlay: our IsMouseOverGameWindow patch was blocking StrategyCameraController.TeleportToMouse()
as a side effect. Added native export RRPOPOUT_GetOverlayMouseMapPos (stores imgPos atomically
each render frame, returns normalized map-image cursor coords) and handle the T key directly in
UiHost.Update(), invoking MapDrag.OnTeleport with the correct viewport position. MapDrag.Update()
stays gated behind _pointerOver which never fires on our collapsed canvas, so there is no
double-teleport.

Popout: T key does not fire through Unity Input System when the game window lacks focus. Added
GetAsyncKeyState(VK_T) rising-edge detection in DetachedPanel.Update(), using the last MouseMove
event position (tracked before the drag-only guard) as the viewport coordinate.

Module disabled: all three MapWindow_Toggle/Show/ShowPos Harmony prefixes now pass through to the
game's native map when PopoutModule.Settings.enabled is false. Previously UiService.Install()
always applied the intercepts regardless of module state.

Also removed F10 as a shortcut to switch to the popout - F9 already does this and F10 conflicts
with the Shift+F10 UMM hotkey.
2026-06-25 18:16:49 -04:00
c5e75ad54a track and industry labels on map; Mesh LOD module skeleton
Map: per-track industry labels rendered via Dear ImGui on both the popout
and in-game overlay. Three zoom stages - per-track labels, merged same-name
clusters, and one-per-industry labels at wider zoom. Settings: font size
(min/max with zoom scaling), leader lines, parallel rotation, collision
spread, utility track filters (repair/diesel/loader/interchange), zoom
thresholds per category. C# runs a world-space AABB solver at rebuild so
offsets are rotation-invariant; native runs a per-frame screen-space
fine-tuning pass and draws leader lines.

Mesh LOD: new module skeleton with 4-level LOD group injected on
HandleModelsLoaded - three progressive renderer cull tiers (sorted by
bounds volume) plus a 12-tri proxy box at max distance. lodBias-corrected
distance-to-screenHeight conversion. Debounced live refresh on settings
change. Disabled by default.
2026-06-25 12:33:37 -04:00
c918dd7867 Fix release URL pattern in repository.json (no v prefix on tags) 2026-06-22 19:14:12 -04:00
29689f8a34 Include last 3 releases in UMM repository manifest 2026-06-22 19:12:21 -04:00
55bc84412a Add UMM auto-update repository manifest 2026-06-22 19:11:43 -04:00
34 changed files with 2441 additions and 342 deletions

View file

@ -2,10 +2,11 @@
"Id": "S3", "Id": "S3",
"DisplayName": "S³ - Seton's Special Sauce", "DisplayName": "S³ - Seton's Special Sauce",
"Author": "seton", "Author": "seton",
"Version": "0.2.5", "Version": "0.2.7",
"ManagerVersion": "0.27.0", "ManagerVersion": "0.27.0",
"GameVersionPoint": "0", "GameVersionPoint": "0",
"AssemblyName": "S3.dll", "AssemblyName": "S3.dll",
"EntryMethod": "S3.Main.Load", "EntryMethod": "S3.Main.Load",
"Requirements": [] "Requirements": [],
"Repository": "https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/raw/branch/main/repository.json"
} }

View file

@ -16,10 +16,14 @@ I originally planned on releasing individual mods, but considering my workflow o
| Module | What it does | | Module | What it does |
|---|---| |---|---|
| Physics Optimizer | Cuts CPU spent on train physics (LOD fast-path + auto-freeze), with a profiler overlay and debug car tinting. Console: `/rpf` | | Map Module | In-game map overlay and detachable popout window for a second monitor. Themes, custom colors, opacity controls, map rotation, track & industry labels, and optional MapEnhancer integration. |
| Map Module | In-game map overlay and detachable popout window for a second monitor. Themes, custom colors, opacity controls, map rotation, and optional MapEnhancer integration. | | Physics Optimizer | Cuts CPU spent on train physics (LOD fast-path + auto-freeze), with debug car tinting. Console: `/rpf` |
| Mesh LOD | Adds level-of-detail to rolling stock: distant cars progressively shed detail and finally collapse to a cheap proxy box, cutting triangle count on large saves. |
| Profiler | Unified in-game performance overlay: a frame-time graph plus live readouts that adapt to whichever optimization modules are enabled. Console: `/rpf overlay` |
Both are disabled by default; enable them per-module from the S³ settings page. A game restart is required for enable/disable to take effect. More modules will follow; S³ is designed to grow. All modules are disabled by default; enable them per-module from the S³ settings page. A game restart is required for enable/disable to take effect. More modules will follow; S³ is designed to grow.
![All S³ modules in the UMM settings page](img/all-umm-modules.png)
--- ---
@ -78,6 +82,14 @@ All of these options are available from the gear menu Icons submenu on the map t
![Icon controls settings](img/map/map_icons_config.png) ![Icon controls settings](img/map/map_icons_config.png)
### Track & Industry Labels
The map labels named tracks and industries, read live from the game's industry data so mod-added tracks appear automatically. Labels adapt to zoom in three stages: individual per-track labels (aligned to the track, with leader lines) up close, merged labels at medium zoom, and one large label per industry area when zoomed out. Font size, leader-line thickness, merge distance, and every zoom threshold are configurable from the gear-menu Labels submenu or the S³ settings page.
<video src="https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/raw/branch/main/img/map/map_track_and_industry_labels/3-stages-demo.mp4" controls></video>
![Track and industry labels on the map](img/map/map_track_and_industry_labels/lables_still.png)
### Popout Window ### Popout Window
Pop the map into a detached native OS window. Drag it to any monitor, resize it freely, and pin it always-on-top via the window's right-click title bar menu. Re-attach it back into the game overlay at any time from the settings panel without losing your position, zoom, or rotation. Pop the map into a detached native OS window. Drag it to any monitor, resize it freely, and pin it always-on-top via the window's right-click title bar menu. Re-attach it back into the game overlay at any time from the settings panel without losing your position, zoom, or rotation.
@ -110,7 +122,7 @@ Reduces CPU time spent on train physics with two complementary strategies:
- **LOD Fast-Path**: cars beyond a configurable distance switch to dead-reckoning, skipping expensive 3D constraint updates. A staggered resync keeps them accurate without a sudden full-update spike. - **LOD Fast-Path**: cars beyond a configurable distance switch to dead-reckoning, skipping expensive 3D constraint updates. A staggered resync keeps them accurate without a sudden full-update spike.
- **Auto Freeze**: consists that are far away and nearly stopped skip the Verlet integration tick entirely, replacing the stock freeze heuristic with a tighter distance + speed threshold. - **Auto Freeze**: consists that are far away and nearly stopped skip the Verlet integration tick entirely, replacing the stock freeze heuristic with a tighter distance + speed threshold.
Locomotives can be excluded from either strategy. A console profiler overlay (`/rpf`) and debug car tinting (yellow = frozen, cyan = fast-path, magenta = full) let you verify the LOD and freeze behavior in your session. Locomotives can be excluded from either strategy. Debug car tinting (yellow = frozen, cyan = fast-path, magenta = full) lets you verify the LOD and freeze behavior in your session, and the **Profiler** module's overlay reports live per-car physics timings and fast-path/freeze counts.
### Settings ### Settings
@ -127,6 +139,41 @@ LOD fast-path and Auto Freeze together cut per-car physics cost from ~28.6 µs t
--- ---
## Mesh LOD
Railroader has no level-of-detail system for rolling stock, so every car renders at full geometry no matter how far away it is. Mesh LOD adds one. Each car gets a four-level LODGroup built at load time:
- **LOD0**: full detail, up close.
- **LOD1 / LOD2**: progressive culling. Renderers are ranked by bounding-box volume (largest is the car body and trucks, smallest are bolts, grab irons, and rivets), and each level keeps a progressively smaller top fraction, so structural geometry survives while fine detail drops first.
- **LOD3**: a single 12-triangle proxy box, tinted to the car's own color, for cars far enough away that only their shape reads.
Transition distances are specified in metres (corrected for the game's LOD quality bias internally), and you can apply the system to locomotives, freight, or both independently. Because the LODGroup is rebuilt from the live car list, mod-added equipment is handled automatically.
This is a GPU-side optimization: it lowers triangle and draw cost for distant cars, so it helps most on large saves with many cars in view when you are GPU-bound.
![Mesh LOD settings](img/mesh_lod/umm_settings.png)
All three LOD reductions side by side, with the transition distances set artificially short to put every level in one frame: full detail at right, structural-only with trucks and accessories culled in the middle, and the flat color-matched proxy box at left.
![The three Mesh LOD levels side by side](img/mesh_lod/proxy_box_comparison.png)
---
## Profiler
A unified in-game performance overlay. It always shows a render + physics frame-time graph (render, FixedUpdate, Tick, and PosCars times) and a timing report, and it grows extra sections for whichever optimization modules are enabled:
- **Physics Optimizer**: LOD fast-path and auto-freeze quick-toggles with live fast/full and frozen counts.
- **Mesh LOD**: total tracked cars, the locomotive/freight split, and how many cars sit at each LOD level right now.
Toggle the overlay with `/rpf overlay`, or from the Profiler settings page.
![Profiler settings](img/profiler/umm_settings.png)
![Unified profiler overlay with Physics Optimizer and Mesh LOD sections](img/profiler/overlay_unified.png)
---
## Migrating from the standalone mods ## Migrating from the standalone mods
S³ replaces the separate **Physics Optimizer** (`RailroaderPhysicsOverhaul`) and S³ replaces the separate **Physics Optimizer** (`RailroaderPhysicsOverhaul`) and

BIN
img/all-umm-modules.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View file

@ -49,6 +49,24 @@ enum UICmd : int32_t {
ToggleEotd = 27, // toggle EOTD dot visibility ToggleEotd = 27, // toggle EOTD dot visibility
ToggleEotdOnlyWhenCulled = 28, // toggle hiding EOTD when car icons are visible ToggleEotdOnlyWhenCulled = 28, // toggle hiding EOTD when car icons are visible
SetEotdSizeScale = 29, // set EOTD dot size; y = [1.0, 10.0] SetEotdSizeScale = 29, // set EOTD dot size; y = [1.0, 10.0]
ToggleTrackLabels = 30, // toggle industry track name labels on the map
TrackLabelSetFontSize = 31, // y = font size px [8, 24]
TrackLabelSetLineThick = 32, // y = leader line thickness [1, 4]
TrackLabelSetZoomLimit = 33, // y = orthographicSize zoom-out limit [200, 8000]
ToggleLeaderLines = 34, // toggle leader lines from label to track anchor
ToggleCollision = 35, // toggle collision-avoidance label spread
ToggleParallelLabels = 36, // rotate labels to run parallel to their track
ToggleMergeLabels = 37, // merge same-name nearby spans into one label
TrackLabelSetMergeZoom = 38, // y = orthographicSize threshold for auto-merge
TrackLabelSetIndustryZoom = 39, // y = orthographicSize threshold for Stage 3 industry labels
ToggleUtilityRepairLabels = 40, // toggle repair-track labels
ToggleUtilityDieselLabels = 41, // toggle diesel-stand labels
ToggleUtilityLoaderLabels = 42, // toggle coal-loader labels
ToggleUtilityInterchangeLabels = 43, // toggle interchange labels (names auto-stripped)
TrackLabelSetUtilityZoom = 44, // y = orthographicSize beyond which utility labels hide
TrackLabelSetAllZoom = 45, // y = orthographicSize beyond which ALL labels hide
ToggleAvoidTrackLabels = 46, // push labels off their own track line
TrackLabelSetFontSizeMin = 47, // y = minimum font size px [4, max]
}; };
// Bit indices for ME bool settings packed into PopoutWindow::imMEFlags. // Bit indices for ME bool settings packed into PopoutWindow::imMEFlags.

View file

@ -449,6 +449,204 @@ void Renderer_ReleaseSwapchain(PopoutWindow* win) {
// is the floating window's map image rect, so the chrome tracks that window. // is the floating window's map image rect, so the chrome tracks that window.
// reserveResizeGrip: shorten the toolbar so the in-game ImGui window's bottom-right // reserveResizeGrip: shorten the toolbar so the in-game ImGui window's bottom-right
// resize grip stays grabbable (the popout uses its OS window border instead). // resize grip stays grabbable (the popout uses its OS window border instead).
// Draw SpawnPoint name labels over the map image. imgPos/imgSize define the
// pixel rect the map occupies on screen. C# pushes (name, u, v) per frame so
// the labels are always at the right world position regardless of pan/zoom.
static void DrawTrackLabels(PopoutWindow* win, ImDrawList* dl,
ImVec2 imgPos, ImVec2 imgSize) {
if (!win || !win->imTrackLabelsEnabled.load()) return;
std::lock_guard<std::mutex> lk(win->imTrackLabelMutex);
if (win->imTrackLabels.empty()) return;
ImFont* font = ImGui::GetFont();
const float baseSz = win->imTrackLabelFontSize.load();
const float lineThick = win->imTrackLabelLineThickness.load();
const bool doLeaders = win->imTrackLeaderLinesEnabled.load();
const bool doCollide = win->imTrackCollisionEnabled.load();
const bool doParallel = win->imTrackParallelEnabled.load();
const float kParallelOffset = 4.f; // extra gap between label and track in parallel mode
const float kDeg2Rad = 3.14159265f / 180.f;
const float kPad = 3.f; // padding added around each text bbox for collision
const float kLineDist = 10.f; // min pixels between label center and anchor to draw a leader line
const ImU32 kOutline = IM_COL32( 0, 0, 0, 220); // 4-direction text outline
const ImU32 kText = IM_COL32(255, 255, 255, 255); // fully opaque white text
const ImU32 kLineShadow= IM_COL32( 0, 0, 0, 180); // line shadow (drawn slightly thicker)
const ImU32 kLine = IM_COL32(255, 255, 255, 220); // near-opaque white leader line
// UV → screen pixel. V is flipped: viewport V=0 at bottom, ImGui Y=0 at top.
auto uvToScreen = [&](float u, float v) -> ImVec2 {
return { imgPos.x + u * imgSize.x, imgPos.y + (1.f - v) * imgSize.y };
};
int n = (int)win->imTrackLabels.size();
// ---- Build layout: one box (pos=top-left, half=half-extents) per label ----
// The UV already incorporates the C# world-space spread offset, so label positions
// are rotation-invariant. We simply center each box at its UV; the native collision
// and avoid-track passes handle any residual screen-space fine-tuning.
struct Box { ImVec2 pos; ImVec2 half; }; // pos = top-left of padded box
std::vector<Box> boxes(n);
std::vector<ImVec2> textSizes(n);
std::vector<ImVec2> initCenters(n); // center positions before native fine-tuning
for (int i = 0; i < n; i++) {
auto& lbl = win->imTrackLabels[i];
const float sz = baseSz * lbl.scale;
ImVec2 tsz = font->CalcTextSizeA(sz, FLT_MAX, 0.f, lbl.name);
textSizes[i] = tsz;
ImVec2 center = uvToScreen(lbl.u, lbl.v);
boxes[i].half = { tsz.x * 0.5f + kPad, tsz.y * 0.5f + kPad };
boxes[i].pos = { center.x - boxes[i].half.x, center.y - boxes[i].half.y };
initCenters[i] = center;
}
// ---- Iterative AABB collision avoidance ----
// Push overlapping boxes apart along the minimum-overlap axis. For stacked
// parallel-track labels this is almost always Y, giving clean vertical separation.
const int kMaxIter = 40;
for (int iter = 0; doCollide && iter < kMaxIter; iter++) {
bool anyOverlap = false;
for (int i = 0; i < n; i++) {
ImVec2 ci = { boxes[i].pos.x + boxes[i].half.x,
boxes[i].pos.y + boxes[i].half.y };
for (int j = i + 1; j < n; j++) {
ImVec2 cj = { boxes[j].pos.x + boxes[j].half.x,
boxes[j].pos.y + boxes[j].half.y };
float overlapX = (boxes[i].half.x + boxes[j].half.x) - fabsf(ci.x - cj.x);
float overlapY = (boxes[i].half.y + boxes[j].half.y) - fabsf(ci.y - cj.y);
if (overlapX <= 0.f || overlapY <= 0.f) continue;
anyOverlap = true;
// Push along whichever axis has less overlap (minimum separating axis)
float pushX = 0.f, pushY = 0.f;
if (overlapX < overlapY) {
pushX = overlapX * 0.55f * (ci.x < cj.x ? -1.f : 1.f);
} else {
pushY = overlapY * 0.55f * (ci.y < cj.y ? -1.f : 1.f);
}
boxes[i].pos.x += pushX; boxes[i].pos.y += pushY;
boxes[j].pos.x -= pushX; boxes[j].pos.y -= pushY;
// Recompute centers after move for subsequent pairs in this iteration
ci = { boxes[i].pos.x + boxes[i].half.x, boxes[i].pos.y + boxes[i].half.y };
}
}
if (!anyOverlap) break;
}
// ---- Track-line avoidance: push each label off its own track line(s) ----
// The track passes through each anchor at lbl.angle degrees. We compute the
// signed perpendicular distance from the label center to that line and push
// outward if the AABB still overlaps it.
const bool doAvoidTrack = win->imTrackLabelAvoidTrack.load();
const float kTrackHalfPx = 2.f; // approximate half-width of the rendered track line
if (doAvoidTrack) {
for (int i = 0; i < n; i++) {
auto& lbl = win->imTrackLabels[i];
if (lbl.anchorCount == 0) continue; // Stage-3 industry labels have no specific track
float rad = lbl.angle * kDeg2Rad;
float perpX = -sinf(rad), perpY = cosf(rad); // unit perpendicular (left of track)
// Use text height only for clearance — avoids over-pushing wide labels on
// diagonal tracks that the full Minkowski projection would cause.
float minClear = boxes[i].half.y + kTrackHalfPx;
ImVec2 ci = { boxes[i].pos.x + boxes[i].half.x,
boxes[i].pos.y + boxes[i].half.y };
float minAbsDist = FLT_MAX;
float bestSignedDist = 0.f;
for (int ai = lbl.anchorStart; ai < lbl.anchorStart + lbl.anchorCount; ai++) {
ImVec2 anchor = uvToScreen(win->imAnchorUs[ai], win->imAnchorVs[ai]);
float signedDist = (ci.x - anchor.x) * perpX + (ci.y - anchor.y) * perpY;
if (fabsf(signedDist) < minAbsDist) {
minAbsDist = fabsf(signedDist);
bestSignedDist = signedDist;
}
}
if (minAbsDist < minClear) {
float push = minClear - minAbsDist;
float dir = bestSignedDist >= 0.f ? 1.f : -1.f;
boxes[i].pos.x += perpX * push * dir;
boxes[i].pos.y += perpY * push * dir;
}
}
}
// Cap how far native fine-tuning can move a label from its UV-derived position.
// The C# world-space solver already handles the primary spread; this prevents any
// residual collision push from producing unexpectedly long leader lines.
const float kMaxDisplace = 60.f;
for (int i = 0; i < n; i++) {
ImVec2 solved = { boxes[i].pos.x + boxes[i].half.x, boxes[i].pos.y + boxes[i].half.y };
float dx = solved.x - initCenters[i].x, dy = solved.y - initCenters[i].y;
float d2 = dx*dx + dy*dy;
if (d2 > kMaxDisplace * kMaxDisplace) {
float sc = kMaxDisplace / sqrtf(d2);
boxes[i].pos.x = initCenters[i].x + dx*sc - boxes[i].half.x;
boxes[i].pos.y = initCenters[i].y + dy*sc - boxes[i].half.y;
}
}
// Compute draw centers from solved box positions.
// No hard edge-clamp: C# already culls centroids outside [0.05, 1.05] UV;
// text that spills slightly past the window edge is clipped naturally by ImGui.
std::vector<ImVec2> drawCenters(n);
for (int i = 0; i < n; i++) {
drawCenters[i] = { boxes[i].pos.x + boxes[i].half.x,
boxes[i].pos.y + boxes[i].half.y };
}
// ---- Pass 1: leader lines (drawn under text so text stays readable) ----
if (doLeaders) {
for (int i = 0; i < n; i++) {
auto& lbl = win->imTrackLabels[i];
ImVec2 lblCenter = drawCenters[i];
for (int ai = lbl.anchorStart; ai < lbl.anchorStart + lbl.anchorCount; ai++) {
// Anchors come from C# UV (already in rotated screen space) — no unrot needed.
ImVec2 anchor = uvToScreen(win->imAnchorUs[ai], win->imAnchorVs[ai]);
float dx = anchor.x - lblCenter.x, dy = anchor.y - lblCenter.y;
if (dx * dx + dy * dy < kLineDist * kLineDist) continue;
dl->AddLine(lblCenter, anchor, kLineShadow, lineThick + 1.5f);
dl->AddLine(lblCenter, anchor, kLine, lineThick);
}
}
}
// ---- Pass 2: text (4-direction outline then white fill; rotate in parallel mode) ----
for (int i = 0; i < n; i++) {
const char* name = win->imTrackLabels[i].name;
const float sz = baseSz * win->imTrackLabels[i].scale;
// Center text on the re-rotated draw position (math: drawCenter = solver_center rerotated,
// and solver_center was placed at the north-up UV position ± any spread offset).
ImVec2 tp = { drawCenters[i].x - textSizes[i].x * 0.5f,
drawCenters[i].y - textSizes[i].y * 0.5f };
int vtxBase = dl->VtxBuffer.Size;
dl->AddText(font, sz, ImVec2(tp.x - 1.f, tp.y ), kOutline, name);
dl->AddText(font, sz, ImVec2(tp.x + 1.f, tp.y ), kOutline, name);
dl->AddText(font, sz, ImVec2(tp.x, tp.y - 1.f), kOutline, name);
dl->AddText(font, sz, ImVec2(tp.x, tp.y + 1.f), kOutline, name);
dl->AddText(font, sz, tp, kText, name);
if (doParallel) {
// Rotate vertices around the draw center using the screen-space track angle.
// lbl.angle is already updated each frame by C# to reflect the current camera rotation.
float rad = win->imTrackLabels[i].angle * kDeg2Rad;
float cosA = cosf(rad), sinA = sinf(rad);
float cx = drawCenters[i].x, cy = drawCenters[i].y;
for (int v = vtxBase; v < dl->VtxBuffer.Size; v++) {
float dx = dl->VtxBuffer[v].pos.x - cx;
float dy = dl->VtxBuffer[v].pos.y - cy;
dl->VtxBuffer[v].pos.x = cx + dx * cosA - dy * sinA;
dl->VtxBuffer[v].pos.y = cy + dx * sinA + dy * cosA;
}
}
}
}
static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h, static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
bool reserveResizeGrip, bool showPopOutBtn, bool reserveResizeGrip, bool showPopOutBtn,
const MapThemeData& theme, float mapAlpha = 1.0f) { const MapThemeData& theme, float mapAlpha = 1.0f) {
@ -911,6 +1109,171 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
ImGui::Separator(); ImGui::Separator();
// Track name labels submenu
if (ImGui::BeginMenu("Track labels")) {
{
bool v = win->imTrackLabelsEnabled.load();
if (ImGui::MenuItem("Enabled", nullptr, v)) {
win->imTrackLabelsEnabled.store(!v);
pushCmd(UICmd::ToggleTrackLabels);
}
}
ImGui::Separator();
// ── Style sub-menu ──────────────────────────────────────────────────
if (ImGui::BeginMenu("Style")) {
{
float v = win->imTrackLabelFontSize.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Font size (max)##tl", &v, 8.f, 32.f, "%.0f px"))
win->imTrackLabelFontSize.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetFontSize, win->imTrackLabelFontSize.load());
}
{
float v = win->imTrackLabelFontSizeMin.load();
float maxSz = win->imTrackLabelFontSize.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Font size (min)##tl", &v, 4.f, maxSz, "%.0f px"))
win->imTrackLabelFontSizeMin.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetFontSizeMin, win->imTrackLabelFontSizeMin.load());
}
{
float v = win->imTrackLabelLineThickness.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Line weight##tl", &v, 1.f, 4.f, "%.1f px"))
win->imTrackLabelLineThickness.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetLineThick, win->imTrackLabelLineThickness.load());
}
ImGui::Separator();
{
bool v = win->imTrackLeaderLinesEnabled.load();
if (ImGui::MenuItem("Leader lines", nullptr, v)) {
win->imTrackLeaderLinesEnabled.store(!v);
pushCmd(UICmd::ToggleLeaderLines);
}
}
{
bool v = win->imTrackCollisionEnabled.load();
if (ImGui::MenuItem("Spread overlapping labels", nullptr, v)) {
win->imTrackCollisionEnabled.store(!v);
pushCmd(UICmd::ToggleCollision);
}
}
{
bool v = win->imTrackParallelEnabled.load();
if (ImGui::MenuItem("Parallel to track", nullptr, v)) {
win->imTrackParallelEnabled.store(!v);
pushCmd(UICmd::ToggleParallelLabels);
}
}
{
bool v = win->imTrackLabelAvoidTrack.load();
if (ImGui::MenuItem("Avoid track lines", nullptr, v)) {
win->imTrackLabelAvoidTrack.store(!v);
pushCmd(UICmd::ToggleAvoidTrackLabels);
}
}
{
bool v = win->imTrackMergeEnabled.load();
if (ImGui::MenuItem("Merge same-name labels", nullptr, v)) {
win->imTrackMergeEnabled.store(!v);
pushCmd(UICmd::ToggleMergeLabels);
}
}
ImGui::EndMenu();
}
// ── Zoom thresholds sub-menu ────────────────────────────────────────
if (ImGui::BeginMenu("Zoom thresholds")) {
ImGui::TextDisabled("Smaller = labels show closer in");
ImGui::Separator();
{
float v = win->imTrackLabelMergeZoom.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Merge beyond##tl", &v, 50.f, 8000.f, "%.0f", ImGuiSliderFlags_Logarithmic))
win->imTrackLabelMergeZoom.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetMergeZoom, win->imTrackLabelMergeZoom.load());
}
{
float v = win->imTrackLabelZoomLimit.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Hide beyond##tl", &v, 200.f, 8000.f, "%.0f", ImGuiSliderFlags_Logarithmic))
win->imTrackLabelZoomLimit.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetZoomLimit, win->imTrackLabelZoomLimit.load());
}
{
float v = win->imTrackIndustryZoom.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Industry beyond##tl", &v, 200.f, 8000.f, "%.0f"))
win->imTrackIndustryZoom.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetIndustryZoom, win->imTrackIndustryZoom.load());
}
{
float v = win->imTrackAllLabelsZoom.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Hide all beyond##tl", &v, 200.f, 8000.f, "%.0f"))
win->imTrackAllLabelsZoom.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetAllZoom, win->imTrackAllLabelsZoom.load());
}
ImGui::EndMenu();
}
// ── Utility tracks sub-menu ─────────────────────────────────────────
if (ImGui::BeginMenu("Utility tracks")) {
ImGui::TextDisabled("Toggle label categories");
ImGui::Separator();
{
bool v = win->imTrackUtilityRepair.load();
if (ImGui::MenuItem("Repair tracks", nullptr, v)) {
win->imTrackUtilityRepair.store(!v);
pushCmd(UICmd::ToggleUtilityRepairLabels);
}
}
{
bool v = win->imTrackUtilityDiesel.load();
if (ImGui::MenuItem("Diesel stands", nullptr, v)) {
win->imTrackUtilityDiesel.store(!v);
pushCmd(UICmd::ToggleUtilityDieselLabels);
}
}
{
bool v = win->imTrackUtilityLoader.load();
if (ImGui::MenuItem("Coal loaders", nullptr, v)) {
win->imTrackUtilityLoader.store(!v);
pushCmd(UICmd::ToggleUtilityLoaderLabels);
}
}
{
bool v = win->imTrackUtilityInterchange.load();
if (ImGui::MenuItem("Interchanges", nullptr, v)) {
win->imTrackUtilityInterchange.store(!v);
pushCmd(UICmd::ToggleUtilityInterchangeLabels);
}
}
ImGui::Separator();
{
float v = win->imTrackUtilityZoom.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Hide beyond##tu", &v, 50.f, 8000.f, "%.0f"))
win->imTrackUtilityZoom.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetUtilityZoom, win->imTrackUtilityZoom.load());
}
ImGui::EndMenu();
}
ImGui::EndMenu();
}
ImGui::Separator();
// Theme preset picker // Theme preset picker
if (ImGui::BeginMenu("Theme")) { if (ImGui::BeginMenu("Theme")) {
int cur = g_currentThemePreset.load(); int cur = g_currentThemePreset.load();
@ -1043,6 +1406,12 @@ void Renderer_Present(PopoutWindow* win) {
BuildMapUI(win, ImVec2(0.f, 0.f), (float)w, (float)h, BuildMapUI(win, ImVec2(0.f, 0.f), (float)w, (float)h,
/*reserveResizeGrip=*/false, /*showPopOutBtn=*/false, theme); /*reserveResizeGrip=*/false, /*showPopOutBtn=*/false, theme);
// Track labels for the popout. The map is a fullscreen blit (not an ImGui
// Image), so (0,0)→(w,h) IS the image rect. Foreground drawlist renders above
// all ImGui windows so labels are always readable.
DrawTrackLabels(win, ImGui::GetForegroundDrawList(),
ImVec2(0.f, 0.f), ImVec2((float)w, (float)h));
ImGui::Render(); ImGui::Render();
ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData()); ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());
win->imWantMouse.store(ImGui::GetIO().WantCaptureMouse); win->imWantMouse.store(ImGui::GetIO().WantCaptureMouse);
@ -1088,6 +1457,20 @@ void Overlay_GetMapView(float* outW, float* outH) {
if (outH) *outH = g_ovViewH.load(); if (outH) *outH = g_ovViewH.load();
} }
// Screen-space top-left corner (pixels, top-left origin) of the map image area, stored
// each frame so C# can compute a normalized in-image mouse position for teleport.
// -1 when the map is not currently drawn (no save loaded, or wrong frame).
static std::atomic<float> g_ovMapImgX {-1.f}, g_ovMapImgY {-1.f};
void Overlay_GetMouseMapPos(float* outX, float* outY) {
float imgX = g_ovMapImgX.load(), imgY = g_ovMapImgY.load();
float vw = g_ovViewW.load(), vh = g_ovViewH.load();
if (imgX < 0.f || vw <= 0.f || vh <= 0.f) {
if (outX) *outX = -1.f; if (outY) *outY = -1.f; return;
}
if (outX) *outX = (g_ovMouseX.load() - imgX) / vw;
if (outY) *outY = (g_ovMouseY.load() - imgY) / vh;
}
void Overlay_SetAlpha(float alpha) { g_ovAlpha.store(std::max(0.1f, std::min(1.0f, alpha))); } void Overlay_SetAlpha(float alpha) { g_ovAlpha.store(std::max(0.1f, std::min(1.0f, alpha))); }
void Overlay_SetMapAlpha(float alpha) { g_mapAlpha.store(std::max(0.0f, std::min(1.0f, alpha))); } void Overlay_SetMapAlpha(float alpha) { g_mapAlpha.store(std::max(0.0f, std::min(1.0f, alpha))); }
void Overlay_SetMapBgAlpha(float alpha) { g_mapBgAlpha.store(std::max(0.0f, std::min(1.0f, alpha))); } void Overlay_SetMapBgAlpha(float alpha) { g_mapBgAlpha.store(std::max(0.0f, std::min(1.0f, alpha))); }
@ -1227,6 +1610,8 @@ void Renderer_PresentOverlay() {
// normalized [0,1] image space (top-left origin) so C# can forward // normalized [0,1] image space (top-left origin) so C# can forward
// them to the map camera with the same math the popout uses. // them to the map camera with the same math the popout uses.
ImVec2 imgPos = ImGui::GetCursorScreenPos(); ImVec2 imgPos = ImGui::GetCursorScreenPos();
g_ovMapImgX.store(imgPos.x);
g_ovMapImgY.store(imgPos.y);
ImGui::InvisibleButton("##mapHit", sz, ImGuiButtonFlags_MouseButtonLeft); ImGui::InvisibleButton("##mapHit", sz, ImGuiButtonFlags_MouseButtonLeft);
bool hov = ImGui::IsItemHovered(); bool hov = ImGui::IsItemHovered();
ImVec2 nrm = { (io.MousePos.x - imgPos.x) / sz.x, ImVec2 nrm = { (io.MousePos.x - imgPos.x) / sz.x,
@ -1254,6 +1639,10 @@ void Renderer_PresentOverlay() {
ImGui::Image((ImTextureID)g_ovMapSRV.Get(), sz, uv0, uv1, tint); ImGui::Image((ImTextureID)g_ovMapSRV.Get(), sz, uv0, uv1, tint);
if (hasAlpha) ImGui::PushStyleVar(ImGuiStyleVar_Alpha, effectiveAlpha); if (hasAlpha) ImGui::PushStyleVar(ImGuiStyleVar_Alpha, effectiveAlpha);
// Track name labels overlaid on the map image (C# pushes name+UV each frame)
if (stateWin)
DrawTrackLabels(stateWin, ImGui::GetWindowDrawList(), imgPos, sz);
// Visible resize-grip indicator. ImGui draws its own grip during // Visible resize-grip indicator. ImGui draws its own grip during
// Begin() — behind our opaque map image, so invisible. We draw one // Begin() — behind our opaque map image, so invisible. We draw one
// on top in the corner only while the window is focused (the dark // on top in the corner only while the window is focused (the dark

View file

@ -43,6 +43,10 @@ int Overlay_PollInput(InputEvent* out, int maxEvents);
// aspect to the window shape (map fills the window, no letterbox bars). // aspect to the window shape (map fills the window, no letterbox bars).
void Overlay_GetMapView(float* outW, float* outH); void Overlay_GetMapView(float* outW, float* outH);
// Current mouse position normalised within the map image (top-left origin, [0,1]).
// Returns (-1, -1) when the map is not currently rendered (no save or map inactive).
void Overlay_GetMouseMapPos(float* outX, float* outY);
// Show/hide the overlay UI. // Show/hide the overlay UI.
void Overlay_SetVisible(bool visible); void Overlay_SetVisible(bool visible);

View file

@ -130,6 +130,13 @@ void RRPOPOUT_GetOverlayMapView(float* outW, float* outH) {
Overlay_GetMapView(outW, outH); Overlay_GetMapView(outW, outH);
} }
// Current mouse position normalised within the map image (top-left origin, [0,1]).
// Returns (-1, -1) when the map image is not visible this frame.
extern "C" __declspec(dllexport)
void RRPOPOUT_GetOverlayMouseMapPos(float* outX, float* outY) {
Overlay_GetMouseMapPos(outX, outY);
}
// Handle of the in-game overlay's shared UI-state holder. C# pushes status text, // Handle of the in-game overlay's shared UI-state holder. C# pushes status text,
// loco/location lists, rotation, follow flags, etc. to this handle with the same // loco/location lists, rotation, follow flags, etc. to this handle with the same
// RRPOPOUT_Set* / RRPOPOUT_PollInputEvents functions the popout uses. // RRPOPOUT_Set* / RRPOPOUT_PollInputEvents functions the popout uses.
@ -355,6 +362,94 @@ void RRPOPOUT_SetOverlayMapAlpha(float alpha) {
Overlay_SetMapAlpha(alpha); Overlay_SetMapAlpha(alpha);
} }
// Push industry track name labels for the map.
// namesBlobW: UTF-16 track names joined by '\n'.
// us/vs: centroid UV per label (C# projects TrackSpan centroid through the map camera).
// anchorUs/anchorVs: flat UV arrays for all span anchor positions.
// anchorStarts/anchorCounts: per-label index + count into the anchor arrays.
// count=0 clears everything (zoomed out or map inactive).
extern "C" __declspec(dllexport)
void RRPOPOUT_SetTrackLabels(int windowHandle, const wchar_t* namesBlobW,
const float* us, const float* vs,
const float* anchorUs, const float* anchorVs,
const int* anchorStarts, const int* anchorCounts,
const float* angles, const float* scales,
int count) {
PopoutWindow* win = GetPopoutWindow(windowHandle);
if (!win) return;
std::vector<PopoutWindow::ImTrackLabel> tmpLabels;
std::vector<float> tmpAnchorUs, tmpAnchorVs;
if (namesBlobW && count > 0) {
tmpLabels.reserve(count);
const wchar_t* p = namesBlobW;
for (int i = 0; i < count; ++i) {
const wchar_t* end = p;
while (*end && *end != L'\n') ++end;
PopoutWindow::ImTrackLabel lbl{};
WideCharToMultiByte(CP_UTF8, 0, p, (int)(end - p),
lbl.name, (int)sizeof(lbl.name) - 1, nullptr, nullptr);
lbl.u = us[i]; lbl.v = vs[i];
lbl.angle = angles[i];
lbl.scale = scales ? scales[i] : 1.0f;
lbl.anchorStart = anchorStarts[i];
lbl.anchorCount = anchorCounts[i];
tmpLabels.push_back(lbl);
p = (*end == L'\n') ? end + 1 : end;
}
int totalAnchors = anchorStarts[count - 1] + anchorCounts[count - 1];
tmpAnchorUs.assign(anchorUs, anchorUs + totalAnchors);
tmpAnchorVs.assign(anchorVs, anchorVs + totalAnchors);
}
std::lock_guard<std::mutex> lk(win->imTrackLabelMutex);
win->imTrackLabels = std::move(tmpLabels);
win->imAnchorUs = std::move(tmpAnchorUs);
win->imAnchorVs = std::move(tmpAnchorVs);
}
// Enable or disable track label rendering for this window. Default: enabled.
extern "C" __declspec(dllexport)
void RRPOPOUT_SetTrackLabelsEnabled(int windowHandle, bool enabled) {
PopoutWindow* win = GetPopoutWindow(windowHandle);
if (win) win->imTrackLabelsEnabled.store(enabled);
}
// Seed all track-label style settings at once. Call on map activate and on any change.
extern "C" __declspec(dllexport)
void RRPOPOUT_SetTrackLabelStyle(int windowHandle,
float fontSize, float lineThickness, float zoomLimit,
bool leaderLinesEnabled, bool collisionEnabled,
bool parallelEnabled, bool mergeEnabled, float mergeZoom,
float industryZoom,
bool utilityRepairEnabled, bool utilityDieselEnabled,
bool utilityLoaderEnabled, bool utilityInterchangeEnabled,
float utilityZoom,
float allLabelsZoom,
bool avoidTrack,
float fontSizeMin) {
PopoutWindow* win = GetPopoutWindow(windowHandle);
if (!win) return;
win->imTrackLabelFontSize.store(fontSize);
win->imTrackLabelLineThickness.store(lineThickness);
win->imTrackLabelZoomLimit.store(zoomLimit);
win->imTrackLeaderLinesEnabled.store(leaderLinesEnabled);
win->imTrackCollisionEnabled.store(collisionEnabled);
win->imTrackParallelEnabled.store(parallelEnabled);
win->imTrackMergeEnabled.store(mergeEnabled);
win->imTrackLabelMergeZoom.store(mergeZoom);
win->imTrackIndustryZoom.store(industryZoom);
win->imTrackUtilityRepair.store(utilityRepairEnabled);
win->imTrackUtilityDiesel.store(utilityDieselEnabled);
win->imTrackUtilityLoader.store(utilityLoaderEnabled);
win->imTrackUtilityInterchange.store(utilityInterchangeEnabled);
win->imTrackUtilityZoom.store(utilityZoom);
win->imTrackAllLabelsZoom.store(allLabelsZoom);
win->imTrackLabelAvoidTrack.store(avoidTrack);
win->imTrackLabelFontSizeMin.store(fontSizeMin);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin_Initialize / Plugin_Shutdown (called from dllmain.cpp) // Plugin_Initialize / Plugin_Shutdown (called from dllmain.cpp)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -105,6 +105,43 @@ struct PopoutWindow {
std::atomic<float> imMETrackThick {1.25f}; std::atomic<float> imMETrackThick {1.25f};
std::atomic<float> imMECrossingSc {0.3f}; std::atomic<float> imMECrossingSc {0.3f};
// -----------------------------------------------------------------------
// Track name labels (industry track names + viewport UVs, pushed from C# each frame).
// C# projects TrackSpan centroids through the map camera, culls off-screen entries,
// and pushes (name, centroid_uv, anchors). Native runs per-frame collision avoidance
// (AABB push in pixel space) then draws leader lines to each anchor before the text.
// -----------------------------------------------------------------------
struct ImTrackLabel {
char name[64];
float u, v; // centroid UV — starting position for the layout solver
float angle; // track direction in screen space (degrees; 0=right, +ve=CW)
float scale; // font size multiplier (1.0 = track label, 1.5 = industry label)
int anchorStart; // index into imAnchorUs/Vs
int anchorCount; // 1 for single span; >1 for merged same-name cluster
};
std::vector<ImTrackLabel> imTrackLabels;
std::vector<float> imAnchorUs; // flat anchor UV arrays, indexed by anchorStart
std::vector<float> imAnchorVs;
std::mutex imTrackLabelMutex;
std::atomic<bool> imTrackLabelsEnabled {true};
std::atomic<float> imTrackLabelFontSize {14.f};
std::atomic<float> imTrackLabelLineThickness {1.5f};
std::atomic<float> imTrackLabelZoomLimit {3000.f};
std::atomic<bool> imTrackLeaderLinesEnabled {true};
std::atomic<bool> imTrackCollisionEnabled {true};
std::atomic<bool> imTrackParallelEnabled {false};
std::atomic<bool> imTrackMergeEnabled {true};
std::atomic<float> imTrackLabelMergeZoom {150.f};
std::atomic<float> imTrackIndustryZoom {200.f};
std::atomic<bool> imTrackUtilityRepair {true};
std::atomic<bool> imTrackUtilityDiesel {true};
std::atomic<bool> imTrackUtilityLoader {true};
std::atomic<bool> imTrackUtilityInterchange {true};
std::atomic<float> imTrackUtilityZoom {200.f};
std::atomic<float> imTrackAllLabelsZoom {8000.f};
std::atomic<bool> imTrackLabelAvoidTrack {false};
std::atomic<float> imTrackLabelFontSizeMin {8.f};
// Loaded geometry from the save file — consumed by MessagePumpThread before CreateWindowExW. // Loaded geometry from the save file — consumed by MessagePumpThread before CreateWindowExW.
std::atomic<int> lastWinX {-1}, lastWinY {-1}; std::atomic<int> lastWinX {-1}, lastWinY {-1};
std::atomic<int> lastWinW {900}, lastWinH {700}; std::atomic<int> lastWinW {900}, lastWinH {700};

19
repository.json Normal file
View file

@ -0,0 +1,19 @@
{
"Releases": [
{
"Id": "S3",
"Version": "0.2.7",
"DownloadUrl": "https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/releases/download/0.2.7/SetonsSpecialSauce-0.2.7.zip"
},
{
"Id": "S3",
"Version": "0.2.5",
"DownloadUrl": "https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/releases/download/0.2.5/SetonsSpecialSauce-0.2.5.zip"
},
{
"Id": "S3",
"Version": "0.2.4",
"DownloadUrl": "https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/releases/download/0.2.4/SetonsSpecialSauce-0.2.4.zip"
}
]
}

View file

@ -1,4 +1,9 @@
using System.Collections; using System.Collections;
using System.Collections.Generic;
using System.Text;
using Helpers; // WorldTransformer
using Model.Ops; // IndustryComponent
using Track; // TrackSpan
using HarmonyLib; using HarmonyLib;
using S3.Modules.Popout; // NativeLoader + Native + PanelFinder + MapEnhancerBridge using S3.Modules.Popout; // NativeLoader + Native + PanelFinder + MapEnhancerBridge
using UI; // GameInput using UI; // GameInput
@ -80,12 +85,14 @@ internal static class GameInput_IsMouseOverGameWindow_Patch
// Intercept the map hotkey (GameInput calls Toggle() when M / the bound key is pressed). // Intercept the map hotkey (GameInput calls Toggle() when M / the bound key is pressed).
// Open our overlay instead of the stock map panel. // Open our overlay instead of the stock map panel.
// Pass through when the Map Module is disabled so the game's native map opens normally.
[HarmonyPatch(typeof(MapWindow), nameof(MapWindow.Toggle))] [HarmonyPatch(typeof(MapWindow), nameof(MapWindow.Toggle))]
internal static class MapWindow_Toggle_Patch internal static class MapWindow_Toggle_Patch
{ {
private static bool Prefix() private static bool Prefix()
{ {
if (UiService.MapBypass) return true; if (UiService.MapBypass) return true;
if (!PopoutModule.Settings.enabled) return true;
UiService.ToggleOverlay(); UiService.ToggleOverlay();
return false; return false;
} }
@ -98,6 +105,7 @@ internal static class MapWindow_Show_Patch
private static bool Prefix() private static bool Prefix()
{ {
if (UiService.MapBypass) return true; if (UiService.MapBypass) return true;
if (!PopoutModule.Settings.enabled) return true;
UiService.OpenOverlay(); UiService.OpenOverlay();
return false; return false;
} }
@ -111,6 +119,7 @@ internal static class MapWindow_ShowPos_Patch
private static bool Prefix(Vector3 gamePosition) private static bool Prefix(Vector3 gamePosition)
{ {
if (UiService.MapBypass) return true; if (UiService.MapBypass) return true;
if (!PopoutModule.Settings.enabled) return true;
UiService.OpenOverlay(); UiService.OpenOverlay();
MapBuilder.Shared?.SetMapCenter(gamePosition); MapBuilder.Shared?.SetMapCenter(gamePosition);
return false; return false;
@ -173,6 +182,40 @@ internal sealed class UiHost : MonoBehaviour
private bool _meDirty; private bool _meDirty;
private string _lastStatus = ""; private string _lastStatus = "";
// Track name labels: rebuilt every 5 s from IndustryComponent list.
// Stored in simulation ("game") space so GameToWorld() is applied each frame
// (WorldTransformer offset can shift between rebuilds).
// trackDir: game-space direction of the track, used to compute per-frame screen angle.
// anchors[]: individual span positions in a merged cluster (for leader lines).
private IndustryComponent[]? _industryComponents;
// Tracks the merge state used for the last rebuild so we can detect transitions
// caused by the auto-merge zoom threshold crossing and force a fresh rebuild.
private bool _effectiveMergeEnabled = true;
private readonly List<(Vector3 centroid, Vector3 trackDir, Vector3[] anchors, string name, int utilityType)> _trackSpanLabels = new();
private readonly List<(Vector3 centroid, string name, int utilityType)> _industryLabels = new();
private float _spawnPointTimer = 99f; // force rebuild on first tick
private int _lastLabelCount = -1;
private readonly StringBuilder _labelNames = new();
// Per-label world-space offset from centroid, computed by ComputeWorldSpaceOffsets()
// during rebuild. Applied in PushTrackLabels so labels stay locked to their tracks
// regardless of camera rotation or pan — the offset is rotation-invariant (world XZ).
private Vector3[] _labelWorldOffsets = System.Array.Empty<Vector3>();
private float[] _labelUs = System.Array.Empty<float>();
private float[] _labelVs = System.Array.Empty<float>();
private float[] _labelAngles = System.Array.Empty<float>(); // per-label screen-space angle (degrees)
private float[] _labelScales = System.Array.Empty<float>(); // per-label font size multiplier
// Flat anchor arrays: all span anchor UVs packed end-to-end across all visible labels.
// _anchorStarts[i] / _anchorCounts[i] index into _anchorUs/Vs for label i.
private float[] _anchorUs = System.Array.Empty<float>();
private float[] _anchorVs = System.Array.Empty<float>();
private int[] _anchorStarts = System.Array.Empty<int>();
private int[] _anchorCounts = System.Array.Empty<int>();
private int _totalAnchorCount = 0; // sum of anchors across all cached labels (array size bound)
private const float kMergeDistGame = 250f; // sim units (≈ft); merge same-name spans within this radius
private static readonly float[] s_emptyFloat = System.Array.Empty<float>();
private static readonly int[] s_emptyInt = System.Array.Empty<int>();
private void Start() private void Start()
{ {
if (!NativeLoader.EnsureLoaded()) if (!NativeLoader.EnsureLoaded())
@ -216,10 +259,16 @@ internal sealed class UiHost : MonoBehaviour
// window, so the rest of the screen stays interactive while the map floats. // window, so the rest of the screen stays interactive while the map floats.
MouseOverOverlay = _visible && Native.RRPOPOUT_OverlayWantsMouse() == 1; MouseOverOverlay = _visible && Native.RRPOPOUT_OverlayWantsMouse() == 1;
// F10: switch the in-game map to the OS popout window. Only fires while the // T key ("Jump to Mouse"): our IsMouseOverGameWindow patch blocks the game's
// in-game overlay is active (no-op if neither map surface is open). // StrategyCameraController.TeleportToMouse() while the overlay is up, so we
if (_visible && Input.GetKeyDown(KeyCode.F10)) // handle it ourselves using the normalised cursor position within the map image.
PopoutModule.ScheduleExternalLaunch(); if (_visible && _mapActive && GameInput.shared.Teleport)
{
Native.RRPOPOUT_GetOverlayMouseMapPos(out float mx, out float my);
// mx/my are top-left origin [0,1]; Unity viewport is bottom-left origin.
if (mx >= 0f && mx <= 1f && my >= 0f && my <= 1f)
PanelFinder.GetMapDrag()?.OnTeleport?.Invoke(new Vector2(mx, 1f - my));
}
} }
private void LateUpdate() private void LateUpdate()
@ -334,6 +383,7 @@ internal sealed class UiHost : MonoBehaviour
if (_mapActive && _mapCamera != null) if (_mapActive && _mapCamera != null)
{ {
UpdateMapStatus(); UpdateMapStatus();
PushTrackLabels();
RefreshMenuLists(); RefreshMenuLists();
ApplyFollowAndSync(); ApplyFollowAndSync();
int cn = Native.RRPOPOUT_PollInputEvents(_overlayHandle, s_inputBuf, kMaxInput); int cn = Native.RRPOPOUT_PollInputEvents(_overlayHandle, s_inputBuf, kMaxInput);
@ -596,6 +646,99 @@ internal sealed class UiHost : MonoBehaviour
PopoutModule.Settings.eotdSizeScale = Mathf.Clamp(e.y, 1.0f, 10.0f); PopoutModule.Settings.eotdSizeScale = Mathf.Clamp(e.y, 1.0f, 10.0f);
PopoutModule.Persist(); PopoutModule.Persist();
break; break;
case UICmd.ToggleTrackLabels:
PopoutModule.Settings.trackLabelsEnabled = !PopoutModule.Settings.trackLabelsEnabled;
PopoutModule.Persist();
Native.RRPOPOUT_SetTrackLabelsEnabled(_overlayHandle, PopoutModule.Settings.trackLabelsEnabled);
if (!PopoutModule.Settings.trackLabelsEnabled)
{
// Clear immediately so no stale labels are shown while disabled
Native.RRPOPOUT_SetTrackLabels(_overlayHandle, "",
s_emptyFloat, s_emptyFloat, s_emptyFloat, s_emptyFloat,
s_emptyInt, s_emptyInt, s_emptyFloat, s_emptyFloat, 0);
_lastLabelCount = 0;
}
break;
case UICmd.TrackLabelSetFontSize:
PopoutModule.Settings.trackLabelFontSize = Mathf.Clamp(e.y, 8f, 24f);
PopoutModule.Persist();
SeedTrackLabelStyle(_overlayHandle);
break;
case UICmd.TrackLabelSetLineThick:
PopoutModule.Settings.trackLabelLineThickness = Mathf.Clamp(e.y, 1f, 4f);
PopoutModule.Persist();
SeedTrackLabelStyle(_overlayHandle);
break;
case UICmd.TrackLabelSetZoomLimit:
PopoutModule.Settings.trackLabelZoomLimit = Mathf.Clamp(e.y, 200f, 8000f);
PopoutModule.Persist();
SeedTrackLabelStyle(_overlayHandle);
break;
case UICmd.ToggleLeaderLines:
PopoutModule.Settings.trackLeaderLinesEnabled = !PopoutModule.Settings.trackLeaderLinesEnabled;
PopoutModule.Persist();
SeedTrackLabelStyle(_overlayHandle);
break;
case UICmd.ToggleCollision:
PopoutModule.Settings.trackCollisionEnabled = !PopoutModule.Settings.trackCollisionEnabled;
PopoutModule.Persist();
SeedTrackLabelStyle(_overlayHandle);
break;
case UICmd.ToggleParallelLabels:
PopoutModule.Settings.trackLabelParallel = !PopoutModule.Settings.trackLabelParallel;
PopoutModule.Persist();
SeedTrackLabelStyle(_overlayHandle);
break;
case UICmd.ToggleMergeLabels:
PopoutModule.Settings.trackLabelMergeEnabled = !PopoutModule.Settings.trackLabelMergeEnabled;
PopoutModule.Persist();
SeedTrackLabelStyle(_overlayHandle);
_spawnPointTimer = 99f;
break;
case UICmd.TrackLabelSetMergeZoom:
PopoutModule.Settings.trackLabelMergeZoom = Mathf.Clamp(e.y, 50f, 8000f);
PopoutModule.Persist();
break;
case UICmd.TrackLabelSetIndustryZoom:
PopoutModule.Settings.trackIndustryLabelZoom = Mathf.Clamp(e.y, 200f, 8000f);
PopoutModule.Persist();
break;
case UICmd.ToggleUtilityRepairLabels:
PopoutModule.Settings.trackUtilityRepairEnabled = !PopoutModule.Settings.trackUtilityRepairEnabled;
PopoutModule.Persist();
_spawnPointTimer = 99f;
break;
case UICmd.ToggleUtilityDieselLabels:
PopoutModule.Settings.trackUtilityDieselEnabled = !PopoutModule.Settings.trackUtilityDieselEnabled;
PopoutModule.Persist();
_spawnPointTimer = 99f;
break;
case UICmd.ToggleUtilityLoaderLabels:
PopoutModule.Settings.trackUtilityLoaderEnabled = !PopoutModule.Settings.trackUtilityLoaderEnabled;
PopoutModule.Persist();
_spawnPointTimer = 99f;
break;
case UICmd.ToggleUtilityInterchangeLabels:
PopoutModule.Settings.trackUtilityInterchangeEnabled = !PopoutModule.Settings.trackUtilityInterchangeEnabled;
PopoutModule.Persist();
_spawnPointTimer = 99f;
break;
case UICmd.TrackLabelSetUtilityZoom:
PopoutModule.Settings.trackUtilityZoomLimit = Mathf.Clamp(e.y, 50f, 8000f);
PopoutModule.Persist();
break;
case UICmd.TrackLabelSetAllZoom:
PopoutModule.Settings.trackAllLabelsZoomLimit = Mathf.Clamp(e.y, 200f, 8000f);
PopoutModule.Persist();
break;
case UICmd.ToggleAvoidTrackLabels:
PopoutModule.Settings.trackLabelAvoidTrack = !PopoutModule.Settings.trackLabelAvoidTrack;
PopoutModule.Persist();
break;
case UICmd.TrackLabelSetFontSizeMin:
PopoutModule.Settings.trackLabelFontSizeMin = Mathf.Clamp(e.y, 4f, PopoutModule.Settings.trackLabelFontSize);
PopoutModule.Persist();
break;
} }
} }
@ -731,6 +874,11 @@ internal sealed class UiHost : MonoBehaviour
Native.RRPOPOUT_SetPanDisablesFollow(_overlayHandle, PopoutModule.Settings.panDisablesFollow); Native.RRPOPOUT_SetPanDisablesFollow(_overlayHandle, PopoutModule.Settings.panDisablesFollow);
Native.RRPOPOUT_SetRightClickRecenter(_overlayHandle, PopoutModule.Settings.rightClickRecenter); Native.RRPOPOUT_SetRightClickRecenter(_overlayHandle, PopoutModule.Settings.rightClickRecenter);
SeedIconCullingState(_overlayHandle); SeedIconCullingState(_overlayHandle);
Native.RRPOPOUT_SetTrackLabelsEnabled(_overlayHandle, PopoutModule.Settings.trackLabelsEnabled);
SeedTrackLabelStyle(_overlayHandle);
_spawnPointTimer = 99f; // force IC/span rebuild on next PushTrackLabels
_trackSpanLabels.Clear();
_lastLabelCount = -1;
Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha); Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha);
var bgc = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme); var bgc = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme);
bgc.a *= PopoutModule.Settings.overlayMapBgAlpha; bgc.a *= PopoutModule.Settings.overlayMapBgAlpha;
@ -793,6 +941,537 @@ internal sealed class UiHost : MonoBehaviour
_mapActive = false; _mapActive = false;
} }
// Returns the midpoint and local direction of the straightest segment near the
// arc-length centre of a track-span polyline. Score = straightness² × proximity,
// so a clearly straight segment beats a curved one even if it's off-centre.
private static (Vector3 pos, Vector3 dir) FindStraightestNearMiddle(IList<Vector3> pts)
{
int n = pts.Count;
Vector3 overallDir = (pts[n - 1] - pts[0]).normalized;
if (n == 2) return ((pts[0] + pts[1]) * 0.5f, overallDir);
float totalLen = 0f;
for (int k = 1; k < n; k++) totalLen += Vector3.Distance(pts[k], pts[k - 1]);
if (totalLen < 0.01f) return (pts[n / 2], overallDir);
float midLen = totalLen * 0.5f;
float bestScore = -1f;
Vector3 bestPos = pts[n / 2];
Vector3 bestDir = overallDir;
float cumLen = 0f;
for (int k = 1; k < n; k++)
{
float segLen = Vector3.Distance(pts[k], pts[k - 1]);
if (segLen < 0.01f) { cumLen += segLen; continue; }
float segMidLen = cumLen + segLen * 0.5f;
cumLen += segLen;
Vector3 segDir = (pts[k] - pts[k - 1]) / segLen;
float straight = Mathf.Abs(Vector3.Dot(segDir, overallDir)); // 1=parallel, 0=perpendicular
float distFromMid = Mathf.Abs(segMidLen - midLen) / midLen; // 0=at mid, 1=at end
float score = straight * straight * (1f - distFromMid * 0.5f);
if (score > bestScore)
{
bestScore = score;
bestPos = (pts[k] + pts[k - 1]) * 0.5f;
bestDir = segDir;
}
}
return (bestPos, bestDir);
}
// Rebuilds the flat (centroid, trackDir, anchors, name) list every 5 s.
// trackDir: average game-space direction of all spans in the cluster, sign-aligned
// so anti-parallel spans don't cancel (span direction is arbitrary in the graph).
private void RebuildTrackSpanLabels()
{
_industryComponents = Object.FindObjectsOfType<IndustryComponent>();
// 1. Collect (pos, dir, name, utilityType) per visible span.
// A TrackSpan can be referenced by more than one IndustryComponent; deduplicate
// by instance so we never emit the same physical track twice.
// Interchange names are normalized ("East Whittier Interchange to X" → "East Whittier
// Interchange") before clustering, so all variants merge into one label.
var raw = new List<(Vector3 pos, Vector3 dir, string name, int utilityType)>();
var seenSpans = new HashSet<TrackSpan>();
var settings = PopoutModule.Settings;
foreach (var ic in _industryComponents)
{
if (ic == null || !ic.IsVisible || ic.trackSpans.Length == 0) continue;
string[] names = ExpandSpanNames(ic);
for (int si = 0; si < ic.trackSpans.Length; si++)
{
var span = ic.trackSpans[si];
if (!seenSpans.Add(span)) continue; // already added via another IC
var pts = span.GetPoints() as IList<Vector3>;
Vector3 pos, dir;
if (pts != null && pts.Count >= 2)
(pos, dir) = FindStraightestNearMiddle(pts);
else
{
pos = span.GetCenterPoint();
dir = Vector3.right;
}
string spanName = NormalizeSpanName(si < names.Length ? names[si] : ic.DisplayName);
int utType = GetUtilityType(spanName);
if (utType == 1 && !settings.trackUtilityRepairEnabled) continue;
if (utType == 2 && !settings.trackUtilityDieselEnabled) continue;
if (utType == 3 && !settings.trackUtilityLoaderEnabled) continue;
if (utType == 4 && !settings.trackUtilityInterchangeEnabled) continue;
raw.Add((pos, dir, spanName, utType));
}
}
// 2a. Per-track mode: one label per span, no clustering.
_trackSpanLabels.Clear();
if (!_effectiveMergeEnabled)
{
foreach (var (pos, dir, name, utType) in raw)
_trackSpanLabels.Add((pos, dir, new[] { pos }, name, utType));
_totalAnchorCount = _trackSpanLabels.Count;
RebuildIndustryLabels();
ComputeWorldSpaceOffsets();
return;
}
// 2b. Group by name; greedy single-linkage cluster within kMergeDistGame.
var grouped = new Dictionary<string, List<(Vector3 pos, Vector3 dir, int utilityType)>>();
foreach (var (pos, dir, name, utType) in raw)
{
if (!grouped.TryGetValue(name, out var list)) grouped[name] = list = new();
list.Add((pos, dir, utType));
}
foreach (var (name, entries) in grouped)
{
var assigned = new bool[entries.Count];
for (int i = 0; i < entries.Count; i++)
{
if (assigned[i]) continue;
var cluster = new List<(Vector3 pos, Vector3 dir, int utilityType)> { entries[i] };
assigned[i] = true;
bool added;
do {
added = false;
for (int j = i + 1; j < entries.Count; j++)
{
if (assigned[j]) continue;
foreach (var (cp, _, _) in cluster)
if (Vector3.Distance(entries[j].pos, cp) < kMergeDistGame)
{ cluster.Add(entries[j]); assigned[j] = true; added = true; break; }
}
} while (added);
int clusterUtType = entries[0].utilityType;
Vector3 centroid = Vector3.zero;
Vector3 refDir = cluster[0].dir;
Vector3 avgDir = Vector3.zero;
var anchorPositions = new Vector3[cluster.Count];
for (int k = 0; k < cluster.Count; k++)
{
centroid += cluster[k].pos;
anchorPositions[k] = cluster[k].pos;
// Align each dir with the reference so anti-parallel spans don't cancel
var d = cluster[k].dir;
avgDir += Vector3.Dot(d, refDir) >= 0f ? d : -d;
}
centroid /= cluster.Count;
_trackSpanLabels.Add((centroid, avgDir.normalized, anchorPositions, name, clusterUtType));
}
}
_totalAnchorCount = 0;
foreach (var (_, _, anchors, _, _) in _trackSpanLabels)
_totalAnchorCount += anchors.Length;
RebuildIndustryLabels();
ComputeWorldSpaceOffsets();
}
// Builds one label per distinct industry from all visible IndustryComponents.
// Groups ICs by their stripped base name (e.g., "Whittier Saw Mill S01/S02" →
// "Whittier Saw Mill") and averages their span positions into a single centroid.
// Called at the end of RebuildTrackSpanLabels so _industryComponents is already set.
private void RebuildIndustryLabels()
{
_industryLabels.Clear();
var byName = new Dictionary<string, (Vector3 sum, int count, int utilityType)>();
var settings = PopoutModule.Settings;
foreach (var ic in _industryComponents)
{
if (ic == null || !ic.IsVisible || ic.trackSpans.Length == 0) continue;
string industryName = GetIndustryName(ic);
int utType = GetUtilityType(industryName);
if (utType == 1 && !settings.trackUtilityRepairEnabled) continue;
if (utType == 2 && !settings.trackUtilityDieselEnabled) continue;
if (utType == 3 && !settings.trackUtilityLoaderEnabled) continue;
if (utType == 4 && !settings.trackUtilityInterchangeEnabled) continue;
Vector3 centroid = Vector3.zero;
int n = 0;
foreach (var span in ic.trackSpans)
{
var pts = span.GetPoints() as IList<Vector3>;
centroid += pts != null && pts.Count >= 2
? FindStraightestNearMiddle(pts).pos
: span.GetCenterPoint();
n++;
}
if (n == 0) continue;
centroid /= n;
if (byName.TryGetValue(industryName, out var entry))
byName[industryName] = (entry.sum + centroid, entry.count + 1, utType);
else
byName[industryName] = (centroid, 1, utType);
}
foreach (var (name, (sum, count, utType)) in byName)
_industryLabels.Add((sum / count, name, utType));
}
// Spreads overlapping labels in world-space XZ so each label has a stable offset
// from its track centroid. By baking the offset into the projected UV (rather than
// letting the native solver run in screen-space), label positions become invariant
// to camera rotation and pan — they always sit in the same world direction relative
// to their track regardless of what the map is doing.
private void ComputeWorldSpaceOffsets()
{
int n = _trackSpanLabels.Count;
if (n == 0) { _labelWorldOffsets = System.Array.Empty<Vector3>(); return; }
// Pixel→world conversion using current camera state.
float screenH = _mapCamera != null && _mapCamera.pixelHeight > 0
? _mapCamera.pixelHeight : 1080f;
float worldH = _mapCamera != null ? _mapCamera.orthographicSize * 2f : 200f;
float wpp = worldH / screenH; // world units per screen pixel
var s = PopoutModule.Settings;
float fH = s.trackLabelFontSize * wpp; // font height in world units
float cW = fH * 0.55f; // approx char width (ProggyClean ~55%)
float pad = 3f * wpp; // 3 px padding
float gap = 4f * wpp; // extra clearance between label and track
var cx = new float[n];
var cz = new float[n];
var hw = new float[n];
var hh = new float[n];
for (int i = 0; i < n; i++)
{
var (centroid, trackDir, _, name, _) = _trackSpanLabels[i];
hw[i] = name.Length * cW * 0.5f + pad;
hh[i] = fH * 0.5f + pad;
// Perpendicular to track in XZ (90° CCW: (-z, x)), normalised.
float lx = trackDir.x, lz = trackDir.z;
float len = Mathf.Sqrt(lx * lx + lz * lz);
if (len > 0.001f) { lx /= len; lz /= len; }
float px = -lz, pz = lx;
// Place label above the track (in the world-perpendicular direction).
float initOff = hh[i] + gap;
cx[i] = centroid.x + px * initOff;
cz[i] = centroid.z + pz * initOff;
}
// Iterative AABB spread — same algorithm as the native solver, run in world XZ.
const int kMaxIter = 40;
for (int iter = 0; iter < kMaxIter; iter++)
{
bool anyOverlap = false;
for (int i = 0; i < n; i++)
{
for (int j = i + 1; j < n; j++)
{
float ox = (hw[i] + hw[j]) - Mathf.Abs(cx[i] - cx[j]);
float oz = (hh[i] + hh[j]) - Mathf.Abs(cz[i] - cz[j]);
if (ox <= 0f || oz <= 0f) continue;
anyOverlap = true;
float pushX = 0f, pushZ = 0f;
if (ox < oz)
pushX = ox * 0.55f * (cx[i] < cx[j] ? -1f : 1f);
else
pushZ = oz * 0.55f * (cz[i] < cz[j] ? -1f : 1f);
cx[i] += pushX; cz[i] += pushZ;
cx[j] -= pushX; cz[j] -= pushZ;
}
}
if (!anyOverlap) break;
}
if (_labelWorldOffsets.Length < n)
_labelWorldOffsets = new Vector3[n];
for (int i = 0; i < n; i++)
{
var (centroid, _, _, _, _) = _trackSpanLabels[i];
_labelWorldOffsets[i] = new Vector3(cx[i] - centroid.x, 0f, cz[i] - centroid.z);
}
}
// Extracts the base industry name from an IC's display name, then normalizes it.
// Multi-span: "Whittier Saw Mill S01/S02/S03" → "Whittier Saw Mill".
// Single-span with track code: "Whittier Saw Mill UFR1" → "Whittier Saw Mill".
// Interchange: "East Whittier Interchange to Atlantic..." → "East Whittier Interchange".
// Single-span without strippable suffix: "East Whittier Coal Loader" → unchanged.
private static string GetIndustryName(IndustryComponent ic)
{
string displayName = ic.DisplayName;
if (ic.trackSpans.Length > 1 && displayName.Contains('/'))
{
string first = displayName.Split('/')[0].Trim();
int sp = first.LastIndexOf(' ');
string baseName = sp >= 0 ? first.Substring(0, sp) : first;
return NormalizeSpanName(baseName);
}
int slash = displayName.IndexOf('/');
string name = slash >= 0 ? displayName.Substring(0, slash).Trim() : displayName;
name = NormalizeSpanName(name);
int lastSp = name.LastIndexOf(' ');
if (lastSp >= 0 && IsTrackCode(name.Substring(lastSp + 1)))
return name.Substring(0, lastSp);
return name;
}
// Strips the " to <Business>" part from interchange track names so all variants
// of the same interchange cluster under one name.
// "East Whittier Interchange to Atlantic Locomotive Works" → "East Whittier Interchange"
private static string NormalizeSpanName(string name)
{
int idx = name.IndexOf(" Interchange to ", System.StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
return name.Substring(0, idx + " Interchange".Length);
return name;
}
// Returns the utility category for a (normalized) span name.
// 0=regular, 1=repair, 2=diesel, 3=loader, 4=interchange.
private static int GetUtilityType(string name)
{
if (name.IndexOf(" Interchange", System.StringComparison.OrdinalIgnoreCase) >= 0 ||
name.StartsWith("Interchange", System.StringComparison.OrdinalIgnoreCase))
return 4;
if (name.EndsWith(" Repair Track", System.StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(" Repair", System.StringComparison.OrdinalIgnoreCase))
return 1;
if (name.EndsWith(" Diesel Stand", System.StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(" Diesel", System.StringComparison.OrdinalIgnoreCase))
return 2;
if (name.EndsWith(" Coal Loader", System.StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(" Loader", System.StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(" Coaling Tower", System.StringComparison.OrdinalIgnoreCase))
return 3;
return 0;
}
// Returns true when s looks like a per-track identifier: 1-3 leading letters
// followed by 0-3 digits (e.g., S01, R1, PH1, WS1, C3, MP2, UFR1).
private static bool IsTrackCode(string s)
{
if (s.Length == 0 || s.Length > 4) return false;
int i = 0;
while (i < s.Length && char.IsLetter(s[i])) i++;
if (i == 0 || i > 3) return false;
while (i < s.Length && char.IsDigit(s[i])) i++;
return i == s.Length;
}
// Expands a component's display name by "/": "Base A/B/C" with 3 spans →
// ["Base A", "Base B", "Base C"]. Falls back to the full name if counts mismatch.
private static string[] ExpandSpanNames(IndustryComponent ic)
{
if (ic.trackSpans.Length <= 1) return new[] { ic.DisplayName };
string[] parts = ic.DisplayName.Split('/');
if (parts.Length != ic.trackSpans.Length) return new[] { ic.DisplayName };
// Extract the prefix from the first part (everything before the last space).
string first = parts[0].Trim();
int sp = first.LastIndexOf(' ');
string prefix = sp >= 0 ? first.Substring(0, sp + 1) : "";
var result = new string[parts.Length];
result[0] = first;
for (int i = 1; i < parts.Length; i++)
result[i] = prefix + parts[i].Trim();
return result;
}
// Per-frame: projects cached (centroid, anchors, name) tuples through the map camera
// and pushes the visible subset to native. Native runs AABB collision avoidance and
// draws leader lines. All positions are in simulation space; GameToWorld() applied here.
private void PushTrackLabels()
{
if (_mapCamera == null) return;
// Recompute effective merge: manual toggle OR auto-merge when zoomed out past threshold.
var s = PopoutModule.Settings;
bool shouldMerge = s.trackLabelMergeEnabled ||
(_mapCamera.orthographicSize > s.trackLabelMergeZoom);
if (shouldMerge != _effectiveMergeEnabled)
{
_effectiveMergeEnabled = shouldMerge;
_spawnPointTimer = 99f; // zoom crossed the threshold — rebuild with new merge state
}
_spawnPointTimer += Time.deltaTime;
if (_spawnPointTimer >= 5f || _industryComponents == null)
{
RebuildTrackSpanLabels();
_spawnPointTimer = 0f;
}
// Grow per-label arrays to cover both stages; done here (before the zoom branch)
// so Stage 3 industry label push can write into them safely.
int maxLabels = System.Math.Max(_trackSpanLabels.Count, _industryLabels.Count);
if (_labelUs.Length < maxLabels)
{
_labelUs = new float[maxLabels];
_labelVs = new float[maxLabels];
_labelAngles = new float[maxLabels];
_labelScales = new float[maxLabels];
_anchorStarts = new int [maxLabels];
_anchorCounts = new int [maxLabels];
}
if (_anchorUs.Length < _totalAnchorCount)
{
_anchorUs = new float[_totalAnchorCount];
_anchorVs = new float[_totalAnchorCount];
}
// Global cutoff: beyond this zoom all labels (incl. Stage 3) are suppressed.
if (_mapCamera.orthographicSize > s.trackAllLabelsZoomLimit)
{
if (_lastLabelCount != 0)
{
Native.RRPOPOUT_SetTrackLabels(_overlayHandle, "",
s_emptyFloat, s_emptyFloat, s_emptyFloat, s_emptyFloat,
s_emptyInt, s_emptyInt, s_emptyFloat, s_emptyFloat, 0);
_lastLabelCount = 0;
}
return;
}
// Stage 3: beyond the industry-label threshold — one big label per industry.
if (_mapCamera.orthographicSize > s.trackIndustryLabelZoom)
{
_labelNames.Clear();
int icCount = 0;
foreach (var (centroid, name, _) in _industryLabels)
{
Vector3 vp = _mapCamera.WorldToViewportPoint(centroid.GameToWorld());
if (vp.z < 0f || vp.x < -0.05f || vp.x > 1.05f ||
vp.y < -0.05f || vp.y > 1.05f) continue;
if (icCount > 0) _labelNames.Append('\n');
_labelNames.Append(name);
_labelUs[icCount] = vp.x;
_labelVs[icCount] = vp.y;
_labelAngles[icCount] = 0f; // horizontal — labels an area, not a track
_labelScales[icCount] = 1.5f; // bigger than per-track labels
_anchorStarts[icCount] = 0;
_anchorCounts[icCount] = 0; // no leader lines at this zoom
icCount++;
}
Native.RRPOPOUT_SetTrackLabels(_overlayHandle, _labelNames.ToString(),
_labelUs, _labelVs,
s_emptyFloat, s_emptyFloat,
_anchorStarts, _anchorCounts,
_labelAngles, _labelScales, icCount);
_lastLabelCount = icCount;
return;
}
// Dead zone: track labels are hidden but industry labels haven't kicked in yet.
if (_mapCamera.orthographicSize > s.trackLabelZoomLimit)
{
if (_lastLabelCount != 0)
{
Native.RRPOPOUT_SetTrackLabels(_overlayHandle, "",
s_emptyFloat, s_emptyFloat, s_emptyFloat, s_emptyFloat,
s_emptyInt, s_emptyInt, s_emptyFloat, s_emptyFloat, 0);
_lastLabelCount = 0;
}
return;
}
// Stage 1 / Stage 2: per-track or merged labels.
_labelNames.Clear();
int si = 0; // index into _trackSpanLabels / _labelWorldOffsets
int count = 0;
int anchorOffset = 0;
foreach (var (centroid, trackDir, anchors, name, utilityType) in _trackSpanLabels)
{
// Utility labels (repair, diesel, loader, interchange) have their own zoom limit
// and hide before the main track labels when the map is zoomed out.
if (utilityType != 0 && _mapCamera.orthographicSize > s.trackUtilityZoomLimit) { si++; continue; }
// Apply world-space spread offset computed at rebuild time. The offset is in
// game-space XZ and is rotation-invariant, so labels stay locked to their tracks
// regardless of camera rotation or pan.
Vector3 labelCenter = si < _labelWorldOffsets.Length
? centroid + _labelWorldOffsets[si]
: centroid;
Vector3 vp = _mapCamera.WorldToViewportPoint(labelCenter.GameToWorld());
if (vp.z < 0f || vp.x < -0.05f || vp.x > 1.05f ||
vp.y < -0.05f || vp.y > 1.05f) { si++; continue; }
if (count > 0) _labelNames.Append('\n');
_labelNames.Append(name);
_labelUs[count] = vp.x;
_labelVs[count] = vp.y;
// Scale label size inversely with zoom so text appears constant-size relative
// to tracks. Reference zoom = half the hide threshold (full size at that level).
// Clamped to [trackLabelFontSizeMin, trackLabelFontSize] in pixel space.
float refZoom = Mathf.Max(s.trackLabelZoomLimit * 0.5f, 1f);
float targetPx = s.trackLabelFontSize * (refZoom / _mapCamera.orthographicSize);
float clampedPx = Mathf.Clamp(targetPx, s.trackLabelFontSizeMin, s.trackLabelFontSize);
_labelScales[count] = clampedPx / s.trackLabelFontSize;
_anchorStarts[count] = anchorOffset;
_anchorCounts[count] = anchors.Length;
// Compute screen-space angle for parallel-label rotation.
// Project two points along the track direction through the camera;
// screen Y is flipped relative to viewport Y, so negate the dy term.
Vector3 p0v = _mapCamera.WorldToViewportPoint((centroid - trackDir * 20f).GameToWorld());
Vector3 p1v = _mapCamera.WorldToViewportPoint((centroid + trackDir * 20f).GameToWorld());
float adx = p1v.x - p0v.x;
float ady = -(p1v.y - p0v.y); // viewport Y up → screen Y down
// Viewport UV is normalized; text is rendered in screen pixels.
// Dividing ady by aspect converts Y from "fraction of height" to
// "fraction of width" so atan2 operates in uniform pixel units.
float aspect = _mapCamera.aspect > 0f ? _mapCamera.aspect : 1f;
float angle = Mathf.Atan2(ady / aspect, adx) * Mathf.Rad2Deg;
// Keep text readable: clamp to (-90°, 90°] so it never renders upside-down.
if (angle > 90f) angle -= 180f;
else if (angle < -90f) angle += 180f;
_labelAngles[count] = angle;
foreach (var ap in anchors)
{
Vector3 av = _mapCamera.WorldToViewportPoint(ap.GameToWorld());
_anchorUs[anchorOffset] = av.x;
_anchorVs[anchorOffset] = av.y;
anchorOffset++;
}
si++;
count++;
}
Native.RRPOPOUT_SetTrackLabels(_overlayHandle, _labelNames.ToString(),
_labelUs, _labelVs,
_anchorUs, _anchorVs,
_anchorStarts, _anchorCounts,
_labelAngles, _labelScales, count);
_lastLabelCount = count;
}
private static void SeedIconCullingState(int handle) private static void SeedIconCullingState(int handle)
{ {
var s = PopoutModule.Settings; var s = PopoutModule.Settings;
@ -800,4 +1479,20 @@ internal sealed class UiHost : MonoBehaviour
s.iconCullingEnabled, s.iconCullingThreshold, s.hideMuLocos, s.locoBoostedScale, s.iconCullingEnabled, s.iconCullingThreshold, s.hideMuLocos, s.locoBoostedScale,
s.eotdEnabled, s.eotdOnlyWhenCulled, s.eotdSizeScale); s.eotdEnabled, s.eotdOnlyWhenCulled, s.eotdSizeScale);
} }
private static void SeedTrackLabelStyle(int handle)
{
var s = PopoutModule.Settings;
Native.RRPOPOUT_SetTrackLabelStyle(handle,
s.trackLabelFontSize, s.trackLabelLineThickness, s.trackLabelZoomLimit,
s.trackLeaderLinesEnabled, s.trackCollisionEnabled, s.trackLabelParallel,
s.trackLabelMergeEnabled, s.trackLabelMergeZoom,
s.trackIndustryLabelZoom,
s.trackUtilityRepairEnabled, s.trackUtilityDieselEnabled,
s.trackUtilityLoaderEnabled, s.trackUtilityInterchangeEnabled,
s.trackUtilityZoomLimit,
s.trackAllLabelsZoomLimit,
s.trackLabelAvoidTrack,
s.trackLabelFontSizeMin);
}
} }

View file

@ -29,6 +29,8 @@ public static class Main
// Modules are registered here. Each module loads its own settings in its // Modules are registered here. Each module loads its own settings in its
// constructor. Order here is display order in the settings panel. // constructor. Order here is display order in the settings panel.
_registry.Register(new Modules.PhysicsOptimizer.PhysicsOptimizerModule()); _registry.Register(new Modules.PhysicsOptimizer.PhysicsOptimizerModule());
_registry.Register(new Modules.MeshLod.MeshLodModule());
_registry.Register(new Modules.Profiler.ProfilerModule());
_registry.Register(new Modules.Popout.PopoutModule()); _registry.Register(new Modules.Popout.PopoutModule());
_registry.EnableConfigured(); _registry.EnableConfigured();

View file

@ -0,0 +1,376 @@
using System;
using System.Collections.Generic;
using System.Linq;
using HarmonyLib;
using Model;
using S3.Core;
using UnityEngine;
namespace S3.Modules.MeshLod;
static class MeshLodInjector
{
static MeshLodSettings _settings = new();
static readonly List<WeakReference<Car>> _trackedCars = new();
// Debounced auto-refresh: set by MarkDirty, consumed by CheckAutoRefresh (called from Update).
static float _pendingRefreshAt = -1f;
const float RefreshDebounce = 0.5f;
// ── Lifecycle ─────────────────────────────────────────────────────────────
public static void Init(MeshLodSettings settings)
{
_settings = settings;
Log.Info($"MeshLOD settings: frac1={settings.lod1RendererFraction:F2} frac2={settings.lod2RendererFraction:F2} " +
$"d1={settings.lod1Distance}m d2={settings.lod2Distance}m d3={settings.lod3Distance}m " +
$"locos={settings.applyToLocomotives} freight={settings.applyToFreightCars} " +
$"lodBias={QualitySettings.lodBias:F2}");
}
public static void ClearTracking()
{
_trackedCars.Clear();
_pendingRefreshAt = -1f;
}
public static (int total, int locos, int freight) GetTrackedCounts()
{
int total = 0, locos = 0;
foreach (var wr in _trackedCars)
{
if (!wr.TryGetTarget(out Car? car) || car == null) continue;
total++;
if (car is BaseLocomotive) locos++;
}
return (total, locos, total - locos);
}
// LOD level distribution — called at most once per second by the Profiler overlay.
public static (int total, int locos, int freight, int atLod0, int atLod1, int atLod2, int atLod3) GetLodStats()
{
int total = 0, locos = 0, freight = 0;
int atLod0 = 0, atLod1 = 0, atLod2 = 0, atLod3 = 0;
foreach (var wr in _trackedCars)
{
if (!wr.TryGetTarget(out Car? car) || car == null || car.BodyTransform == null) continue;
total++;
bool isLoco = car is BaseLocomotive;
if (isLoco) locos++; else freight++;
switch (GetLodLevel(car))
{
case 0: atLod0++; break;
case 1: atLod1++; break;
case 2: atLod2++; break;
default: atLod3++; break; // 3 = proxy box, -1 = no group (treated same)
}
}
return (total, locos, freight, atLod0, atLod1, atLod2, atLod3);
}
// Detect current LOD level by reading which renderer sets are active on the LODGroup.
// lods[0] = all renderers; lods[1] = lod1Keep subset; lods[2] = lod2Keep subset;
// lods[3] = bbox. Counting enabled renderers in lods[0] vs the subset sizes
// avoids storing extra per-car state.
static int GetLodLevel(Car car)
{
var lg = car.BodyTransform?.GetComponent<LODGroup>();
if (lg == null) return -1;
LOD[] lods = lg.GetLODs();
if (lods.Length < 4) return -1; // not our LODGroup
// LOD3: proxy box renderer is enabled
if (lods[3].renderers.Length > 0 && lods[3].renderers[0] is { } bbox && bbox.enabled)
return 3;
int enabled = 0;
foreach (var r in lods[0].renderers)
if (r != null && r.enabled) enabled++;
int total = lods[0].renderers.Length;
int lod2Keep = lods[2].renderers.Length;
if (enabled == total) return 0; // all renderers on: full detail
if (enabled == 0) return 3; // culled entirely: treat as LOD3
return enabled > lod2Keep ? 1 : 2; // more than structural count: LOD1, else LOD2
}
// Called from the UI whenever any setting changes.
// Triggers RefreshAll after RefreshDebounce seconds with no further changes,
// so slider drags don't spam scene rebuilds.
public static void MarkDirty() =>
_pendingRefreshAt = Time.realtimeSinceStartup + RefreshDebounce;
// Called every Update by MeshLodHost.
public static void CheckAutoRefresh()
{
if (_pendingRefreshAt < 0f) return;
if (Time.realtimeSinceStartup < _pendingRefreshAt) return;
_pendingRefreshAt = -1f;
RefreshAll();
}
// ── Harmony entry point ───────────────────────────────────────────────────
public static void OnModelLoaded(Car car)
{
if (car?.BodyTransform == null) return;
if (car.BodyTransform.GetComponent<LODGroup>() != null) return;
bool isLoco = car is BaseLocomotive;
if ( isLoco && !_settings.applyToLocomotives) return;
if (!isLoco && !_settings.applyToFreightCars) return;
MeshRenderer[] renderers = CollectRenderers(car.BodyTransform);
if (renderers.Length == 0) return;
try
{
ApplyLods(car, renderers);
// Opportunistically drop dead weak-refs so the list can't grow unbounded over a
// long session of cars loading/unloading (RefreshAll also compacts, but only runs
// when a setting changes). Cheap: the modulo check runs once per car load.
if (_trackedCars.Count > 0 && _trackedCars.Count % 128 == 0)
_trackedCars.RemoveAll(wr => !wr.TryGetTarget(out _));
_trackedCars.Add(new WeakReference<Car>(car));
}
catch (Exception ex) { Log.Warn($"MeshLOD: apply failed for {car.DisplayName}: {ex.Message}"); }
}
// ── Explicit refresh ──────────────────────────────────────────────────────
public static void RefreshAll()
{
int refreshed = 0, skipped = 0;
foreach (WeakReference<Car> wr in _trackedCars.ToArray())
{
if (!wr.TryGetTarget(out Car? car) || car == null) continue;
if (car.BodyTransform == null) { skipped++; continue; }
RemoveLods(car);
bool isLoco = car is BaseLocomotive;
if ( isLoco && !_settings.applyToLocomotives) { skipped++; continue; }
if (!isLoco && !_settings.applyToFreightCars) { skipped++; continue; }
MeshRenderer[] renderers = CollectRenderers(car.BodyTransform);
if (renderers.Length == 0) { skipped++; continue; }
try { ApplyLods(car, renderers); refreshed++; }
catch (Exception ex) { Log.Warn($"MeshLOD: refresh failed for {car.DisplayName}: {ex.Message}"); }
}
_trackedCars.RemoveAll(wr => !wr.TryGetTarget(out _));
Log.Info($"MeshLOD: refreshed {refreshed} car(s), skipped {skipped}");
}
// ── Renderer collection ───────────────────────────────────────────────────
static MeshRenderer[] CollectRenderers(Transform bodyRoot)
{
// Build exclusion set: renderers already claimed by the game's own child LODGroups
// (coupler-lods, gauge objects, etc.) must not be given to our outer LODGroup.
var alreadyClaimed = new HashSet<Renderer>();
foreach (LODGroup existing in bodyRoot.GetComponentsInChildren<LODGroup>())
foreach (LOD lod in existing.GetLODs())
foreach (Renderer r in lod.renderers)
if (r != null) alreadyClaimed.Add(r);
var list = new List<MeshRenderer>();
foreach (MeshRenderer mr in bodyRoot.GetComponentsInChildren<MeshRenderer>())
{
if (alreadyClaimed.Contains(mr)) continue;
if (IsBBoxChild(mr.transform)) continue;
if (mr.GetComponent<MeshFilter>()?.sharedMesh == null) continue;
list.Add(mr);
}
return list.ToArray();
}
// Our proxy box GO is named "S3_BBox"; skip renderers that live inside it.
static bool IsBBoxChild(Transform t) => t.parent?.name == "S3_BBox";
// ── LOD application ───────────────────────────────────────────────────────
static void ApplyLods(Car car, MeshRenderer[] allRenderers)
{
Transform bodyRoot = car.BodyTransform;
// Sort by world-bounds volume descending: largest = car body / trucks (structural),
// smallest = bolts, grab irons, rivets (detail).
MeshRenderer[] sorted = allRenderers
.OrderByDescending(r => BoundsVolume(r.bounds))
.ToArray();
// LOD1: minor cull — keep the top lod1RendererFraction of renderers
int lod1Keep = Mathf.Max(1, Mathf.RoundToInt(sorted.Length * _settings.lod1RendererFraction));
// LOD2: major cull — keep only the top lod2RendererFraction (must be ≤ lod1Keep)
int lod2Keep = Mathf.Clamp(Mathf.RoundToInt(sorted.Length * _settings.lod2RendererFraction), 1, lod1Keep);
Renderer[] lod0Rend = allRenderers.Cast<Renderer>().ToArray();
Renderer[] lod1Rend = sorted.Take(lod1Keep).Cast<Renderer>().ToArray();
Renderer[] lod2Rend = sorted.Take(lod2Keep).Cast<Renderer>().ToArray();
// LOD3: 12-triangle proxy box derived from the largest renderer's mesh-local AABB.
// Reuse the largest renderer's own material so the box matches the car's colour
// (a shared/tinted material can't match per-car, and Material.color sets the legacy
// _Color, not URP's _BaseColor — both reasons the previous matte material rendered gray).
Bounds localBounds = ComputeMeshLocalBounds(sorted[0], bodyRoot);
MeshRenderer bboxMr = CreateBoundingBoxRenderer(bodyRoot, localBounds,
sorted[0].sharedMaterial,
sorted[0].gameObject.layer);
Renderer[] lod3Rend = new[] { (Renderer)bboxMr };
// Convert distance settings → screenRelativeTransitionHeight.
// Multiply by lodBias so that transitions fire at the specified metre distance
// even when QualitySettings.lodBias ≠ 1. (Without this, transitions fire at
// d × lodBias instead of d — Railroader uses lodBias=2.5.)
float sphereDiameter = Mathf.Max(localBounds.extents.magnitude * 2f, 0.5f);
float fov = Camera.main != null ? Camera.main.fieldOfView : 60f;
float invFovFactor = 2f * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);
float lodBias = Mathf.Max(QualitySettings.lodBias, 0.01f);
float k = sphereDiameter * lodBias / invFovFactor;
float lod1H = Mathf.Clamp(k / _settings.lod1Distance, 0.001f, 0.999f);
float lod2H = Mathf.Clamp(k / _settings.lod2Distance, 0.001f, lod1H * 0.9f);
float lod3H = Mathf.Clamp(k / _settings.lod3Distance, 0.001f, lod2H * 0.9f);
var lodGroup = bodyRoot.gameObject.AddComponent<LODGroup>();
lodGroup.SetLODs(new LOD[]
{
new(lod1H, lod0Rend), // full detail
new(lod2H, lod1Rend), // minor cull
new(lod3H, lod2Rend), // structural only
new(0f, lod3Rend), // proxy box
});
lodGroup.RecalculateBounds();
Log.Info($"MeshLOD: {car.DisplayName} " +
$"lod0={lod0Rend.Length} lod1={lod1Keep} lod2={lod2Keep} lod3=bbox " +
$"sphere={sphereDiameter:F1}m bias={lodBias:F2} " +
$"thresh={lod1H:F3}/{lod2H:F3}/{lod3H:F3}");
}
// Tears down our LODGroup and proxy box, re-enabling any renderers that the
// LODGroup had disabled during a LOD1/LOD2/LOD3 transition.
static void RemoveLods(Car car)
{
if (car?.BodyTransform == null) return;
LODGroup? lg = car.BodyTransform.GetComponent<LODGroup>();
if (lg != null)
{
LOD[] lods = lg.GetLODs();
if (lods.Length > 0)
foreach (Renderer r in lods[0].renderers)
if (r != null) r.enabled = true;
UnityEngine.Object.Destroy(lg);
}
Transform? bbox = car.BodyTransform.Find("S3_BBox");
if (bbox != null)
{
// Destroy the procedural box mesh too: destroying the GameObject alone leaves
// the Mesh asset alive until scene unload (a small leak on every refresh).
MeshFilter? bf = bbox.GetComponent<MeshFilter>();
if (bf != null && bf.sharedMesh != null) UnityEngine.Object.Destroy(bf.sharedMesh);
UnityEngine.Object.Destroy(bbox.gameObject);
}
}
// ── Bounds helpers ────────────────────────────────────────────────────────
static Bounds ComputeMeshLocalBounds(MeshRenderer mr, Transform bodyRoot)
{
MeshFilter? mf = mr.GetComponent<MeshFilter>();
if (mf?.sharedMesh != null)
{
try
{
Bounds mb = mf.sharedMesh.bounds;
Matrix4x4 m2b = bodyRoot.worldToLocalMatrix * mr.transform.localToWorldMatrix;
Vector3 c = mb.center, e = mb.extents;
bool init = false;
Bounds local = default;
for (int sx = -1; sx <= 1; sx += 2)
for (int sy = -1; sy <= 1; sy += 2)
for (int sz = -1; sz <= 1; sz += 2)
{
Vector3 lp = m2b.MultiplyPoint3x4(c + new Vector3(e.x * sx, e.y * sy, e.z * sz));
if (!init) { local = new Bounds(lp, Vector3.zero); init = true; }
else local.Encapsulate(lp);
}
return local;
}
catch { /* fall through */ }
}
return WorldToLocalBounds(new[] { mr }, bodyRoot);
}
static Bounds WorldToLocalBounds(MeshRenderer[] renderers, Transform bodyRoot)
{
Matrix4x4 w2l = bodyRoot.worldToLocalMatrix;
bool init = false;
Bounds local = default;
foreach (MeshRenderer mr in renderers)
{
Bounds wb = mr.bounds;
Vector3 c = wb.center, ex = wb.extents;
for (int sx = -1; sx <= 1; sx += 2)
for (int sy = -1; sy <= 1; sy += 2)
for (int sz = -1; sz <= 1; sz += 2)
{
Vector3 lp = w2l.MultiplyPoint3x4(c + new Vector3(ex.x * sx, ex.y * sy, ex.z * sz));
if (!init) { local = new Bounds(lp, Vector3.zero); init = true; }
else local.Encapsulate(lp);
}
}
return local;
}
// ── Proxy box mesh ────────────────────────────────────────────────────────
static MeshRenderer CreateBoundingBoxRenderer(Transform parent, Bounds localBounds,
Material mat, int layer)
{
var go = new GameObject("S3_BBox");
go.transform.SetParent(parent, worldPositionStays: false);
go.layer = layer;
Vector3 c = localBounds.center, e = localBounds.extents;
var verts = new[]
{
c + new Vector3(-e.x,-e.y,-e.z), c + new Vector3( e.x,-e.y,-e.z),
c + new Vector3( e.x, e.y,-e.z), c + new Vector3(-e.x, e.y,-e.z),
c + new Vector3(-e.x,-e.y, e.z), c + new Vector3( e.x,-e.y, e.z),
c + new Vector3( e.x, e.y, e.z), c + new Vector3(-e.x, e.y, e.z),
};
var tris = new[]
{
0,2,1, 0,3,2, 4,5,6, 4,6,7,
0,1,5, 0,5,4, 2,3,7, 2,7,6,
0,4,7, 0,7,3, 1,2,6, 1,6,5,
};
var mesh = new Mesh { name = "S3_BBox" };
mesh.SetVertices(verts);
mesh.SetTriangles(tris, 0);
mesh.RecalculateNormals();
mesh.RecalculateBounds();
mesh.UploadMeshData(markNoLongerReadable: true);
go.AddComponent<MeshFilter>().sharedMesh = mesh;
var mr = go.AddComponent<MeshRenderer>();
mr.sharedMaterial = mat;
return mr;
}
static float BoundsVolume(Bounds b) { Vector3 s = b.size; return s.x * s.y * s.z; }
}
// ── Harmony patch ─────────────────────────────────────────────────────────────
[HarmonyPatch(typeof(Car), "HandleModelsLoaded")]
static class HandleModelsLoadedLodPatch
{
static void Postfix(Car __instance) => MeshLodInjector.OnModelLoaded(__instance);
}

View file

@ -0,0 +1,65 @@
using HarmonyLib;
using S3.Core;
using UnityEngine;
namespace S3.Modules.MeshLod;
public sealed class MeshLodModule : IModule
{
private const string SettingsFile = "S3.meshlod.json";
public static MeshLodSettings Settings { get; private set; } = new();
private static Harmony? _harmony;
private static GameObject? _hostGo;
public MeshLodModule() => Settings = SettingsStore.Load<MeshLodSettings>(SettingsFile);
public string Id => "meshlod";
public string DisplayName => "Mesh LOD";
public string Description =>
"Reduces rendering triangle count for rolling stock and locomotives at distance " +
"by progressively hiding small-detail renderers (bolts, grab irons, rivets) and " +
"replacing very distant cars with a single bounding-box proxy mesh.";
public bool Enabled
{
get => Settings.enabled;
set => Settings.enabled = value;
}
public void OnEnable()
{
MeshLodInjector.Init(Settings);
_harmony = new Harmony("S3.meshlod");
var applied = _harmony.CreateClassProcessor(typeof(HandleModelsLoadedLodPatch)).Patch();
Log.Info($"MeshLOD: patched {applied?.Count ?? 0} method(s)");
// Lightweight MonoBehaviour solely to drive the auto-refresh debounce timer.
_hostGo = new GameObject("[S3] MeshLodHost");
_hostGo.AddComponent<MeshLodHost>();
UnityEngine.Object.DontDestroyOnLoad(_hostGo);
}
public void OnDisable()
{
_harmony?.UnpatchAll("S3.meshlod");
_harmony = null;
MeshLodInjector.ClearTracking();
if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo);
_hostGo = null;
}
public void SaveSettings() => Persist();
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
public void DrawSettings() => MeshLodSettingsUI.Draw();
}
// Drives the debounce timer. Exists only while the module is enabled.
sealed class MeshLodHost : MonoBehaviour
{
void Update() => MeshLodInjector.CheckAutoRefresh();
}

View file

@ -0,0 +1,22 @@
using System;
namespace S3.Modules.MeshLod;
[Serializable]
public class MeshLodSettings
{
public bool enabled = false;
// Fraction of renderers kept at each progressive cull level (sorted by bounds volume desc).
public float lod1RendererFraction = 0.50f; // minor cull: remove tiny details
public float lod2RendererFraction = 0.15f; // major cull: structural geometry only
// Distance in metres for each LOD transition. Converted to Unity's
// screenRelativeTransitionHeight at apply time, corrected for lodBias.
public float lod1Distance = 80f; // LOD0 -> LOD1 (full detail -> minor cull)
public float lod2Distance = 200f; // LOD1 -> LOD2 (minor cull -> structural)
public float lod3Distance = 600f; // LOD2 -> LOD3 (structural -> proxy box)
public bool applyToLocomotives = true;
public bool applyToFreightCars = true;
}

View file

@ -0,0 +1,126 @@
using UnityEngine;
namespace S3.Modules.MeshLod;
static class MeshLodSettingsUI
{
public static void Draw()
{
MeshLodSettings s = MeshLodModule.Settings;
bool changed = false;
GUILayout.BeginVertical();
GUILayout.Label("<b>Mesh LOD</b> — 4-level renderer culling for distant rolling stock");
GUILayout.Space(4f);
GUILayout.Label(
" Renderers are ranked by bounding-box volume (largest = car body, trucks).\n" +
" Each LOD level keeps a progressively smaller subset of the highest-ranked renderers.",
GUI.skin.label);
// ── Apply to ──────────────────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label("<b>Apply to</b>");
GUILayout.Space(4f);
bool newLocos = GUILayout.Toggle(s.applyToLocomotives, " Locomotives");
if (newLocos != s.applyToLocomotives) { s.applyToLocomotives = newLocos; changed = true; }
bool newCars = GUILayout.Toggle(s.applyToFreightCars, " Freight / passenger cars");
if (newCars != s.applyToFreightCars) { s.applyToFreightCars = newCars; changed = true; }
// ── Renderer fractions ────────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label("<b>Detail cull fractions</b> (fraction of renderers to keep per LOD level)");
GUILayout.Space(4f);
int ex60 = 60; // example car with 60 renderers
GUILayout.Label(
$" LOD1 (minor cull): {s.lod1RendererFraction * 100f:F0}% kept " +
$"— {Mathf.Max(1, Mathf.RoundToInt(ex60 * s.lod1RendererFraction))}/{ex60} for a 60-renderer car",
GUI.skin.label);
GUILayout.BeginHorizontal();
GUILayout.Space(4f);
float newF1 = GUILayout.HorizontalSlider(s.lod1RendererFraction, 0.15f, 0.80f, GUILayout.Width(220f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newF1 - s.lod1RendererFraction) > 0.005f)
{
s.lod1RendererFraction = Mathf.Round(newF1 * 20f) / 20f; // snap to 5% steps
if (s.lod1RendererFraction <= s.lod2RendererFraction)
s.lod2RendererFraction = Mathf.Max(0.05f, s.lod1RendererFraction - 0.10f);
changed = true;
}
GUILayout.Space(4f);
GUILayout.Label(
$" LOD2 (structural): {s.lod2RendererFraction * 100f:F0}% kept " +
$"— {Mathf.Max(1, Mathf.RoundToInt(ex60 * s.lod2RendererFraction))}/{ex60} for a 60-renderer car",
GUI.skin.label);
GUILayout.BeginHorizontal();
GUILayout.Space(4f);
float newF2 = GUILayout.HorizontalSlider(s.lod2RendererFraction, 0.05f, 0.40f, GUILayout.Width(220f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newF2 - s.lod2RendererFraction) > 0.005f)
{
s.lod2RendererFraction = Mathf.Round(newF2 * 20f) / 20f;
if (s.lod2RendererFraction >= s.lod1RendererFraction)
s.lod1RendererFraction = Mathf.Min(0.80f, s.lod2RendererFraction + 0.10f);
changed = true;
}
// ── Transition distances ──────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label("<b>LOD transition distances</b> (metres from camera)");
GUILayout.Space(4f);
GUILayout.Label(
" Converted to Unity screen-height thresholds at runtime using the car's actual\n" +
" bounding sphere and camera FOV, corrected for the game's LOD quality bias (2.5×).",
GUI.skin.label);
GUILayout.Space(4f);
DrawDistanceSlider(ref s.lod1Distance, "LOD0→LOD1 (minor cull)", 30f, 500f, ref changed);
GUILayout.Space(2f);
DrawDistanceSlider(ref s.lod2Distance, "LOD1→LOD2 (structural)", s.lod1Distance + 20f, 1000f, ref changed);
if (s.lod2Distance <= s.lod1Distance) { s.lod2Distance = s.lod1Distance + 50f; changed = true; }
GUILayout.Space(2f);
DrawDistanceSlider(ref s.lod3Distance, "LOD2→LOD3 (proxy box) ", s.lod2Distance + 20f, 1500f, ref changed);
if (s.lod3Distance <= s.lod2Distance) { s.lod3Distance = s.lod2Distance + 100f; changed = true; }
// ── Manual refresh ────────────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label(" Settings auto-refresh loaded cars ~0.5 s after the last change.", GUI.skin.label);
GUILayout.Space(4f);
GUILayout.BeginHorizontal();
if (GUILayout.Button("Refresh Now", GUILayout.Width(120f)))
{
MeshLodModule.Persist();
MeshLodInjector.RefreshAll();
}
var (total, locos, freight) = MeshLodInjector.GetTrackedCounts();
GUILayout.Label(
$" {total} tracked ({locos} loco{(locos == 1 ? "" : "s")}, {freight} car{(freight == 1 ? "" : "s")})",
GUI.skin.label);
GUILayout.EndHorizontal();
GUILayout.EndVertical();
if (changed)
{
MeshLodModule.Persist();
MeshLodInjector.MarkDirty();
}
}
static void DrawDistanceSlider(ref float value, string label, float min, float max, ref bool changed)
{
GUILayout.BeginHorizontal();
GUILayout.Label($" {label}: {value:F0} m", GUILayout.Width(270f));
float newVal = GUILayout.HorizontalSlider(value, min, max, GUILayout.Width(200f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newVal - value) > 1f)
{
value = Mathf.Round(newVal / 10f) * 10f;
changed = true;
}
}
}

View file

@ -105,8 +105,8 @@ static class RpfCommands
static string ToggleOverlay() static string ToggleOverlay()
{ {
var gui = PhysicsOverlayGUI.Instance; var gui = Profiler.ProfilerOverlayGUI.Instance;
if (gui == null) return "Overlay not initialized."; if (gui == null) return "Overlay not initialized (enable the Profiler module).";
gui.Visible = !gui.Visible; gui.Visible = !gui.Visible;
return $"Overlay {(gui.Visible ? "shown" : "hidden")}."; return $"Overlay {(gui.Visible ? "shown" : "hidden")}.";
} }

View file

@ -20,6 +20,7 @@ public sealed class PhysicsOptimizerModule : IModule
public static PhysicsSettings Settings { get; private set; } = new(); public static PhysicsSettings Settings { get; private set; } = new();
private static Harmony? _harmony; private static Harmony? _harmony;
private static GameObject? _hostGo;
// Attribute-annotated patch classes the module owns. PosCarsTimerPatch is NOT // Attribute-annotated patch classes the module owns. PosCarsTimerPatch is NOT
// here — it has no [HarmonyPatch] attribute and is applied dynamically by // here — it has no [HarmonyPatch] attribute and is applied dynamically by
@ -40,7 +41,7 @@ public sealed class PhysicsOptimizerModule : IModule
public string DisplayName => "Physics Optimizer"; public string DisplayName => "Physics Optimizer";
public string Description => public string Description =>
"Cuts CPU time spent on train physics (LOD fast-path + auto-freeze for far/slow consists). " + "Cuts CPU time spent on train physics (LOD fast-path + auto-freeze for far/slow consists). " +
"Includes a profiler overlay and debug car tinting. Console: /rpf"; "Includes debug car tinting. Enable the Profiler module for the overlay. Console: /rpf";
public bool Enabled public bool Enabled
{ {
@ -57,21 +58,17 @@ public sealed class PhysicsOptimizerModule : IModule
_harmony.CreateClassProcessor(t).Patch(); _harmony.CreateClassProcessor(t).Patch();
PhysicsTimer.TryPatchPositionCars(_harmony, Main.ModEntry.Logger); PhysicsTimer.TryPatchPositionCars(_harmony, Main.ModEntry.Logger);
var go = new GameObject("S3.PhysicsOptimizer.Host"); _hostGo = new GameObject("[S3] PhysicsOptimizerHost");
UnityEngine.Object.DontDestroyOnLoad(go); UnityEngine.Object.DontDestroyOnLoad(_hostGo);
PhysicsOverlayGUI overlay = go.AddComponent<PhysicsOverlayGUI>(); _hostGo.AddComponent<CarDebugVisualizer>();
overlay.Visible = Settings.ShowOverlay;
overlay.Opacity = Settings.OverlayOpacity;
go.AddComponent<CarDebugVisualizer>();
} }
public void OnDisable() public void OnDisable()
{ {
// Reserved for live toggling. Today modules apply on next launch, so this
// is not called; when it is, unpatch via _harmony.UnpatchAll("S3.physics")
// and destroy the host GameObject.
_harmony?.UnpatchAll("S3.physics"); _harmony?.UnpatchAll("S3.physics");
_harmony = null; _harmony = null;
if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo);
_hostGo = null;
} }
public void SaveSettings() => Persist(); public void SaveSettings() => Persist();

View file

@ -1,286 +0,0 @@
using UnityEngine;
namespace S3.Modules.PhysicsOptimizer;
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 = "";
string _debugStatsStr = "";
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 = "S3PhysicsOverlay".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.
PhysicsOptimizerModule.Settings.OptimizerEnabled = ConsistLOD.Enabled;
PhysicsOptimizerModule.Persist();
}
// 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}";
bool anyDbg = PhysicsOptimizerModule.Settings.DebugHighlightFrozen ||
PhysicsOptimizerModule.Settings.DebugHighlightFastPath ||
PhysicsOptimizerModule.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));
if (GUILayout.Button(ConsistFreezer.AutoFreezeEnabled ? "ON" : "OFF",
ConsistFreezer.AutoFreezeEnabled ? _onStyle : _offStyle, GUILayout.Width(34f)))
{
ConsistFreezer.AutoFreezeEnabled = !ConsistFreezer.AutoFreezeEnabled;
PhysicsOptimizerModule.Settings.AutoFreezeEnabled = ConsistFreezer.AutoFreezeEnabled;
PhysicsOptimizerModule.Persist();
}
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;
}
}

View file

@ -20,10 +20,6 @@ public class PhysicsSettings
public float AutoFreezeDistance = 200f; // meters public float AutoFreezeDistance = 200f; // meters
public float AutoFreezeSpeedThreshold = 0.09f; // m/s (~0.2 mph) public float AutoFreezeSpeedThreshold = 0.09f; // m/s (~0.2 mph)
// Profiler overlay
public bool ShowOverlay = true;
public float OverlayOpacity = 0.75f;
// Locomotive exclusions // Locomotive exclusions
public bool ExcludeLocosFromLOD = true; public bool ExcludeLocosFromLOD = true;
public bool ExcludeLocosFromFreeze = true; public bool ExcludeLocosFromFreeze = true;

View file

@ -230,35 +230,6 @@ static class PhysicsSettingsUI
GUILayout.Space(12f); GUILayout.Space(12f);
// ── Profiler Overlay ──────────────────────────────────────────────────────
GUILayout.Label("<b>Profiler Overlay</b>", GUILayout.ExpandWidth(false));
GUILayout.Space(4f);
bool newOverlay = GUILayout.Toggle(s.ShowOverlay, " Show in-game profiler overlay");
if (newOverlay != s.ShowOverlay)
{
s.ShowOverlay = newOverlay;
if (PhysicsOverlayGUI.Instance != null)
PhysicsOverlayGUI.Instance.Visible = newOverlay;
changed = true;
}
GUILayout.Space(4f);
GUILayout.BeginHorizontal();
GUILayout.Label($"Opacity: {s.OverlayOpacity * 100f:F0}%", GUILayout.Width(110f));
float newOpacity = GUILayout.HorizontalSlider(s.OverlayOpacity, 0.1f, 1.0f, GUILayout.Width(200f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newOpacity - s.OverlayOpacity) > 0.01f)
{
s.OverlayOpacity = newOpacity;
if (PhysicsOverlayGUI.Instance != null)
PhysicsOverlayGUI.Instance.Opacity = s.OverlayOpacity;
changed = true;
}
GUILayout.Space(12f);
// ── Debug Visualization ─────────────────────────────────────────────────── // ── Debug Visualization ───────────────────────────────────────────────────
GUILayout.Label("<b>Debug Visualization</b>", GUILayout.ExpandWidth(false)); GUILayout.Label("<b>Debug Visualization</b>", GUILayout.ExpandWidth(false));
GUILayout.Space(4f); GUILayout.Space(4f);

View file

@ -102,7 +102,7 @@ public static class PhysicsTimer
_frames = 0; _frames = 0;
} }
// Called from PhysicsOverlayGUI.LateUpdate() to capture render frame time. // Called from ProfilerOverlayGUI.LateUpdate() to capture render frame time.
public static void RecordRenderFrame(float deltaMs) public static void RecordRenderFrame(float deltaMs)
{ {
_lastInstantRenderMs = deltaMs; // sampled into RingRender on the next RecordFrame call _lastInstantRenderMs = deltaMs; // sampled into RingRender on the next RecordFrame call

View file

@ -36,8 +36,14 @@ namespace S3.Modules.Popout {
[DllImport("user32.dll")] private static extern short GetAsyncKeyState(int vKey); [DllImport("user32.dll")] private static extern short GetAsyncKeyState(int vKey);
private bool _prevHotkeyDown; private bool _prevHotkeyDown;
private bool _prevTeleportDown;
public bool CloseRequested { get; private set; } public bool CloseRequested { get; private set; }
// Last mouse position (normalised [0,1], top-left origin) received via MouseMove.
// Updated by ForwardInputEvent; used for popout teleport key handling.
private float _lastMouseX = -1f;
private float _lastMouseY = -1f;
// Status bar: only push to native when the string changes. // Status bar: only push to native when the string changes.
private string _lastStatusText = ""; private string _lastStatusText = "";
@ -169,6 +175,15 @@ namespace S3.Modules.Popout {
bool hotkeyNow = IsHotkeyDown(); bool hotkeyNow = IsHotkeyDown();
if (hotkeyNow && !_prevHotkeyDown) CloseRequested = true; if (hotkeyNow && !_prevHotkeyDown) CloseRequested = true;
_prevHotkeyDown = hotkeyNow; _prevHotkeyDown = hotkeyNow;
// T key ("Jump to Mouse"): teleport the player to the last-known cursor position
// on the map. We use GetAsyncKeyState so this fires even when the game window
// doesn't have focus. Unity's Input System won't fire Teleport from the popout
// window, so we poll here instead. VK_T = 0x54 (matches the game's default binding).
bool teleportNow = IsDown(0x54);
if (teleportNow && !_prevTeleportDown && _lastMouseX >= 0f)
PanelFinder.GetMapDrag()?.OnTeleport?.Invoke(new Vector2(_lastMouseX, 1f - _lastMouseY));
_prevTeleportDown = teleportNow;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -321,6 +336,8 @@ namespace S3.Modules.Popout {
break; break;
case InputEventType.MouseMove: case InputEventType.MouseMove:
_lastMouseX = e.x;
_lastMouseY = e.y;
if (!_isDragging) break; if (!_isDragging) break;
if (!_didDrag && if (!_didDrag &&
(Mathf.Abs(e.x - _dragStartX) > kClickThreshold || (Mathf.Abs(e.x - _dragStartX) > kClickThreshold ||

View file

@ -54,6 +54,24 @@ namespace S3.Modules.Popout {
ToggleEotd = 27, ToggleEotd = 27,
ToggleEotdOnlyWhenCulled = 28, ToggleEotdOnlyWhenCulled = 28,
SetEotdSizeScale = 29, // y = [1.0, 10.0] SetEotdSizeScale = 29, // y = [1.0, 10.0]
ToggleTrackLabels = 30, // toggle industry track name labels on the map
TrackLabelSetFontSize = 31, // y = font size px [8, 24]
TrackLabelSetLineThick = 32, // y = leader line thickness [1, 4]
TrackLabelSetZoomLimit = 33, // y = orthographicSize zoom-out limit [200, 8000]
ToggleLeaderLines = 34, // toggle leader lines from label to track anchor
ToggleCollision = 35, // toggle collision-avoidance label spread
ToggleParallelLabels = 36, // rotate labels to run parallel to their track
ToggleMergeLabels = 37, // merge same-name nearby spans into one label
TrackLabelSetMergeZoom = 38, // y = orthographicSize threshold for auto-merge
TrackLabelSetIndustryZoom = 39, // y = orthographicSize threshold for Stage 3 industry labels
ToggleUtilityRepairLabels = 40, // toggle repair-track labels
ToggleUtilityDieselLabels = 41, // toggle diesel-stand labels
ToggleUtilityLoaderLabels = 42, // toggle coal-loader labels
ToggleUtilityInterchangeLabels = 43, // toggle interchange labels
TrackLabelSetUtilityZoom = 44, // y = orthographicSize beyond which utility labels hide
TrackLabelSetAllZoom = 45, // y = orthographicSize beyond which ALL labels hide
ToggleAvoidTrackLabels = 46, // push labels off their own track line
TrackLabelSetFontSizeMin = 47, // y = minimum font size px [4, max]; labels auto-scale with zoom
} }
// Must match MapThemeData in native/include/shared_types.h exactly (36 floats = 144 bytes). // Must match MapThemeData in native/include/shared_types.h exactly (36 floats = 144 bytes).
@ -205,6 +223,11 @@ namespace S3.Modules.Popout {
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_GetOverlayMapView(out float outW, out float outH); public static extern void RRPOPOUT_GetOverlayMapView(out float outW, out float outH);
// Current mouse position normalised within the map image (top-left origin, [0,1]).
// Returns (-1, -1) when the map is not visible this frame.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_GetOverlayMouseMapPos(out float outX, out float outY);
// Handle of the in-game overlay's shared UI-state holder. Push state to it // Handle of the in-game overlay's shared UI-state holder. Push state to it
// (status/loco/location/rotation/follow) and poll it for toolbar commands // (status/loco/location/rotation/follow) and poll it for toolbar commands
// with the same handle-based functions the popout uses. // with the same handle-based functions the popout uses.
@ -252,5 +275,41 @@ namespace S3.Modules.Popout {
int windowHandle, uint flags, int windowHandle, uint flags,
float flareScale, float junctionScale, float flareScale, float junctionScale,
float trackThickness, float crossingScale); float trackThickness, float crossingScale);
// Push industry track name labels for the map.
// namesBlob: track names joined by '\n'.
// us/vs: centroid UV per label (C# projects TrackSpan centroid through the map camera).
// anchorUs/anchorVs: flat UV arrays for all anchor span positions across all labels.
// anchorStarts/anchorCounts: per-label start index and count into the anchor arrays.
// count=0 clears everything (call when zoomed out or map inactive).
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
public static extern void RRPOPOUT_SetTrackLabels(
int windowHandle, string namesBlob,
[In] float[] us, [In] float[] vs,
[In] float[] anchorUs, [In] float[] anchorVs,
[In] int[] anchorStarts, [In] int[] anchorCounts,
[In] float[] angles, // screen-space angle per label (degrees; 0=right, +ve=CW in screen space)
[In] float[] scales, // font size multiplier per label (1.0=track label, 1.5=industry label)
int count);
// Enable or disable track label rendering for this window. Default: enabled.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetTrackLabelsEnabled(int windowHandle, bool enabled);
// Seed all track-label style settings at once. Called on map activate and
// whenever any of the settings change via the gear menu.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetTrackLabelStyle(
int windowHandle,
float fontSize, float lineThickness, float zoomLimit,
bool leaderLinesEnabled, bool collisionEnabled, bool parallelEnabled,
bool mergeEnabled, float mergeZoom,
float industryZoom,
bool utilityRepairEnabled, bool utilityDieselEnabled,
bool utilityLoaderEnabled, bool utilityInterchangeEnabled,
float utilityZoom,
float allLabelsZoom,
bool avoidTrack,
float fontSizeMin);
} }
} }

View file

@ -54,6 +54,28 @@ public class PopoutSettings
// Dot size: 1 (tiny) to 10 (large). Applied as orthographicSize * value * 0.002. // Dot size: 1 (tiny) to 10 (large). Applied as orthographicSize * value * 0.002.
public float eotdSizeScale = 8f; public float eotdSizeScale = 8f;
// Track name labels — shown on the map at each industry track, hidden when zoomed out.
public bool trackLabelsEnabled = true;
public float trackLabelFontSize = 12f; // px; range 8-32
public float trackLabelLineThickness = 1.5f; // px; range 1-4
public float trackLabelZoomLimit = 200f; // orthographicSize beyond which labels hide
public bool trackLeaderLinesEnabled = true; // draw lines from label to each track anchor
public bool trackCollisionEnabled = true; // spread overlapping labels apart
public bool trackLabelParallel = false; // rotate labels parallel to their track
public bool trackLabelMergeEnabled = true; // merge same-name nearby spans into one label
public float trackLabelMergeZoom = 150f; // auto-merge when orthographicSize exceeds this
public float trackIndustryLabelZoom = 200f; // Stage 3: show industry labels beyond this zoom
// Utility track filters — per-type visibility toggle + shared zoom limit.
// Interchange tracks are also name-normalized (strip " to <Business>" suffix).
public bool trackUtilityRepairEnabled = true;
public bool trackUtilityDieselEnabled = true;
public bool trackUtilityLoaderEnabled = true;
public bool trackUtilityInterchangeEnabled = true;
public float trackUtilityZoomLimit = 200f; // utility tracks hide before this zoom
public float trackAllLabelsZoomLimit = 8000f; // all labels (incl. industry) hide beyond this zoom
public bool trackLabelAvoidTrack = false; // push labels perpendicular so they don't cover track lines
public float trackLabelFontSizeMin = 10f; // smallest pixel size (at zoom limit); auto-scales between this and trackLabelFontSize
// Custom theme colors — edited live in the Settings color picker. // Custom theme colors — edited live in the Settings color picker.
// Initialized to S3 Dark so first-launch looks reasonable before the user tunes it. // Initialized to S3 Dark so first-launch looks reasonable before the user tunes it.
public MapThemeData customTheme = new MapThemeData { public MapThemeData customTheme = new MapThemeData {

View file

@ -0,0 +1,48 @@
using S3.Core;
using UnityEngine;
namespace S3.Modules.Profiler;
public sealed class ProfilerModule : IModule
{
private const string SettingsFile = "S3.profiler.json";
public static ProfilerSettings Settings { get; private set; } = new();
private static GameObject? _hostGo;
public ProfilerModule() => Settings = SettingsStore.Load<ProfilerSettings>(SettingsFile);
public string Id => "profiler";
public string DisplayName => "Profiler";
public string Description =>
"In-game performance overlay: render and physics frame-time graph, " +
"Physics Optimizer quick-toggles, and Mesh LOD car counts. " +
"Sections appear only when the corresponding module is enabled.";
public bool Enabled
{
get => Settings.enabled;
set => Settings.enabled = value;
}
public void OnEnable()
{
_hostGo = new GameObject("[S3] ProfilerHost");
UnityEngine.Object.DontDestroyOnLoad(_hostGo);
var overlay = _hostGo.AddComponent<ProfilerOverlayGUI>();
overlay.Visible = Settings.visible;
overlay.Opacity = Settings.opacity;
}
public void OnDisable()
{
if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo);
_hostGo = null;
}
public void SaveSettings() => Persist();
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
public void DrawSettings() => ProfilerSettingsUI.Draw();
}

View file

@ -0,0 +1,290 @@
using S3.Modules.MeshLod;
using S3.Modules.PhysicsOptimizer;
using UnityEngine;
namespace S3.Modules.Profiler;
public class ProfilerOverlayGUI : MonoBehaviour
{
public static ProfilerOverlayGUI? Instance { get; private set; }
public bool Visible = true;
public float Opacity = 0.85f;
Rect _windowRect = new(10f, 10f, 420f, 10f);
GUIStyle _blueLabel;
GUIStyle _greenLabel;
GUIStyle _yellowLabel;
GUIStyle _orangeLabel;
GUIStyle _dimLabel;
GUIStyle _fps60Label;
GUIStyle _fps30Label;
GUIStyle _onStyle;
GUIStyle _offStyle;
GUIStyle _windowStyle;
Texture2D _solidTex;
Texture2D _graphTex;
Color32[] _graphPixels;
const int TexW = 300;
const int TexH = 90;
static readonly Color32 ColBg = new(16, 16, 16, 220);
static readonly Color32 Col60 = new(210, 210, 210, 255);
static readonly Color32 Col30 = new(210, 210, 210, 255);
static readonly Color32 ColRender = new(50, 140, 215, 255);
static readonly Color32 ColFrame = new(70, 200, 70, 255);
static readonly Color32 ColTick = new(210, 185, 50, 255);
static readonly Color32 ColPosCars = new(220, 110, 30, 255);
static readonly int WindowId = "S3ProfilerOverlay".GetHashCode();
const float RefFps60Ms = 16.7f;
const float RefFps30Ms = 33.3f;
// Per-second stats cache
string _physLodStr = "";
string _physFreezeStr = "";
string _physDebugStr = "";
string _meshLodStr = "";
int _statsFrame;
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() => 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();
Color prevBg = GUI.backgroundColor;
GUI.backgroundColor = new Color(0.063f, 0.063f, 0.063f, Opacity);
_windowRect = GUILayout.Window(WindowId, _windowRect, DrawWindow,
"Profiler", _windowStyle, GUILayout.MinWidth(420f));
GUI.backgroundColor = prevBg;
}
void DrawWindow(int _)
{
GUI.backgroundColor = Color.white;
// ── Frame-time report ─────────────────────────────────────────────────
GUILayout.Label(PhysicsTimer.GetReport());
Rect graphRect = GUILayoutUtility.GetRect(
GUIContent.none, GUIStyle.none,
GUILayout.Height(TexH), GUILayout.ExpandWidth(true));
if (Event.current.type == EventType.Repaint)
{
UpdateGraphTexture();
GUI.DrawTexture(graphRect, _graphTex, ScaleMode.StretchToFill);
float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f);
GUI.Label(new Rect(graphRect.xMax - 54f, LocalGaugeY(graphRect, maxMs, RefFps60Ms) - 9f, 52f, 18f), "60 fps", _fps60Label);
GUI.Label(new Rect(graphRect.xMax - 54f, LocalGaugeY(graphRect, maxMs, RefFps30Ms) - 9f, 52f, 18f), "30 fps", _fps30Label);
int prev = (PhysicsTimer.RingWrite - 1 + PhysicsTimer.RingSize) % PhysicsTimer.RingSize;
GUI.Label(new Rect(graphRect.x + 4f, graphRect.y + 2f, 340f, 20f),
$"render:{PhysicsTimer.RingRender[prev]:F2}ms phys:{PhysicsTimer.RingFrame[prev]:F2}ms tick:{PhysicsTimer.RingTick[prev]:F2}ms",
_dimLabel);
}
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();
// Refresh per-second stats cache
if (Time.frameCount != _statsFrame && Time.frameCount % 60 == 0)
{
_statsFrame = Time.frameCount;
RefreshStats();
}
// ── Physics Optimizer section ─────────────────────────────────────────
if (PhysicsOptimizerModule.Settings.enabled && ProfilerModule.Settings.showPhysicsSection)
{
GUILayout.Space(4f);
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;
PhysicsOptimizerModule.Settings.OptimizerEnabled = ConsistLOD.Enabled;
PhysicsOptimizerModule.Persist();
}
GUILayout.Label(_physLodStr, _dimLabel);
GUILayout.EndHorizontal();
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;
PhysicsOptimizerModule.Settings.AutoFreezeEnabled = ConsistFreezer.AutoFreezeEnabled;
PhysicsOptimizerModule.Persist();
}
GUILayout.Label(_physFreezeStr, _dimLabel);
GUILayout.EndHorizontal();
if (_physDebugStr.Length > 0)
GUILayout.Label(_physDebugStr, _dimLabel);
}
// ── Mesh LOD section ──────────────────────────────────────────────────
if (MeshLodModule.Settings.enabled && ProfilerModule.Settings.showMeshLodSection)
{
GUILayout.Space(4f);
GUILayout.Label(_meshLodStr, _dimLabel);
}
GUI.DragWindow(new Rect(0f, 0f, _windowRect.width, 20f));
}
void RefreshStats()
{
bool physOn = PhysicsOptimizerModule.Settings.enabled;
if (physOn && ConsistLOD.Enabled)
{
int fast = ConsistLOD.LastFastPathCount;
int full = ConsistLOD.LastFullPathCount;
_physLodStr = $"fast:{fast}/{fast + full} full:{full}/{fast + full} dist:{ConsistLOD.DistanceThreshold:F0}m resync:1/{ConsistLOD.ResyncInterval}";
}
else
{
_physLodStr = "";
}
if (physOn)
{
_physFreezeStr = $"stopped:{ConsistFreezer.LastAtRestCars} dist/spd:{ConsistFreezer.LastDistanceCars}";
bool anyDbg = PhysicsOptimizerModule.Settings.DebugHighlightFrozen ||
PhysicsOptimizerModule.Settings.DebugHighlightFastPath ||
PhysicsOptimizerModule.Settings.DebugHighlightFullPath;
_physDebugStr = anyDbg
? $"dbg frozen:{ConsistFreezer.FrozenCars.Count} fast:{ConsistLOD.FastPathCars.Count} full:{ConsistLOD.FullPathCars.Count} tinted:{CarDebugVisualizer.Instance?.TintedCount ?? 0}"
: "";
}
else
{
_physFreezeStr = _physDebugStr = "";
}
if (MeshLodModule.Settings.enabled)
{
var (tot, locos, freight, l0, l1, l2, l3) = MeshLodInjector.GetLodStats();
_meshLodStr = tot == 0
? "Mesh LOD — no cars loaded yet"
: $"Mesh LOD — {tot} tracked ({locos}L / {freight}C) LOD0:{l0} LOD1:{l1} LOD2:{l2} LOD3:{l3}";
}
else
{
_meshLodStr = "";
}
}
// ── Graph rendering ───────────────────────────────────────────────────────
void UpdateGraphTexture()
{
float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f);
int write = PhysicsTimer.RingWrite;
int n = PhysicsTimer.RingSize;
for (int i = 0; i < _graphPixels.Length; i++)
_graphPixels[i] = ColBg;
DrawFilledArea(PhysicsTimer.RingRender, null, n, write, maxMs, ColRender);
DrawFilledArea(PhysicsTimer.RingFrame, PhysicsTimer.RingRender, n, write, maxMs, ColFrame);
DrawFilledArea(PhysicsTimer.RingTick, PhysicsTimer.RingRender, n, write, maxMs, ColTick);
if (PhysicsTimer.HasPosCarsData)
DrawFilledArea(PhysicsTimer.RingPosCars, PhysicsTimer.RingRender, n, write, maxMs, ColPosCars);
DrawHLine(maxMs, RefFps60Ms, Col60);
DrawHLine(maxMs, RefFps30Ms, Col30);
_graphTex.SetPixels32(_graphPixels);
_graphTex.Apply(false);
}
static float LocalGaugeY(Rect r, float maxMs, float ms) =>
r.yMax - Mathf.Clamp01(ms / maxMs) * r.height;
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);
for (int x = 0; x < TexW; x++)
_graphPixels[row * TexW + x] = color;
}
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) },
};
_windowStyle = new GUIStyle(GUI.skin.window);
_windowStyle.normal.background = _solidTex;
_windowStyle.onNormal.background = _solidTex;
_windowStyle.focused.background = _solidTex;
_windowStyle.onFocused.background = _solidTex;
}
}

View file

@ -0,0 +1,13 @@
using System;
namespace S3.Modules.Profiler;
[Serializable]
public class ProfilerSettings
{
public bool enabled = false;
public bool visible = true;
public float opacity = 0.85f;
public bool showPhysicsSection = true;
public bool showMeshLodSection = true;
}

View file

@ -0,0 +1,76 @@
using S3.Modules.MeshLod;
using S3.Modules.PhysicsOptimizer;
using UnityEngine;
namespace S3.Modules.Profiler;
static class ProfilerSettingsUI
{
public static void Draw()
{
ProfilerSettings s = ProfilerModule.Settings;
bool changed = false;
GUILayout.BeginVertical();
GUILayout.Label("<b>Profiler</b> — in-game frame-time overlay with module readouts");
GUILayout.Space(4f);
GUILayout.Label(
" Always shows the render and physics frame-time graph.\n" +
" Physics Optimizer and Mesh LOD sections appear when those modules are enabled.",
GUI.skin.label);
// ── Overlay visibility ────────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label("<b>Display</b>");
GUILayout.Space(4f);
bool newVisible = GUILayout.Toggle(s.visible, " Show overlay in-game");
if (newVisible != s.visible)
{
s.visible = newVisible;
if (ProfilerOverlayGUI.Instance != null)
ProfilerOverlayGUI.Instance.Visible = newVisible;
changed = true;
}
GUILayout.Space(4f);
GUILayout.BeginHorizontal();
GUILayout.Label($"Opacity: {s.opacity * 100f:F0}%", GUILayout.Width(110f));
float newOpacity = GUILayout.HorizontalSlider(s.opacity, 0.1f, 1.0f, GUILayout.Width(200f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newOpacity - s.opacity) > 0.01f)
{
s.opacity = newOpacity;
if (ProfilerOverlayGUI.Instance != null)
ProfilerOverlayGUI.Instance.Opacity = newOpacity;
changed = true;
}
// ── Section visibility ────────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label("<b>Sections</b>");
GUILayout.Space(4f);
bool physAvail = PhysicsOptimizerModule.Settings.enabled;
GUI.enabled = physAvail;
bool newPhys = GUILayout.Toggle(s.showPhysicsSection && physAvail,
" Physics Optimizer" + (physAvail ? "" : " (module not enabled)"));
GUI.enabled = true;
if (newPhys != s.showPhysicsSection && physAvail)
{ s.showPhysicsSection = newPhys; changed = true; }
bool meshAvail = MeshLodModule.Settings.enabled;
GUI.enabled = meshAvail;
bool newMesh = GUILayout.Toggle(s.showMeshLodSection && meshAvail,
" Mesh LOD" + (meshAvail ? "" : " (module not enabled)"));
GUI.enabled = true;
if (newMesh != s.showMeshLodSection && meshAvail)
{ s.showMeshLodSection = newMesh; changed = true; }
GUILayout.EndVertical();
if (changed) ProfilerModule.Persist();
}
}