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> _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(); 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() != 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)); } 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 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(); foreach (LODGroup existing in bodyRoot.GetComponentsInChildren()) foreach (LOD lod in existing.GetLODs()) foreach (Renderer r in lod.renderers) if (r != null) alreadyClaimed.Add(r); var list = new List(); foreach (MeshRenderer mr in bodyRoot.GetComponentsInChildren()) { if (alreadyClaimed.Contains(mr)) continue; if (IsBBoxChild(mr.transform)) continue; if (mr.GetComponent()?.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().ToArray(); Renderer[] lod1Rend = sorted.Take(lod1Keep).Cast().ToArray(); Renderer[] lod2Rend = sorted.Take(lod2Keep).Cast().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.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(); 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(); 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(); 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().sharedMesh = mesh; var mr = go.AddComponent(); 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); }