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.
376 lines
16 KiB
C#
376 lines
16 KiB
C#
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);
|
||
}
|