feat: add a debugging UI for the reactive components system (#1049)

This commit is contained in:
bd_ 2024-08-25 20:19:04 -07:00 committed by GitHub
parent 07660164ba
commit 87a385a43e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 1688 additions and 410 deletions

View File

@ -19,7 +19,7 @@
"dependencies": {}
},
"nadena.dev.ndmf": {
"version": "1.5.0-beta.5"
"version": "1.5.0-rc.1"
}
}
}

View File

@ -20,6 +20,14 @@ namespace nadena.dev.modular_avatar.core.editor
private VisualElement _inner;
public new class UxmlFactory : UxmlFactory<LogoElement, UxmlTraits>
{
}
public new class UxmlTraits : VisualElement.UxmlTraits
{
}
private static void RegisterNode(LogoElement target)
{
if (_logoDisplayNode == null)

View File

@ -1,5 +1,6 @@
#region
using UnityEditor;
using UnityEngine.UIElements;
#endregion
@ -28,11 +29,26 @@ namespace nadena.dev.modular_avatar.core.editor
var image = new Image();
image.image = LogoDisplay.LOGO_ASSET;
image.style.width = new Length(LogoDisplay.ImageWidth(LogoDisplay.TARGET_HEIGHT), LengthUnit.Pixel);
image.style.height = new Length(LogoDisplay.TARGET_HEIGHT, LengthUnit.Pixel);
SetImageSize(image);
_inner.Add(image);
Add(_inner);
}
private static void SetImageSize(Image image, int maxTries = 10)
{
var targetHeight = LogoDisplay.TARGET_HEIGHT;
if (targetHeight == 0)
{
if (maxTries <= 0) return;
EditorApplication.delayCall += () => SetImageSize(image, maxTries - 1);
targetHeight = 45;
}
image.style.width = new Length(LogoDisplay.ImageWidth(targetHeight), LengthUnit.Pixel);
image.style.height = new Length(targetHeight, LengthUnit.Pixel);
}
}
}

View File

@ -11,7 +11,20 @@ namespace nadena.dev.modular_avatar.core.editor
internal static class LogoDisplay
{
internal static readonly Texture2D LOGO_ASSET;
internal static float TARGET_HEIGHT => EditorStyles.label.lineHeight * 3;
internal static float TARGET_HEIGHT
{
get {
try
{
return (EditorStyles.label?.lineHeight ?? 0) * 3;
}
catch (NullReferenceException e)
{
// This can happen in early initialization...
return 0;
}
}
}
internal static float ImageWidth(float height)
{

View File

@ -19,6 +19,7 @@
</ui:VisualElement>
</ui:VisualElement>
<ma:ROSimulatorButton/>
<ma:LanguageSwitcherElement/>
</ui:VisualElement>
</UXML>

View File

@ -29,6 +29,8 @@ namespace nadena.dev.modular_avatar.core.editor.ShapeChanger
root.styleSheets.Add(uss);
root.Bind(serializedObject);
ROSimulatorButton.BindRefObject(root, target);
var listView = root.Q<ListView>("Shapes");

View File

@ -18,7 +18,8 @@
/>
</ui:VisualElement>
</ui:VisualElement>
<ma:ROSimulatorButton/>
<ma:LanguageSwitcherElement/>
</ui:VisualElement>
</UXML>

View File

@ -29,6 +29,8 @@ namespace nadena.dev.modular_avatar.core.editor.ShapeChanger
root.styleSheets.Add(uss);
root.Bind(serializedObject);
ROSimulatorButton.BindRefObject(root, target);
var listView = root.Q<ListView>("Shapes");

View File

@ -19,6 +19,7 @@
</ui:VisualElement>
</ui:VisualElement>
<ma:ROSimulatorButton/>
<ma:LanguageSwitcherElement/>
</ui:VisualElement>
</UXML>

View File

@ -33,6 +33,8 @@ namespace nadena.dev.modular_avatar.core.editor.ShapeChanger
root.styleSheets.Add(uss);
root.Bind(serializedObject);
ROSimulatorButton.BindRefObject(root, target);
var listView = root.Q<ListView>("Shapes");

View File

@ -251,5 +251,32 @@
"ma_info.param_usage_ui.no_data": "[ NO DATA ]",
"reactive_object.inverse": "Inverse Condition",
"reactive_object.material-setter.set-to": "Set material to: ",
"menuitem.misc.add_toggle": "Add toggle"
"menuitem.misc.add_toggle": "Add toggle",
"ro_sim.open_debugger_button": "Open reaction debugger",
"ro_sim.window.title": "MA Reaction Debugger",
"ro_sim.header.inspecting": "Inspecting object",
"ro_sim.header.clear_overrides": "Clear all overrides",
"ro_sim.header.object_state": "Object state",
"ro_sim.state.active": "ACTIVE",
"ro_sim.state.inactive": "INACTIVE",
"ro_sim.header.override_gameobject_state": "Override GameObject state",
"ro_sim.header.override_menuitem_state": "Override MenuItem state",
"ro_sim.affected_by.title": "Affected by:",
"ro_sim.effect_group.component": "Reactive Component",
"ro_sim.effect_group.controls_obj_state": "Controls object state:",
"ro_sim.effect_group.target_component": "Target Component",
"ro_sim.effect_group.target_component.tooltip": "The component that will be affected by the Reactive Object",
"ro_sim.effect_group.property": "Property",
"ro_sim.effect_group.property.tooltip": "The property of the target component that will be affected by the Reactive Object",
"ro_sim.effect_group.value": "Value",
"ro_sim.effect_group.value.tooltip": "The value that the property will be set to when the Reactive Object is active",
"ro_sim.effect_group.material": "Material",
"ro_sim.effect_group.material.tooltip": "The material to set on the target component when the Reactive Object is active",
"ro_sim.effect_group.rule_inverted": "This rule is inverted",
"ro_sim.effect_group.rule_inverted.tooltip": "This rule will be applied when one of its conditions is NOT met",
"ro_sim.effect_group.conditions": "Conditions"
}

View File

@ -247,5 +247,32 @@
"ma_info.param_usage_ui.no_data": "[ NO DATA ]",
"reactive_object.inverse": "条件を反転",
"reactive_object.material-setter.set-to": "変更先のマテリアル ",
"menuitem.misc.add_toggle": "トグルを追加"
"menuitem.misc.add_toggle": "トグルを追加",
"ro_sim.open_debugger_button": "リアクションデバッグツールを開く",
"ro_sim.window.title": "MA リアクションデバッグツール",
"ro_sim.header.inspecting": "表示中のオブジェクト",
"ro_sim.header.clear_overrides": "すべてのオーバーライドを解除",
"ro_sim.header.object_state": "オブジェクトのアクティブ状態",
"ro_sim.state.active": "アクティブ",
"ro_sim.state.inactive": "非アクティブ",
"ro_sim.header.override_gameobject_state": "GameObject のアクティブ状態をオーバーライド",
"ro_sim.header.override_menuitem_state": "MenuItem の選択状態をオーバーライト",
"ro_sim.affected_by.title": "以下のルールに影響されています",
"ro_sim.effect_group.component": "Reactive Component",
"ro_sim.effect_group.controls_obj_state": "オブジェクトのアクティブ状態を設定する➡",
"ro_sim.effect_group.target_component": "コンポーネント",
"ro_sim.effect_group.target_component.tooltip": "Reactive Componentに影響されるコンポーネント",
"ro_sim.effect_group.property": "プロパティ",
"ro_sim.effect_group.property.tooltip": "設定されるプロパティ",
"ro_sim.effect_group.value": "値",
"ro_sim.effect_group.value.tooltip": "上記 Reactive Component が活性状態の時に設定される値",
"ro_sim.effect_group.material": "マテリアル",
"ro_sim.effect_group.material.tooltip": "上記 Reactive Component が活性状態の時に設定されるマテリアル",
"ro_sim.effect_group.rule_inverted": "このルールの条件が反転されています",
"ro_sim.effect_group.rule_inverted.tooltip": "このルールの条件がどれか一つ満たされない時に適用されます",
"ro_sim.effect_group.conditions": "条件"
}

View File

@ -5,7 +5,7 @@ namespace nadena.dev.modular_avatar.core.editor
internal class ControlCondition
{
public string Parameter;
public UnityEngine.Object ControllingObject;
public UnityEngine.Object DebugReference;
public string DebugName;
public bool IsConstant;

View File

@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using nadena.dev.ndmf.preview;
using UnityEngine;
namespace nadena.dev.modular_avatar.core.editor
{
internal class PropCache<Key, Value>
{
private class CacheEntry
{
public ComputeContext GenerateContext, ObserverContext;
public PropCache<Key, Value> Owner;
public Key Key;
public Value Value;
}
private readonly Func<ComputeContext, Key, Value> _operator;
private readonly Func<Value, Value, bool> _equalityComparer;
private readonly Dictionary<Key, CacheEntry> _cache = new();
public PropCache(Func<ComputeContext, Key, Value> operatorFunc, Func<Value, Value, bool> equalityComparer = null)
{
_operator = operatorFunc;
_equalityComparer = equalityComparer;
}
private static void InvalidateEntry(CacheEntry entry)
{
var newGenContext = new ComputeContext("PropCache for key " + entry.Key);
var newValue = entry.Owner._operator(newGenContext, entry.Key);
if (entry.Owner._equalityComparer != null && entry.Owner._equalityComparer(entry.Value, newValue))
{
entry.GenerateContext = newGenContext;
entry.GenerateContext.InvokeOnInvalidate(entry, InvalidateEntry);
return;
}
entry.Owner._cache.Remove(entry.Key);
entry.ObserverContext.Invalidate();
}
public Value Get(ComputeContext context, Key key)
{
if (!_cache.TryGetValue(key, out var entry) || entry.GenerateContext.IsInvalidated)
{
var subContext = new ComputeContext("PropCache for key " + key);
entry = new CacheEntry
{
GenerateContext = subContext,
ObserverContext = new ComputeContext("Observer for PropCache for key " + key),
Owner = this,
Key = key,
Value = _operator(subContext, key)
};
_cache[key] = entry;
subContext.InvokeOnInvalidate(entry, InvalidateEntry);
}
entry.ObserverContext.Invalidates(context);
return entry.Value;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d838828e060c4ad7bae36334d868ae36
timeCreated: 1724624517

View File

@ -7,52 +7,17 @@ namespace nadena.dev.modular_avatar.core.editor
{
internal class ReactionRule
{
public ReactionRule(ndmf.BuildContext context, TargetProp key, GameObject controllingObject, float value)
: this(context, key, controllingObject, (object)value) { }
public ReactionRule(TargetProp key, float value)
: this(key, (object)value) { }
public ReactionRule(ndmf.BuildContext context, TargetProp key, GameObject controllingObject, UnityEngine.Object value)
: this(context, key, controllingObject, (object)value) { }
public ReactionRule(TargetProp key, UnityEngine.Object value)
: this(key, (object)value) { }
private ReactionRule(ndmf.BuildContext context, TargetProp key, GameObject controllingObject, object value)
private ReactionRule(TargetProp key, object value)
{
var asc = context?.Extension<AnimationServicesContext>();
TargetProp = key;
var conditions = new List<ControlCondition>();
var cursor = controllingObject?.transform;
bool did_mami = false;
while (cursor != null && !RuntimeUtil.IsAvatarRoot(cursor))
{
// Only look at the menu item closest to the object we're directly attached to, to avoid submenus
// causing issues...
var mami = cursor?.GetComponent<ModularAvatarMenuItem>();
if (mami != null && !did_mami)
{
did_mami = true;
var mami_condition = ParameterAssignerPass.AssignMenuItemParameter(mami);
if (mami_condition != null) conditions.Add(mami_condition);
}
conditions.Add(new ControlCondition
{
Parameter = asc?.GetActiveSelfProxy(cursor.gameObject) ?? RuntimeUtil.AvatarRootPath(cursor.gameObject),
DebugName = cursor.gameObject.name,
IsConstant = false,
InitialValue = cursor.gameObject.activeSelf ? 1.0f : 0.0f,
ParameterValueLo = 0.5f,
ParameterValueHi = float.PositiveInfinity,
ReferenceObject = cursor.gameObject
});
cursor = cursor.parent;
}
ControllingConditions = conditions;
ControllingConditions = new();
Value = value;
}
@ -60,7 +25,9 @@ namespace nadena.dev.modular_avatar.core.editor
public TargetProp TargetProp;
public object Value;
public readonly List<ControlCondition> ControllingConditions;
public Component ControllingObject;
public List<ControlCondition> ControllingConditions;
public bool InitiallyActive =>
((ControllingConditions.Count == 0) || ControllingConditions.All(c => c.InitiallyActive)) ^ Inverted;

View File

@ -1,28 +1,111 @@
using System.Collections.Generic;
using System.Linq;
using nadena.dev.modular_avatar.animation;
using nadena.dev.ndmf.preview;
using UnityEngine;
namespace nadena.dev.modular_avatar.core.editor
{
partial class ReactiveObjectAnalyzer
{
private ReactionRule ObjectRule(TargetProp key, Component controllingObject, float value)
{
var rule = new ReactionRule(key, value);
BuildConditions(controllingObject, rule);
return rule;
}
private ReactionRule ObjectRule(TargetProp key, Component controllingObject, UnityEngine.Object value)
{
var rule = new ReactionRule(key, value);
BuildConditions(controllingObject, rule);
return rule;
}
private string GetActiveSelfProxy(GameObject obj)
{
if (_asc != null)
{
return _asc.GetActiveSelfProxy(obj);
}
else
{
var param = "__ActiveSelfProxy/" + obj.GetInstanceID();
_simulationInitialStates[param] = obj.activeSelf ? 1.0f : 0.0f;
return param;
}
}
private void BuildConditions(Component controllingComponent, ReactionRule rule)
{
rule.ControllingObject = controllingComponent;
var conditions = new List<ControlCondition>();
var cursor = controllingComponent?.transform;
bool did_mami = false;
_computeContext.ObservePath(controllingComponent.transform);
while (cursor != null && !RuntimeUtil.IsAvatarRoot(cursor))
{
// Only look at the menu item closest to the object we're directly attached to, to avoid submenus
// causing issues...
var mami = _computeContext.GetComponent<ModularAvatarMenuItem>(cursor.gameObject);
if (mami != null && !did_mami)
{
did_mami = true;
_computeContext.Observe(mami, c => (c.Control?.parameter, c.Control?.type, c.Control?.value, c.isDefault));
var mami_condition = ParameterAssignerPass.AssignMenuItemParameter(mami, _simulationInitialStates);
if (mami_condition != null) conditions.Add(mami_condition);
}
conditions.Add(new ControlCondition
{
Parameter = GetActiveSelfProxy(cursor.gameObject),
DebugName = cursor.gameObject.name,
IsConstant = false,
InitialValue = cursor.gameObject.activeSelf ? 1.0f : 0.0f,
ParameterValueLo = 0.5f,
ParameterValueHi = float.PositiveInfinity,
ReferenceObject = cursor.gameObject,
DebugReference = cursor.gameObject,
});
cursor = cursor.parent;
}
rule.ControllingConditions = conditions;
}
private Dictionary<TargetProp, AnimatedProperty> FindShapes(GameObject root)
{
var changers = root.GetComponentsInChildren<ModularAvatarShapeChanger>(true);
var changers = _computeContext.GetComponentsInChildren<ModularAvatarShapeChanger>(root, true);
Dictionary<TargetProp, AnimatedProperty> shapeKeys = new();
foreach (var changer in changers)
{
if (changer.Shapes == null) continue;
var shapes = _computeContext.Observe(changer,
c => c.Shapes.Select(s => s.Clone()).ToList(),
(a,b) => a.SequenceEqual(b)
);
foreach (var shape in changer.Shapes)
foreach (var shape in shapes)
{
var renderer = shape.Object.Get(changer)?.GetComponent<SkinnedMeshRenderer>();
var renderer = _computeContext.GetComponent<SkinnedMeshRenderer>(shape.Object.Get(changer));
if (renderer == null) continue;
var mesh = renderer.sharedMesh;
_computeContext.Observe(mesh);
if (mesh == null) continue;
var shapeId = mesh.GetBlendShapeIndex(shape.ShapeName);
@ -41,12 +124,12 @@ namespace nadena.dev.modular_avatar.core.editor
shapeKeys[key] = info;
// Add initial state
var agk = new ReactionRule(context, key, null, value);
var agk = new ReactionRule(key, value);
agk.Value = renderer.GetBlendShapeWeight(shapeId);
info.actionGroups.Add(agk);
}
var action = new ReactionRule(context, key, changer.gameObject, value);
var action = ObjectRule(key, changer, value);
action.Inverted = changer.Inverted;
var isCurrentlyActive = changer.gameObject.activeInHierarchy;
@ -63,20 +146,14 @@ namespace nadena.dev.modular_avatar.core.editor
if (changer.gameObject.activeInHierarchy) info.currentState = action.Value;
Debug.Log("Trying merge: " + action);
if (info.actionGroups.Count == 0)
{
info.actionGroups.Add(action);
}
else if (!info.actionGroups[^1].TryMerge(action))
{
Debug.Log("Failed merge");
info.actionGroups.Add(action);
}
else
{
Debug.Log("Post merge: " + info.actionGroups[^1]);
}
}
}
@ -91,9 +168,9 @@ namespace nadena.dev.modular_avatar.core.editor
{
if (setter.Objects == null) continue;
foreach (var obj in setter.Objects)
foreach (var obj in _computeContext.Observe(setter, c => c.Objects.ToList(), Enumerable.SequenceEqual))
{
var renderer = obj.Object.Get(setter)?.GetComponent<Renderer>();
var renderer = _computeContext.GetComponent<Renderer>(obj.Object.Get(setter));
if (renderer == null || renderer.sharedMaterials.Length < obj.MaterialIndex) continue;
var key = new TargetProp
@ -108,7 +185,7 @@ namespace nadena.dev.modular_avatar.core.editor
objectGroups[key] = group;
}
var action = new ReactionRule(context, key, setter.gameObject, obj.Material);
var action = ObjectRule(key, setter, obj.Material);
action.Inverted = setter.Inverted;
if (group.actionGroups.Count == 0)
@ -126,7 +203,7 @@ namespace nadena.dev.modular_avatar.core.editor
{
if (toggle.Objects == null) continue;
foreach (var obj in toggle.Objects)
foreach (var obj in _computeContext.Observe(toggle, c => c.Objects.ToList(), Enumerable.SequenceEqual))
{
var target = obj.Object.Get(toggle);
if (target == null) continue;
@ -144,7 +221,7 @@ namespace nadena.dev.modular_avatar.core.editor
}
var value = obj.Active ? 1 : 0;
var action = new ReactionRule(context, key, toggle.gameObject, value);
var action = ObjectRule(key, toggle, value);
action.Inverted = toggle.Inverted;
if (group.actionGroups.Count == 0)

View File

@ -1,6 +1,9 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using nadena.dev.modular_avatar.animation;
using nadena.dev.modular_avatar.core.editor.Simulator;
using nadena.dev.ndmf.preview;
using UnityEngine;
namespace nadena.dev.modular_avatar.core.editor
@ -11,16 +14,65 @@ namespace nadena.dev.modular_avatar.core.editor
/// </summary>
internal partial class ReactiveObjectAnalyzer
{
private readonly ndmf.BuildContext context;
private readonly ComputeContext _computeContext;
private readonly ndmf.BuildContext _context;
private readonly AnimationServicesContext _asc;
private Dictionary<string, float> _simulationInitialStates;
public ImmutableDictionary<string, float> ForcePropertyOverrides { get; set; } = ImmutableDictionary<string, float>.Empty;
public ReactiveObjectAnalyzer(ndmf.BuildContext context)
{
this.context = context;
_computeContext = ComputeContext.NullContext;
_context = context;
_asc = context.Extension<AnimationServicesContext>();
_simulationInitialStates = null;
}
public ReactiveObjectAnalyzer()
public ReactiveObjectAnalyzer(ComputeContext computeContext = null)
{
context = null;
_computeContext = computeContext ?? ComputeContext.NullContext;
_context = null;
_asc = null;
_simulationInitialStates = new();
}
public string GetGameObjectStateProperty(GameObject obj)
{
return GetActiveSelfProxy(obj);
}
public string GetMenuItemProperty(GameObject obj)
{
var mami = obj?.GetComponent<ModularAvatarMenuItem>();
if (mami == null) return null;
return ParameterAssignerPass.AssignMenuItemParameter(mami, _simulationInitialStates)?.Parameter;
}
public struct AnalysisResult
{
public Dictionary<TargetProp, AnimatedProperty> Shapes;
public Dictionary<TargetProp, object> InitialStates;
public HashSet<TargetProp> DeletedShapes;
}
private static PropCache<GameObject, AnalysisResult> _analysisCache;
public static AnalysisResult CachedAnalyze(ComputeContext context, GameObject root)
{
if (_analysisCache == null)
{
_analysisCache = new PropCache<GameObject, AnalysisResult>((ctx, root) =>
{
var analysis = new ReactiveObjectAnalyzer(ctx);
analysis.ForcePropertyOverrides = ctx.Observe(ROSimulator.PropertyOverrides, a=>a, (a,b) => false)
?? ImmutableDictionary<string, float>.Empty;
return analysis.Analyze(root);
});
}
return _analysisCache.Get(context, root);
}
/// <summary>
@ -30,21 +82,49 @@ namespace nadena.dev.modular_avatar.core.editor
/// <param name="initialStates">A dictionary of target property to initial state (float or UnityEngine.Object)</param>
/// <param name="deletedShapes">A hashset of blendshape properties which are always deleted</param>
/// <returns></returns>
public Dictionary<TargetProp, AnimatedProperty> Analyze(
GameObject root,
out Dictionary<TargetProp, object> initialStates,
out HashSet<TargetProp> deletedShapes
public AnalysisResult Analyze(
GameObject root
)
{
AnalysisResult result = new();
if (root == null)
{
result.Shapes = new();
result.InitialStates = new();
result.DeletedShapes = new();
return result;
}
Dictionary<TargetProp, AnimatedProperty> shapes = FindShapes(root);
FindObjectToggles(shapes, root);
FindMaterialSetters(shapes, root);
ApplyInitialStateOverrides(shapes);
AnalyzeConstants(shapes);
ResolveToggleInitialStates(shapes);
PreprocessShapes(shapes, out initialStates, out deletedShapes);
PreprocessShapes(shapes, out result.InitialStates, out result.DeletedShapes);
result.Shapes = shapes;
return shapes;
return result;
}
private void ApplyInitialStateOverrides(Dictionary<TargetProp, AnimatedProperty> shapes)
{
foreach (var prop in shapes.Values)
{
foreach (var rule in prop.actionGroups)
{
foreach (var cond in rule.ControllingConditions)
{
var paramName = cond.Parameter;
if (ForcePropertyOverrides.TryGetValue(paramName, out var value))
{
cond.InitialValue = value;
}
}
}
}
}
/// <summary>
@ -54,7 +134,7 @@ namespace nadena.dev.modular_avatar.core.editor
/// <param name="shapes"></param>
private void AnalyzeConstants(Dictionary<TargetProp, AnimatedProperty> shapes)
{
var asc = context?.Extension<AnimationServicesContext>();
var asc = _context?.Extension<AnimationServicesContext>();
HashSet<GameObject> toggledObjects = new();
if (asc == null) return;
@ -98,11 +178,16 @@ namespace nadena.dev.modular_avatar.core.editor
/// <param name="groups"></param>
private void ResolveToggleInitialStates(Dictionary<TargetProp, AnimatedProperty> groups)
{
var asc = context?.Extension<AnimationServicesContext>();
var asc = _context?.Extension<AnimationServicesContext>();
Dictionary<string, bool> propStates = new Dictionary<string, bool>();
Dictionary<string, bool> nextPropStates = new Dictionary<string, bool>();
Dictionary<string, float> propStates = new();
Dictionary<string, float> nextPropStates = new();
int loopLimit = 5;
foreach (var kvp in ForcePropertyOverrides)
{
propStates[kvp.Key] = kvp.Value;
}
bool unsettled = true;
while (unsettled && loopLimit-- > 0)
@ -114,10 +199,10 @@ namespace nadena.dev.modular_avatar.core.editor
if (group.TargetProp.PropertyName != "m_IsActive") continue;
if (!(group.TargetProp.TargetObject is GameObject targetObject)) continue;
var pathKey = asc?.GetActiveSelfProxy(targetObject) ?? RuntimeUtil.AvatarRootPath(targetObject);
bool state;
if (!propStates.TryGetValue(pathKey, out state)) state = targetObject.activeSelf;
var pathKey = GetActiveSelfProxy(targetObject);
float state;
if (!propStates.TryGetValue(pathKey, out state)) state = targetObject.activeSelf ? 1 : 0;
foreach (var actionGroup in group.actionGroups)
{
@ -126,10 +211,10 @@ namespace nadena.dev.modular_avatar.core.editor
{
if (!propStates.TryGetValue(condition.Parameter, out var propCondition))
{
propCondition = condition.InitiallyActive;
propCondition = condition.InitiallyActive ? 1 : 0;
}
if (!propCondition)
if (propCondition < 0.5f)
{
evaluated = false;
break;
@ -140,18 +225,23 @@ namespace nadena.dev.modular_avatar.core.editor
if (evaluated)
{
state = (float) actionGroup.Value > 0.5f;
state = (float) actionGroup.Value;
}
}
nextPropStates[pathKey] = state;
if (!propStates.TryGetValue(pathKey, out var oldState) || oldState != state)
if (!propStates.TryGetValue(pathKey, out var oldState) || !Mathf.Approximately(oldState, state))
{
unsettled = true;
}
}
foreach (var kvp in ForcePropertyOverrides)
{
nextPropStates[kvp.Key] = kvp.Value;
}
propStates = nextPropStates;
nextPropStates = new();
}
@ -163,7 +253,7 @@ namespace nadena.dev.modular_avatar.core.editor
foreach (var condition in action.ControllingConditions)
{
if (propStates.TryGetValue(condition.Parameter, out var state))
condition.InitialValue = state ? 1.0f : 0.0f;
condition.InitialValue = state;
}
}
}

View File

@ -33,12 +33,11 @@ namespace nadena.dev.modular_avatar.core.editor
internal void Execute()
{
Dictionary<TargetProp, AnimatedProperty> shapes =
new ReactiveObjectAnalyzer(context).Analyze(
context.AvatarRootObject,
out var initialStates,
out var deletedShapes
);
var analysis = new ReactiveObjectAnalyzer(context).Analyze(context.AvatarRootObject);
var shapes = analysis.Shapes;
var initialStates = analysis.InitialStates;
var deletedShapes = analysis.DeletedShapes;
GenerateActiveSelfProxies(shapes);

View File

@ -7,6 +7,15 @@ namespace nadena.dev.modular_avatar.core.editor
public Object TargetObject;
public string PropertyName;
public static TargetProp ForObjectActive(GameObject targetObject)
{
return new TargetProp
{
TargetObject = targetObject,
PropertyName = "m_IsActive"
};
}
public bool Equals(TargetProp other)
{
return Equals(TargetObject, other.TargetObject) && PropertyName == other.PropertyName;

View File

@ -25,115 +25,104 @@ namespace nadena.dev.modular_avatar.core.editor
return context.Observe(EnableNode.IsEnabled);
}
private const string PREFIX = "m_Materials.Array.data[";
private PropCache<Renderer, ImmutableList<(int, Material)>> _cache = new(
GetMaterialOverridesForRenderer, Enumerable.SequenceEqual
);
private static ImmutableList<(int, Material)> GetMaterialOverridesForRenderer(ComputeContext ctx, Renderer r)
{
var avatar = ctx.GetAvatarRoot(r.gameObject);
var analysis = ReactiveObjectAnalyzer.CachedAnalyze(ctx, avatar);
var materials = ImmutableList<(int, Material)>.Empty;
foreach (var prop in analysis.Shapes.Values)
{
var target = prop.TargetProp;
if (target.TargetObject != r) continue;
if (!target.PropertyName.StartsWith(PREFIX)) continue;
var index = int.Parse(target.PropertyName.Substring(PREFIX.Length, target.PropertyName.IndexOf(']') - PREFIX.Length));
var activeRule = prop.actionGroups.FirstOrDefault(r => r.InitiallyActive);
if (activeRule == null || activeRule.Value is not Material mat) continue;
materials = materials.Add((index, mat));
}
return materials.OrderBy(kv => kv.Item1).ToImmutableList();
}
private IEnumerable<RenderGroup> GroupsForAvatar(ComputeContext context, GameObject avatarRoot)
{
var analysis = ReactiveObjectAnalyzer.CachedAnalyze(context, avatarRoot);
HashSet<Renderer> renderers = new();
foreach (var prop in analysis.Shapes.Values)
{
var target = prop.TargetProp;
if (target.TargetObject is not Renderer r || r == null) continue;
if (!target.PropertyName.StartsWith(PREFIX)) continue;
var index = int.Parse(target.PropertyName.Substring(PREFIX.Length, target.PropertyName.IndexOf(']') - PREFIX.Length));
var activeRule = prop.actionGroups.FirstOrDefault(r => r.InitiallyActive);
if (activeRule == null || activeRule.Value is not Material mat) continue;
renderers.Add(r);
}
return renderers.Select(RenderGroup.For);
}
public ImmutableList<RenderGroup> GetTargetGroups(ComputeContext context)
{
var menuItemPreview = new MenuItemPreviewCondition(context);
var setters = context.GetComponentsByType<ModularAvatarMaterialSetter>();
var builders =
new Dictionary<Renderer, ImmutableList<ModularAvatarMaterialSetter>.Builder>(
new ObjectIdentityComparer<Renderer>());
foreach (var setter in setters)
{
if (setter == null) continue;
var mami = context.GetComponent<ModularAvatarMenuItem>(setter.gameObject);
bool active = context.ActiveAndEnabled(setter) && (mami == null || menuItemPreview.IsEnabledForPreview(mami));
if (active == context.Observe(setter, s => s.Inverted)) continue;
var objects = context.Observe(setter, s => s.Objects.Select(o => (o.Object.Get(s), o.Material, o.MaterialIndex)).ToList(), Enumerable.SequenceEqual);
foreach (var (target, mat, index) in objects)
{
var renderer = context.GetComponent<Renderer>(target);
if (renderer == null) continue;
if (renderer is not MeshRenderer and not SkinnedMeshRenderer) continue;
var matCount = context.Observe(renderer, r => r.sharedMaterials.Length);
if (matCount <= index) continue;
if (!builders.TryGetValue(renderer, out var builder))
{
builder = ImmutableList.CreateBuilder<ModularAvatarMaterialSetter>();
builders[renderer] = builder;
}
builder.Add(setter);
}
}
return builders.Select(g => RenderGroup.For(g.Key).WithData(g.Value.ToImmutable()))
.ToImmutableList();
var avatarRoots = context.GetAvatarRoots();
return avatarRoots.SelectMany(r => GroupsForAvatar(context, r)).ToImmutableList();
}
public Task<IRenderFilterNode> Instantiate(RenderGroup group, IEnumerable<(Renderer, Renderer)> proxyPairs, ComputeContext context)
{
var setters = group.GetData<ImmutableList<ModularAvatarMaterialSetter>>();
var node = new Node(setters);
var node = new Node(_cache, proxyPairs.First().Item1);
return node.Refresh(proxyPairs, context, 0);
return node.Refresh(null, context, 0);
}
private class Node : IRenderFilterNode
{
private readonly ImmutableList<ModularAvatarMaterialSetter> _setters;
private ImmutableList<(int, Material)> _materials;
private readonly Renderer _target;
private readonly PropCache<Renderer, ImmutableList<(int, Material)>> _cache;
private ImmutableList<(int, Material)> _materials = ImmutableList<(int, Material)>.Empty;
public RenderAspects WhatChanged => RenderAspects.Material;
public RenderAspects WhatChanged { get; private set; } = RenderAspects.Material;
public Node(ImmutableList<ModularAvatarMaterialSetter> setters)
public Node(PropCache<Renderer, ImmutableList<(int, Material)>> cache, Renderer renderer)
{
_setters = setters;
_materials = ImmutableList<(int, Material)>.Empty;
_cache = cache;
_target = renderer;
}
public Task<IRenderFilterNode> Refresh(IEnumerable<(Renderer, Renderer)> proxyPairs, ComputeContext context, RenderAspects updatedAspects)
{
var (original, proxy) = proxyPairs.First();
var newMaterials = _cache.Get(context, _target);
if (original == null || proxy == null) return null;
if (original is not MeshRenderer and not SkinnedMeshRenderer || proxy is not MeshRenderer and not SkinnedMeshRenderer) return null;
var mats = new Material[proxy.sharedMaterials.Length];
foreach (var setter in _setters)
if (newMaterials.SequenceEqual(_materials))
{
if (setter == null) continue;
var objects = context.Observe(setter, s => s.Objects.Select(o => (o.Object.Get(s), o.Material, o.MaterialIndex)).ToList(), Enumerable.SequenceEqual);
foreach (var (target, mat, index) in objects)
{
var renderer = context.GetComponent<Renderer>(target);
if (renderer != original) continue;
if (index <= mats.Length)
{
mats[index] = mat;
}
}
WhatChanged = 0;
} else {
_materials = newMaterials;
WhatChanged = RenderAspects.Material;
}
var materials = mats.Select((m, i) => (i, m)).Where(kvp => kvp.m != null).ToImmutableList();
if (!materials.SequenceEqual(_materials))
{
return Task.FromResult<IRenderFilterNode>(new Node(_setters)
{
_materials = materials,
});
}
return Task.FromResult<IRenderFilterNode>(this);
}
public void OnFrame(Renderer original, Renderer proxy)
{
if (original == null || proxy == null) return;
if (original is not MeshRenderer and not SkinnedMeshRenderer || proxy is not MeshRenderer and not SkinnedMeshRenderer) return;
var mats = proxy.sharedMaterials;

View File

@ -2,6 +2,7 @@
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using nadena.dev.modular_avatar.core.editor.Simulator;
using nadena.dev.ndmf.preview;
using UnityEngine;
@ -27,99 +28,55 @@ namespace nadena.dev.modular_avatar.core.editor
return context.Observe(EnableNode.IsEnabled);
}
private IEnumerable<RenderGroup> RootsForAvatar(ComputeContext context, GameObject avatarRoot)
{
if (!context.ActiveInHierarchy(avatarRoot))
{
yield break;
}
var analysis = ReactiveObjectAnalyzer.CachedAnalyze(context, avatarRoot);
var initialStates = analysis.InitialStates;
var renderers = context.GetComponentsInChildren<Renderer>(avatarRoot, true);
foreach (var renderer in renderers)
{
bool currentlyEnabled = context.ActiveInHierarchy(renderer.gameObject);
bool overrideEnabled = true;
Transform cursor = renderer.transform;
while (cursor != null && !RuntimeUtil.IsAvatarRoot(cursor))
{
if (initialStates.TryGetValue(TargetProp.ForObjectActive(cursor.gameObject), out var initialState) && initialState is float f)
{
if (f < 0.5f)
{
overrideEnabled = false;
break;
}
}
else if (!cursor.gameObject.activeSelf)
{
overrideEnabled = false;
break;
}
cursor = cursor.parent;
}
if (overrideEnabled != currentlyEnabled)
{
yield return RenderGroup.For(renderer).WithData(overrideEnabled);
}
}
}
public ImmutableList<RenderGroup> GetTargetGroups(ComputeContext context)
{
var menuItemPreview = new MenuItemPreviewCondition(context);
var allToggles = context.GetComponentsByType<ModularAvatarObjectToggle>();
var objectGroups =
new Dictionary<GameObject, ImmutableList<(ModularAvatarObjectToggle, int)>.Builder>(
new ObjectIdentityComparer<GameObject>());
foreach (var toggle in allToggles)
{
var mami = context.GetComponent<ModularAvatarMenuItem>(toggle.gameObject);
bool active = context.ActiveAndEnabled(toggle) && (mami == null || menuItemPreview.IsEnabledForPreview(mami));
if (active == context.Observe(toggle, t => t.Inverted)) continue;
context.Observe(toggle,
t => t.Objects.Select(o => o.Object.referencePath).ToList(),
(x, y) => x.SequenceEqual(y)
);
if (toggle.Objects == null) continue;
var index = -1;
foreach (var switched in toggle.Objects)
{
index++;
if (switched.Object == null) continue;
var target = context.Observe(toggle, _ => switched.Object.Get(toggle));
if (target == null) continue;
if (!objectGroups.TryGetValue(target, out var group))
{
group = ImmutableList.CreateBuilder<(ModularAvatarObjectToggle, int)>();
objectGroups[target] = group;
}
group.Add((toggle, index));
}
}
var affectedRenderers = objectGroups.Keys
.SelectMany(go => context.GetComponentsInChildren<Renderer>(go, true))
// If we have overlapping objects, we need to sort by child to parent, so parent configuration overrides
// the child. We do this by simply looking at how many times we observe each renderer.
.GroupBy(r => r)
.Select(g => g.Key)
.ToHashSet();
var renderGroups = new List<RenderGroup>();
var roots = context.GetAvatarRoots();
foreach (var r in affectedRenderers)
{
var shouldEnable = true;
var obj = r.gameObject;
context.ActiveInHierarchy(obj); // observe path changes & object state changes
while (obj != null)
{
var enableAtNode = obj.activeSelf;
var group = objectGroups.GetValueOrDefault(obj);
if (group == null && !obj.activeSelf)
{
// always inactive
shouldEnable = false;
break;
}
if (group != null)
{
var (toggle, index) = group[^1];
enableAtNode = context.Observe(toggle, t => t.Objects.Count > index && t.Objects[index].Active);
}
if (!enableAtNode)
{
shouldEnable = false;
break;
}
obj = obj.transform.parent?.gameObject;
}
if (shouldEnable != r.gameObject.activeInHierarchy)
renderGroups.Add(RenderGroup.For(r).WithData(shouldEnable));
}
return renderGroups.ToImmutableList();
return roots.SelectMany(av => RootsForAvatar(context, av)).ToImmutableList();
}
public Task<IRenderFilterNode> Instantiate(RenderGroup group, IEnumerable<(Renderer, Renderer)> proxyPairs,

View File

@ -118,18 +118,33 @@ namespace nadena.dev.modular_avatar.core.editor
}
}
internal static ControlCondition AssignMenuItemParameter(ModularAvatarMenuItem mami)
internal static ControlCondition AssignMenuItemParameter(ModularAvatarMenuItem mami, Dictionary<string, float> simulationInitialStates = null)
{
if (string.IsNullOrWhiteSpace(mami?.Control?.parameter?.name)) return null;
var paramName = mami?.Control?.parameter?.name;
if (mami?.Control != null && simulationInitialStates != null && ShouldAssignParametersToMami(mami))
{
paramName = "___AutoProp/" + mami.Control?.parameter?.name;
if (mami.isDefault)
{
simulationInitialStates[paramName] = mami.Control.value;
} else if (!simulationInitialStates.ContainsKey(paramName))
{
simulationInitialStates[paramName] = -999;
}
}
if (string.IsNullOrWhiteSpace(paramName)) return null;
return new ControlCondition
{
Parameter = mami.Control.parameter.name,
Parameter = paramName,
DebugName = mami.gameObject.name,
IsConstant = false,
InitialValue = mami.isDefault ? mami.Control.value : -999, // TODO
ParameterValueLo = mami.Control.value - 0.5f,
ParameterValueHi = mami.Control.value + 0.5f
ParameterValueHi = mami.Control.value + 0.5f,
DebugReference = mami,
};
}
}

View File

@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using nadena.dev.modular_avatar.core.editor.Simulator;
using nadena.dev.ndmf.preview;
using UnityEngine;
using Object = UnityEngine.Object;
@ -30,173 +31,159 @@ namespace nadena.dev.modular_avatar.core.editor
{
return context.Observe(EnableNode.IsEnabled);
}
private class StaticContext
{
public StaticContext(GameObject avatarRoot, IEnumerable<int> shapes)
{
AvatarRoot = avatarRoot;
Shapes = shapes.OrderBy(i => i).ToImmutableList();
}
public GameObject AvatarRoot { get; }
public ImmutableList<int> Shapes { get; }
public override bool Equals(object obj)
{
return obj is StaticContext other && Shapes.SequenceEqual(other.Shapes) && AvatarRoot == other.AvatarRoot;
}
public override int GetHashCode()
{
int hash = AvatarRoot.GetHashCode();
foreach (var shape in Shapes)
{
hash = hash * 31 + shape.GetHashCode();
}
return hash;
}
}
private PropCache<GameObject, ImmutableDictionary<SkinnedMeshRenderer, ImmutableList<(int, float)>>>
_blendshapeCache = new(ShapesForAvatar);
private static ImmutableDictionary<SkinnedMeshRenderer, ImmutableList<(int, float)>> ShapesForAvatar(ComputeContext context, GameObject avatarRoot)
{
if (avatarRoot == null || !context.ActiveInHierarchy(avatarRoot))
{
return ImmutableDictionary<SkinnedMeshRenderer, ImmutableList<(int, float)>>.Empty;
}
var analysis = ReactiveObjectAnalyzer.CachedAnalyze(context, avatarRoot);
var shapes = analysis.Shapes;
ImmutableDictionary<SkinnedMeshRenderer, ImmutableList<(int, float)>>.Builder rendererStates =
ImmutableDictionary.CreateBuilder<SkinnedMeshRenderer, ImmutableList<(int, float)>>(
new ObjectIdentityComparer<SkinnedMeshRenderer>()
);
var avatarRootTransform = avatarRoot.transform;
foreach (var prop in shapes.Values)
{
var target = prop.TargetProp;
if (target.TargetObject == null || target.TargetObject is not SkinnedMeshRenderer r) continue;
if (!r.transform.IsChildOf(avatarRootTransform)) continue;
if (!target.PropertyName.StartsWith("blendShape.")) continue;
var mesh = r.sharedMesh;
if (mesh == null) continue;
var shapeName = target.PropertyName.Substring("blendShape.".Length);
if (!rendererStates.TryGetValue(r, out var states))
{
states = ImmutableList<(int, float)>.Empty;
rendererStates[r] = states;
}
var index = r.sharedMesh.GetBlendShapeIndex(shapeName);
if (index < 0) continue;
var activeRule = prop.actionGroups.LastOrDefault(rule => rule.InitiallyActive);
if (activeRule == null || activeRule.Value is not float value) continue;
value = Math.Clamp(value, 0, 100);
if (activeRule.IsDelete) value = -1;
states = states.Add((index, value));
rendererStates[r] = states;
}
return rendererStates.ToImmutableDictionary();
}
private IEnumerable<RenderGroup> ShapesToGroups(GameObject avatarRoot, ImmutableDictionary<SkinnedMeshRenderer, ImmutableList<(int, float)>> shapes)
{
return shapes.Select(kv => RenderGroup.For(kv.Key).WithData(
new StaticContext(avatarRoot, kv.Value.Where(shape => shape.Item2 < 0).Select(shape => shape.Item1))
));
}
public ImmutableList<RenderGroup> GetTargetGroups(ComputeContext context)
{
var menuItemPreview = new MenuItemPreviewCondition(context);
var changers = context.GetComponentsByType<ModularAvatarShapeChanger>();
var builders =
new Dictionary<Renderer, ImmutableList<ModularAvatarShapeChanger>.Builder>(
new ObjectIdentityComparer<Renderer>());
foreach (var changer in changers)
{
if (changer == null) continue;
var mami = context.GetComponent<ModularAvatarMenuItem>(changer.gameObject);
bool active = context.ActiveAndEnabled(changer) && (mami == null || menuItemPreview.IsEnabledForPreview(mami));
if (active == context.Observe(changer, c => c.Inverted)) continue;
var shapes = context.Observe(changer, c => c.Shapes.Select(s => (s.Object.Get(c), s.ShapeName, s.ChangeType, s.Value)).ToList(), Enumerable.SequenceEqual);
foreach (var (target, name, type, value) in shapes)
{
var renderer = context.GetComponent<SkinnedMeshRenderer>(target);
if (renderer == null) continue;
if (!builders.TryGetValue(renderer, out var builder))
{
builder = ImmutableList.CreateBuilder<ModularAvatarShapeChanger>();
builders[renderer] = builder;
}
builder.Add(changer);
}
}
var roots = context.GetAvatarRoots();
return builders.Select(g => RenderGroup.For(g.Key).WithData(g.Value.ToImmutable()))
return roots
.SelectMany(av =>
ShapesToGroups(av, _blendshapeCache.Get(context, av))
)
.ToImmutableList();
}
public Task<IRenderFilterNode> Instantiate(RenderGroup group, IEnumerable<(Renderer, Renderer)> proxyPairs, ComputeContext context)
{
var changers = group.GetData<ImmutableList<ModularAvatarShapeChanger>>();
var node = new Node(changers);
var shapeValues = group.GetData<StaticContext>();
var node = new Node(shapeValues, proxyPairs.First().Item2 as SkinnedMeshRenderer, _blendshapeCache);
return node.Refresh(proxyPairs, context, 0);
}
private class Node : IRenderFilterNode
{
private readonly ImmutableList<ModularAvatarShapeChanger> _changers;
private ImmutableHashSet<(int, float)> _shapes;
private readonly PropCache<GameObject, ImmutableDictionary<SkinnedMeshRenderer, ImmutableList<(int, float)>>> _blendshapeCache;
private readonly GameObject _avatarRoot;
private ImmutableList<(int, float)> _shapes;
private ImmutableHashSet<int> _toDelete;
private Mesh _generatedMesh = null;
public RenderAspects WhatChanged => RenderAspects.Shapes | RenderAspects.Mesh;
internal Node(ImmutableList<ModularAvatarShapeChanger> changers)
internal Node(StaticContext staticContext, SkinnedMeshRenderer proxySmr, PropCache<GameObject, ImmutableDictionary<SkinnedMeshRenderer, ImmutableList<(int, float)>>> blendshapeCache)
{
_changers = changers;
_shapes = ImmutableHashSet<(int, float)>.Empty;
_toDelete = ImmutableHashSet<int>.Empty;
_generatedMesh = null;
_blendshapeCache = blendshapeCache;
_avatarRoot = staticContext.AvatarRoot;
_toDelete = staticContext.Shapes.ToImmutableHashSet();
_shapes = ImmutableList<(int, float)>.Empty;
_generatedMesh = GetGeneratedMesh(proxySmr, _toDelete);
}
public Task<IRenderFilterNode> Refresh(IEnumerable<(Renderer, Renderer)> proxyPairs, ComputeContext context, RenderAspects updatedAspects)
{
var (original, proxy) = proxyPairs.First();
if (original == null || proxy == null) return null;
if (original is not SkinnedMeshRenderer originalSmr || proxy is not SkinnedMeshRenderer proxySmr) return null;
var shapes = GetShapesSet(originalSmr, proxySmr, context);
var toDelete = GetToDeleteSet(originalSmr, proxySmr, context);
if (!toDelete.SequenceEqual(_toDelete))
if ((updatedAspects & RenderAspects.Mesh) != 0)
{
return Task.FromResult<IRenderFilterNode>(new Node(_changers)
{
_shapes = shapes,
_toDelete = toDelete,
_generatedMesh = GetGeneratedMesh(proxySmr, toDelete),
});
return Task.FromResult<IRenderFilterNode>(null);
}
if (!shapes.SequenceEqual(_shapes))
var avatarInfo = _blendshapeCache.Get(context, _avatarRoot);
if (!avatarInfo.TryGetValue((SkinnedMeshRenderer)proxyPairs.First().Item1, out var shapes))
{
var reusableMesh = _generatedMesh;
_generatedMesh = null;
return Task.FromResult<IRenderFilterNode>(new Node(_changers)
{
_shapes = shapes,
_toDelete = toDelete,
_generatedMesh = reusableMesh,
});
return Task.FromResult<IRenderFilterNode>(null);
}
var toDelete = shapes.Where(shape => shape.Item2 < 0).Select(shape => shape.Item1).ToImmutableHashSet();
if (!_toDelete.SetEquals(toDelete))
{
return Task.FromResult<IRenderFilterNode>(null);
}
_shapes = shapes;
return Task.FromResult<IRenderFilterNode>(this);
}
private ImmutableHashSet<(int, float)> GetShapesSet(SkinnedMeshRenderer original, SkinnedMeshRenderer proxy, ComputeContext context)
{
var builder = ImmutableHashSet.CreateBuilder<(int, float)>();
var mesh = context.Observe(proxy, p => p.sharedMesh, (a, b) =>
{
if (a != b)
{
Debug.Log($"mesh changed {a.GetInstanceID()} -> {b.GetInstanceID()}");
return false;
}
return true;
});
foreach (var changer in _changers)
{
if (changer == null) continue;
var shapes = context.Observe(changer, c => c.Shapes.Select(s => (s.Object.Get(c), s.ShapeName, s.ChangeType, s.Value)).ToList(), Enumerable.SequenceEqual);
foreach (var (target, name, type, value) in shapes)
{
var renderer = context.GetComponent<SkinnedMeshRenderer>(target);
if (renderer != original) continue;
var index = mesh.GetBlendShapeIndex(name);
if (index < 0) continue;
builder.Add((index, type == ShapeChangeType.Delete ? 100 : value));
}
}
return builder.ToImmutable();
}
private ImmutableHashSet<int> GetToDeleteSet(SkinnedMeshRenderer original, SkinnedMeshRenderer proxy, ComputeContext context)
{
var builder = ImmutableHashSet.CreateBuilder<int>();
var mesh = context.Observe(proxy, p => p.sharedMesh, (a, b) =>
{
if (a != b)
{
Debug.Log($"mesh changed {a.GetInstanceID()} -> {b.GetInstanceID()}");
return false;
}
return true;
});
foreach (var changer in _changers)
{
var shapes = context.Observe(changer, c => c.Shapes.Select(s => (s.Object.Get(c), s.ShapeName, s.ChangeType, s.Value)).ToList(), Enumerable.SequenceEqual);
foreach (var (target, name, type, value) in shapes)
{
if (type != ShapeChangeType.Delete) continue;
var renderer = context.GetComponent<SkinnedMeshRenderer>(target);
if (renderer != original) continue;
var index = mesh.GetBlendShapeIndex(name);
if (index < 0) continue;
builder.Add(index);
}
}
return builder.ToImmutable();
}
public Mesh GetGeneratedMesh(SkinnedMeshRenderer proxy, ImmutableHashSet<int> toDelete)
{
var mesh = proxy.sharedMesh;
@ -263,7 +250,7 @@ namespace nadena.dev.modular_avatar.core.editor
foreach (var shape in _shapes)
{
proxySmr.SetBlendShapeWeight(shape.Item1, shape.Item2);
proxySmr.SetBlendShapeWeight(shape.Item1, shape.Item2 < 0 ? 100 : shape.Item2);
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 8df71f6c94c04c82b246b7cc8c1e3ef3
timeCreated: 1724533011

View File

@ -0,0 +1,33 @@
<UXML xmlns:ui="UnityEngine.UIElements" xmlns:ed="UnityEditor.UIElements"
xmlns:ma="nadena.dev.modular_avatar.core.editor">
<ui:VisualElement class="effect-group">
<ed:ObjectField label="Reactive Component" name="effect__source"/>
<ui:VisualElement name="effect__set-active" class="unity-base-field h-group">
<ui:Label text="ro_sim.effect_group.controls_obj_state" class="unity-base-field__label ndmf-tr"/>
<ui:Label text="ro_sim.state.active" class="st-enabled ndmf-tr"/>
</ui:VisualElement>
<ui:VisualElement name="effect__set-inactive" class="unity-base-field h-group">
<ui:Label text="ro_sim.effect_group.controls_obj_state" class="unity-base-field__label ndmf-tr"/>
<ui:Label text="ro_sim.state.inactive" class="st-disabled ndmf-tr"/>
</ui:VisualElement>
<ed:ObjectField label="ro_sim.effect_group.target_component" name="effect__target" class="ndmf-tr"/>
<ui:TextField label="ro_sim.effect_group.property" text="blendShapes.x" name="effect__prop" class="ndmf-tr-label"/>
<ed:FloatField label="ro_sim.effect_group.value" name="effect__value" class="ndmf-tr-label"/>
<ed:ObjectField label="ro_sim.effect_group.material" name="effect__material" class="ndmf-tr"/>
<ui:TextField label="ro_sim.effect_group.value" text="DELETE" name="effect__deleted" class="ndmf-tr-label"/>
<ui:VisualElement name="controlling-conditions">
<ui:Label text="ro_sim.state.active" class="st-enabled when-active state-header ndmf-tr"/>
<ui:Label text="ro_sim.state.inactive" class="st-disabled when-inactive state-header ndmf-tr"/>
<ui:HelpBox text="ro_sim.effect_group.rule_inverted" message-type="Warning" class="when-inverted ndmf-tr"/>
<ui:VisualElement class="spacer"/>
<ui:Label text="ro_sim.effect_group.conditions" class="heading ndmf-tr"/>
</ui:VisualElement>
</ui:VisualElement>
</UXML>

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9af042eb03914091ba0152eb8e67ab26
timeCreated: 1724539594

View File

@ -0,0 +1,516 @@
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);
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;
EditorApplication.delayCall += LoadUI;
Selection.selectionChanged += SelectionChanged;
is_enabled = true;
}
private void OnDisable()
{
Selection.selectionChanged -= SelectionChanged;
is_enabled = false;
PropertyOverrides.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 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);
}
EditorApplication.delayCall += RefreshUI;
}
private void UpdatePropertyOverride(string prop, bool? value)
{
if (value == null)
{
PropertyOverrides.Value = PropertyOverrides.Value.Remove(prop);
}
else
{
PropertyOverrides.Value = PropertyOverrides.Value.SetItem(prop, value.Value ? 1f : 0f);
}
RefreshUI();
}
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;
RefreshUI();
};
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);
RefreshUI();
}
private void RefreshUI()
{
var avatar = RuntimeUtil.FindAvatarInParents(currentSelection?.transform);
if (avatar == null)
{
e_debugInfo.style.display = DisplayStyle.None;
return;
}
_btn_clear.SetEnabled(!PropertyOverrides.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;
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.RefreshUI();
}
}
private void SetThisObjectOverrides(ReactiveObjectAnalyzer analysis)
{
BindOverrideToParameter("this-obj-override", analysis.GetGameObjectStateProperty(currentSelection), 1);
BindOverrideToParameter("this-menu-override", analysis.GetMenuItemProperty(currentSelection),
currentSelection.GetComponent<ModularAvatarMenuItem>()?.Control?.value ?? 1
);
}
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);
}
soc.OnStateOverrideChanged += value =>
{
UpdatePropertyOverride(property, value, targetValue);
};
}
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);
}
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;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 7b6dd43c1ba24df49f9254a89b65979a
timeCreated: 1724533122

View File

@ -0,0 +1,193 @@
.rootVisualContent > TemplateContainer {
width: 100%;
height: 100%;
}
LogoImage {
padding-bottom: 10px;
flex-shrink: 0;
}
#root-box {
flex-direction: column;
height: 100%;
width: 100%;
padding: 2px;
flex-grow: 1;
}
#content {
flex-grow: 1;
}
#effect-list {
overflow: scroll;
}
.effect-group {
flex-shrink: 0;
}
.heading {
align-self: center;
margin-top: 10px;
}
.h-button-group {
flex-direction: row;
margin-top: 5px;
}
.h-button-group StateOverrideController {
margin-right: 5px;
}
.h-button-group Label {
flex-grow: 1;
align-self: center;
}
.state-header {
font-size: 20px;
align-self: center;
}
.st-enabled {
color: green;
}
.st-disabled {
color: red;
}
.source-active Label {
color: green;
}
.source-inactive Label {
color: red;
}
.ag-inactive {
opacity: 0.5;
}
.source-active {
opacity: 1;
}
.source-inactive {
opacity: 1;
}
.h-group {
flex-direction: row
}
.h-group > * {
flex-grow: 1;
}
.h-group > Label {
flex-grow: 0;
align-self: flex-end;
}
.h-group > Label.force-center {
align-self: center;
}
.spacer {
margin: 5px;
}
.effect-group {
border-color: black;
border-width: 1px;
margin: 2px;
padding: 2px;
opacity: 0.5;
}
.effect-group .unity-object-field__selector {
display: none;
}
.effect-group > * {
opacity: 1;
}
.st-active .effect-group {
opacity: 1;
}
#effect__source {
opacity: 1;
}
#effect-list > Foldout.foldout-open {
background-color: rgba(0,0,0,0.1);
margin-bottom: 10px;
}
#effect__target {
padding-top: 10px;
}
#controlling-conditions {
width: 100%;
border-top-color: black;
border-top-width: 1px;
margin-top: 10px;
padding-top: 4px;
}
#controlling-conditions Label.heading {
margin-top: 0;
}
#controlling-conditions .when-active {
display: None;
}
.st-active #controlling-conditions .when-inactive {
display: None;
}
.st-active #controlling-conditions .when-active {
display: flex;
}
#controlling-conditions > HelpBox {
align-self: center;
}
#controlling-conditions > HelpBox .unity-help-box__icon {
min-width: 16px;
min-height: 16px;
}
.controlling-condition {
flex-direction: row;
}
.controlling-condition StateOverrideController {
flex-grow: 0;
}
.controlling-condition ObjectField {
flex-grow: 1;
flex-shrink: 1;
}
.controlling-condition ObjectField > Label {
-unity-text-align: middle-center;
}
.when-inverted {
display: None;
}
.rule-inverted .when-inverted {
display: flex;
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: fa036359c61e4012b59578ee211f73e1
timeCreated: 1724533027

View File

@ -0,0 +1,39 @@
<UXML xmlns:ui="UnityEngine.UIElements" xmlns:ed="UnityEditor.UIElements"
xmlns:ma="nadena.dev.modular_avatar.core.editor">
<ui:VisualElement name="root-box">
<ma:LogoImage/>
<ui:VisualElement name="content">
<ed:ObjectField label="ro_sim.header.inspecting" name="inspecting" class="ndmf-tr"/>
<ui:Button text="ro_sim.header.clear_overrides" name="clear-overrides" class="ndmf-tr"/>
<ui:VisualElement class="h-divider"/>
<ui:VisualElement name="debug-info">
<ui:Label text="ro_sim.header.object_state" class="heading ndmf-tr"/>
<ui:Label text="ro_sim.state.active" name="state-enabled" class="state-header st-enabled ndmf-tr"/>
<ui:Label text="ro_sim.state.inactive" name="state-disabled" class="state-header st-disabled ndmf-tr"/>
<ui:VisualElement class="h-button-group unity-base-field" name="this-obj-override">
<ma:StateOverrideController/>
<ui:Label text="ro_sim.header.override_gameobject_state" class="unity-base-field__label ndmf-tr"/>
</ui:VisualElement>
<ui:VisualElement class="h-button-group unity-base-field" name="this-menu-override">
<ma:StateOverrideController/>
<ui:Label text="ro_sim.header.override_menuitem_state" class="unity-base-field__label ndmf-tr"/>
</ui:VisualElement>
<ui:VisualElement class="h-divider"/>
<ui:Label text="ro_sim.affected_by.title" class="heading ndmf-tr"/>
<ui:ScrollView name="effect-list">
</ui:ScrollView>
</ui:VisualElement>
</ui:VisualElement>
<ma:LanguageSwitcherElement/>
<ui:Button text="debug: reload" name="debug__reload" style="display: none"/>
</ui:VisualElement>
</UXML>

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 76e8ef6b8e2b44748b921102cb9e7e9a
timeCreated: 1724533027

View File

@ -0,0 +1,52 @@
using nadena.dev.modular_avatar.core.editor;
using nadena.dev.modular_avatar.core.editor.Simulator;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
namespace nadena.dev.modular_avatar.core.editor
{
internal class ROSimulatorButton : VisualElement
{
public new class UxmlFactory : UxmlFactory<ROSimulatorButton, UxmlTraits>
{
}
public new class UxmlTraits : VisualElement.UxmlTraits
{
}
private Button btn;
public UnityEngine.Object ReferenceObject;
public static void BindRefObject(VisualElement elem, UnityEngine.Object obj)
{
var button = elem.Q<ROSimulatorButton>();
if (button != null)
{
button.ReferenceObject = obj;
}
}
public ROSimulatorButton()
{
btn = new Button();
btn.AddToClassList("ndmf-tr");
btn.text = "ro_sim.open_debugger_button";
Add(btn);
btn.clicked += OpenDebugger;
}
private void OpenDebugger()
{
GameObject target = Selection.activeGameObject;
if (ReferenceObject is Component c) target = c.gameObject;
else if (ReferenceObject is GameObject go) target = go;
ROSimulator.OpenDebugger(target);
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e63eb3f1209e468cb737b0ec08d8a8c1
timeCreated: 1724632949

View File

@ -0,0 +1,77 @@
using nadena.dev.modular_avatar.core.editor.Simulator;
using UnityEditor;
using UnityEngine.UIElements;
namespace nadena.dev.modular_avatar.core.editor
{
internal class StateOverrideController : VisualElement
{
public new class UxmlFactory : UxmlFactory<StateOverrideController, UxmlTraits>
{
}
public new class UxmlTraits : VisualElement.UxmlTraits
{
}
private static StyleSheet uss;
private Button btn_disable, btn_default, btn_enable;
public event System.Action<bool?> OnStateOverrideChanged;
public StateOverrideController()
{
uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(ROSimulator.ROOT_PATH + "StateOverrideController.uss");
styleSheets.Add(uss);
AddToClassList("state-override-controller");
btn_disable = new Button();
btn_disable.AddToClassList("btn-disable");
btn_default = new Button();
btn_default.AddToClassList("btn-default");
btn_enable = new Button();
btn_enable.AddToClassList("btn-enable");
btn_disable.text = "-";
btn_default.text = " ";
btn_enable.text = "+";
btn_disable.clicked += () => SetStateOverride(false);
btn_default.clicked += () => SetStateOverride(null);
btn_enable.clicked += () => SetStateOverride(true);
Add(btn_disable);
Add(btn_default);
Add(btn_enable);
}
private void SetStateOverride(bool? state)
{
SetWithoutNotify(state);
OnStateOverrideChanged?.Invoke(state);
}
public void SetWithoutNotify(bool? state)
{
RemoveFromClassList("override-enable");
RemoveFromClassList("override-disable");
RemoveFromClassList("override-default");
if (state == null) AddToClassList("override-default");
else if (state == true) AddToClassList("override-enable");
else AddToClassList("override-disable");
}
public void SetWithoutNotify(ControlCondition condition, float value)
{
if (value >= condition.ParameterValueLo && value <= condition.ParameterValueHi)
{
SetWithoutNotify(true);
}
else
{
SetWithoutNotify(false);
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 4041f4df348b4745ae26140a99d01fd2
timeCreated: 1724543447

View File

@ -0,0 +1,22 @@
.state-override-controller {
flex-direction: row;
align-self: center;
}
.state-override-controller > Button {
margin: 0;
max-width: 16px;
min-width: 16px;
max-height: 16px;
min-height: 16px;
padding: 0;
}
.state-override-controller.override-enable .btn-enable {
background-color: var(--unity-colors-link-text);
}
.state-override-controller.override-disable .btn-disable {
background-color: var(--unity-colors-error-text);
color: white;
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0083d985d28a4e698e77e5f2b4d7a680
timeCreated: 1724543503

View File

@ -20,6 +20,15 @@ namespace nadena.dev.modular_avatar.core
private string _cachedPath;
private GameObject _cachedReference;
public AvatarObjectReference Clone()
{
return new AvatarObjectReference
{
referencePath = referencePath,
targetObject = targetObject
};
}
#if UNITY_EDITOR
public static GameObject Get(SerializedProperty prop)
{

View File

@ -24,6 +24,17 @@ namespace nadena.dev.modular_avatar.core
public ShapeChangeType ChangeType;
public float Value;
public ChangedShape Clone()
{
return new ChangedShape
{
Object = Object.Clone(),
ShapeName = ShapeName,
ChangeType = ChangeType,
Value = Value
};
}
public bool Equals(ChangedShape other)
{
return Equals(Object, other.Object) && ShapeName == other.ShapeName && ChangeType == other.ChangeType && Value.Equals(other.Value);

View File

@ -15,6 +15,9 @@
internal const string GameObject_EnableInfo = "GameObject/Modular Avatar/Show Modular Avatar Information";
internal const int GameObject_EnableInfoOrder = -799;
internal const string GameObject_ShowReactionDebugger = "GameObject/Modular Avatar/Show Reaction Debugger";
internal const int GameObject_ShowReactionDebuggerOrder = GameObject_EnableInfoOrder + 1;
internal const string GameObject_ExtractMenu = "GameObject/Modular Avatar/Extract Menu";
internal const int GameObject_ExtractMenuOrder = GameObject_EnableInfoOrder + 100;

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -0,0 +1,41 @@
---
sidebar_position: 900
---
# Reaction Debugger
![Reaction Debugger](debugger-main-0.png)
The Reactive Component Debugger allows you to virtually change the state of menu items and game objects in your scene,
so you can test how your reactive components will behave without needing to manually interact with your avatar.
To open the Reactive Component Debugger, right click a Game Object and choose `Modular Avatar -> Show Reaction Debugger`.
Alternatively, you can click the `Open reaction debugger` button on a reactive component.
The debugger window is divided into two sections: One showing the state of the object itself, and one for the reactions
that affect it.
## Object state section
![Top section of the debugger](top-section.png)
The top section lets you select which object you want to examine, by changing the `Inspecting object` field. By default,
it will show you the currently selected object, but if you change the field you can examine some other object instead.
As with inspector windows, you can lock the view to avoid it changing when you select different objects in the scene.
Below the `Inspecting object` field, there is a button to clear all active overrides. More on that later. We also see an
`Object state: ACTIVE` here, showing that this object is (virtually) active.
Below that are two options to force the object to be active/inactive, and to force the associated menu item to be selected
or deselected. Click the `-` or `+` buttons to trigger such a forced state, or the middle (blank) button to clear the
override.
Forcing an object to be "active" or "inactive" doesn't actually change its state in the hierarchy, but it'll be shown as
if it was enabled or disabled in the scene view window.
## Reaction section
![Bottom section of the debugger](bottom-section.png)
The reaction section shows all "incoming" reactions that affect this object. It also shows what conditions need to be
true for the reaction to be triggered, and lets you easily override those conditions.

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -60,10 +60,9 @@ A -> B -> C, and A is turned off, the timing will be as follows:
* Frame 2: A is disabled (B's disable is delayed)
* Frame 3: B and C are disabled at the same time.
### Preview system
### Debugging problems
The effect of reactive components on mesh visibility is immediately reflected in the editor scene view. However, this
has some limitations; in particular, it considers the current active state of objects, and the "default" state of Menu
Items, but does not consider the effect of Object Toggles on other reactive components. To see the full effect of
reactive
components, you must enter play mode.
The Reactive Objects system includes a debugger which can be used to simulate the effect of toggling on/off various
objects or menu items on your avatar. To access it, right click a Game Object and choose
`Modular Avatar -> Show Reaction Debugger`. For a detailed description of how it works, see the
[debugger documentation](./debugger/index.md).

View File

@ -16,6 +16,6 @@
},
"vpmDependencies": {
"com.vrchat.avatars": ">=3.7.0",
"nadena.dev.ndmf": ">=1.5.0-rc.0 <2.0.0-a"
"nadena.dev.ndmf": ">=1.5.0-rc.1 <2.0.0-a"
}
}