modular-avatar/Editor/ReactiveObjects/Simulator/ROSimulator.cs
bd_ 389ae4f2cc
fix: performance issues when RO Simulator is open for too long (#1101)
Also fix an issue where the clear overrides button doesn't work for menu item overrides.

Closes: #1100
2024-09-04 12:40:48 -07:00

604 lines
24 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using nadena.dev.modular_avatar.ui;
using nadena.dev.ndmf.localization;
using nadena.dev.ndmf.preview;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace nadena.dev.modular_avatar.core.editor.Simulator
{
internal class ROSimulator : EditorWindow, IHasCustomMenu
{
public static PublishedValue<ImmutableDictionary<string, float>> PropertyOverrides = new(null);
public static PublishedValue<ImmutableDictionary<string, ModularAvatarMenuItem>> MenuItemOverrides = new(null);
internal static string ROOT_PATH = "Packages/nadena.dev.modular-avatar/Editor/ReactiveObjects/Simulator/";
private static string USS = ROOT_PATH + "ROSimulator.uss";
private static string UXML = ROOT_PATH + "ROSimulator.uxml";
private static string EFFECT_GROUP_UXML = ROOT_PATH + "EffectGroup.uxml";
private ObjectField f_inspecting;
private VisualElement e_debugInfo;
private VisualTreeAsset effectGroupTemplate;
private StyleSheet uss;
[MenuItem(UnityMenuItems.GameObject_ShowReactionDebugger, false, UnityMenuItems.GameObject_ShowReactionDebuggerOrder)]
internal static void ShowWindow(MenuCommand command)
{
OpenDebugger(command.context as GameObject);
}
private void Awake()
{
void SetTitle(EditorWindow w)
{
w.titleContent = new GUIContent(Localization.L.GetLocalizedString("ro_sim.window.title"));
}
LanguagePrefs.RegisterLanguageChangeCallback(this, SetTitle);
SetTitle(this);
}
public static void OpenDebugger(GameObject target)
{
var window = GetWindow<ROSimulator>();
if (window.is_enabled && window.locked) return;
window.locked = target != Selection.activeGameObject;
// avoid racing with initial creation
if (window.f_inspecting == null)
{
window.LoadUI();
}
// ReSharper disable once PossibleNullReferenceException
window.f_inspecting.SetValueWithoutNotify(target);
window.RefreshUI();
}
private void OnEnable()
{
PropertyOverrides.Value = ImmutableDictionary<string, float>.Empty;
MenuItemOverrides.Value = ImmutableDictionary<string, ModularAvatarMenuItem>.Empty;
EditorApplication.delayCall += LoadUI;
Selection.selectionChanged += SelectionChanged;
is_enabled = true;
}
private void OnDisable()
{
Selection.selectionChanged -= SelectionChanged;
is_enabled = false;
// Delay this to ensure that we don't try to change this value from within assembly reload callbacks
// (which generates a noisy exception)
EditorApplication.delayCall += () =>
{
PropertyOverrides.Value = null;
MenuItemOverrides.Value = null;
};
}
private ComputeContext _lastComputeContext;
private GameObject currentSelection;
private GUIStyle lockButtonStyle;
private bool locked, is_enabled;
private Dictionary<(int, string), bool> foldoutState = new();
private Button _btn_clear;
private bool _refreshPending;
private void RequestRefresh()
{
if (_refreshPending) return;
_refreshPending = true;
EditorApplication.delayCall += RefreshUI;
}
private void UpdatePropertyOverride(string prop, bool? enable, float f_val)
{
if (enable == null)
{
PropertyOverrides.Value = PropertyOverrides.Value.Remove(prop);
} else if (enable.Value)
{
PropertyOverrides.Value = PropertyOverrides.Value.SetItem(prop, f_val);
}
else
{
PropertyOverrides.Value = PropertyOverrides.Value.SetItem(prop, 0f);
}
RequestRefresh();
}
private void UpdateMenuItemOverride(string prop, ModularAvatarMenuItem item, bool? value)
{
if (value == null)
{
MenuItemOverrides.Value = MenuItemOverrides.Value.Remove(prop);
}
else if (value.Value)
{
MenuItemOverrides.Value = MenuItemOverrides.Value.SetItem(prop, item);
}
else
{
if (MenuItemOverrides.Value.TryGetValue(prop, out var existing) && ReferenceEquals(existing, item))
MenuItemOverrides.Value = MenuItemOverrides.Value.SetItem(prop, null);
}
RequestRefresh();
}
private void ShowButton(Rect rect)
{
if (lockButtonStyle == null)
{
lockButtonStyle = "IN LockButton";
}
locked = GUI.Toggle(rect, locked, GUIContent.none, lockButtonStyle);
}
void IHasCustomMenu.AddItemsToMenu(GenericMenu menu)
{
menu.AddItem(new GUIContent("Lock"), locked, () => locked = !locked);
}
void SelectionChanged()
{
if (locked) return;
if (currentSelection != Selection.activeGameObject)
{
UpdateSelection();
}
}
private void LoadUI()
{
var root = rootVisualElement;
root.Clear();
root.AddToClassList("rootVisualContent");
uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(USS);
root.styleSheets.Add(uss);
var content = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UXML).CloneTree();
root.Add(content);
Localization.L.LocalizeUIElements(content);
root.Q<Button>("debug__reload").clickable.clicked += LoadUI;
f_inspecting = root.Q<ObjectField>("inspecting");
f_inspecting.RegisterValueChangedCallback(evt =>
{
locked = true;
UpdateSelection();
});
_btn_clear = root.Q<Button>("clear-overrides");
_btn_clear.clickable.clicked += () =>
{
PropertyOverrides.Value = ImmutableDictionary<string, float>.Empty;
MenuItemOverrides.Value = ImmutableDictionary<string, ModularAvatarMenuItem>.Empty;
RequestRefresh();
};
e_debugInfo = root.Q<VisualElement>("debug-info");
effectGroupTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(EFFECT_GROUP_UXML);
UpdateSelection();
}
private void UpdateSelection()
{
currentSelection = locked ? f_inspecting.value as GameObject : Selection.activeGameObject;
f_inspecting.SetValueWithoutNotify(currentSelection);
RequestRefresh();
}
private void RefreshUI()
{
_refreshPending = false;
var avatar = RuntimeUtil.FindAvatarInParents(currentSelection?.transform);
if (avatar == null)
{
e_debugInfo.style.display = DisplayStyle.None;
return;
}
_btn_clear.SetEnabled(!PropertyOverrides.Value.IsEmpty || !MenuItemOverrides.Value.IsEmpty);
e_debugInfo.style.display = DisplayStyle.Flex;
_lastComputeContext = new ComputeContext("RO Simulator");
_lastComputeContext.InvokeOnInvalidate(this, MaybeRefreshUI);
var analysis = new ReactiveObjectAnalyzer(_lastComputeContext);
analysis.ForcePropertyOverrides = PropertyOverrides.Value;
analysis.ForceMenuItems = MenuItemOverrides.Value;
var result = analysis.Analyze(avatar.gameObject);
SetThisObjectOverrides(analysis);
SetOverallActiveHeader(currentSelection, result.InitialStates);
SetAffectedBy(currentSelection, result.Shapes);
}
private static void MaybeRefreshUI(ROSimulator self)
{
if (self.is_enabled)
{
self.RequestRefresh();
}
}
private void SetThisObjectOverrides(ReactiveObjectAnalyzer analysis)
{
BindOverrideToParameter("this-obj-override", analysis.GetGameObjectStateProperty(currentSelection), 1);
currentSelection.TryGetComponent<ModularAvatarMenuItem>(out var mami);
BindOverrideToMenuItem("this-menu-override", mami);
}
private string _menuItemOverrideProperty;
private ModularAvatarMenuItem _menuItemOverrideTarget;
private void BindOverrideToMenuItem(string overrideElemName, ModularAvatarMenuItem mami)
{
var elem = e_debugInfo.Q<VisualElement>(overrideElemName);
var soc = elem.Q<StateOverrideController>();
if (mami == null)
{
elem.style.display = DisplayStyle.None;
return;
}
var prop = ParameterAssignerPass.AssignMenuItemParameter(mami)?.Parameter;
if (prop == null)
{
elem.style.display = DisplayStyle.None;
return;
}
elem.style.display = DisplayStyle.Flex;
if (MenuItemOverrides.Value.TryGetValue(prop, out var overrideValue))
soc.SetWithoutNotify(ReferenceEquals(mami, overrideValue));
else
soc.SetWithoutNotify(null);
// Avoid multiple registration of the same delegate here by reusing the same delegate instead of binding
// these properties in a closure
_menuItemOverrideProperty = prop;
_menuItemOverrideTarget = mami;
soc.OnStateOverrideChanged += MenuItemOverrideChanged;
}
private void MenuItemOverrideChanged(bool? obj)
{
UpdateMenuItemOverride(_menuItemOverrideProperty, _menuItemOverrideTarget, obj);
}
private string _propertyOverrideProperty;
private float _propertyOverrideTargetValue;
private void BindOverrideToParameter(string overrideElemName, string property, float targetValue)
{
var elem = e_debugInfo.Q<VisualElement>(overrideElemName);
var soc = elem.Q<StateOverrideController>();
if (property == null)
{
elem.style.display = DisplayStyle.None;
return;
}
elem.style.display = DisplayStyle.Flex;
if (PropertyOverrides.Value.TryGetValue(property, out var overrideValue))
{
soc.SetWithoutNotify(Mathf.Approximately(overrideValue, targetValue));
}
else
{
soc.SetWithoutNotify(null);
}
_propertyOverrideProperty = property;
_propertyOverrideTargetValue = targetValue;
soc.OnStateOverrideChanged += OnParameterOverrideChanged;
}
private void OnParameterOverrideChanged(bool? state)
{
UpdatePropertyOverride(_propertyOverrideProperty, state, _propertyOverrideTargetValue);
}
private void SetAffectedBy(GameObject gameObject, Dictionary<TargetProp, AnimatedProperty> shapes)
{
var effect_list = e_debugInfo.Q<ScrollView>("effect-list");
effect_list.Clear();
var orderedShapes = shapes.Values
.Where(s => AffectedBy(s.TargetProp, gameObject))
.OrderBy(
s =>
{
if (s.TargetProp.TargetObject is GameObject go && s.TargetProp.PropertyName == "m_IsActive")
return (0, null, null);
return (1, s.TargetProp.TargetObject.GetType().ToString(), s.TargetProp.PropertyName);
}
);
foreach (var shape in orderedShapes)
{
var targetProp = shape.TargetProp;
var propInfo = shape;
var propGroup = new Foldout();
propGroup.text = targetProp.TargetObject.GetType() + "." + targetProp.PropertyName;
var foldoutStateKey = (shape.TargetProp.TargetObject?.GetInstanceID() ?? -1, shape.TargetProp.PropertyName);
propGroup.RegisterValueChangedCallback(evt =>
{
foldoutState[foldoutStateKey] = evt.newValue;
if (evt.newValue)
{
propGroup.AddToClassList("foldout-open");
}
else
{
propGroup.RemoveFromClassList("foldout-open");
}
});
if (shape.TargetProp.TargetObject is GameObject go && shape.TargetProp.PropertyName == "m_IsActive")
{
propGroup.text = "Active State";
propGroup.value = true;
}
else
{
propGroup.value = false;
}
effect_list.Add(propGroup);
if (foldoutState.TryGetValue(foldoutStateKey, out var state))
{
propGroup.value = state;
}
if (propGroup.value)
{
propGroup.AddToClassList("foldout-open");
}
else
{
propGroup.RemoveFromClassList("foldout-open");
}
foreach (var reactionRule in propInfo.actionGroups)
{
if (reactionRule.ControllingObject == null) continue;
var effectGroup = effectGroupTemplate.CloneTree();
propGroup.Add(effectGroup);
effectGroup.styleSheets.Add(uss);
if (reactionRule.InitiallyActive)
{
effectGroup.AddToClassList("st-active");
}
var source = effectGroup.Q<ObjectField>("effect__source");
source.SetEnabled(false);
source.SetValueWithoutNotify(reactionRule.ControllingObject);
var conditions = effectGroup.Q<VisualElement>("controlling-conditions");
BuildRuleConditionBlock(conditions, reactionRule);
// For our TextFields, we want to localize the label, not the text, so we'll do this manually...
foreach (var field in effectGroup.Query<VisualElement>(classes:"ndmf-tr-label").ToList())
{
var labelProp = field.GetType().GetProperty("label");
var tooltipProp = field.GetType().GetProperty("tooltip");
if (labelProp == null) continue;
var key = labelProp.GetValue(field) as string;
Action<VisualElement> relocalize = f2 =>
{
labelProp.SetValue(f2, Localization.L.GetLocalizedString(key));
tooltipProp?.SetValue(f2, Localization.L.GetLocalizedString(key + ".tooltip"));
};
relocalize(field);
LanguagePrefs.RegisterLanguageChangeCallback(field, relocalize);
field.RemoveFromClassList("ndmf-tr-label");
}
// Localize once we've built the condition blocks as they contain dynamically created translated
// strings.
Localization.L.LocalizeUIElements(effectGroup);
var f_target_component = effectGroup.Q<ObjectField>("effect__target");
var f_property = effectGroup.Q<TextField>("effect__prop");
var f_set_active = effectGroup.Q<VisualElement>("effect__set-active");
var f_set_inactive = effectGroup.Q<VisualElement>("effect__set-inactive");
var f_value = effectGroup.Q<FloatField>("effect__value");
var f_material = effectGroup.Q<ObjectField>("effect__material");
var f_delete = effectGroup.Q("effect__deleted");
f_target_component.style.display = DisplayStyle.None;
f_target_component.SetEnabled(false);
f_property.style.display = DisplayStyle.None;
f_property.SetEnabled(false);
f_set_active.style.display = DisplayStyle.None;
f_set_inactive.style.display = DisplayStyle.None;
f_value.style.display = DisplayStyle.None;
f_value.SetEnabled(false);
f_material.style.display = DisplayStyle.None;
f_material.SetEnabled(false);
f_delete.style.display = DisplayStyle.None;
f_delete.SetEnabled(false);
if (targetProp.TargetObject is GameObject && targetProp.PropertyName == "m_IsActive")
{
if (((float)reactionRule.Value) > 0.5f)
{
f_set_active.style.display = DisplayStyle.Flex;
}
else
{
f_set_inactive.style.display = DisplayStyle.Flex;
}
}
else
{
f_target_component.SetValueWithoutNotify(targetProp.TargetObject);
f_target_component.style.display = DisplayStyle.Flex;
f_property.value = targetProp.PropertyName;
f_property.style.display = DisplayStyle.Flex;
if (reactionRule.IsDelete)
{
f_delete.style.display = DisplayStyle.Flex;
} else if (reactionRule.Value is float f)
{
f_value.SetValueWithoutNotify(f);
f_value.style.display = DisplayStyle.Flex;
} else if (reactionRule.Value is Material m)
{
f_material.SetValueWithoutNotify(m);
f_material.style.display = DisplayStyle.Flex;
}
}
}
}
}
private bool AffectedBy(TargetProp shapeKey, GameObject gameObject)
{
return (shapeKey.TargetObject == gameObject) ||
(shapeKey.TargetObject is Component c && c.gameObject == gameObject);
}
private void BuildRuleConditionBlock(VisualElement conditions, ReactionRule rule)
{
if (rule.Inverted)
{
conditions.AddToClassList("rule-inverted");
}
foreach (var condition in rule.ControllingConditions)
{
var conditionElem = new VisualElement();
conditions.Add(conditionElem);
conditionElem.AddToClassList("controlling-condition");
var soc = new StateOverrideController();
conditionElem.Add(soc);
var prop = condition.Parameter;
if (PropertyOverrides.Value.TryGetValue(prop, out var overrideValue))
{
soc.SetWithoutNotify(condition, overrideValue);
}
float targetValue;
if (!float.IsFinite(condition.ParameterValueHi))
{
targetValue = Mathf.Round(condition.ParameterValueLo + 0.5f);
} else if (!float.IsFinite(condition.ParameterValueLo))
{
targetValue = Mathf.Round(condition.ParameterValueHi - 0.5f);
}
else
{
targetValue = Mathf.Round((condition.ParameterValueLo + condition.ParameterValueHi) / 2);
}
if (condition.DebugReference is ModularAvatarMenuItem mami)
{
bool? menuOverride = null;
if (MenuItemOverrides.Value.TryGetValue(prop, out var target))
{
menuOverride = ReferenceEquals(mami, target);
soc.SetWithoutNotify(menuOverride);
}
soc.OnStateOverrideChanged += value => { UpdateMenuItemOverride(prop, mami, value); };
}
else
{
soc.OnStateOverrideChanged += value => UpdatePropertyOverride(prop, value, targetValue);
}
var active = condition.InitiallyActive;
var active_label = active ? "active" : "inactive";
active_label = "ro_sim.state." + active_label;
var active_classname = active ? "source-active" : "source-inactive";
switch (condition.DebugReference)
{
case GameObject go:
{
var controller = new ObjectField(active_label);
controller.SetEnabled(false);
controller.SetValueWithoutNotify(go);
controller.AddToClassList(active_classname);
controller.AddToClassList("ndmf-tr");
conditionElem.Add(controller);
break;
}
case Component c:
{
var controller = new ObjectField(active_label);
controller.SetEnabled(false);
controller.SetValueWithoutNotify(c);
controller.AddToClassList(active_classname);
controller.AddToClassList("ndmf-tr");
conditionElem.Add(controller);
break;
}
default:
{
var controller = new TextField(active_label);
controller.SetEnabled(false);
controller.value = condition.DebugReference.ToString();
controller.AddToClassList(active_classname);
controller.AddToClassList("ndmf-tr");
conditionElem.Add(controller);
break;
}
}
}
}
private void SetOverallActiveHeader(GameObject obj, Dictionary<TargetProp, object> initialStates)
{
bool activeState = obj.activeInHierarchy;
if (initialStates.TryGetValue(TargetProp.ForObjectActive(obj), out var activeStateObj))
{
activeState = ((float)activeStateObj) > 0;
}
var ve_active = e_debugInfo.Q<VisualElement>("state-enabled");
var ve_inactive = e_debugInfo.Q<VisualElement>("state-disabled");
ve_active.style.display = activeState ? DisplayStyle.Flex : DisplayStyle.None;
ve_inactive.style.display = activeState ? DisplayStyle.None : DisplayStyle.Flex;
}
}
}