From 1b4ab97be5f0cc821bb44fedf66b08a3a6038245 Mon Sep 17 00:00:00 2001 From: seton Date: Thu, 25 Jun 2026 18:57:50 -0400 Subject: [PATCH] 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. --- src/Modules/MeshLod/MeshLodInjector.cs | 59 ++++++++++++++++++------ src/Modules/MeshLod/MeshLodSettings.cs | 14 +++--- src/Modules/MeshLod/MeshLodSettingsUI.cs | 52 ++++++++++++--------- 3 files changed, 80 insertions(+), 45 deletions(-) diff --git a/src/Modules/MeshLod/MeshLodInjector.cs b/src/Modules/MeshLod/MeshLodInjector.cs index af8b195..7238a44 100644 --- a/src/Modules/MeshLod/MeshLodInjector.cs +++ b/src/Modules/MeshLod/MeshLodInjector.cs @@ -12,6 +12,7 @@ static class MeshLodInjector { static MeshLodSettings _settings = new(); static readonly List> _trackedCars = new(); + static Material? _bboxMat; // Debounced auto-refresh: set by MarkDirty, consumed by CheckAutoRefresh (called from Update). static float _pendingRefreshAt = -1f; @@ -22,9 +23,9 @@ static class MeshLodInjector 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} " + + 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}"); } @@ -32,6 +33,19 @@ static class MeshLodInjector { _trackedCars.Clear(); _pendingRefreshAt = -1f; + if (_bboxMat != null) { UnityEngine.Object.Destroy(_bboxMat); _bboxMat = null; } + } + + 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); } // Called from the UI whenever any setting changes. @@ -57,8 +71,8 @@ static class MeshLodInjector if (car.BodyTransform.GetComponent() != null) return; bool isLoco = car is BaseLocomotive; - if ( isLoco && !_settings.ApplyToLocomotives) return; - if (!isLoco && !_settings.ApplyToFreightCars) return; + if ( isLoco && !_settings.applyToLocomotives) return; + if (!isLoco && !_settings.applyToFreightCars) return; MeshRenderer[] renderers = CollectRenderers(car.BodyTransform); if (renderers.Length == 0) return; @@ -84,8 +98,8 @@ static class MeshLodInjector RemoveLods(car); bool isLoco = car is BaseLocomotive; - if ( isLoco && !_settings.ApplyToLocomotives) { skipped++; continue; } - if (!isLoco && !_settings.ApplyToFreightCars) { skipped++; continue; } + if ( isLoco && !_settings.applyToLocomotives) { skipped++; continue; } + if (!isLoco && !_settings.applyToFreightCars) { skipped++; continue; } MeshRenderer[] renderers = CollectRenderers(car.BodyTransform); if (renderers.Length == 0) { skipped++; continue; } @@ -135,10 +149,10 @@ static class MeshLodInjector .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); + // 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(); @@ -147,7 +161,7 @@ static class MeshLodInjector // LOD3: 12-triangle proxy box derived from the largest renderer's mesh-local AABB. Bounds localBounds = ComputeMeshLocalBounds(sorted[0], bodyRoot); MeshRenderer bboxMr = CreateBoundingBoxRenderer(bodyRoot, localBounds, - sorted[0].sharedMaterial, + GetOrCreateBBoxMat(sorted[0].sharedMaterial), sorted[0].gameObject.layer); Renderer[] lod3Rend = new[] { (Renderer)bboxMr }; @@ -161,9 +175,9 @@ static class MeshLodInjector 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); + 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[] @@ -253,6 +267,21 @@ static class MeshLodInjector // ── Proxy box mesh ──────────────────────────────────────────────────────── + // One matte Standard material shared across all proxy boxes; tinted to the + // car body's albedo so it looks approximately correct under any lighting. + static Material GetOrCreateBBoxMat(Material? src) + { + if (_bboxMat != null) return _bboxMat; + var shader = Shader.Find("Standard") ?? Shader.Find("Legacy Shaders/Diffuse"); + _bboxMat = shader != null ? new Material(shader) : new Material(src); + _bboxMat.color = src != null && src.HasProperty("_Color") + ? src.color + : new Color(0.45f, 0.45f, 0.45f); + if (_bboxMat.HasProperty("_Metallic")) _bboxMat.SetFloat("_Metallic", 0f); + if (_bboxMat.HasProperty("_Glossiness")) _bboxMat.SetFloat("_Glossiness", 0.1f); + return _bboxMat; + } + static MeshRenderer CreateBoundingBoxRenderer(Transform parent, Bounds localBounds, Material mat, int layer) { diff --git a/src/Modules/MeshLod/MeshLodSettings.cs b/src/Modules/MeshLod/MeshLodSettings.cs index 4bcfc9e..f18f6c6 100644 --- a/src/Modules/MeshLod/MeshLodSettings.cs +++ b/src/Modules/MeshLod/MeshLodSettings.cs @@ -8,15 +8,15 @@ 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 + 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 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; + public bool applyToLocomotives = true; + public bool applyToFreightCars = true; } diff --git a/src/Modules/MeshLod/MeshLodSettingsUI.cs b/src/Modules/MeshLod/MeshLodSettingsUI.cs index aea097c..e2b669e 100644 --- a/src/Modules/MeshLod/MeshLodSettingsUI.cs +++ b/src/Modules/MeshLod/MeshLodSettingsUI.cs @@ -23,11 +23,11 @@ static class MeshLodSettingsUI GUILayout.Label("Apply to"); GUILayout.Space(4f); - bool newLocos = GUILayout.Toggle(s.ApplyToLocomotives, " Locomotives"); - if (newLocos != s.ApplyToLocomotives) { s.ApplyToLocomotives = newLocos; changed = true; } + 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; } + bool newCars = GUILayout.Toggle(s.applyToFreightCars, " Freight / passenger cars"); + if (newCars != s.applyToFreightCars) { s.applyToFreightCars = newCars; changed = true; } // ── Renderer fractions ──────────────────────────────────────────────── GUILayout.Space(10f); @@ -36,35 +36,35 @@ static class MeshLodSettingsUI 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", + $" 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)); + float newF1 = GUILayout.HorizontalSlider(s.lod1RendererFraction, 0.15f, 0.80f, GUILayout.Width(220f)); GUILayout.EndHorizontal(); - if (Mathf.Abs(newF1 - s.Lod1RendererFraction) > 0.005f) + 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); + 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", + $" 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)); + float newF2 = GUILayout.HorizontalSlider(s.lod2RendererFraction, 0.05f, 0.40f, GUILayout.Width(220f)); GUILayout.EndHorizontal(); - if (Mathf.Abs(newF2 - s.Lod2RendererFraction) > 0.005f) + 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); + s.lod2RendererFraction = Mathf.Round(newF2 * 20f) / 20f; + if (s.lod2RendererFraction >= s.lod1RendererFraction) + s.lod1RendererFraction = Mathf.Min(0.80f, s.lod2RendererFraction + 0.10f); changed = true; } @@ -78,23 +78,29 @@ static class MeshLodSettingsUI GUI.skin.label); GUILayout.Space(4f); - DrawDistanceSlider(ref s.Lod1Distance, "LOD0→LOD1 (minor cull)", 30f, 500f, ref changed); + 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; } + 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; } + 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();