diff --git a/Source/DynamicProperties/Disposable.cs b/Source/DynamicProperties/Disposable.cs new file mode 100644 index 0000000..01800a0 --- /dev/null +++ b/Source/DynamicProperties/Disposable.cs @@ -0,0 +1,39 @@ +using System; +using KSPBuildTools; + +namespace Shabby.DynamicProperties; + +public abstract class Disposable : IDisposable +{ + protected virtual bool IsUnused() => false; + + protected abstract void OnDispose(); + + private bool _disposed = false; + + private void HandleDispose(bool disposing) + { + if (_disposed) return; + + if (disposing) { + Log.Debug($"disposing {GetType().Name} instance {GetHashCode()}"); + OnDispose(); + } else if (!IsUnused()) { + Log.Warning( + $"active {GetType().Name} instance {GetHashCode()} was not disposed"); + } + + _disposed = true; + } + + public void Dispose() + { + HandleDispose(true); + GC.SuppressFinalize(this); + } + + ~Disposable() + { + HandleDispose(false); + } +} diff --git a/Source/DynamicProperties/MaterialPropertyManager.cs b/Source/DynamicProperties/MaterialPropertyManager.cs new file mode 100644 index 0000000..40392c2 --- /dev/null +++ b/Source/DynamicProperties/MaterialPropertyManager.cs @@ -0,0 +1,177 @@ +#nullable enable + +using System.Collections.Generic; +using KSPBuildTools; +using UnityEngine; + +namespace Shabby.DynamicProperties; + +[KSPAddon(KSPAddon.Startup.EveryScene, false)] +public sealed class MaterialPropertyManager : MonoBehaviour +{ + #region Fields + + public static MaterialPropertyManager? Instance { get; private set; } + + private readonly Dictionary rendererCascades = []; + + private readonly List propsLateUpdateQueue = []; + + #endregion + + #region Lifecycle + + private MaterialPropertyManager() + { + } + + private void Awake() + { + if (Instance != null) { + DestroyImmediate(this); + return; + } + + name = nameof(MaterialPropertyManager); + Instance = this; + } + + private void OnDestroy() + { + if (Instance != this) return; + + Instance = null; + foreach (var cascade in rendererCascades.Values) cascade.Dispose(); + MpbCompilerCache.CheckCleared(); + + // Poor man's GC :'( + PartPatch.CheckCleared(); + MaterialColorUpdaterPatch.CheckCleared(); + ModuleColorChangerPatch.CheckCleared(); + FairingPanelPatch.CheckCleared(); + + this.LogMessage("destroyed"); + } + + #endregion + + #region Public API + + public bool Set(Renderer renderer, Props props) + { + if (!CheckRendererAlive(renderer)) return false; + + if (!rendererCascades.TryGetValue(renderer, out var cascade)) { + rendererCascades[renderer] = cascade = new PropsCascade(renderer); + } + + return cascade.Add(props); + } + + public bool Unset(Renderer renderer, Props props) + { + if (!CheckRendererAlive(renderer)) return false; + if (!rendererCascades.TryGetValue(renderer, out var cascade)) return false; + return cascade.Remove(props); + } + + public bool Unregister(Renderer renderer) + { + if (renderer.IsNullref()) return false; + if (!rendererCascades.Remove(renderer, out var cascade)) return false; + if (renderer.IsDestroyed()) this.LogDebug($"destroyed renderer {renderer.GetHashCode()}"); + cascade.Dispose(); + return true; + } + + /// Get a reference to the `Props` instance containing the stock properties of the given + /// `part` (namely, `_Opacity`, `_RimFalloff`, and `_RimColor`). + /// The returned instance must not be written to. + public Props? GetStockPropsForPart(Part part) => PartPatch.Props.GetValueOrDefault(part); + + /// Get the part's current `_TemperatureColor` property, if it is set (only in flight). + public Color? GetStockTemperatureColorForPart(Part part) + { + if (!MaterialColorUpdaterPatch.Props.TryGetValue(part.temperatureRenderer, out var props)) { + return null; + } + + if (!props.HasColor(PhysicsGlobals.temperaturePropertyID)) return null; + return props.GetColorOrDefault(PhysicsGlobals.temperaturePropertyID); + } + + public static void RegisterPropertyNamesForDebugLogging(params string[] properties) + { + foreach (var property in properties) PropIdToName.Register(property); + } + + #endregion + + private bool CheckRendererAlive(Renderer renderer) + { + if (renderer.IsNullref()) { + Log.LogError(this, "renderer reference is null"); + return false; + } + + if (renderer.IsDestroyed()) { + this.LogWarning($"cannot modify destroyed renderer {renderer.GetHashCode()}"); + Unregister(renderer); + return false; + } + + return true; + } + + private readonly List _destroyedRenderers = []; + + internal void CheckRemoveDestroyedRenderers() + { + foreach (var renderer in rendererCascades.Keys) { + if (renderer.IsDestroyed()) _destroyedRenderers.Add(renderer); + } + + foreach (var destroyed in _destroyedRenderers) Unregister(destroyed); + _destroyedRenderers.Clear(); + } + + /// Public API equivalent is calling `Props.Dispose`. + internal void Unregister(Props props) + { + foreach (var (renderer, cascade) in rendererCascades) { + if (!renderer.IsDestroyed()) cascade.Remove(props); + } + + CheckRemoveDestroyedRenderers(); + } + + private bool _propsUpdateScheduled = false; + private static readonly WaitForEndOfFrame WfEoF = new(); + + private IEnumerator Co_PropsLateUpdate() + { + yield return WfEoF; + + foreach (var props in propsLateUpdateQueue) { + if (props.NeedsEntriesUpdate) { + props.OnEntriesChanged?.Invoke(props); + } else if (props.NeedsValueUpdate) { + props.OnValueChanged?.Invoke(props, null); + } + + props.SuppressEagerUpdate = + props.NeedsEntriesUpdate = props.NeedsValueUpdate = false; + } + + propsLateUpdateQueue.Clear(); + _propsUpdateScheduled = false; + } + + internal void ScheduleLateUpdate(Props props) + { + propsLateUpdateQueue.Add(props); + if (_propsUpdateScheduled) return; + StartCoroutine(Co_PropsLateUpdate()); + _propsUpdateScheduled = true; + } +} diff --git a/Source/DynamicProperties/MpbCompiler.cs b/Source/DynamicProperties/MpbCompiler.cs new file mode 100644 index 0000000..152ee71 --- /dev/null +++ b/Source/DynamicProperties/MpbCompiler.cs @@ -0,0 +1,140 @@ +#nullable enable + +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using KSPBuildTools; +using UnityEngine; + +namespace Shabby.DynamicProperties; + +internal class MpbCompiler : Disposable +{ + #region Fields + + /// Immutable. + internal readonly SortedSet Cascade; + + private readonly HashSet managedRenderers = []; + private readonly MaterialPropertyBlock mpb = new(); + private readonly Dictionary idManagers = []; + + private static readonly MaterialPropertyBlock EmptyMpb = new(); + + #endregion + + internal MpbCompiler(SortedSet cascade) + { + MaterialPropertyManager.Instance?.LogDebug( + $"new MpbCompiler instance {RuntimeHelpers.GetHashCode(this)}"); + + Cascade = cascade; + RebuildManagerMap(); + RewriteMpb(); + foreach (var props in Cascade) { + props.OnValueChanged += OnPropsValueChanged; + props.OnEntriesChanged += OnPropsEntriesChanged; + } + } + + #region Renderer registration + + internal void Register(Renderer renderer) + { + managedRenderers.Add(renderer); + Apply(renderer); + } + + internal void Unregister(Renderer renderer) + { + managedRenderers.Remove(renderer); + if (!renderer.IsDestroyed()) renderer.SetPropertyBlock(EmptyMpb); + + if (managedRenderers.Count > 0) return; + Log.Debug( + $"last renderer unregistered from MpbCompiler instance {RuntimeHelpers.GetHashCode(this)}"); + MpbCompilerCache.Remove(this); + } + + #endregion + + #region Props updates + + private void RebuildManagerMap() + { + idManagers.Clear(); + foreach (var props in Cascade) { + foreach (var id in props.ManagedIds) { + idManagers[id] = props; + } + } + } + + private void OnPropsEntriesChanged(Props props) + { + RebuildManagerMap(); + RewriteMpb(); + ApplyAll(); + } + + private void OnPropsValueChanged(Props props, int? id) + { + WriteMpb(props, id); + ApplyAll(); + } + + #endregion + + #region Apply + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void WriteMpb(Props props, int? id) + { + if (id.HasValue) { + var changedId = id.GetValueOrDefault(); + if (idManagers[changedId] != props) return; + props.Write(changedId, mpb); + } else { + foreach (var (managedId, managingProps) in idManagers) { + if (props != managingProps) continue; + props.Write(managedId, mpb); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void RewriteMpb() + { + mpb.Clear(); + foreach (var (id, props) in idManagers) props.Write(id, mpb); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Apply(Renderer renderer) => renderer.SetPropertyBlock(mpb); + + private void ApplyAll() + { + var hasDestroyedRenderer = false; + + foreach (var renderer in managedRenderers) { + if (renderer.IsDestroyed()) { + hasDestroyedRenderer = true; + } else { + Apply(renderer); + } + } + + if (hasDestroyedRenderer) MaterialPropertyManager.Instance?.CheckRemoveDestroyedRenderers(); + } + + #endregion + + protected override bool IsUnused() => managedRenderers.Count == 0; + + protected override void OnDispose() + { + foreach (var props in Cascade) { + props.OnEntriesChanged -= OnPropsEntriesChanged; + props.OnValueChanged -= OnPropsValueChanged; + } + } +} diff --git a/Source/DynamicProperties/MpbCompilerCache.cs b/Source/DynamicProperties/MpbCompilerCache.cs new file mode 100644 index 0000000..3576592 --- /dev/null +++ b/Source/DynamicProperties/MpbCompilerCache.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using KSPBuildTools; + +namespace Shabby.DynamicProperties; + +internal static class MpbCompilerCache +{ + private static readonly IEqualityComparer> CacheKeyComparer = + SortedSet.CreateSetComparer(); // Object equality is fine. + + private static readonly Dictionary, MpbCompiler> Cache = + new(CacheKeyComparer); + + internal static MpbCompiler Get(SortedSet cascade) + { + if (Cache.TryGetValue(cascade, out var compiler)) { + MaterialPropertyManager.Instance?.LogDebug( + $"MpbCompiler cache hit instance {RuntimeHelpers.GetHashCode(compiler)}"); + return compiler; + } + + // Don't accidentally mutate the cache key... + var clonedCascade = new SortedSet(cascade); + compiler = new MpbCompiler(clonedCascade); +#if DEBUG + if (!(!ReferenceEquals(cascade, compiler.Cascade) && + CacheKeyComparer.Equals(cascade, compiler.Cascade))) { + throw new InvalidOperationException("cache key equality check failed"); + } +#endif + Cache[compiler.Cascade] = compiler; + return compiler; + } + + internal static void Remove(MpbCompiler entry) + { + Cache.Remove(entry.Cascade); + entry.Dispose(); + } + + internal static void CheckCleared() + { + if (Cache.Count == 0) return; + + Log.Error($"{Cache.Count} MpbCompilers were not disposed; forcing removal"); + foreach (var compiler in Cache.Values) compiler.Dispose(); + Cache.Clear(); + } +} diff --git a/Source/DynamicProperties/Patches/FairingPanelPatch.cs b/Source/DynamicProperties/Patches/FairingPanelPatch.cs new file mode 100644 index 0000000..92505ad --- /dev/null +++ b/Source/DynamicProperties/Patches/FairingPanelPatch.cs @@ -0,0 +1,38 @@ +using HarmonyLib; +using ProceduralFairings; + +namespace Shabby.DynamicProperties; + +[HarmonyPatch(typeof(FairingPanel))] +internal class FairingPanelPatch : StockPatchBase +{ + [HarmonyPrefix] + [HarmonyPatch(nameof(FairingPanel.SetOpacity))] + private static bool SetOpacity_Transpiler(FairingPanel __instance, float o) + { + __instance.opacity = o; + + if (!Props.TryGetValue(__instance, out var props)) { + props = Props[__instance] = new Props(0); + MaterialPropertyManager.Instance?.Set(__instance.mr, props); + if (__instance.attachedFlagParts is { Count: > 0 }) { + foreach (var flagPart in __instance.attachedFlagParts) { + foreach (var flagRenderer in flagPart.flagMeshRenderers) { + MaterialPropertyManager.Instance?.Set(flagRenderer, props); + } + } + } + } + + props.SetFloat(PropertyIDs._Opacity, o); + + return false; + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(FairingPanel.Despawn))] + private static void FairingPanel_Despawn(FairingPanel __instance) + { + if (Props.Remove(__instance, out var props)) props.Dispose(); + } +} diff --git a/Source/DynamicProperties/Patches/MaterialColorUpdaterPatch.cs b/Source/DynamicProperties/Patches/MaterialColorUpdaterPatch.cs new file mode 100644 index 0000000..4bd8bb3 --- /dev/null +++ b/Source/DynamicProperties/Patches/MaterialColorUpdaterPatch.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using System.Reflection.Emit; +using HarmonyLib; +using UnityEngine; + +namespace Shabby.DynamicProperties; + +[HarmonyPatch(typeof(MaterialColorUpdater))] +internal class MaterialColorUpdaterPatch : StockPatchBase +{ + [HarmonyPostfix] + [HarmonyPatch("CreateRendererList")] + private static void MaterialColorUpdater_CreateRendererList_Postfix( + MaterialColorUpdater __instance) + { + var props = Props[__instance] = new Props(int.MinValue + 1); + foreach (var renderer in __instance.renderers) { + MaterialPropertyManager.Instance?.Set(renderer, props); + } + } + + private static void Update_SetProperty(MaterialColorUpdater mcu) + { + Props[mcu].SetColor(mcu.propertyID, mcu.setColor); + } + + [HarmonyTranspiler] + [HarmonyPatch(nameof(MaterialColorUpdater.Update))] + private static IEnumerable Update_Transpiler( + IEnumerable insns) + { + var MPB_SetColor = AccessTools.Method( + typeof(MaterialPropertyBlock), + nameof(MaterialPropertyBlock.SetColor), + [typeof(int), typeof(Color)]); + + foreach (var insn in insns) { + yield return insn; + + // this.mpb.SetColor(this.propertyID, this.setColor); + // IL_0022: ldarg.0 // this + // IL_0023: ldfld class UnityEngine.MaterialPropertyBlock MaterialColorUpdater::mpb + // IL_0028: ldarg.0 // this + // IL_0029: ldfld int32 MaterialColorUpdater::propertyID + // IL_002e: ldarg.0 // this + // IL_002f: ldfld valuetype UnityEngine.Color MaterialColorUpdater::setColor + // IL_0034: callvirt instance void UnityEngine.MaterialPropertyBlock::SetColor(int32, valuetype UnityEngine.Color) + if (insn.Calls(MPB_SetColor)) break; + // Remaining code applies MPB to renderers. + } + + // MaterialColorUpdaterPatch.Update_SetProperty(this); + // return; + CodeInstruction[] updateProp = [ + new(OpCodes.Ldarg_0), // this + CodeInstruction.Call(() => Update_SetProperty(default)), + new(OpCodes.Ret) + ]; + foreach (var insn in updateProp) yield return insn; + } + + private static void DisposeIfExists(MaterialColorUpdater mcu) + { + if (mcu == null) return; + if (Props.TryGetValue(mcu, out var props)) props.Dispose(); + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(Part), nameof(Part.ResetMPB))] + private static void Part_ResetMPB_Prefix(Part __instance) + { + DisposeIfExists(__instance.temperatureRenderer); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(Part), "OnDestroy")] + private static void Part_OnDestroy_Postfix(Part __instance) + { + DisposeIfExists(__instance.temperatureRenderer); + } + + [HarmonyTranspiler] + [HarmonyPatch(typeof(ModuleJettison), nameof(ModuleJettison.Jettison))] + private static IEnumerable ModuleJettison_Jettison_Transpiler( + IEnumerable insns) + { + var ModuleJettison_jettisonTemperatureRenderer = AccessTools.Field( + typeof(ModuleJettison), nameof(ModuleJettison.jettisonTemperatureRenderer)); + + // this.jettisonTemperatureRenderer = null; + // IL_0327: ldarg.0 // this + // IL_0328: ldnull + // IL_0329: stfld class MaterialColorUpdater ModuleJettison::jettisonTemperatureRenderer + CodeMatch[] matchSetTempRendererNull = [ + new(OpCodes.Ldarg_0), + new(OpCodes.Ldnull), + new(OpCodes.Stfld, ModuleJettison_jettisonTemperatureRenderer) + ]; + + var matcher = new CodeMatcher(insns); + + matcher + .MatchStartForward(matchSetTempRendererNull) + .ThrowIfNotMatch("failed to find set temp renderer null") + .Insert( + // MaterialColorUpdaterPatch.DisposeIfExists(this.jettisonTemperatureRenderer); + new CodeInstruction(OpCodes.Ldarg_0), // this + new CodeInstruction(OpCodes.Ldfld, ModuleJettison_jettisonTemperatureRenderer), + CodeInstruction.Call(() => DisposeIfExists(default)) + ); + + return matcher.InstructionEnumeration(); + } + + // FIXME: write a transpiler for ModuleJettison.Jettison. + + [HarmonyPostfix] + [HarmonyPatch(typeof(ModuleJettison), "OnDestroy")] + private static void ModuleJettison_OnDestroy_Postfix(ModuleJettison __instance) + { + DisposeIfExists(__instance.jettisonTemperatureRenderer); + } +} diff --git a/Source/DynamicProperties/Patches/ModuleColorChangerPatch.cs b/Source/DynamicProperties/Patches/ModuleColorChangerPatch.cs new file mode 100644 index 0000000..9a8ac24 --- /dev/null +++ b/Source/DynamicProperties/Patches/ModuleColorChangerPatch.cs @@ -0,0 +1,43 @@ +using HarmonyLib; + +namespace Shabby.DynamicProperties; + +[HarmonyPatch(typeof(ModuleColorChanger))] +internal class ModuleColorChangerPatch : StockPatchBase +{ + [HarmonyPostfix] + [HarmonyPatch(nameof(ModuleColorChanger.OnStart))] + private static void OnStart_Postfix(ModuleColorChanger __instance) + { + Props[__instance] = new Props(0); + } + + [HarmonyPostfix] + [HarmonyPatch("EditRenderers")] + private static void EditRenderers_Postfix(ModuleColorChanger __instance) + { + var props = Props[__instance]; + foreach (var renderer in __instance.renderers) { + MaterialPropertyManager.Instance?.Set(renderer, props); + } + } + + [HarmonyPrefix] + [HarmonyPatch("UpdateColor")] + public static bool UpdateColor_Prefix(ModuleColorChanger __instance) + { + Props[__instance].SetColor(__instance.shaderPropertyInt, __instance.color); + return false; + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(Part), "OnDestroy")] + private static void Part_OnDestroy_Postfix(Part __instance) + { + foreach (var mcc in __instance.FindModulesImplementing()) { + if (Props.Remove(mcc, out var props)) props.Dispose(); + } + } + + // FIXME: are part modules destroyed in other places? Icon renderers? Drag cube renderers? +} diff --git a/Source/DynamicProperties/Patches/NoDuplicateMaterials.cs b/Source/DynamicProperties/Patches/NoDuplicateMaterials.cs new file mode 100644 index 0000000..a86eae9 --- /dev/null +++ b/Source/DynamicProperties/Patches/NoDuplicateMaterials.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Highlighting; +using KSPBuildTools; +using UnityEngine; + +namespace Shabby; + +// [HarmonyPatch(typeof(Renderer))] +// internal static class MaterialAccessWatchdog +// { +// [HarmonyPatch(nameof(Renderer.materials), MethodType.Getter)] +// [HarmonyPostfix] +// internal static void Renderer_materials_get_Postfix() +// { +// var trace = new StackTrace(); +// foreach (var frame in trace.GetFrames()!) { +// var type = frame.GetMethod()?.DeclaringType; +// if (type == typeof(KSP.UI.Screens.EditorPartIcon) || +// type == typeof(PSystemManager) || +// type == typeof(PSystemSetup) || +// type == typeof(Upgradeables.UpgradeableObject) || +// type == typeof(KSP.UI.Screens.Flight.NavBall)) { +// return; +// } +// } +// +// Log.Debug($"Called `Renderer.materials`\n{trace}"); +// } +// } + +[HarmonyPatch] +internal static class NoDuplicateMaterials +{ + private static readonly MethodInfo mInfo_Renderer_material_get = + AccessTools.PropertyGetter(typeof(Renderer), nameof(Renderer.material)); + + private static readonly MethodInfo mInfo_Renderer_materials_get = + AccessTools.PropertyGetter(typeof(Renderer), nameof(Renderer.materials)); + + private static readonly MethodInfo mInfo_Renderer_sharedMaterial_get = + AccessTools.PropertyGetter(typeof(Renderer), nameof(Renderer.sharedMaterial)); + + private static readonly MethodInfo mInfo_Renderer_sharedMaterials_get = + AccessTools.PropertyGetter(typeof(Renderer), nameof(Renderer.sharedMaterials)); + + private static IEnumerable TargetMethods() => [ + AccessTools.Method(typeof(Highlighter), "GrabRenderers"), + // AccessTools.Method(typeof(MaterialColorUpdater), "CreateRendererList"), + AccessTools.Method(typeof(ModuleColorChanger), "ProcessMaterialsList") + // AccessTools.Method( + // typeof(GameObjectExtension), nameof(GameObjectExtension.SetLayerRecursive), + // [typeof(GameObject), typeof(int), typeof(bool), typeof(int)]) + ]; + + [HarmonyTranspiler] + internal static IEnumerable MaterialToSharedMaterialTranspiler( + MethodBase targetMethod, IEnumerable instructions) + { + foreach (var insn in instructions) { + if (insn.Calls(mInfo_Renderer_material_get)) { + insn.operand = mInfo_Renderer_sharedMaterial_get; + Log.Debug("patched `Renderer.material` getter"); + } else if (insn.Calls(mInfo_Renderer_materials_get)) { + insn.operand = mInfo_Renderer_sharedMaterials_get; + Log.Debug("patched `Renderer.materials` getter"); + } + + yield return insn; + } + } +} diff --git a/Source/DynamicProperties/Patches/PartPatch.cs b/Source/DynamicProperties/Patches/PartPatch.cs new file mode 100644 index 0000000..cb9fbfc --- /dev/null +++ b/Source/DynamicProperties/Patches/PartPatch.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; +using System.Reflection.Emit; +using HarmonyLib; +using UnityEngine; + +namespace Shabby.DynamicProperties; + +[HarmonyPatch(typeof(Part))] +internal class PartPatch : StockPatchBase +{ + [HarmonyPostfix] + [HarmonyPatch("Awake")] + private static void Awake_Postfix(Part __instance) + { + Props[__instance] = new Props(int.MinValue + 1); + } + + [HarmonyPostfix] + [HarmonyPatch("CreateRendererLists")] + private static void CreateRendererLists_Postfix(Part __instance) + { + var props = Props[__instance]; + props.SetFloat(PropertyIDs._RimFalloff, 2f); + props.SetColor(PropertyIDs._RimColor, Part.defaultHighlightNone); + foreach (var renderer in __instance.HighlightRenderer) { + MaterialPropertyManager.Instance?.Set(renderer, props); + } + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(Part.SetOpacity))] + private static bool SetOpacity_Prefix(Part __instance, float opacity) + { + __instance.CreateRendererLists(); + __instance.mpb.SetFloat(PropertyIDs._Opacity, opacity); + Props[__instance].SetFloat(PropertyIDs._Opacity, opacity); + return false; + } + + private static void Highlight_SetRimColor(Part part, Color color) + { + Props[part].SetColor(PropertyIDs._RimColor, color); + } + + [HarmonyTranspiler] + [HarmonyPatch(nameof(Part.Highlight), typeof(Color))] + private static IEnumerable Highlight_Transpiler( + IEnumerable insns) + { + var MPB_SetColor = AccessTools.Method( + typeof(MaterialPropertyBlock), + nameof(MaterialPropertyBlock.SetColor), + [typeof(int), typeof(Color)]); + var Part_get_mpb = AccessTools.PropertyGetter(typeof(Part), nameof(Part.mpb)); + var Part_highlightRenderer = + AccessTools.Field(typeof(Part), nameof(Part.highlightRenderer)); + var PropertyIDs__RimColor = + AccessTools.Field(typeof(PropertyIDs), nameof(PropertyIDs._RimColor)); + var Renderer_SetPropertyBlock = AccessTools.Method( + typeof(Renderer), + nameof(Renderer.SetPropertyBlock), + [typeof(MaterialPropertyBlock)]); + + CodeMatch[] matchDupPop = [new(OpCodes.Dup), new(OpCodes.Pop)]; + + // mpb.SetColor(PropertyIDs._RimColor, value); + // IL_0049: ldarg.0 // this + // IL_004a: call instance class UnityEngine.MaterialPropertyBlock Part::get_mpb() + // IL_004f: ldsfld int32 PropertyIDs::_RimColor + // IL_0054: ldloc.0 // color + // IL_0055: callvirt instance void UnityEngine.MaterialPropertyBlock::SetColor(int32, valuetype UnityEngine.Color) + CodeMatch[] matchSetRimColor = [ + new(OpCodes.Ldarg_0), + new(OpCodes.Call, Part_get_mpb), + new(OpCodes.Ldsfld, PropertyIDs__RimColor), + new(OpCodes.Ldloc_0), + new(OpCodes.Callvirt, MPB_SetColor) + ]; + + // highlightRenderer[count].SetPropertyBlock(mpb); + // IL_008a: ldarg.0 // this; jump target + // IL_008b: ldfld class System.Collections.Generic.List`1 Part::highlightRenderer + // IL_0090: ldloc.1 // count + // IL_0091: callvirt instance !0/*class UnityEngine.Renderer*/ class System.Collections.Generic.List`1::get_Item(int32) + // IL_0096: ldarg.0 // this + // IL_0097: call instance class UnityEngine.MaterialPropertyBlock Part::get_mpb() + // IL_009c: callvirt instance void UnityEngine.Renderer::SetPropertyBlock(class UnityEngine.MaterialPropertyBlock) + CodeMatch[] matchSetMpb = [ + new(OpCodes.Ldarg_0), + new(OpCodes.Ldfld, Part_highlightRenderer), + new(OpCodes.Ldloc_1), + new(OpCodes.Callvirt), // can't easily specify indexer... + new(OpCodes.Ldarg_0), + new(OpCodes.Call, Part_get_mpb), + new(OpCodes.Callvirt, Renderer_SetPropertyBlock) + ]; + + var matcher = new CodeMatcher(insns); + matcher + .MatchStartForward(matchDupPop) + .Repeat(cm => cm.RemoveInstructions(matchDupPop.Length)) + .Start() + .MatchStartForward(matchSetRimColor) + .ThrowIfNotMatch("failed to find MPB set _RimColor call") + .Advance(matchSetRimColor.Length) + .InsertAndAdvance( + // PartPatch.Highlight_SetRimColor(this, value); + new CodeInstruction(OpCodes.Ldarg_0), // `this` + new CodeInstruction(OpCodes.Ldloc_0), // `value` + CodeInstruction.Call(() => Highlight_SetRimColor(default, default))) + .MatchStartForward(matchSetMpb) + .ThrowIfNotMatch("failed to find Renderer.SetMPB call") + // No need to replace application, since that is automatic. + .SetAndAdvance(OpCodes.Nop, null) // preserve label + .RemoveInstructions(matchSetMpb.Length - 1); + return matcher.InstructionEnumeration(); + } + + [HarmonyPostfix] + [HarmonyPatch("OnDestroy")] + private static void OnDestroy_Postfix(Part __instance) + { + if (Props.Remove(__instance, out var props)) { + props.Dispose(); + } + } +} diff --git a/Source/DynamicProperties/Patches/StockPatch.cs b/Source/DynamicProperties/Patches/StockPatch.cs new file mode 100644 index 0000000..3cd85d6 --- /dev/null +++ b/Source/DynamicProperties/Patches/StockPatch.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using KSPBuildTools; + +namespace Shabby.DynamicProperties; + +internal abstract class StockPatchBase +{ + internal static readonly Dictionary Props = []; + + internal static void CheckCleared() + { + if (Props.Count == 0) return; + + Log.Message($"cleared {Props.Count} Props instances", $"[{typeof(T).Name} MPM Patch]"); + Props.Clear(); + } +} diff --git a/Source/DynamicProperties/Prop.cs b/Source/DynamicProperties/Prop.cs new file mode 100644 index 0000000..becddac --- /dev/null +++ b/Source/DynamicProperties/Prop.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace Shabby.DynamicProperties; + +internal static class PropIdToName +{ + private static readonly string[] StockProperties = [ + "_BumpMap", + "_Color", + "_EmissiveColor", + "_MainTex", + "_MaxX", + "_MaxY", + "_MinX", + "_MinY", + "_Multiplier", + "_Opacity", + "_RimColor", + "_RimFalloff", + "_TemperatureColor", + "_TintColor", + "_subdiv", + "localMatrix", + "upMatrix" + ]; + + private static readonly Dictionary IdToName = + StockProperties.ToDictionary(Shader.PropertyToID, name => name); + + internal static void Register(string property) => + IdToName[Shader.PropertyToID(property)] = property; + + internal static string Get(int id) => + IdToName.TryGetValue(id, out var name) ? name : $"<{id}>"; +} + +internal abstract class Prop +{ + internal abstract void Write(int id, MaterialPropertyBlock mpb); +} + +internal abstract class Prop(T value) : Prop +{ + internal T Value = value; + + internal abstract bool UpdateIfChanged(T value); + public override string ToString() => Value.ToString(); +} + +internal class PropColor(Color value) : Prop(value) +{ + internal override bool UpdateIfChanged(Color value) + { + if (Utils.ApproxEquals(value, Value)) return false; + Value = value; + return true; + } + + internal override void Write(int id, MaterialPropertyBlock mpb) => mpb.SetColor(id, Value); +} + +internal class PropFloat(float value) : Prop(value) +{ + internal override bool UpdateIfChanged(float value) + { + if (Utils.ApproxEqualsRel(value, Value)) return false; + Value = value; + return true; + } + + internal override void Write(int id, MaterialPropertyBlock mpb) => mpb.SetFloat(id, Value); +} + +internal class PropInt(int value) : Prop(value) +{ + internal override bool UpdateIfChanged(int value) + { + if (value == Value) return false; + Value = value; + return true; + } + + internal override void Write(int id, MaterialPropertyBlock mpb) => mpb.SetInt(id, Value); +} + +internal class PropTexture(Texture value) : Prop(value) +{ + internal override bool UpdateIfChanged(Texture value) + { + if (ReferenceEquals(value, Value)) return false; + Value = value; + return true; + } + + internal override void Write(int id, MaterialPropertyBlock mpb) => mpb.SetTexture(id, Value); +} + +internal class PropVector(Vector4 value) : Prop(value) +{ + internal override bool UpdateIfChanged(Vector4 value) + { + if (Utils.ApproxEqualsRel(value, Value)) return false; + Value = value; + return true; + } + + internal override void Write(int id, MaterialPropertyBlock mpb) => mpb.SetVector(id, Value); +} diff --git a/Source/DynamicProperties/Props.cs b/Source/DynamicProperties/Props.cs new file mode 100644 index 0000000..6b4c3b8 --- /dev/null +++ b/Source/DynamicProperties/Props.cs @@ -0,0 +1,182 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using KSPBuildTools; +using UnityEngine; + +namespace Shabby.DynamicProperties; + +public sealed class Props : Disposable, IComparable +{ + #region Fields + + private static uint _idCounter = 0; + private static uint _nextId() => _idCounter++; + + public uint UniqueId { get; } = _nextId(); + + public int Priority { get; } + + private readonly Dictionary props = []; + + internal IEnumerable ManagedIds => props.Keys; + + internal delegate void EntriesChangedHandler(Props props); + + internal EntriesChangedHandler? OnEntriesChanged = null; + + internal delegate void ValueChangedHandler(Props props, int? id); + + internal ValueChangedHandler? OnValueChanged = null; + + internal bool SuppressEagerUpdate = false; + internal bool NeedsEntriesUpdate = false; + internal bool NeedsValueUpdate = false; + + #endregion + + public Props(int priority) + { + Priority = priority; + SuppressEagerUpdatesThisFrame(); + Log.Debug($"new Props instance {UniqueId}"); + } + + /// Ordered by lowest to highest priority. Equal priority is disambiguated by unique IDs. + public int CompareTo(Props? other) + { + if (ReferenceEquals(this, other)) return 0; + if (other == null) return 1; + var priorityCmp = Priority.CompareTo(other.Priority); + return priorityCmp != 0 ? priorityCmp : UniqueId.CompareTo(other.UniqueId); + } + + /// This is equivalent to reference equality. + public override int GetHashCode() => unchecked((int)UniqueId); + + public override string ToString() + { + var sb = StringBuilderCache.Acquire(); + sb.AppendFormat("(Priority {0}) {{\n", Priority); + foreach (var (id, prop) in props) { + sb.AppendFormat("{0} = {1}\n", PropIdToName.Get(id), prop); + } + + sb.AppendLine("}"); + return sb.ToStringAndRelease(); + } + + #region Set/Remove + + public void SuppressEagerUpdatesThisFrame() + { + SuppressEagerUpdate = true; + MaterialPropertyManager.Instance?.ScheduleLateUpdate(this); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void FireOnEntriesChanged() + { + if (!SuppressEagerUpdate) { + OnEntriesChanged?.Invoke(this); + } else { + NeedsEntriesUpdate = true; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void FireOnValueChanged(int? id) + { + if (!SuppressEagerUpdate) { + OnValueChanged?.Invoke(this, id); + } else { + NeedsValueUpdate = true; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void _internalSet(int id, T value) where TProp : Prop + { + if (props.TryGetValue(id, out var prop)) { + if (prop is TProp typedProp) { + if (!typedProp.UpdateIfChanged(value)) return; + FireOnValueChanged(id); + return; + } + + MaterialPropertyManager.Instance?.LogWarning( + $"property {PropIdToName.Get(id)} has mismatched type; overwriting with {typeof(T).Name}!"); + } + + props[id] = (TProp)Activator.CreateInstance(typeof(TProp), value); + FireOnEntriesChanged(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetColor(int id, Color value) => _internalSet(id, value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetFloat(int id, float value) => _internalSet(id, value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetInt(int id, int value) => _internalSet(id, value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetTexture(int id, Texture value) => _internalSet(id, value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetVector(int id, Vector4 value) => _internalSet(id, value); + + public bool Remove(int id) + { + var removed = props.Remove(id); + if (!removed) return false; + FireOnEntriesChanged(); + return true; + } + + #endregion + + #region Has/Get + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool _internalHas(int id) => props.TryGetValue(id, out var prop) && prop is Prop; + + public bool HasColor(int id) => _internalHas(id); + public bool HasFloat(int id) => _internalHas(id); + public bool HasInt(int id) => _internalHas(id); + public bool HasTexture(int id) => _internalHas(id); + public bool HasVector(int id) => _internalHas(id); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private T? _internalGet(int id) where TProp : Prop => + props.TryGetValue(id, out var prop) && prop is TProp typedProp + ? typedProp.Value + : default; + + public Color GetColorOrDefault(int id) => _internalGet(id); + public float GetFloatOrDefault(int id) => _internalGet(id); + public int GetIntOrDefault(int id) => _internalGet(id); + public Texture? GetTextureOrDefault(int id) => _internalGet(id); + public Vector4 GetVectorOrDefault(int id) => _internalGet(id); + + #endregion + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void Write(int id, MaterialPropertyBlock mpb) + { + if (!props.TryGetValue(id, out var prop)) { + throw new KeyNotFoundException($"property {PropIdToName.Get(id)} not found"); + } + + MaterialPropertyManager.Instance?.LogDebug( + $"writing property {PropIdToName.Get(id)} = {prop}"); + + prop.Write(id, mpb); + } + + protected override bool IsUnused() => OnEntriesChanged == null && OnValueChanged == null; + protected override void OnDispose() => MaterialPropertyManager.Instance?.Unregister(this); +} diff --git a/Source/DynamicProperties/PropsCascade.cs b/Source/DynamicProperties/PropsCascade.cs new file mode 100644 index 0000000..f058406 --- /dev/null +++ b/Source/DynamicProperties/PropsCascade.cs @@ -0,0 +1,44 @@ +#nullable enable + +using System.Collections.Generic; +using UnityEngine; + +namespace Shabby.DynamicProperties; + +internal class PropsCascade(Renderer renderer) : Disposable +{ + private readonly Renderer renderer = renderer; + private readonly SortedSet cascade = new(); + private MpbCompiler? compiler = null; + + internal bool Add(Props props) + { + if (!cascade.Add(props)) return false; + + ReacquireCompiler(); + return true; + } + + internal bool Remove(Props props) + { + if (!cascade.Remove(props)) return false; + + ReacquireCompiler(); + return true; + } + + private void ReacquireCompiler() + { + UnregisterFromCompiler(); + compiler = MpbCompilerCache.Get(cascade); + compiler.Register(renderer); + } + + private void UnregisterFromCompiler() + { + compiler?.Unregister(renderer); + compiler = null; + } + + protected override void OnDispose() => UnregisterFromCompiler(); +} diff --git a/Source/DynamicProperties/README.md b/Source/DynamicProperties/README.md new file mode 100644 index 0000000..ae6808e --- /dev/null +++ b/Source/DynamicProperties/README.md @@ -0,0 +1,44 @@ +# Dynamic Property Management + +This is a developer-facing API that fully reimplements how the stock code handles +`MaterialPropertyBlock`s (MPBs). + +## Public API + +TODO. + +## Implementation Notes + +The `MaterialPropertyManager` class implements very little behavior. It maintains an association of +registered `Renderer`s to their `Props` instances, stored in `PropsCascade` instances that +facilitate the sorting of the `Props` by priority. + +Upon the addition or removal of a `Props` instance from a `Cascade`, it queries the +`MpbCompilerCache` singleton for a `MpbCompiler` instance linked to the same cascade (lowercase, +_i.e._ a sorted set of `Props`). The renderer managed by the `Cascade` is unregistered from the +previous `Compiler` instance, and registered to the new instance. The previous `Compiler` instance +then checks if it has any remaining linked renderers, and evicts itself from the cache if it has +become unused. + +The `MpbCompiler` maintains a "manager map" of all the property IDs to their managing `Props` +instances, resolved by priority in case of conflict, as well as a single Unity MPB applied to all of +its linked renderers. Upon creation, the `Compiler` registers change handlers to each of its `Props` +instances. There are two types of handlers. When an existing property is changed, the value-changed +handler is fired. This is a fast path, as the managing `Props` of a given ID cannot change. Only +that particular entry of the MPB is updated, and is applied immediately to all linked renderers. +Upon the addition or removal of a property from a `Props`, the entry-changed handler is fired, to +recompute the manager map. The MPB is cleared, repopulated, and reapplied. + +The mod-facing `Props` handles are stored throughout the stack and must be explicitly `Dispose`d. +Upon disposal, `MaterialPropertyManager` removes it from all registered `Cascade`s. This unlinks +each `Cascade` from their `MpbCompiler`s. As all such compilers will reference the disposed `Props`, +they will become dead themselves and be disposed upon unregistration of the last `Cascade`. + +If a renderer is detected to be Unity GCed during MPB application by a `Compiler`, it is removed +from the `MaterialPropertyManager` using the public API. This disposes the associated `Cascade`, and +would dispose the originating `Compiler` instance if its last renderer was unregistered. + +Upon destruction at scene change, `MaterialPropertyManager` disposes all `Cascade`s. This should +clear all `Compiler` cache instances, and is checked to have done so. Any remaining `Props` +instances would be kept alive by external references, and would have been unlinked from their update +handlers. diff --git a/Source/DynamicProperties/Utils.cs b/Source/DynamicProperties/Utils.cs new file mode 100644 index 0000000..e8ddf01 --- /dev/null +++ b/Source/DynamicProperties/Utils.cs @@ -0,0 +1,47 @@ +using System; +using System.Runtime.CompilerServices; +using UnityEngine; + +namespace Shabby.DynamicProperties; + +public static class Utils +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsDestroyed(this UnityEngine.Object obj) => obj.m_CachedPtr == IntPtr.Zero; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsNullref(this UnityEngine.Object obj) => ReferenceEquals(obj, null); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool ApproxEqualsAbs(float a, float b, float eps) => + Math.Abs(b - a) <= eps; + + /// https://randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool ApproxEqualsRel(float a, float b, + float absDiff = 1e-4f, float relDiff = float.Epsilon) + { + if (a == b) return true; + + var diff = Math.Abs(a - b); + if (diff < absDiff) return true; + + a = Math.Abs(a); + b = Math.Abs(b); + var largest = b > a ? b : a; + return diff <= largest * relDiff; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool ApproxEqualsRel(Vector4 a, Vector4 b, + float absDiff = 1e-4f, float relDiff = float.Epsilon) => + ApproxEqualsRel(a.x, b.x, absDiff, relDiff) && + ApproxEqualsRel(a.y, b.y, absDiff, relDiff) && + ApproxEqualsRel(a.z, b.z, absDiff, relDiff) && + ApproxEqualsRel(a.w, b.w, absDiff, relDiff); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool ApproxEquals(Color a, Color b, float eps = 1e-2f) => + ApproxEqualsAbs(a.r, b.r, eps) && ApproxEqualsAbs(a.g, b.g, eps) && + ApproxEqualsAbs(a.b, b.b, eps) && ApproxEqualsAbs(a.a, b.a, eps); +} diff --git a/Source/Shabby.csproj b/Source/Shabby.csproj index 6bcfd1d..57adc86 100644 --- a/Source/Shabby.csproj +++ b/Source/Shabby.csproj @@ -3,7 +3,11 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + all runtime; build; native; contentfiles; analyzers @@ -37,6 +41,12 @@ + + + + + +