feat: add material switcher and inverse mode (#974)

* feat: add material switcher

Also refactor everything...

* refactor: simplify object curve handling

* refactor: additional refactoring and bugfixes

* feat: inverse mode

* feat: add material setter inspector UI

* chore: set material setter icon

* chore: fix error on build

* chore: adjust order of inverted element
This commit is contained in:
bd_ 2024-08-10 18:03:50 -07:00 committed by GitHub
parent 3d1b4f1c76
commit d998763fbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1690 additions and 931 deletions

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 131d9706ddc04331bd09cf13b863c537
timeCreated: 1723334567

View File

@ -0,0 +1,24 @@
<UXML xmlns:ui="UnityEngine.UIElements" xmlns:ed="UnityEditor.UIElements"
xmlns:ma="nadena.dev.modular_avatar.core.editor">
<ui:VisualElement name="root-box">
<ui:VisualElement name="group-box">
<ui:VisualElement name="ListViewContainer">
<ui:ListView virtualization-method="DynamicHeight"
reorder-mode="Animated"
reorderable="true"
show-add-remove-footer="true"
show-border="true"
show-foldout-header="false"
name="Shapes"
item-height="100"
binding-path="m_objects"
style="flex-grow: 1;"
/>
</ui:VisualElement>
</ui:VisualElement>
<ed:PropertyField binding-path="m_inverted"/>
<ma:LanguageSwitcherElement/>
</ui:VisualElement>
</UXML>

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: cd5c518316b2435d8a666911d4131903
timeCreated: 1723334567

View File

@ -0,0 +1,41 @@
#region
using System;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
#endregion
namespace nadena.dev.modular_avatar.core.editor.ShapeChanger
{
[CustomEditor(typeof(ModularAvatarMaterialSetter))]
public class MaterialSetterEditor : MAEditorBase
{
[SerializeField] private StyleSheet uss;
[SerializeField] private VisualTreeAsset uxml;
protected override void OnInnerInspectorGUI()
{
throw new NotImplementedException();
}
protected override VisualElement CreateInnerInspectorGUI()
{
var root = uxml.CloneTree();
Localization.UI.Localize(root);
root.styleSheets.Add(uss);
root.Bind(serializedObject);
var listView = root.Q<ListView>("Shapes");
listView.showBoundCollectionSize = false;
listView.virtualizationMethod = CollectionVirtualizationMethod.DynamicHeight;
return root;
}
}
}

View File

@ -0,0 +1,13 @@
fileFormatVersion: 2
guid: 339dd3848a2044b1aa04f543226de0e8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences:
- uss: {fileID: 7433441132597879392, guid: fce9f3fe74434b718abac5ea66775acb, type: 3}
- uxml: {fileID: 9197481963319205126, guid: cd5c518316b2435d8a666911d4131903, type: 3}
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,35 @@
VisualElement {
}
#group-box {
margin-top: 4px;
margin-bottom: 4px;
padding: 4px;
border-width: 3px;
border-left-color: rgba(0, 1, 0, 0.2);
border-top-color: rgba(0, 1, 0, 0.2);
border-right-color: rgba(0, 1, 0, 0.2);
border-bottom-color: rgba(0, 1, 0, 0.2);
border-radius: 4px;
/* background-color: rgba(0, 0, 0, 0.1); */
}
#ListViewContainer {
margin-top: 4px;
}
#group-box > Label {
-unity-font-style: bold;
}
.horizontal {
flex-direction: row;
}
.horizontal #f-object {
flex-grow: 1;
}
#f-material-index-int {
display: none;
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: fce9f3fe74434b718abac5ea66775acb
timeCreated: 1723334567

View File

@ -0,0 +1,111 @@
#region
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.UIElements;
#endregion
namespace nadena.dev.modular_avatar.core.editor.ShapeChanger
{
[CustomPropertyDrawer(typeof(MaterialSwitchObject))]
public class MaterialSwitchObjectEditor : PropertyDrawer
{
private const string Root = "Packages/nadena.dev.modular-avatar/Editor/Inspector/MaterialSetter/";
private const string UxmlPath = Root + "MaterialSwitchObjectEditor.uxml";
private const string UssPath = Root + "MaterialSetterStyles.uss";
public override VisualElement CreatePropertyGUI(SerializedProperty property)
{
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath).CloneTree();
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
Localization.UI.Localize(uxml);
uxml.styleSheets.Add(uss);
uxml.BindProperty(property);
var f_material_index = uxml.Q<DropdownField>("f-material-index");
var f_object = uxml.Q<PropertyField>("f-object");
f_object.RegisterValueChangeCallback(evt => { UpdateMaterialDropdown(); });
UpdateMaterialDropdown();
// Link dropdown to material index field
var f_material_index_int = uxml.Q<IntegerField>("f-material-index-int");
f_material_index_int.RegisterValueChangedCallback(evt =>
{
f_material_index.SetValueWithoutNotify("" + evt.newValue);
});
f_material_index.RegisterValueChangedCallback(evt =>
{
if (evt.newValue != null && int.TryParse(evt.newValue, out var i))
{
f_material_index_int.value = i;
}
});
return uxml;
void UpdateMaterialDropdown()
{
var toggledObject = AvatarObjectReference.Get(property.FindPropertyRelative("Object"));
Material[] sharedMaterials;
try
{
sharedMaterials = toggledObject?.GetComponent<Renderer>()?.sharedMaterials;
}
catch (MissingComponentException e)
{
sharedMaterials = null;
}
if (sharedMaterials != null)
{
var matCount = sharedMaterials.Length;
f_material_index.SetEnabled(true);
f_material_index.choices.Clear();
for (int i = 0; i < matCount; i++)
{
f_material_index.choices.Add(i.ToString());
}
f_material_index.formatListItemCallback = idx_s =>
{
if (string.IsNullOrWhiteSpace(idx_s)) return "";
var idx = int.Parse(idx_s);
if (idx < 0 || idx >= sharedMaterials.Length)
{
return idx + ": <???>";
}
else if (sharedMaterials[idx] == null)
{
return idx + ": <none>";
}
else
{
return idx + ": " + sharedMaterials[idx].name;
}
};
f_material_index.formatSelectedValueCallback = f_material_index.formatListItemCallback;
}
else
{
f_material_index.SetEnabled(false);
if (f_material_index.choices.Count == 0)
{
f_material_index.choices.Add("0");
}
f_material_index.formatListItemCallback = _ => "<Missing Renderer>";
f_material_index.formatSelectedValueCallback = f_material_index.formatListItemCallback;
}
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 6361a17f884644988ef3ece7fbe73ab7
timeCreated: 1723334567

View File

@ -0,0 +1,10 @@
<UXML xmlns:ui="UnityEngine.UIElements" xmlns:ed="UnityEditor.UIElements">
<ui:VisualElement class="toggled-object-editor">
<ui:VisualElement class="horizontal">
<ed:PropertyField binding-path="Object" label="" name="f-object" class="f-object"/>
<ui:DropdownField name="f-material-index" binding-path="MaterialIndex"/>
<ed:IntegerField binding-path="MaterialIndex" name="f-material-index-int"/>
</ui:VisualElement>
<ed:PropertyField binding-path="Material" label="" name="f-material"/>
</ui:VisualElement>
</UXML>

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 55b5e53f6c364089a1871b68e0de17c6
timeCreated: 1723334567

View File

@ -1,4 +1,4 @@
<UXML xmlns:ui="UnityEngine.UIElements"
<UXML xmlns:ui="UnityEngine.UIElements" xmlns:ed="UnityEditor.UIElements"
xmlns:ma="nadena.dev.modular_avatar.core.editor">
<ui:VisualElement name="root-box">
<ui:VisualElement name="group-box">
@ -17,6 +17,8 @@
</ui:VisualElement>
</ui:VisualElement>
<ed:PropertyField binding-path="m_inverted"/>
<ma:LanguageSwitcherElement/>
</ui:VisualElement>
</UXML>

View File

@ -3,6 +3,7 @@
<ui:VisualElement name="root-box">
<ui:VisualElement name="group-box">
<ed:PropertyField binding-path="m_targetRenderer"/>
<ed:PropertyField binding-path="m_inverted"/>
<ui:VisualElement name="ListViewContainer">
<ui:ListView virtualization-method="DynamicHeight"
@ -19,6 +20,8 @@
</ui:VisualElement>
</ui:VisualElement>
<ed:PropertyField binding-path="m_inverted"/>
<ma:LanguageSwitcherElement/>
</ui:VisualElement>
</UXML>

View File

@ -48,7 +48,7 @@ namespace nadena.dev.modular_avatar.core.editor.plugin
seq.Run(MeshSettingsPluginPass.Instance);
seq.Run(ScaleAdjusterPass.Instance).PreviewingWith(new ScaleAdjusterPreview());
#if MA_VRCSDK3_AVATARS
seq.Run(PropertyOverlayPrePass.Instance);
seq.Run(ReactiveObjectPrepass.Instance);
seq.Run(RenameParametersPluginPass.Instance);
seq.Run(ParameterAssignerPass.Instance);
seq.Run(MergeBlendTreePass.Instance);
@ -57,8 +57,8 @@ namespace nadena.dev.modular_avatar.core.editor.plugin
#endif
seq.WithRequiredExtension(typeof(AnimationServicesContext), _s2 =>
{
seq.Run("Shape Changer", ctx => new PropertyOverlayPass(ctx).Execute())
.PreviewingWith(new ShapeChangerPreview(), new ObjectSwitcherPreview());
seq.Run("Shape Changer", ctx => new ReactiveObjectPass(ctx).Execute())
.PreviewingWith(new ShapeChangerPreview(), new ObjectSwitcherPreview(), new MaterialSetterPreview());
seq.Run(MergeArmaturePluginPass.Instance);
seq.Run(BoneProxyPluginPass.Instance);
seq.Run(VisibleHeadAccessoryPluginPass.Instance);

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 4e0a91a8758c4946899e34d25372bd3e
timeCreated: 1723325795

View File

@ -0,0 +1,29 @@
using System.Collections.Generic;
using UnityEngine;
namespace nadena.dev.modular_avatar.core.editor
{
internal class AnimatedProperty
{
public TargetProp TargetProp { get; }
public string ControlParam { get; set; }
public bool alwaysDeleted;
public object currentState;
// Objects which trigger deletion of this shape key.
public List<ReactionRule> actionGroups = new List<ReactionRule>();
public AnimatedProperty(TargetProp key, float currentState)
{
TargetProp = key;
this.currentState = currentState;
}
public AnimatedProperty(TargetProp key, Object currentState)
{
TargetProp = key;
this.currentState = currentState;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c64796ed187a4de9bca2bcb5d0c6b029
timeCreated: 1723325881

View File

@ -4,7 +4,10 @@ namespace nadena.dev.modular_avatar.core.editor
{
internal class ControlCondition
{
public string Parameter, DebugName;
public string Parameter;
public UnityEngine.Object ControllingObject;
public string DebugName;
public bool IsConstant;
public float ParameterValueLo, ParameterValueHi, InitialValue;
public bool InitiallyActive => InitialValue > ParameterValueLo && InitialValue < ParameterValueHi;

View File

@ -0,0 +1,96 @@
using System.Collections.Generic;
using System.Linq;
using nadena.dev.modular_avatar.animation;
using UnityEngine;
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(ndmf.BuildContext context, TargetProp key, GameObject controllingObject, UnityEngine.Object value)
: this(context, key, controllingObject, (object)value) { }
private ReactionRule(ndmf.BuildContext context, TargetProp key, GameObject controllingObject, 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;
Value = value;
}
public TargetProp TargetProp;
public object Value;
public readonly List<ControlCondition> ControllingConditions;
public bool InitiallyActive =>
((ControllingConditions.Count == 0) || ControllingConditions.All(c => c.InitiallyActive)) ^ Inverted;
public bool IsDelete;
public bool Inverted;
public bool IsConstant => ControllingConditions.Count == 0 || ControllingConditions.All(c => c.IsConstant);
public bool IsConstantOn => IsConstant && InitiallyActive;
public override string ToString()
{
return $"AGK: {TargetProp}={Value}";
}
public bool TryMerge(ReactionRule other)
{
if (!TargetProp.Equals(other.TargetProp)) return false;
// Value checks
if (Value == other.Value) { /* objects match */ }
else if (Value is float a && other.Value is float b)
{
if (Mathf.Abs(a - b) > 0.001f) return false;
}
else return false;
if (!ControllingConditions.SequenceEqual(other.ControllingConditions)) return false;
if (IsDelete || other.IsDelete) return false;
return true;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3142032fc2624e528b917973d9bc79f6
timeCreated: 1723325905

View File

@ -0,0 +1,158 @@
using System.Collections.Generic;
using nadena.dev.modular_avatar.animation;
using UnityEngine;
namespace nadena.dev.modular_avatar.core.editor
{
partial class ReactiveObjectAnalyzer
{
private Dictionary<TargetProp, AnimatedProperty> FindShapes(GameObject root)
{
var changers = root.GetComponentsInChildren<ModularAvatarShapeChanger>(true);
Dictionary<TargetProp, AnimatedProperty> shapeKeys = new();
foreach (var changer in changers)
{
var renderer = changer.targetRenderer.Get(changer)?.GetComponent<SkinnedMeshRenderer>();
if (renderer == null) continue;
var mesh = renderer.sharedMesh;
if (mesh == null) continue;
foreach (var shape in changer.Shapes)
{
var shapeId = mesh.GetBlendShapeIndex(shape.ShapeName);
if (shapeId < 0) continue;
var key = new TargetProp
{
TargetObject = renderer,
PropertyName = "blendShape." + shape.ShapeName,
};
var value = shape.ChangeType == ShapeChangeType.Delete ? 100 : shape.Value;
if (!shapeKeys.TryGetValue(key, out var info))
{
info = new AnimatedProperty(key, renderer.GetBlendShapeWeight(shapeId));
shapeKeys[key] = info;
// Add initial state
var agk = new ReactionRule(context, key, null, value);
agk.Value = renderer.GetBlendShapeWeight(shapeId);
info.actionGroups.Add(agk);
}
var action = new ReactionRule(context, key, changer.gameObject, value);
action.Inverted = changer.Inverted;
var isCurrentlyActive = changer.gameObject.activeInHierarchy;
if (shape.ChangeType == ShapeChangeType.Delete)
{
action.IsDelete = true;
if (isCurrentlyActive) info.currentState = 100;
info.actionGroups.Add(action); // Never merge
continue;
}
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]);
}
}
}
return shapeKeys;
}
private void FindMaterialSetters(Dictionary<TargetProp, AnimatedProperty> objectGroups, GameObject root)
{
var materialSetters = root.GetComponentsInChildren<ModularAvatarMaterialSetter>(true);
foreach (var setter in materialSetters)
{
if (setter.Objects == null) continue;
foreach (var obj in setter.Objects)
{
var target = obj.Object.Get(setter);
if (target == null) continue;
var renderer = target.GetComponent<Renderer>();
if (renderer == null || renderer.sharedMaterials.Length < obj.MaterialIndex) continue;
var key = new TargetProp
{
TargetObject = renderer,
PropertyName = "m_Materials.Array.data[" + obj.MaterialIndex + "]",
};
if (!objectGroups.TryGetValue(key, out var group))
{
group = new AnimatedProperty(key, renderer.sharedMaterials[obj.MaterialIndex]);
objectGroups[key] = group;
}
var action = new ReactionRule(context, key, setter.gameObject, obj.Material);
action.Inverted = setter.Inverted;
if (group.actionGroups.Count == 0)
group.actionGroups.Add(action);
else if (!group.actionGroups[^1].TryMerge(action)) group.actionGroups.Add(action);
}
}
}
private void FindObjectToggles(Dictionary<TargetProp, AnimatedProperty> objectGroups, GameObject root)
{
var toggles = root.GetComponentsInChildren<ModularAvatarObjectToggle>(true);
foreach (var toggle in toggles)
{
if (toggle.Objects == null) continue;
foreach (var obj in toggle.Objects)
{
var target = obj.Object.Get(toggle);
if (target == null) continue;
var key = new TargetProp
{
TargetObject = target,
PropertyName = "m_IsActive"
};
if (!objectGroups.TryGetValue(key, out var group))
{
group = new AnimatedProperty(key, target.activeSelf ? 1 : 0);
objectGroups[key] = group;
}
var value = obj.Active ? 1 : 0;
var action = new ReactionRule(context, key, toggle.gameObject, value);
action.Inverted = toggle.Inverted;
if (group.actionGroups.Count == 0)
group.actionGroups.Add(action);
else if (!group.actionGroups[^1].TryMerge(action)) group.actionGroups.Add(action);
}
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 1b8b0b40f3fe4334a9a458d23baca09c
timeCreated: 1723325966

View File

@ -0,0 +1,222 @@
using System.Collections.Generic;
using System.Linq;
using nadena.dev.modular_avatar.animation;
using UnityEngine;
namespace nadena.dev.modular_avatar.core.editor
{
/// <summary>
/// Performs analysis of reactive object rules prior to animation generation. This is used for debug
/// displays/introspection as well.
/// </summary>
internal partial class ReactiveObjectAnalyzer
{
private readonly ndmf.BuildContext context;
public ReactiveObjectAnalyzer(ndmf.BuildContext context)
{
this.context = context;
}
public ReactiveObjectAnalyzer()
{
context = null;
}
/// <summary>
/// Find all reactive object rules
/// </summary>
/// <param name="root">The avatar root</param>
/// <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
)
{
Dictionary<TargetProp, AnimatedProperty> shapes = FindShapes(root);
FindObjectToggles(shapes, root);
FindMaterialSetters(shapes, root);
AnalyzeConstants(shapes);
ResolveToggleInitialStates(shapes);
PreprocessShapes(shapes, out initialStates, out deletedShapes);
return shapes;
}
/// <summary>
/// Determines which animated properties have a constant state, and prunes the set of rules appropriately.
/// No-op if there is not build context (as animations cannot be determined)
/// </summary>
/// <param name="shapes"></param>
private void AnalyzeConstants(Dictionary<TargetProp, AnimatedProperty> shapes)
{
var asc = context?.Extension<AnimationServicesContext>();
HashSet<GameObject> toggledObjects = new();
if (asc == null) return;
foreach (var targetProp in shapes.Keys)
if (targetProp is { TargetObject: GameObject go, PropertyName: "m_IsActive" })
toggledObjects.Add(go);
foreach (var group in shapes.Values)
{
foreach (var actionGroup in group.actionGroups)
{
foreach (var condition in actionGroup.ControllingConditions)
if (condition.ReferenceObject != null && !toggledObjects.Contains(condition.ReferenceObject))
condition.IsConstant = asc.AnimationDatabase.ClipsForPath(asc.PathMappings.GetObjectIdentifier(condition.ReferenceObject)).IsEmpty;
var i = 0;
// Remove redundant active conditions.
int retain = 0;
actionGroup.ControllingConditions.RemoveAll(c => c.IsConstant && c.InitiallyActive);
}
// Remove any action groups with always-unsatisfied conditions
group.actionGroups.RemoveAll(agk => agk.IsConstant && !agk.InitiallyActive);
// Remove all action groups up until the last one where we're always on
var lastAlwaysOnGroup = group.actionGroups.FindLastIndex(ag => ag.IsConstantOn);
if (lastAlwaysOnGroup > 0)
group.actionGroups.RemoveRange(0, lastAlwaysOnGroup - 1);
}
// Remove shapes with no action groups
foreach (var kvp in shapes.ToList())
if (kvp.Value.actionGroups.Count == 0)
shapes.Remove(kvp.Key);
}
/// <summary>
/// Resolves the initial active state of all GameObjects
/// </summary>
/// <param name="groups"></param>
private void ResolveToggleInitialStates(Dictionary<TargetProp, AnimatedProperty> groups)
{
var asc = context?.Extension<AnimationServicesContext>();
Dictionary<string, bool> propStates = new Dictionary<string, bool>();
Dictionary<string, bool> nextPropStates = new Dictionary<string, bool>();
int loopLimit = 5;
bool unsettled = true;
while (unsettled && loopLimit-- > 0)
{
unsettled = false;
foreach (var group in groups.Values)
{
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;
foreach (var actionGroup in group.actionGroups)
{
bool evaluated = true;
foreach (var condition in actionGroup.ControllingConditions)
{
if (!propStates.TryGetValue(condition.Parameter, out var propCondition))
{
propCondition = condition.InitiallyActive;
}
if (!propCondition)
{
evaluated = false;
break;
}
}
if (actionGroup.Inverted) evaluated = !evaluated;
if (evaluated)
{
state = (float) actionGroup.Value > 0.5f;
}
}
nextPropStates[pathKey] = state;
if (!propStates.TryGetValue(pathKey, out var oldState) || oldState != state)
{
unsettled = true;
}
}
propStates = nextPropStates;
nextPropStates = new();
}
foreach (var group in groups.Values)
{
foreach (var action in group.actionGroups)
{
foreach (var condition in action.ControllingConditions)
{
if (propStates.TryGetValue(condition.Parameter, out var state))
condition.InitialValue = state ? 1.0f : 0.0f;
}
}
}
}
/// <summary>
/// Determine initial state and deleted shapes for all properties
/// </summary>
/// <param name="shapes"></param>
/// <param name="initialStates"></param>
/// <param name="deletedShapes"></param>
private void PreprocessShapes(Dictionary<TargetProp, AnimatedProperty> shapes, out Dictionary<TargetProp, object> initialStates, out HashSet<TargetProp> deletedShapes)
{
// For each shapekey, determine 1) if we can just set an initial state and skip and 2) if we can delete the
// corresponding mesh. If we can't, delete ops are merged into the main list of operations.
initialStates = new Dictionary<TargetProp, object>();
deletedShapes = new HashSet<TargetProp>();
foreach (var (key, info) in shapes.ToList())
{
if (info.actionGroups.Count == 0)
{
// never active control; ignore it entirely
shapes.Remove(key);
continue;
}
var deletions = info.actionGroups.Where(agk => agk.IsDelete).ToList();
if (deletions.Any(d => d.ControllingConditions.All(c => c.IsConstantActive)))
{
// always deleted
shapes.Remove(key);
deletedShapes.Add(key);
continue;
}
// Move deleted shapes to the end of the list, so they override all Set actions
info.actionGroups = info.actionGroups.Where(agk => !agk.IsDelete).Concat(deletions).ToList();
var initialState = info.actionGroups.Where(agk => agk.InitiallyActive)
.Select(agk => agk.Value)
.Prepend(info.currentState) // use scene state if everything is disabled
.Last();
initialStates[key] = initialState;
// If we're now constant-on, we can skip animation generation
if (info.actionGroups[^1].IsConstant)
{
shapes.Remove(key);
}
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d0693ea763ed432d8f825999a037fae9
timeCreated: 1723327408

View File

@ -0,0 +1,562 @@
#region
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using nadena.dev.modular_avatar.animation;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
using VRC.SDK3.Avatars.Components;
using EditorCurveBinding = UnityEditor.EditorCurveBinding;
using Object = UnityEngine.Object;
#endregion
namespace nadena.dev.modular_avatar.core.editor
{
internal partial class ReactiveObjectPass
{
private readonly ndmf.BuildContext context;
private Dictionary<string, float> initialValues = new();
// Properties that are being driven, either by foreign animations or Object Toggles
private HashSet<string> activeProps = new();
private AnimationClip _initialStateClip;
public ReactiveObjectPass(ndmf.BuildContext context)
{
this.context = context;
}
internal void Execute()
{
Dictionary<TargetProp, AnimatedProperty> shapes =
new ReactiveObjectAnalyzer(context).Analyze(
context.AvatarRootObject,
out var initialStates,
out var deletedShapes
);
GenerateActiveSelfProxies(shapes);
ProcessInitialStates(initialStates);
ProcessInitialAnimatorVariables(shapes);
foreach (var groups in shapes.Values)
{
ProcessShapeKey(groups);
}
ProcessMeshDeletion(deletedShapes);
}
private void GenerateActiveSelfProxies(Dictionary<TargetProp, AnimatedProperty> shapes)
{
var asc = context.Extension<AnimationServicesContext>();
foreach (var prop in shapes.Keys)
{
if (prop.TargetObject is GameObject go && prop.PropertyName == "m_IsActive")
{
// Ensure a proxy exists for each object we're going to be toggling.
// TODO: is this still needed?
asc.GetActiveSelfProxy(go);
}
}
}
private void ProcessInitialAnimatorVariables(Dictionary<TargetProp, AnimatedProperty> shapes)
{
foreach (var group in shapes.Values)
foreach (var agk in group.actionGroups)
foreach (var condition in agk.ControllingConditions)
{
if (condition.IsConstant) continue;
if (!initialValues.ContainsKey(condition.Parameter))
initialValues[condition.Parameter] = condition.InitialValue;
}
}
private void ProcessInitialStates(Dictionary<TargetProp, object> initialStates)
{
var asc = context.Extension<AnimationServicesContext>();
// We need to track _two_ initial states: the initial state we'll apply at build time (which applies
// when animations are disabled) and the animation base state. Confusingly, the animation base state
// should be the state that is currently applied to the object...
var clips = context.Extension<AnimationServicesContext>().AnimationDatabase;
var initialStateHolder = clips.ClipsForPath(ReactiveObjectPrepass.TAG_PATH).FirstOrDefault();
if (initialStateHolder == null) return;
_initialStateClip = new AnimationClip();
_initialStateClip.name = "MA Shape Changer Defaults";
initialStateHolder.CurrentClip = _initialStateClip;
foreach (var (key, initialState) in initialStates)
{
string path;
Type componentType;
var applied = false;
object animBaseState = (float) 0;
if (key.TargetObject is GameObject go)
{
path = RuntimeUtil.RelativePath(context.AvatarRootObject, go);
componentType = typeof(GameObject);
}
else if (key.TargetObject is Renderer r)
{
path = RuntimeUtil.RelativePath(context.AvatarRootObject, r.gameObject);
componentType = r.GetType();
if (r is SkinnedMeshRenderer smr && key.PropertyName.StartsWith("blendShape."))
{
var blendShape = key.PropertyName.Substring("blendShape.".Length);
var index = smr.sharedMesh?.GetBlendShapeIndex(blendShape);
if (index != null && index >= 0)
{
animBaseState = smr.GetBlendShapeWeight(index.Value);
smr.SetBlendShapeWeight(index.Value, (float) initialState);
}
applied = true;
}
}
else
{
throw new InvalidOperationException("Invalid target object: " + key.TargetObject);
}
if (!applied)
{
var serializedObject = new SerializedObject(key.TargetObject);
var prop = serializedObject.FindProperty(key.PropertyName);
if (prop != null)
{
switch (prop.propertyType)
{
case SerializedPropertyType.Boolean:
animBaseState = prop.boolValue ? 1.0f : 0.0f;
prop.boolValue = ((float)initialState) > 0.5f;
break;
case SerializedPropertyType.Float:
animBaseState = prop.floatValue;
prop.floatValue = (float) initialState;
break;
case SerializedPropertyType.ObjectReference:
animBaseState = prop.objectReferenceValue;
prop.objectReferenceValue = (Object) initialState;
break;
}
serializedObject.ApplyModifiedPropertiesWithoutUndo();
}
}
if (animBaseState is float f)
{
var binding = EditorCurveBinding.FloatCurve(
path,
componentType,
key.PropertyName
);
var curve = new AnimationCurve();
curve.AddKey(0, f);
curve.AddKey(1, f);
AnimationUtility.SetEditorCurve(_initialStateClip, binding, curve);
if (componentType == typeof(GameObject) && key.PropertyName == "m_IsActive")
{
binding = EditorCurveBinding.FloatCurve(
"",
typeof(Animator),
asc.GetActiveSelfProxy((GameObject)key.TargetObject)
);
AnimationUtility.SetEditorCurve(_initialStateClip, binding, curve);
}
}
else if (animBaseState is Object obj)
{
var binding = EditorCurveBinding.PPtrCurve(
path,
componentType,
key.PropertyName
);
AnimationUtility.SetObjectReferenceCurve(_initialStateClip, binding, new []
{
new ObjectReferenceKeyframe()
{
value = obj,
time = 0
},
new ObjectReferenceKeyframe()
{
value = obj,
time = 1
}
});
}
}
}
#region Mesh processing
private void ProcessMeshDeletion(HashSet<TargetProp> deletedKeys)
{
ImmutableDictionary<SkinnedMeshRenderer, List<TargetProp>> renderers = deletedKeys
.GroupBy(
v => (SkinnedMeshRenderer) v.TargetObject
).ToImmutableDictionary(
g => (SkinnedMeshRenderer) g.Key,
g => g.ToList()
);
foreach (var (renderer, infos) in renderers)
{
if (renderer == null) continue;
var mesh = renderer.sharedMesh;
if (mesh == null) continue;
renderer.sharedMesh = RemoveBlendShapeFromMesh.RemoveBlendshapes(
mesh,
infos
.Select(i => mesh.GetBlendShapeIndex(i.PropertyName.Substring("blendShape.".Length)))
.Where(k => k >= 0)
.ToList()
);
}
}
#endregion
private void ProcessShapeKey(AnimatedProperty info)
{
// TODO: prune non-animated keys
// Check if this is non-animated and skip most processing if so
if (info.alwaysDeleted || info.actionGroups[^1].IsConstant) return;
var asm = GenerateStateMachine(info);
ApplyController(asm, "MA Responsive: " + info.TargetProp.TargetObject.name);
}
private AnimatorStateMachine GenerateStateMachine(AnimatedProperty info)
{
var asc = context.Extension<AnimationServicesContext>();
var asm = new AnimatorStateMachine();
asm.name = "MA Shape Changer " + info.TargetProp.TargetObject.name;
var x = 200;
var y = 0;
var yInc = 60;
asm.anyStatePosition = new Vector3(-200, 0);
var initial = new AnimationClip();
var initialState = new AnimatorState();
initialState.motion = initial;
initialState.writeDefaultValues = false;
initialState.name = "<default>";
asm.defaultState = initialState;
asm.entryPosition = new Vector3(0, 0);
var states = new List<ChildAnimatorState>();
states.Add(new ChildAnimatorState
{
position = new Vector3(x, y),
state = initialState
});
asc.AnimationDatabase.RegisterState(states[^1].state);
var lastConstant = info.actionGroups.FindLastIndex(agk => agk.IsConstant);
var transitionBuffer = new List<(AnimatorState, List<AnimatorStateTransition>)>();
var entryTransitions = new List<AnimatorTransition>();
transitionBuffer.Add((initialState, new List<AnimatorStateTransition>()));
foreach (var group in info.actionGroups.Skip(lastConstant))
{
y += yInc;
var clip = AnimResult(group.TargetProp, group.Value);
if (group.IsConstant)
{
clip.name = "Property Overlay constant " + group.Value;
initialState.motion = clip;
}
else
{
clip.name = "Property Overlay controlled by " + group.ControllingConditions[0].DebugName + " " +
group.Value;
var conditions = GetTransitionConditions(asc, group);
foreach (var (st, transitions) in transitionBuffer)
{
if (!group.Inverted)
{
var transition = new AnimatorStateTransition
{
isExit = true,
hasExitTime = false,
duration = 0,
hasFixedDuration = true,
conditions = (AnimatorCondition[])conditions.Clone()
};
transitions.Add(transition);
}
else
{
foreach (var cond in conditions)
{
transitions.Add(new AnimatorStateTransition
{
isExit = true,
hasExitTime = false,
duration = 0,
hasFixedDuration = true,
conditions = new[] { InvertCondition(cond) }
});
}
}
}
var state = new AnimatorState();
state.name = group.ControllingConditions[0].DebugName;
state.motion = clip;
state.writeDefaultValues = false;
states.Add(new ChildAnimatorState
{
position = new Vector3(x, y),
state = state
});
asc.AnimationDatabase.RegisterState(states[^1].state);
var transitionList = new List<AnimatorStateTransition>();
transitionBuffer.Add((state, transitionList));
if (!group.Inverted)
{
entryTransitions.Add(new AnimatorTransition
{
destinationState = state,
conditions = conditions
});
foreach (var cond in conditions)
{
var inverted = InvertCondition(cond);
transitionList.Add(new AnimatorStateTransition
{
isExit = true,
hasExitTime = false,
duration = 0,
hasFixedDuration = true,
conditions = new[] { inverted }
});
}
}
else
{
// inverted condition
foreach (var cond in conditions)
{
entryTransitions.Add(new AnimatorTransition()
{
destinationState = state,
conditions = new[] { InvertCondition(cond) }
});
}
transitionList.Add(new AnimatorStateTransition
{
isExit = true,
hasExitTime = false,
duration = 0,
hasFixedDuration = true,
conditions = conditions
});
}
}
}
foreach (var (st, transitions) in transitionBuffer) st.transitions = transitions.ToArray();
asm.states = states.ToArray();
entryTransitions.Reverse();
asm.entryTransitions = entryTransitions.ToArray();
asm.exitPosition = new Vector3(500, 0);
return asm;
}
private static AnimatorCondition InvertCondition(AnimatorCondition cond)
{
return new AnimatorCondition
{
parameter = cond.parameter,
mode = cond.mode == AnimatorConditionMode.Greater
? AnimatorConditionMode.Less
: AnimatorConditionMode.Greater,
threshold = cond.threshold
};
}
private AnimatorCondition[] GetTransitionConditions(AnimationServicesContext asc, ReactionRule group)
{
var conditions = new List<AnimatorCondition>();
foreach (var condition in group.ControllingConditions)
{
if (condition.IsConstant) continue;
if (float.IsFinite(condition.ParameterValueLo))
{
conditions.Add(new AnimatorCondition
{
parameter = condition.Parameter,
mode = AnimatorConditionMode.Greater,
threshold = condition.ParameterValueLo
});
}
if (float.IsFinite(condition.ParameterValueHi))
{
conditions.Add(new AnimatorCondition
{
parameter = condition.Parameter,
mode = AnimatorConditionMode.Less,
threshold = condition.ParameterValueHi
});
}
}
if (conditions.Count == 0)
throw new InvalidOperationException("No controlling parameters found for " + group);
return conditions.ToArray();
}
private Motion AnimResult(TargetProp key, object value)
{
string path;
Type componentType;
if (key.TargetObject is GameObject go)
{
path = RuntimeUtil.RelativePath(context.AvatarRootObject, go);
componentType = typeof(GameObject);
}
else if (key.TargetObject is SkinnedMeshRenderer smr)
{
path = RuntimeUtil.RelativePath(context.AvatarRootObject, smr.gameObject);
componentType = typeof(SkinnedMeshRenderer);
}
else
{
throw new InvalidOperationException("Invalid target object: " + key.TargetObject);
}
var clip = new AnimationClip();
clip.name = $"Set {path}:{key.PropertyName}={value}";
if (value is UnityEngine.Object obj)
{
var binding = EditorCurveBinding.PPtrCurve(path, componentType, key.PropertyName);
AnimationUtility.SetObjectReferenceCurve(clip, binding, new []
{
new ObjectReferenceKeyframe()
{
value = obj,
time = 0
},
new ObjectReferenceKeyframe()
{
value = obj,
time = 1
}
});
}
else
{
var curve = new AnimationCurve();
curve.AddKey(0, (float) value);
curve.AddKey(1, (float) value);
var binding = EditorCurveBinding.FloatCurve(path, componentType, key.PropertyName);
AnimationUtility.SetEditorCurve(clip, binding, curve);
if (key.TargetObject is GameObject targetObject && key.PropertyName == "m_IsActive")
{
var asc = context.Extension<AnimationServicesContext>();
var propName = asc.GetActiveSelfProxy(targetObject);
binding = EditorCurveBinding.FloatCurve("", typeof(Animator), propName);
AnimationUtility.SetEditorCurve(clip, binding, curve);
}
}
return clip;
}
private void ApplyController(AnimatorStateMachine asm, string layerName)
{
var fx = context.AvatarDescriptor.baseAnimationLayers
.FirstOrDefault(l => l.type == VRCAvatarDescriptor.AnimLayerType.FX);
if (fx.animatorController == null)
{
throw new InvalidOperationException("No FX layer found");
}
if (!context.IsTemporaryAsset(fx.animatorController))
{
throw new InvalidOperationException("FX layer is not a temporary asset");
}
if (!(fx.animatorController is AnimatorController animController))
{
throw new InvalidOperationException("FX layer is not an animator controller");
}
var paramList = animController.parameters.ToList();
var paramSet = paramList.Select(p => p.name).ToHashSet();
foreach (var paramName in initialValues.Keys.Except(paramSet))
{
paramList.Add(new AnimatorControllerParameter()
{
name = paramName,
type = AnimatorControllerParameterType.Float,
defaultFloat = initialValues[paramName], // TODO
});
paramSet.Add(paramName);
}
animController.parameters = paramList.ToArray();
animController.layers = animController.layers.Append(
new AnimatorControllerLayer
{
stateMachine = asm,
name = "MA Shape Changer " + layerName,
defaultWeight = 1
}
).ToArray();
}
}
}

View File

@ -0,0 +1,54 @@
using nadena.dev.ndmf;
using UnityEditor.Animations;
using UnityEngine;
namespace nadena.dev.modular_avatar.core.editor
{
/// <summary>
/// Reserve an animator layer for reactive object use. We do this here so that we can take advantage of MergeAnimator's
/// layer reference correction logic; this can go away once we have a more unified animation services API.
/// </summary>
internal class ReactiveObjectPrepass : Pass<ReactiveObjectPrepass>
{
internal const string TAG_PATH = "__MA/ShapeChanger/PrepassPlaceholder";
protected override void Execute(ndmf.BuildContext context)
{
var hasShapeChanger = context.AvatarRootObject.GetComponentInChildren<ModularAvatarShapeChanger>() != null;
var hasObjectSwitcher =
context.AvatarRootObject.GetComponentInChildren<ModularAvatarObjectToggle>() != null;
if (hasShapeChanger || hasObjectSwitcher)
{
var clip = new AnimationClip();
clip.name = "MA Shape Changer Defaults";
var curve = new AnimationCurve();
curve.AddKey(0, 0);
clip.SetCurve(TAG_PATH, typeof(Transform), "localPosition.x", curve);
// Merge using a null blend tree. This also ensures that we initialize the Merge Blend Tree system.
var bt = new BlendTree();
bt.name = "MA Shape Changer Defaults";
bt.blendType = BlendTreeType.Direct;
bt.children = new[]
{
new ChildMotion
{
motion = clip,
timeScale = 1,
cycleOffset = 0,
directBlendParameter = MergeBlendTreePass.ALWAYS_ONE
}
};
bt.useAutomaticThresholds = false;
// This is a hack and a half - put in a dummy path so we can find the cloned clip later on...
var obj = new GameObject("MA SC Defaults");
obj.transform.SetParent(context.AvatarRootTransform);
var mambt = obj.AddComponent<ModularAvatarMergeBlendTree>();
mambt.BlendTree = bt;
mambt.PathMode = MergeAnimatorPathMode.Absolute;
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 4b9815a0578e46d7985971eaf9a9bcb6
timeCreated: 1723325828

View File

@ -0,0 +1,35 @@
using UnityEngine;
namespace nadena.dev.modular_avatar.core.editor
{
struct TargetProp
{
public Object TargetObject;
public string PropertyName;
public bool Equals(TargetProp other)
{
return Equals(TargetObject, other.TargetObject) && PropertyName == other.PropertyName;
}
public override bool Equals(object obj)
{
return obj is TargetProp other && Equals(other);
}
public override int GetHashCode()
{
unchecked
{
var hashCode = (TargetObject != null ? TargetObject.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ (PropertyName != null ? PropertyName.GetHashCode() : 0);
return hashCode;
}
}
public override string ToString()
{
return $"{TargetObject}.{PropertyName}";
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 61908419c3e34e88838beb3f3d2fc706
timeCreated: 1723325848

View File

@ -0,0 +1,145 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using nadena.dev.ndmf.preview;
using UnityEngine;
namespace nadena.dev.modular_avatar.core.editor
{
public class MaterialSetterPreview : IRenderFilter
{
static TogglablePreviewNode EnableNode = TogglablePreviewNode.Create(
() => "Material Setter",
qualifiedName: "nadena.dev.modular-avatar/MaterialSetterPreview",
true
);
public IEnumerable<TogglablePreviewNode> GetPreviewControlNodes()
{
yield return EnableNode;
}
public bool IsEnabled(ComputeContext context)
{
return context.Observe(EnableNode.IsEnabled);
}
public ImmutableList<RenderGroup> GetTargetGroups(ComputeContext context)
{
var menuItemPreview = new MenuItemPreviewCondition(context);
var setters = context.GetComponentsByType<ModularAvatarMaterialSetter>();
var groups = new Dictionary<Renderer, ImmutableList<ModularAvatarMaterialSetter>>();
foreach (var setter in setters)
{
var mami = context.GetComponent<ModularAvatarMenuItem>(setter.gameObject);
bool active = context.ActiveAndEnabled(setter) && (mami == null || menuItemPreview.IsEnabledForPreview(mami));
if (active == context.Observe(setter, t => t.Inverted)) continue;
var objs = context.Observe(setter, s => s.Objects.Select(o => (o.Object.Get(s), o.Material, o.MaterialIndex)).ToList(), (x, y) => x.SequenceEqual(y));
if (setter.Objects == null) continue;
foreach (var (obj, mat, index) in objs)
{
if (obj == null) continue;
var renderer = context.GetComponent<Renderer>(obj);
if (renderer == null) continue;
var matCount = context.Observe(renderer, r => r.sharedMaterials.Length);
if (matCount <= index) continue;
if (!groups.TryGetValue(renderer, out var list))
{
list = ImmutableList.Create<ModularAvatarMaterialSetter>();
groups.Add(renderer, list);
}
groups[renderer] = list.Add(setter);
}
}
var finalGroups = groups.Select(g => RenderGroup.For(g.Key).WithData(g.Value)).ToImmutableList();
return finalGroups;
}
public Task<IRenderFilterNode> Instantiate(RenderGroup group, IEnumerable<(Renderer, Renderer)> proxyPairs, ComputeContext context)
{
var setters = group.GetData<ImmutableList<ModularAvatarMaterialSetter>>();
var node = new Node(setters);
return node.Refresh(proxyPairs, context, 0);
}
private class Node : IRenderFilterNode
{
private readonly ImmutableList<ModularAvatarMaterialSetter> _setters;
private ImmutableList<(int, Material)> _materials;
public RenderAspects WhatChanged {get; private set; }
public void OnFrame(Renderer original, Renderer proxy)
{
var mats = proxy.sharedMaterials;
foreach (var mat in _materials)
{
if (mat.Item1 <= mats.Length)
{
mats[mat.Item1] = mat.Item2;
}
}
proxy.sharedMaterials = mats;
}
public Node(ImmutableList<ModularAvatarMaterialSetter> setters)
{
_setters = setters;
_materials = ImmutableList<(int, Material)>.Empty;
WhatChanged = RenderAspects.Material;
}
public Task<IRenderFilterNode> Refresh(IEnumerable<(Renderer, Renderer)> proxyPairs, ComputeContext context, RenderAspects updatedAspects)
{
var proxyPair = proxyPairs.First();
var original = proxyPair.Item1;
var proxy = proxyPair.Item2;
var mats = new Material[proxy.sharedMaterials.Length];
foreach (var setter in _setters)
{
var objects = context.Observe(setter, s => s.Objects
.Where(obj => obj.Object.Get(s) == original.gameObject)
.Select(obj => (obj.Material, obj.MaterialIndex)),
(x, y) => x.SequenceEqual(y)
);
foreach (var (mat, index) in objects)
{
if (index <= mats.Length)
{
mats[index] = mat;
}
}
}
var materials = mats.Select((m, i) => (i, m)).Where(kvp => kvp.m != null).ToImmutableList();
if (materials.SequenceEqual(_materials))
{
return Task.FromResult<IRenderFilterNode>(this);
}
else
{
return Task.FromResult<IRenderFilterNode>(new Node(_setters) { _materials = materials });
}
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9b1909b15d714ed59e4bd64bc2f13b1a
timeCreated: 1723255528

View File

@ -36,12 +36,10 @@ namespace nadena.dev.modular_avatar.core.editor
foreach (var toggle in allToggles)
{
if (!context.ActiveAndEnabled(toggle)) continue;
var mami = context.GetComponent<ModularAvatarMenuItem>(toggle.gameObject);
if (mami != null)
if (!menuItemPreview.IsEnabledForPreview(mami))
continue;
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(),
@ -103,7 +101,7 @@ namespace nadena.dev.modular_avatar.core.editor
if (group != null)
{
var (toggle, index) = group[^1];
enableAtNode = context.Observe(toggle, t => t.Objects[index].Active);
enableAtNode = context.Observe(toggle, t => t.Objects.Count > index && t.Objects[index].Active);
}
if (!enableAtNode)

View File

@ -88,7 +88,7 @@ namespace nadena.dev.modular_avatar.core.editor
}
}
internal static ControlCondition AssignMenuItemParameter(ndmf.BuildContext context, ModularAvatarMenuItem mami)
internal static ControlCondition AssignMenuItemParameter(ModularAvatarMenuItem mami)
{
if (mami?.Control?.parameter?.name == null) return null;
@ -97,7 +97,7 @@ namespace nadena.dev.modular_avatar.core.editor
Parameter = mami.Control.parameter.name,
DebugName = mami.gameObject.name,
IsConstant = false,
InitialValue = 0, // TODO
InitialValue = mami.isDefault ? mami.Control.value : -999, // TODO
ParameterValueLo = mami.Control.value - 0.5f,
ParameterValueHi = mami.Control.value + 0.5f
};

View File

@ -1,912 +0,0 @@
#region
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using nadena.dev.modular_avatar.animation;
using nadena.dev.ndmf;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
using VRC.SDK3.Avatars.Components;
using EditorCurveBinding = UnityEditor.EditorCurveBinding;
using Object = UnityEngine.Object;
#endregion
namespace nadena.dev.modular_avatar.core.editor
{
/// <summary>
/// Reserve an animator layer for Shape Changer's use. We do this here so that we can take advantage of MergeAnimator's
/// layer reference correction logic; this can go away once we have a more unified animation services API.
/// </summary>
internal class PropertyOverlayPrePass : Pass<PropertyOverlayPrePass>
{
internal const string TAG_PATH = "__MA/ShapeChanger/PrepassPlaceholder";
protected override void Execute(ndmf.BuildContext context)
{
var hasShapeChanger = context.AvatarRootObject.GetComponentInChildren<ModularAvatarShapeChanger>() != null;
var hasObjectSwitcher =
context.AvatarRootObject.GetComponentInChildren<ModularAvatarObjectToggle>() != null;
if (hasShapeChanger || hasObjectSwitcher)
{
var clip = new AnimationClip();
clip.name = "MA Shape Changer Defaults";
var curve = new AnimationCurve();
curve.AddKey(0, 0);
clip.SetCurve(TAG_PATH, typeof(Transform), "localPosition.x", curve);
// Merge using a null blend tree. This also ensures that we initialize the Merge Blend Tree system.
var bt = new BlendTree();
bt.name = "MA Shape Changer Defaults";
bt.blendType = BlendTreeType.Direct;
bt.children = new[]
{
new ChildMotion
{
motion = clip,
timeScale = 1,
cycleOffset = 0,
directBlendParameter = MergeBlendTreePass.ALWAYS_ONE
}
};
bt.useAutomaticThresholds = false;
// This is a hack and a half - put in a dummy path so we can find the cloned clip later on...
var obj = new GameObject("MA SC Defaults");
obj.transform.SetParent(context.AvatarRootTransform);
var mambt = obj.AddComponent<ModularAvatarMergeBlendTree>();
mambt.BlendTree = bt;
mambt.PathMode = MergeAnimatorPathMode.Absolute;
}
}
}
internal class PropertyOverlayPass
{
struct TargetProp
{
public Object TargetObject;
public string PropertyName;
public bool Equals(TargetProp other)
{
return Equals(TargetObject, other.TargetObject) && PropertyName == other.PropertyName;
}
public override bool Equals(object obj)
{
return obj is TargetProp other && Equals(other);
}
public override int GetHashCode()
{
unchecked
{
var hashCode = (TargetObject != null ? TargetObject.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ (PropertyName != null ? PropertyName.GetHashCode() : 0);
return hashCode;
}
}
public void ApplyImmediate(float value)
{
var renderer = (SkinnedMeshRenderer)TargetObject;
renderer.SetBlendShapeWeight(renderer.sharedMesh.GetBlendShapeIndex(
PropertyName.Substring("blendShape.".Length)
), value);
}
}
class PropGroup
{
public TargetProp TargetProp { get; }
public string ControlParam { get; set; }
public bool alwaysDeleted;
public float currentState;
// Objects which trigger deletion of this shape key.
public List<ActionGroupKey> actionGroups = new List<ActionGroupKey>();
public PropGroup(TargetProp key, float currentState)
{
TargetProp = key;
this.currentState = currentState;
}
}
class ActionGroupKey
{
public ActionGroupKey(ndmf.BuildContext context, TargetProp key, GameObject controllingObject, float value)
{
var asc = context.Extension<AnimationServicesContext>();
TargetProp = key;
var conditions = new List<ControlCondition>();
var cursor = controllingObject?.transform;
// Only look at the menu item we're directly attached to, to avoid submenus causing issues...
var mami = cursor?.GetComponent<ModularAvatarMenuItem>();
if (mami != null)
{
var mami_condition = ParameterAssignerPass.AssignMenuItemParameter(context, mami);
if (mami_condition != null) conditions.Add(mami_condition);
}
while (cursor != null && !RuntimeUtil.IsAvatarRoot(cursor))
{
conditions.Add(new ControlCondition
{
Parameter = asc.GetActiveSelfProxy(cursor.gameObject),
DebugName = cursor.gameObject.name,
IsConstant = false,
InitialValue = cursor.gameObject.activeSelf ? 1.0f : 0.0f,
ParameterValueLo = 0.5f,
ParameterValueHi = 1.5f,
ReferenceObject = cursor.gameObject
});
cursor = cursor.parent;
}
ControllingConditions = conditions;
Value = value;
}
public TargetProp TargetProp;
public float Value;
public readonly List<ControlCondition> ControllingConditions;
public bool InitiallyActive =>
ControllingConditions.Count == 0 || ControllingConditions.All(c => c.InitiallyActive);
public bool IsDelete;
public bool IsConstant => ControllingConditions.Count == 0 || ControllingConditions.All(c => c.IsConstant);
public bool IsConstantOn => IsConstant && InitiallyActive;
public override string ToString()
{
return $"AGK: {TargetProp}={Value}";
}
public bool TryMerge(ActionGroupKey other)
{
if (!TargetProp.Equals(other.TargetProp)) return false;
if (Mathf.Abs(Value - other.Value) > 0.001f) return false;
if (!ControllingConditions.SequenceEqual(other.ControllingConditions)) return false;
if (IsDelete || other.IsDelete) return false;
return true;
}
}
private readonly ndmf.BuildContext context;
private Dictionary<string, float> initialValues = new();
// Properties that are being driven, either by foreign animations or Object Toggles
private HashSet<string> activeProps = new();
private AnimationClip _initialStateClip;
public PropertyOverlayPass(ndmf.BuildContext context)
{
this.context = context;
}
internal void Execute()
{
Dictionary<TargetProp, PropGroup> shapes = FindShapes(context);
FindObjectToggles(shapes, context);
AnalyzeConstants(shapes);
ResolveToggleInitialStates(shapes);
PreprocessShapes(shapes, out var initialStates, out var deletedShapes);
ProcessInitialStates(initialStates);
ProcessInitialAnimatorVariables(shapes);
foreach (var groups in shapes.Values)
{
ProcessShapeKey(groups);
}
ProcessMeshDeletion(deletedShapes);
}
private void AnalyzeConstants(Dictionary<TargetProp, PropGroup> shapes)
{
var asc = context.Extension<AnimationServicesContext>();
HashSet<GameObject> toggledObjects = new();
foreach (var targetProp in shapes.Keys)
if (targetProp is { TargetObject: GameObject go, PropertyName: "m_IsActive" })
toggledObjects.Add(go);
foreach (var group in shapes.Values)
{
foreach (var actionGroup in group.actionGroups)
{
foreach (var condition in actionGroup.ControllingConditions)
if (condition.ReferenceObject != null && !toggledObjects.Contains(condition.ReferenceObject))
condition.IsConstant = asc.AnimationDatabase.ClipsForPath(asc.PathMappings.GetObjectIdentifier(condition.ReferenceObject)).IsEmpty;
var i = 0;
// Remove redundant conditions
actionGroup.ControllingConditions.RemoveAll(c => c.IsConstant && c.InitiallyActive && (i++ != 0));
}
// Remove any action groups with always-off conditions
group.actionGroups.RemoveAll(agk =>
agk.ControllingConditions.Any(c => !c.InitiallyActive && c.IsConstant));
// Remove all action groups up until the last one where we're always on
var lastAlwaysOnGroup = group.actionGroups.FindLastIndex(ag => ag.IsConstantOn);
if (lastAlwaysOnGroup > 0)
group.actionGroups.RemoveRange(0, lastAlwaysOnGroup - 1);
}
// Remove shapes with no action groups
foreach (var kvp in shapes.ToList())
if (kvp.Value.actionGroups.Count == 0)
shapes.Remove(kvp.Key);
}
private void ProcessInitialAnimatorVariables(Dictionary<TargetProp, PropGroup> shapes)
{
foreach (var group in shapes.Values)
foreach (var agk in group.actionGroups)
foreach (var condition in agk.ControllingConditions)
{
if (condition.IsConstant) continue;
if (!initialValues.ContainsKey(condition.Parameter))
initialValues[condition.Parameter] = condition.InitialValue;
}
}
private void PreprocessShapes(Dictionary<TargetProp, PropGroup> shapes, out Dictionary<TargetProp, float> initialStates, out HashSet<TargetProp> deletedShapes)
{
// For each shapekey, determine 1) if we can just set an initial state and skip and 2) if we can delete the
// corresponding mesh. If we can't, delete ops are merged into the main list of operations.
initialStates = new Dictionary<TargetProp, float>();
deletedShapes = new HashSet<TargetProp>();
foreach (var (key, info) in shapes.ToList())
{
if (info.actionGroups.Count == 0)
{
// never active control; ignore it entirely
shapes.Remove(key);
continue;
}
var deletions = info.actionGroups.Where(agk => agk.IsDelete).ToList();
if (deletions.Any(d => d.ControllingConditions.All(c => c.IsConstantActive)))
{
// always deleted
shapes.Remove(key);
deletedShapes.Add(key);
continue;
}
// Move deleted shapes to the end of the list, so they override all Set actions
info.actionGroups = info.actionGroups.Where(agk => !agk.IsDelete).Concat(deletions).ToList();
var initialState = info.actionGroups.Where(agk => agk.InitiallyActive)
.Select(agk => agk.Value)
.Prepend(info.currentState) // use scene state if everything is disabled
.Last();
initialStates[key] = initialState;
// If we're now constant-on, we can skip animation generation
if (info.actionGroups[^1].IsConstant)
{
shapes.Remove(key);
}
}
}
private void ResolveToggleInitialStates(Dictionary<TargetProp, PropGroup> groups)
{
var asc = context.Extension<AnimationServicesContext>();
Dictionary<string, bool> propStates = new Dictionary<string, bool>();
Dictionary<string, bool> nextPropStates = new Dictionary<string, bool>();
int loopLimit = 5;
bool unsettled = true;
while (unsettled && loopLimit-- > 0)
{
unsettled = false;
foreach (var group in groups.Values)
{
if (group.TargetProp.PropertyName != "m_IsActive") continue;
if (!(group.TargetProp.TargetObject is GameObject targetObject)) continue;
var pathKey = asc.GetActiveSelfProxy(targetObject);
bool state;
if (!propStates.TryGetValue(pathKey, out state)) state = targetObject.activeSelf;
foreach (var actionGroup in group.actionGroups)
{
bool evaluated = true;
foreach (var condition in actionGroup.ControllingConditions)
{
if (!propStates.TryGetValue(condition.Parameter, out var propCondition))
{
propCondition = condition.InitiallyActive;
}
if (!propCondition)
{
evaluated = false;
break;
}
}
if (evaluated)
{
state = actionGroup.Value > 0.5f;
}
}
nextPropStates[pathKey] = state;
if (!propStates.TryGetValue(pathKey, out var oldState) || oldState != state)
{
unsettled = true;
}
}
propStates = nextPropStates;
nextPropStates = new();
}
foreach (var group in groups.Values)
{
foreach (var action in group.actionGroups)
{
foreach (var condition in action.ControllingConditions)
{
if (propStates.TryGetValue(condition.Parameter, out var state))
condition.InitialValue = state ? 1.0f : 0.0f;
}
}
}
}
private void ProcessInitialStates(Dictionary<TargetProp, float> initialStates)
{
var asc = context.Extension<AnimationServicesContext>();
// We need to track _two_ initial states: the initial state we'll apply at build time (which applies
// when animations are disabled) and the animation base state. Confusingly, the animation base state
// should be the state that is currently applied to the object...
var clips = context.Extension<AnimationServicesContext>().AnimationDatabase;
var initialStateHolder = clips.ClipsForPath(PropertyOverlayPrePass.TAG_PATH).FirstOrDefault();
if (initialStateHolder == null) return;
_initialStateClip = new AnimationClip();
_initialStateClip.name = "MA Shape Changer Defaults";
initialStateHolder.CurrentClip = _initialStateClip;
foreach (var (key, initialState) in initialStates)
{
string path;
Type componentType;
var applied = false;
float animBaseState = 0;
if (key.TargetObject is GameObject go)
{
path = RuntimeUtil.RelativePath(context.AvatarRootObject, go);
componentType = typeof(GameObject);
}
else if (key.TargetObject is SkinnedMeshRenderer smr)
{
path = RuntimeUtil.RelativePath(context.AvatarRootObject, smr.gameObject);
componentType = typeof(SkinnedMeshRenderer);
if (key.PropertyName.StartsWith("blendShape."))
{
var blendShape = key.PropertyName.Substring("blendShape.".Length);
var index = smr.sharedMesh?.GetBlendShapeIndex(blendShape);
if (index != null && index >= 0)
{
animBaseState = smr.GetBlendShapeWeight(index.Value);
smr.SetBlendShapeWeight(index.Value, initialState);
}
applied = true;
}
}
else
{
throw new InvalidOperationException("Invalid target object: " + key.TargetObject);
}
if (!applied)
{
var serializedObject = new SerializedObject(key.TargetObject);
var prop = serializedObject.FindProperty(key.PropertyName);
if (prop != null)
{
switch (prop.propertyType)
{
case SerializedPropertyType.Boolean:
animBaseState = prop.boolValue ? 1 : 0;
prop.boolValue = initialState > 0.5f;
break;
case SerializedPropertyType.Float:
animBaseState = prop.floatValue;
prop.floatValue = initialState;
break;
}
serializedObject.ApplyModifiedPropertiesWithoutUndo();
}
}
var curve = new AnimationCurve();
curve.AddKey(0, animBaseState);
curve.AddKey(1, animBaseState);
var binding = EditorCurveBinding.FloatCurve(
path,
componentType,
key.PropertyName
);
AnimationUtility.SetEditorCurve(_initialStateClip, binding, curve);
if (componentType == typeof(GameObject) && key.PropertyName == "m_IsActive")
{
binding = EditorCurveBinding.FloatCurve(
"",
typeof(Animator),
asc.GetActiveSelfProxy((GameObject)key.TargetObject)
);
AnimationUtility.SetEditorCurve(_initialStateClip, binding, curve);
}
}
}
#region Mesh processing
private void ProcessMeshDeletion(HashSet<TargetProp> deletedKeys)
{
ImmutableDictionary<SkinnedMeshRenderer, List<TargetProp>> renderers = deletedKeys
.GroupBy(
v => (SkinnedMeshRenderer) v.TargetObject
).ToImmutableDictionary(
g => (SkinnedMeshRenderer) g.Key,
g => g.ToList()
);
foreach (var (renderer, infos) in renderers)
{
if (renderer == null) continue;
var mesh = renderer.sharedMesh;
if (mesh == null) continue;
renderer.sharedMesh = RemoveBlendShapeFromMesh.RemoveBlendshapes(
mesh,
infos
.Select(i => mesh.GetBlendShapeIndex(i.PropertyName.Substring("blendShape.".Length)))
.Where(k => k >= 0)
.ToList()
);
}
}
#endregion
private void ProcessShapeKey(PropGroup info)
{
// TODO: prune non-animated keys
// Check if this is non-animated and skip most processing if so
if (info.alwaysDeleted) return;
if (info.actionGroups[^1].IsConstant)
{
info.TargetProp.ApplyImmediate(info.actionGroups[0].Value);
return;
}
var asm = GenerateStateMachine(info);
ApplyController(asm, "MA Responsive: " + info.TargetProp.TargetObject.name);
}
private AnimatorStateMachine GenerateStateMachine(PropGroup info)
{
var asc = context.Extension<AnimationServicesContext>();
var asm = new AnimatorStateMachine();
asm.name = "MA Shape Changer " + info.TargetProp.TargetObject.name;
var x = 200;
var y = 0;
var yInc = 60;
asm.anyStatePosition = new Vector3(-200, 0);
var initial = new AnimationClip();
var initialState = new AnimatorState();
initialState.motion = initial;
initialState.writeDefaultValues = false;
initialState.name = "<default>";
asm.defaultState = initialState;
asm.entryPosition = new Vector3(0, 0);
var states = new List<ChildAnimatorState>();
states.Add(new ChildAnimatorState
{
position = new Vector3(x, y),
state = initialState
});
asc.AnimationDatabase.RegisterState(states[^1].state);
var lastConstant = info.actionGroups.FindLastIndex(agk => agk.IsConstant);
var transitionBuffer = new List<(AnimatorState, List<AnimatorStateTransition>)>();
var entryTransitions = new List<AnimatorTransition>();
transitionBuffer.Add((initialState, new List<AnimatorStateTransition>()));
foreach (var group in info.actionGroups.Skip(lastConstant))
{
y += yInc;
var clip = AnimResult(group.TargetProp, group.Value);
if (group.IsConstant)
{
clip.name = "Property Overlay constant " + group.Value;
initialState.motion = clip;
}
else
{
clip.name = "Property Overlay controlled by " + group.ControllingConditions[0].DebugName + " " +
group.Value;
var conditions = GetTransitionConditions(asc, group);
foreach (var (st, transitions) in transitionBuffer)
{
var transition = new AnimatorStateTransition
{
isExit = true,
hasExitTime = false,
duration = 0,
hasFixedDuration = true,
conditions = (AnimatorCondition[])conditions.Clone()
};
transitions.Add(transition);
}
var state = new AnimatorState();
state.name = group.ControllingConditions[0].DebugName;
state.motion = clip;
state.writeDefaultValues = false;
states.Add(new ChildAnimatorState
{
position = new Vector3(x, y),
state = state
});
asc.AnimationDatabase.RegisterState(states[^1].state);
var transitionList = new List<AnimatorStateTransition>();
transitionBuffer.Add((state, transitionList));
entryTransitions.Add(new AnimatorTransition
{
destinationState = state,
conditions = conditions
});
foreach (var cond in conditions)
{
var inverted = new AnimatorCondition
{
parameter = cond.parameter,
mode = cond.mode == AnimatorConditionMode.Greater
? AnimatorConditionMode.Less
: AnimatorConditionMode.Greater,
threshold = cond.threshold
};
transitionList.Add(new AnimatorStateTransition
{
isExit = true,
hasExitTime = false,
duration = 0,
hasFixedDuration = true,
conditions = new[] { inverted }
});
}
}
}
foreach (var (st, transitions) in transitionBuffer) st.transitions = transitions.ToArray();
asm.states = states.ToArray();
entryTransitions.Reverse();
asm.entryTransitions = entryTransitions.ToArray();
asm.exitPosition = new Vector3(500, 0);
return asm;
}
private AnimatorCondition[] GetTransitionConditions(AnimationServicesContext asc, ActionGroupKey group)
{
var conditions = new List<AnimatorCondition>();
foreach (var condition in group.ControllingConditions)
{
if (condition.IsConstant) continue;
conditions.Add(new AnimatorCondition
{
parameter = condition.Parameter,
mode = AnimatorConditionMode.Greater,
threshold = condition.ParameterValueLo
});
conditions.Add(new AnimatorCondition
{
parameter = condition.Parameter,
mode = AnimatorConditionMode.Less,
threshold = condition.ParameterValueHi
});
}
if (conditions.Count == 0)
throw new InvalidOperationException("No controlling parameters found for " + group);
return conditions.ToArray();
}
private Motion AnimResult(TargetProp key, float value)
{
string path;
Type componentType;
if (key.TargetObject is GameObject go)
{
path = RuntimeUtil.RelativePath(context.AvatarRootObject, go);
componentType = typeof(GameObject);
}
else if (key.TargetObject is SkinnedMeshRenderer smr)
{
path = RuntimeUtil.RelativePath(context.AvatarRootObject, smr.gameObject);
componentType = typeof(SkinnedMeshRenderer);
}
else
{
throw new InvalidOperationException("Invalid target object: " + key.TargetObject);
}
var clip = new AnimationClip();
clip.name = $"Set {path}:{key.PropertyName}={value}";
var curve = new AnimationCurve();
curve.AddKey(0, value);
curve.AddKey(1, value);
var binding = EditorCurveBinding.FloatCurve(path, componentType, key.PropertyName);
AnimationUtility.SetEditorCurve(clip, binding, curve);
if (key.TargetObject is GameObject obj && key.PropertyName == "m_IsActive")
{
var asc = context.Extension<AnimationServicesContext>();
var propName = asc.GetActiveSelfProxy(obj);
binding = EditorCurveBinding.FloatCurve("", typeof(Animator), propName);
AnimationUtility.SetEditorCurve(clip, binding, curve);
}
return clip;
}
private void ApplyController(AnimatorStateMachine asm, string layerName)
{
var fx = context.AvatarDescriptor.baseAnimationLayers
.FirstOrDefault(l => l.type == VRCAvatarDescriptor.AnimLayerType.FX);
if (fx.animatorController == null)
{
throw new InvalidOperationException("No FX layer found");
}
if (!context.IsTemporaryAsset(fx.animatorController))
{
throw new InvalidOperationException("FX layer is not a temporary asset");
}
if (!(fx.animatorController is AnimatorController animController))
{
throw new InvalidOperationException("FX layer is not an animator controller");
}
var paramList = animController.parameters.ToList();
var paramSet = paramList.Select(p => p.name).ToHashSet();
foreach (var paramName in initialValues.Keys.Except(paramSet))
{
paramList.Add(new AnimatorControllerParameter()
{
name = paramName,
type = AnimatorControllerParameterType.Float,
defaultFloat = initialValues[paramName], // TODO
});
paramSet.Add(paramName);
}
animController.parameters = paramList.ToArray();
animController.layers = animController.layers.Append(
new AnimatorControllerLayer
{
stateMachine = asm,
name = "MA Shape Changer " + layerName,
defaultWeight = 1
}
).ToArray();
}
private AnimationClip AnimParam(string param, float val)
{
return AnimParam((param, val));
}
private AnimationClip AnimParam(params (string param, float val)[] pairs)
{
AnimationClip clip = new AnimationClip();
clip.name = "Set " + string.Join(", ", pairs.Select(p => $"{p.param}={p.val}"));
// TODO - check property syntax
foreach (var (param, val) in pairs)
{
var curve = new AnimationCurve();
curve.AddKey(0, val);
curve.AddKey(1, val);
clip.SetCurve("", typeof(Animator), "" + param, curve);
}
return clip;
}
private void FindObjectToggles(Dictionary<TargetProp, PropGroup> objectGroups, ndmf.BuildContext context)
{
var asc = context.Extension<AnimationServicesContext>();
var toggles = this.context.AvatarRootObject.GetComponentsInChildren<ModularAvatarObjectToggle>(true);
foreach (var toggle in toggles)
{
if (toggle.Objects == null) continue;
foreach (var obj in toggle.Objects)
{
var target = obj.Object.Get(toggle);
if (target == null) continue;
// Make sure we generate an animator prop for each controlled object, as we intend to generate
// animations for them.
asc.GetActiveSelfProxy(target);
var key = new TargetProp
{
TargetObject = target,
PropertyName = "m_IsActive"
};
if (!objectGroups.TryGetValue(key, out var group))
{
group = new PropGroup(key, target.activeSelf ? 1 : 0);
objectGroups[key] = group;
}
var value = obj.Active ? 1 : 0;
var action = new ActionGroupKey(context, key, toggle.gameObject, value);
if (group.actionGroups.Count == 0)
group.actionGroups.Add(action);
else if (!group.actionGroups[^1].TryMerge(action)) group.actionGroups.Add(action);
}
}
}
private Dictionary<TargetProp, PropGroup> FindShapes(ndmf.BuildContext context)
{
var asc = context.Extension<AnimationServicesContext>();
var changers = context.AvatarRootObject.GetComponentsInChildren<ModularAvatarShapeChanger>(true);
Dictionary<TargetProp, PropGroup> shapeKeys = new();
foreach (var changer in changers)
{
var renderer = changer.targetRenderer.Get(changer)?.GetComponent<SkinnedMeshRenderer>();
if (renderer == null) continue;
var mesh = renderer.sharedMesh;
if (mesh == null) continue;
foreach (var shape in changer.Shapes)
{
var shapeId = mesh.GetBlendShapeIndex(shape.ShapeName);
if (shapeId < 0) continue;
var key = new TargetProp
{
TargetObject = renderer,
PropertyName = "blendShape." + shape.ShapeName,
};
var value = shape.ChangeType == ShapeChangeType.Delete ? 100 : shape.Value;
if (!shapeKeys.TryGetValue(key, out var info))
{
info = new PropGroup(key, renderer.GetBlendShapeWeight(shapeId));
shapeKeys[key] = info;
// Add initial state
var agk = new ActionGroupKey(context, key, null, value);
agk.Value = renderer.GetBlendShapeWeight(shapeId);
info.actionGroups.Add(agk);
}
var action = new ActionGroupKey(context, key, changer.gameObject, value);
var isCurrentlyActive = changer.gameObject.activeInHierarchy;
if (shape.ChangeType == ShapeChangeType.Delete)
{
action.IsDelete = true;
if (isCurrentlyActive) info.currentState = 100;
info.actionGroups.Add(action); // Never merge
continue;
}
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]);
}
}
}
return shapeKeys;
}
}
}

View File

@ -33,7 +33,7 @@ namespace nadena.dev.modular_avatar.core.editor
public ImmutableList<RenderGroup> GetTargetGroups(ComputeContext ctx)
{
var menuItemPreviewCondition = new MenuItemPreviewCondition(ctx);
var menuItemPreview = new MenuItemPreviewCondition(ctx);
var allChangers = ctx.GetComponentsByType<ModularAvatarShapeChanger>();
@ -45,11 +45,9 @@ namespace nadena.dev.modular_avatar.core.editor
{
if (changer == null) continue;
// TODO: observe avatar root
if (!ctx.ActiveAndEnabled(changer)) continue;
var mami = ctx.GetComponent<ModularAvatarMenuItem>(changer.gameObject);
if (mami != null && !menuItemPreviewCondition.IsEnabledForPreview(mami)) continue;
bool active = ctx.ActiveAndEnabled(changer) && (mami == null || menuItemPreview.IsEnabledForPreview(mami));
if (active == ctx.Observe(changer, t => t.Inverted)) continue;
var target = ctx.Observe(changer, _ => changer.targetRenderer.Get(changer));
var renderer = ctx.GetComponent<SkinnedMeshRenderer>(target);

View File

@ -1,4 +1,7 @@
using System;
#if UNITY_EDITOR
using UnityEditor;
#endif
using UnityEngine;
namespace nadena.dev.modular_avatar.core
@ -17,6 +20,28 @@ namespace nadena.dev.modular_avatar.core
private string _cachedPath;
private GameObject _cachedReference;
#if UNITY_EDITOR
public static GameObject Get(SerializedProperty prop)
{
var rootObject = prop.serializedObject.targetObject;
if (rootObject == null) return null;
var avatarRoot = RuntimeUtil.FindAvatarTransformInParents((rootObject as Component)?.transform ?? (rootObject as GameObject)?.transform);
if (avatarRoot == null) return null;
var referencePath = prop.FindPropertyRelative("referencePath").stringValue;
var targetObject = prop.FindPropertyRelative("targetObject").objectReferenceValue as GameObject;
if (targetObject != null && targetObject.transform.IsChildOf(avatarRoot))
return targetObject;
if (referencePath == AVATAR_ROOT)
return avatarRoot.gameObject;
return avatarRoot.Find(referencePath)?.gameObject;
}
#endif
public GameObject Get(Component container)
{
bool cacheValid = _cacheValid || ReferencesLockedAtFrame == Time.frameCount;

View File

@ -1,10 +1,21 @@
namespace nadena.dev.modular_avatar.core
using UnityEngine;
using UnityEngine.Serialization;
namespace nadena.dev.modular_avatar.core
{
/// <summary>
/// Tag class used internally to mark reactive components. Not publicly extensible.
/// </summary>
public abstract class ReactiveComponent : AvatarTagComponent
{
[SerializeField] private bool m_inverted = false;
public bool Inverted
{
get => m_inverted;
set => m_inverted = value;
}
internal ReactiveComponent()
{
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: bd73c1b15bec4119b573ad3b12a3371c
timeCreated: 1723330403

View File

@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace nadena.dev.modular_avatar.core
{
[Serializable]
public struct MaterialSwitchObject
{
public AvatarObjectReference Object;
public Material Material;
public int MaterialIndex;
public bool Equals(MaterialSwitchObject other)
{
return Equals(Object, other.Object) && Equals(Material, other.Material) && MaterialIndex == other.MaterialIndex;
}
public override bool Equals(object obj)
{
return obj is MaterialSwitchObject other && Equals(other);
}
public override int GetHashCode()
{
return HashCode.Combine(Object, Material, MaterialIndex);
}
}
[AddComponentMenu("Modular Avatar/MA Material Setter")]
[HelpURL("https://modular-avatar.nadena.dev/docs/reference/material-setter?lang=auto")]
public class ModularAvatarMaterialSetter : ReactiveComponent
{
[SerializeField] private List<MaterialSwitchObject> m_objects = new();
public List<MaterialSwitchObject> Objects
{
get => m_objects;
set => m_objects = value;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0adf335711644e34b6c635e94ae61fa7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: a8edd5bd1a0a64a40aa99cc09fb5f198, type: 3}
userData:
assetBundleName:
assetBundleVariant: