feat: shape changer can respond to multiple parent objects being animated (#916)

* refactor: generalize the shape changer implementation a bit

* feat: shape changer can respond to multiple parent objects being animated

Closes: #865
This commit is contained in:
bd_ 2024-07-06 21:39:42 -07:00 committed by GitHub
parent 746a72f60b
commit 57fe84548c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 897 additions and 785 deletions

View File

@ -4,6 +4,7 @@ using System.Linq;
using nadena.dev.modular_avatar.animation;
using UnityEditor;
using UnityEngine;
using EditorCurveBinding = UnityEditor.EditorCurveBinding;
using Object = UnityEngine.Object;
namespace nadena.dev.modular_avatar.core.editor

View File

@ -7,6 +7,7 @@ using nadena.dev.ndmf;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
using VRC.SDK3.Avatars.Components;
#endregion
@ -29,6 +30,8 @@ namespace nadena.dev.modular_avatar.animation
private BuildContext _context;
private AnimationDatabase _animationDatabase;
private PathMappings _pathMappings;
private ReadableProperty _readableProperty;
private Dictionary<GameObject, string> _selfProxies = new();
public void OnActivate(BuildContext context)
@ -40,6 +43,8 @@ namespace nadena.dev.modular_avatar.animation
_pathMappings = new PathMappings();
_pathMappings.OnActivate(context, _animationDatabase);
_readableProperty = new ReadableProperty(_context, _animationDatabase, this);
}
public void OnDeactivate(BuildContext context)
@ -79,6 +84,17 @@ namespace nadena.dev.modular_avatar.animation
}
}
// HACK: This is a temporary crutch until we rework the entire animator services system
public void AddPropertyDefinition(AnimatorControllerParameter paramDef)
{
var fx = (AnimatorController)
_context.AvatarDescriptor.baseAnimationLayers
.First(l => l.type == VRCAvatarDescriptor.AnimLayerType.FX)
.animatorController;
fx.parameters = fx.parameters.Concat(new[] { paramDef }).ToArray();
}
/// <summary>
/// Returns a parameter which proxies the "activeSelf" state of the specified GameObject.
/// </summary>
@ -98,76 +114,8 @@ namespace nadena.dev.modular_avatar.animation
return false;
}
var iid = obj.GetInstanceID();
paramName = $"_MA/ActiveSelf/{iid}";
var binding = EditorCurveBinding.FloatCurve(path, typeof(GameObject), "m_IsActive");
bool hadAnyClip = false;
foreach (var clip in clips)
{
Motion newMotion = ProcessActiveSelf(clip.CurrentClip, paramName, binding);
if (newMotion != clip.CurrentClip)
{
clip.SetCurrentNoInvalidate(newMotion);
hadAnyClip = true;
}
}
if (hadAnyClip)
{
_selfProxies[obj] = paramName;
return true;
}
else
{
_selfProxies[obj] = "";
return false;
}
}
private Motion ProcessActiveSelf(Motion motion, string paramName, EditorCurveBinding binding)
{
if (motion is AnimationClip clip)
{
var curve = AnimationUtility.GetEditorCurve(clip, binding);
if (curve == null) return motion;
var newClip = new AnimationClip();
EditorUtility.CopySerialized(motion, newClip);
newClip.SetCurve("", typeof(Animator), paramName, curve);
return newClip;
}
else if (motion is BlendTree bt)
{
bool anyChanged = false;
var motions = bt.children.Select(c => // c is struct ChildMotion
{
var newMotion = ProcessActiveSelf(c.motion, paramName, binding);
anyChanged |= newMotion != c.motion;
c.motion = newMotion;
return c;
}).ToArray();
if (anyChanged)
{
var newBt = new BlendTree();
EditorUtility.CopySerialized(bt, newBt);
newBt.children = motions;
return newBt;
}
else
{
return bt;
}
}
else
{
return motion;
}
paramName = _readableProperty.ForActiveSelf(_pathMappings.GetObjectIdentifier(obj));
return true;
}
}
}

View File

@ -191,7 +191,7 @@ namespace nadena.dev.modular_avatar.animation
}
}
private static string MapPath(EditorCurveBinding binding, string basePath)
private static string MapPath(UnityEditor.EditorCurveBinding binding, string basePath)
{
if (binding.type == typeof(Animator) && binding.path == "")
{

View File

@ -0,0 +1,18 @@
using System.Collections.Generic;
using UnityEditor;
namespace nadena.dev.modular_avatar.animation
{
internal class EditorCurveBindingComparer : IEqualityComparer<EditorCurveBinding>
{
public bool Equals(UnityEditor.EditorCurveBinding x, UnityEditor.EditorCurveBinding y)
{
return x.path == y.path && x.type == y.type && x.propertyName == y.propertyName;
}
public int GetHashCode(UnityEditor.EditorCurveBinding obj)
{
return obj.path.GetHashCode() ^ obj.type.GetHashCode() ^ obj.propertyName.GetHashCode();
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e751f7889323485bbe202285a47cb0d4
timeCreated: 1719196767

View File

@ -33,6 +33,7 @@ namespace nadena.dev.modular_avatar.animation
private HashSet<GameObject> _transformLookthroughObjects = new HashSet<GameObject>();
private ImmutableDictionary<string, string> _originalPathToMappedPath = null;
private ImmutableDictionary<string, string> _transformOriginalPathToMappedPath = null;
private ImmutableDictionary<string, GameObject> _pathToObject = null;
internal void OnActivate(BuildContext context, AnimationDatabase animationDatabase)
{
@ -51,6 +52,7 @@ namespace nadena.dev.modular_avatar.animation
{
_originalPathToMappedPath = null;
_transformOriginalPathToMappedPath = null;
_pathToObject = null;
}
/// <summary>
@ -194,7 +196,7 @@ namespace nadena.dev.modular_avatar.animation
}
}
private string MapPath(EditorCurveBinding binding)
private string MapPath(UnityEditor.EditorCurveBinding binding)
{
if (binding.type == typeof(Animator) && binding.path == "")
{
@ -298,5 +300,23 @@ namespace nadena.dev.modular_avatar.animation
listener.OnCommitObjectRenames(context, this);
}
}
public GameObject PathToObject(string path)
{
if (_pathToObject == null)
{
_pathToObject = _objectToOriginalPaths.SelectMany(kvp => kvp.Value.Select(p => (p, kvp.Key)))
.ToImmutableDictionary(t => t.p, t => t.Key);
}
if (_pathToObject.TryGetValue(path, out var obj))
{
return obj;
}
else
{
return null;
}
}
}
}

View File

@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
using nadena.dev.ndmf;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
using Object = UnityEngine.Object;
namespace nadena.dev.modular_avatar.animation
{
internal class ReadableProperty
{
private readonly BuildContext _context;
private readonly AnimationDatabase _animDB;
private readonly AnimationServicesContext _asc;
private readonly Dictionary<EditorCurveBinding, string> _alreadyBound = new();
private long _nextIndex;
public ReadableProperty(BuildContext context, AnimationDatabase animDB, AnimationServicesContext asc)
{
_context = context;
_animDB = animDB;
_asc = asc;
}
/// <summary>
/// Creates an animator parameter which tracks the effective value of a property on a component. This only
/// tracks FX layer properties.
/// </summary>
/// <param name="ecb"></param>
/// <returns></returns>
public string ForBinding(string path, Type componentType, string property)
{
var ecb = new EditorCurveBinding
{
path = path,
type = componentType,
propertyName = property
};
if (_alreadyBound.TryGetValue(ecb, out var reader))
{
return reader;
}
var lastComponent = path.Split("/")[^1];
var emuPropName = $"__MA/ReadableProp/{lastComponent}/{componentType}/{property}#{_nextIndex++}";
float initialValue = 0;
var gameObject = _asc.PathMappings.PathToObject(path);
Object component = componentType == typeof(GameObject)
? gameObject
: gameObject?.GetComponent(componentType);
if (component != null)
{
var so = new SerializedObject(component);
var prop = so.FindProperty(property);
if (prop != null)
switch (prop.propertyType)
{
case SerializedPropertyType.Boolean:
initialValue = prop.boolValue ? 1 : 0;
break;
case SerializedPropertyType.Float:
initialValue = prop.floatValue;
break;
case SerializedPropertyType.Integer:
initialValue = prop.intValue;
break;
default: throw new NotImplementedException($"Property type {prop.type} not supported");
}
}
_asc.AddPropertyDefinition(new AnimatorControllerParameter
{
defaultFloat = initialValue,
name = emuPropName,
type = AnimatorControllerParameterType.Float
});
BindProperty(ecb, emuPropName);
_alreadyBound[ecb] = emuPropName;
return emuPropName;
}
private void BindProperty(EditorCurveBinding ecb, string propertyName)
{
var boundProp = new EditorCurveBinding
{
path = "",
type = typeof(Animator),
propertyName = propertyName
};
foreach (var clip in _animDB.ClipsForPath(ecb.path)) ProcessAnyClip(clip);
void ProcessBlendTree(BlendTree blendTree)
{
foreach (var child in blendTree.children)
switch (child.motion)
{
case AnimationClip animationClip:
ProcessAnimationClip(animationClip);
break;
case BlendTree subBlendTree:
ProcessBlendTree(subBlendTree);
break;
}
}
void ProcessAnimationClip(AnimationClip animationClip)
{
var curve = AnimationUtility.GetEditorCurve(animationClip, ecb);
if (curve == null) return;
AnimationUtility.SetEditorCurve(animationClip, boundProp, curve);
}
void ProcessAnyClip(AnimationDatabase.ClipHolder clip)
{
switch (clip.CurrentClip)
{
case AnimationClip animationClip:
ProcessAnimationClip(animationClip);
break;
case BlendTree blendTree:
ProcessBlendTree(blendTree);
break;
}
}
}
public string ForActiveSelf(string path)
{
return ForBinding(path, typeof(GameObject), "m_IsActive");
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 1074339e2a59465ba585cb8cbbc4a88c
timeCreated: 1719195449

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(ShapeChangerPrePass.Instance);
seq.Run(PropertyOverlayPrePass.Instance);
seq.Run(RenameParametersPluginPass.Instance);
seq.Run(MergeBlendTreePass.Instance);
seq.Run(MergeAnimatorPluginPass.Instance);
@ -57,7 +57,7 @@ namespace nadena.dev.modular_avatar.core.editor.plugin
#endif
seq.WithRequiredExtension(typeof(AnimationServicesContext), _s2 =>
{
seq.Run("Shape Changer", ctx => new ShapeChangerPass(ctx).Execute())
seq.Run("Shape Changer", ctx => new PropertyOverlayPass(ctx).Execute())
.PreviewingWith(new ShapeChangerPreview());
seq.Run(MergeArmaturePluginPass.Instance);
seq.Run(BoneProxyPluginPass.Instance);

View File

@ -0,0 +1,682 @@
#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)
{
if (context.AvatarRootObject.GetComponentInChildren<ModularAvatarShapeChanger>() != null)
{
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(AnimationServicesContext asc, TargetProp key, GameObject controllingObject, float value)
{
TargetProp = key;
InitiallyActive = controllingObject?.activeInHierarchy == true;
var origControlling = controllingObject?.name ?? "<null>";
while (controllingObject != null && !asc.TryGetActiveSelfProxy(controllingObject, out _))
{
controllingObject = controllingObject.transform.parent?.gameObject;
if (controllingObject != null && RuntimeUtil.IsAvatarRoot(controllingObject.transform))
{
controllingObject = null;
}
}
var newControlling = controllingObject?.name ?? "<null>";
Debug.Log("AGK: Controlling object " + origControlling + " => " + newControlling);
ControllingObject = controllingObject;
Value = value;
}
public TargetProp TargetProp;
public float Value;
public float ConditionKey;
// When constructing the 1D blend tree to interpret the sum-of-condition-keys value, we need to ensure that
// all valid values are solidly between two control points with the same animation clip, to avoid undesired
// interpolation. This is done by constructing a "guard band":
// [ valid range ] [ guard band ] [ valid range ]
//
// The valid range must contain all values that could be created by valid summations. We therefore reserve
// a "guard band" in between; by reserving the exponent below each valid stop, we can put our guard bands
// in there.
// [ valid ] [ guard ] [ valid ]
// ^-r0 ^-g0 ^-g1
// ^- r1
// g0 = r1 / 2 = r0 * 2
// g1 = BitDecrement(r1) (we don't actually use this currently as r0-g0 is enough)
public float Guard => ConditionKey * 2;
public bool ConditionKeyIsValid => float.IsFinite(ConditionKey)
&& float.IsFinite(Guard)
&& ConditionKey > 0;
public GameObject ControllingObject;
public bool InitiallyActive;
public bool IsDelete;
public override string ToString()
{
var obj = ControllingObject?.name ?? "<null>";
return $"AGK: {TargetProp}={Value} " +
$"range={ConditionKey}/{Guard} controlling object={obj}";
}
public bool TryMerge(ActionGroupKey other)
{
if (!TargetProp.Equals(other.TargetProp)) return false;
if (Mathf.Abs(Value - other.Value) > 0.001f) return false;
if (ControllingObject != other.ControllingObject) return false;
if (IsDelete || other.IsDelete) return false;
return true;
}
}
private readonly ndmf.BuildContext context;
private Dictionary<string, float> initialValues = new();
private AnimationClip _initialStateClip;
public PropertyOverlayPass(ndmf.BuildContext context)
{
this.context = context;
}
internal void Execute()
{
Dictionary<TargetProp, PropGroup> shapes = FindShapes(context);
PreprocessShapes(shapes, out var initialStates, out var deletedShapes);
ProcessInitialStates(initialStates);
foreach (var groups in shapes.Values)
{
ProcessShapeKey(groups);
}
ProcessMeshDeletion(deletedShapes);
}
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())
{
var deletions = info.actionGroups.Where(agk => agk.IsDelete).ToList();
if (deletions.Any(d => d.ControllingObject == null))
{
// 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].ControllingObject == null)
{
shapes.Remove(key);
}
}
}
private void ProcessInitialStates(Dictionary<TargetProp, float> initialStates)
{
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)
{
var curve = new AnimationCurve();
curve.AddKey(0, initialState);
curve.AddKey(1, initialState);
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);
if (key.PropertyName.StartsWith("blendShape."))
{
var blendShape = key.PropertyName.Substring("blendShape.".Length);
var index = smr.sharedMesh?.GetBlendShapeIndex(blendShape);
if (index != null && index >= 0) smr.SetBlendShapeWeight(index.Value, initialState);
}
}
else
{
throw new InvalidOperationException("Invalid target object: " + key.TargetObject);
}
var binding = EditorCurveBinding.FloatCurve(
path,
componentType,
key.PropertyName
);
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].ControllingObject == null)
{
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
});
var lastConstant = info.actionGroups.FindLastIndex(agk => agk.ControllingObject == null);
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.ControllingObject == null)
{
clip.name = "Property Overlay constant " + group.Value;
initialState.motion = clip;
}
else
{
clip.name = "Property Overlay controlled by " + group.ControllingObject.name + " " + 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.ControllingObject.name;
state.motion = clip;
state.writeDefaultValues = false;
states.Add(new ChildAnimatorState
{
position = new Vector3(x, y),
state = 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 = AnimatorConditionMode.Less,
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>();
var controller = group.ControllingObject.transform;
while (controller != null && !RuntimeUtil.IsAvatarRoot(controller))
{
if (asc.TryGetActiveSelfProxy(controller.gameObject, out var paramName))
{
initialValues[paramName] = controller.gameObject.activeSelf ? 1 : 0;
conditions.Add(new AnimatorCondition
{
parameter = paramName,
mode = AnimatorConditionMode.Greater,
threshold = 0.5f
});
}
controller = controller.parent;
}
if (conditions.Count == 0) throw new InvalidOperationException("No controlling object 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);
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 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;
int rendererInstanceId = renderer.GetInstanceID();
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(asc, key, null, value);
agk.InitiallyActive = true;
agk.Value = renderer.GetBlendShapeWeight(shapeId);
info.actionGroups.Add(agk);
}
var action = new ActionGroupKey(asc, 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;
// TODO: lift controlling object resolution out of loop?
if (action.ControllingObject == null)
{
if (action.InitiallyActive)
{
// always active control
info.actionGroups.Clear();
}
else
{
// never active control
continue;
}
}
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

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: fe9990c696424aada1212aebd23f4c64
timeCreated: 1719196895

View File

@ -1,708 +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;
#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 ShapeChangerPrePass : Pass<ShapeChangerPrePass>
{
internal const string TAG_PATH = "__MA/ShapeChanger/PrepassPlaceholder";
protected override void Execute(ndmf.BuildContext context)
{
if (context.AvatarRootObject.GetComponentInChildren<ModularAvatarShapeChanger>() != null)
{
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 ShapeChangerPass
{
struct ShapeKey
{
public int RendererInstanceId;
public int ShapeIndex;
public string ShapeKeyName; // not equated
public bool Equals(ShapeKey other)
{
return RendererInstanceId == other.RendererInstanceId && ShapeIndex == other.ShapeIndex;
}
public override bool Equals(object obj)
{
return obj is ShapeKey other && Equals(other);
}
public override int GetHashCode()
{
unchecked
{
return (RendererInstanceId * 397) ^ ShapeIndex;
}
}
}
class ShapeKeyInfo
{
public ShapeKey ShapeKey { get; }
public string SetParam { get; set; }
public string DeleteParam { get; set; }
public bool alwaysDeleted;
public float currentState;
// Objects which trigger deletion of this shape key.
public List<GameObject> deletionObjects = new List<GameObject>();
public List<ActionGroupKey> setObjects = new List<ActionGroupKey>();
public ShapeKeyInfo(ShapeKey key)
{
ShapeKey = key;
currentState = (EditorUtility.InstanceIDToObject(key.RendererInstanceId) as SkinnedMeshRenderer)
?.GetBlendShapeWeight(key.ShapeIndex)
?? 0;
}
}
class ActionGroupKey
{
public ActionGroupKey(AnimationServicesContext asc, ShapeKey key, GameObject controllingObject,
ChangedShape shape)
{
ShapeKey = key;
InitiallyActive = controllingObject?.activeInHierarchy == true;
var origControlling = controllingObject?.name ?? "<null>";
while (controllingObject != null && !asc.TryGetActiveSelfProxy(controllingObject, out _))
{
controllingObject = controllingObject.transform.parent?.gameObject;
}
var newControlling = controllingObject?.name ?? "<null>";
Debug.Log("AGK: Controlling object " + origControlling + " => " + newControlling);
ControllingObject = controllingObject;
IsDelete = shape.ChangeType == ShapeChangeType.Delete;
Value = IsDelete ? 100 : shape.Value;
}
public ShapeKey ShapeKey;
public bool IsDelete;
public float Value;
public float ConditionKey;
// When constructing the 1D blend tree to interpret the sum-of-condition-keys value, we need to ensure that
// all valid values are solidly between two control points with the same animation clip, to avoid undesired
// interpolation. This is done by constructing a "guard band":
// [ valid range ] [ guard band ] [ valid range ]
//
// The valid range must contain all values that could be created by valid summations. We therefore reserve
// a "guard band" in between; by reserving the exponent below each valid stop, we can put our guard bands
// in there.
// [ valid ] [ guard ] [ valid ]
// ^-r0 ^-g0 ^-g1
// ^- r1
// g0 = r1 / 2 = r0 * 2
// g1 = BitDecrement(r1) (we don't actually use this currently as r0-g0 is enough)
public float Guard => ConditionKey * 2;
public bool ConditionKeyIsValid => float.IsFinite(ConditionKey)
&& float.IsFinite(Guard)
&& ConditionKey > 0;
public GameObject ControllingObject;
public bool InitiallyActive;
public override string ToString()
{
var obj = ControllingObject?.name ?? "<null>";
return $"AGK: {ShapeKey.RendererInstanceId}:{ShapeKey.ShapeIndex} ({ShapeKey.ShapeKeyName})={Value} " +
$"range={ConditionKey}/{Guard} controlling object={obj}";
}
public bool TryMerge(ActionGroupKey other)
{
if (!ShapeKey.Equals(other.ShapeKey)) return false;
if (Mathf.Abs(Value - other.Value) > 0.001f) return false;
if (ControllingObject != other.ControllingObject) return false;
IsDelete |= other.IsDelete;
return true;
}
}
private readonly ndmf.BuildContext context;
private Dictionary<string, float> initialValues = new();
private AnimationClip _initialStateClip;
public ShapeChangerPass(ndmf.BuildContext context)
{
this.context = context;
}
internal void Execute()
{
Dictionary<ShapeKey, ShapeKeyInfo> shapes = FindShapes(context);
ProcessInitialStates(shapes);
foreach (var groups in shapes.Values)
{
ProcessShapeKey(groups);
}
ProcessMeshDeletion(shapes);
}
private void ProcessInitialStates(Dictionary<ShapeKey, ShapeKeyInfo> shapes)
{
var clips = context.Extension<AnimationServicesContext>().AnimationDatabase;
var initialStateHolder = clips.ClipsForPath(ShapeChangerPrePass.TAG_PATH).FirstOrDefault();
if (initialStateHolder == null) return;
_initialStateClip = new AnimationClip();
_initialStateClip.name = "MA Shape Changer Defaults";
initialStateHolder.CurrentClip = _initialStateClip;
foreach (var (key, info) in shapes)
{
if (info.alwaysDeleted) continue;
var curve = new AnimationCurve();
curve.AddKey(0, info.currentState);
curve.AddKey(1, info.currentState);
var renderer = (SkinnedMeshRenderer)EditorUtility.InstanceIDToObject(key.RendererInstanceId);
var binding = EditorCurveBinding.FloatCurve(
RuntimeUtil.RelativePath(context.AvatarRootObject, renderer.gameObject),
typeof(SkinnedMeshRenderer),
$"blendShape.{key.ShapeKeyName}"
);
AnimationUtility.SetEditorCurve(_initialStateClip, binding, curve);
}
}
#region Mesh processing
private void ProcessMeshDeletion(Dictionary<ShapeKey, ShapeKeyInfo> shapes)
{
ImmutableDictionary<int /* renderer */, List<ShapeKeyInfo>> renderers = shapes.Values.GroupBy(
v => v.ShapeKey.RendererInstanceId
).ToImmutableDictionary(
g => g.Key,
g => g.ToList()
);
foreach (var (rendererId, infos) in renderers)
{
var renderer = (SkinnedMeshRenderer)EditorUtility.InstanceIDToObject(rendererId);
if (renderer == null) continue;
var mesh = renderer.sharedMesh;
if (mesh == null) continue;
renderer.sharedMesh = RemoveBlendShapeFromMesh.RemoveBlendshapes(
mesh,
infos.Where(i => i.alwaysDeleted).Select(i => i.ShapeKey.ShapeIndex)
);
}
}
#endregion
private void ProcessShapeKey(ShapeKeyInfo info)
{
// TODO: prune non-animated keys
// Check if this is non-animated and skip most processing if so
if (info.alwaysDeleted) return;
if (info.setObjects[^1].ControllingObject == null)
{
var renderer = (SkinnedMeshRenderer)EditorUtility.InstanceIDToObject(info.ShapeKey.RendererInstanceId);
renderer.SetBlendShapeWeight(info.ShapeKey.ShapeIndex, info.setObjects[0].Value);
return;
}
// This value is the first non-subnormal float32
float shift = BitConverter.Int32BitsToSingle(0x00800000);
foreach (var group in info.setObjects)
{
group.ConditionKey = shift;
shift *= 4;
if (!group.ConditionKeyIsValid)
{
throw new ArithmeticException("Floating point overflow - too many shape key controls");
}
}
info.SetParam =
$"_MA/ShapeChanger/{info.ShapeKey.RendererInstanceId}/{info.ShapeKey.ShapeIndex}/set";
info.DeleteParam = $"_MA/ShapeChanger/{info.ShapeKey.RendererInstanceId}/{info.ShapeKey.ShapeIndex}/delete";
var summationTree = BuildSummationTree(info);
var applyTree = BuildApplyTree(info);
var merged = BuildMergeTree(summationTree, applyTree);
ApplyController(merged, "ShapeChanger Apply: " + info.ShapeKey.ShapeKeyName);
}
private BlendTree BuildMergeTree(BlendTree summationTree, BlendTree applyTree)
{
var bt = new BlendTree();
bt.blendType = BlendTreeType.Direct;
bt.blendParameter = MergeBlendTreePass.ALWAYS_ONE;
bt.useAutomaticThresholds = false;
bt.children = new[]
{
new ChildMotion()
{
motion = summationTree,
directBlendParameter = MergeBlendTreePass.ALWAYS_ONE,
timeScale = 1,
},
new ChildMotion()
{
motion = applyTree,
directBlendParameter = MergeBlendTreePass.ALWAYS_ONE,
timeScale = 1,
},
};
return bt;
}
private BlendTree BuildApplyTree(ShapeKeyInfo info)
{
var groups = info.setObjects;
var setTree = new BlendTree();
setTree.blendType = BlendTreeType.Simple1D;
setTree.blendParameter = info.SetParam;
setTree.useAutomaticThresholds = false;
var childMotions = new List<ChildMotion>();
childMotions.Add(new ChildMotion()
{
motion = AnimResult(groups.First().ShapeKey, 0),
timeScale = 1,
threshold = 0,
});
foreach (var group in groups)
{
var lo = group.ConditionKey;
var hi = group.Guard;
Debug.Log("Threshold: [" + lo + ", " + hi + "]: " + group);
childMotions.Add(new ChildMotion()
{
motion = AnimResult(group.ShapeKey, group.Value),
timeScale = 1,
threshold = lo,
});
childMotions.Add(new ChildMotion()
{
motion = AnimResult(group.ShapeKey, group.Value),
timeScale = 1,
threshold = hi,
});
}
setTree.children = childMotions.ToArray();
var delTree = new BlendTree();
delTree.blendType = BlendTreeType.Simple1D;
delTree.blendParameter = info.DeleteParam;
delTree.useAutomaticThresholds = false;
delTree.children = new[]
{
new ChildMotion()
{
motion = setTree,
timeScale = 1,
threshold = 0
},
new ChildMotion()
{
motion = setTree,
timeScale = 1,
threshold = 0.4f
},
new ChildMotion()
{
motion = AnimResult(info.ShapeKey, 100),
timeScale = 1,
threshold = 0.6f
},
new ChildMotion()
{
motion = AnimResult(info.ShapeKey, 100),
timeScale = 1,
threshold = 1
},
};
return delTree;
}
private Motion AnimResult(ShapeKey key, float value)
{
var renderer = EditorUtility.InstanceIDToObject(key.RendererInstanceId) as SkinnedMeshRenderer;
if (renderer == null) throw new InvalidOperationException("Failed to get object");
var obj = renderer.gameObject;
var path = RuntimeUtil.RelativePath(context.AvatarRootObject, obj);
var clip = new AnimationClip();
clip.name = $"Set {obj.name}:{key.ShapeKeyName}={value}";
var curve = new AnimationCurve();
curve.AddKey(0, value);
curve.AddKey(1, value);
var binding =
EditorCurveBinding.FloatCurve(path, typeof(SkinnedMeshRenderer), $"blendShape.{key.ShapeKeyName}");
AnimationUtility.SetEditorCurve(clip, binding, curve);
return clip;
}
private BlendTree BuildSummationTree(ShapeKeyInfo info)
{
var groups = info.setObjects;
var setParam = info.SetParam;
var delParam = info.DeleteParam;
var asc = context.Extension<AnimationServicesContext>();
BlendTree bt = new BlendTree();
bt.blendType = BlendTreeType.Direct;
HashSet<string> paramNames = new HashSet<string>();
var childMotions = new List<ChildMotion>();
// TODO eliminate excess motion field
var initMotion = new ChildMotion()
{
motion = AnimParam((setParam, 0), (delParam, 0)),
directBlendParameter = MergeBlendTreePass.ALWAYS_ONE,
timeScale = 1,
};
childMotions.Add(initMotion);
paramNames.Add(MergeBlendTreePass.ALWAYS_ONE);
initialValues[MergeBlendTreePass.ALWAYS_ONE] = 1;
initialValues[setParam] = 0;
initialValues[delParam] = 0;
foreach (var group in groups)
{
Debug.Log("Group: " + group);
string controllingParam;
if (group.ControllingObject == null)
{
controllingParam = MergeBlendTreePass.ALWAYS_ONE;
}
else
{
// TODO: path evaluation
if (!asc.TryGetActiveSelfProxy(group.ControllingObject, out controllingParam))
{
throw new InvalidOperationException("Failed to get active self proxy");
}
initialValues[controllingParam] = group.ControllingObject.activeSelf ? 1 : 0;
}
var childMotion = new ChildMotion()
{
motion = AnimParam(setParam, group.ConditionKey),
directBlendParameter = controllingParam,
timeScale = 1,
};
childMotions.Add(childMotion);
paramNames.Add(controllingParam);
}
foreach (var delController in info.deletionObjects)
{
if (!asc.TryGetActiveSelfProxy(delController, out var controllingParam))
{
throw new InvalidOperationException("Failed to get active self proxy");
}
initialValues[controllingParam] = delController.activeSelf ? 1 : 0;
var childMotion = new ChildMotion()
{
motion = AnimParam(delParam, 1),
directBlendParameter = controllingParam,
timeScale = 1,
};
childMotions.Add(childMotion);
paramNames.Add(controllingParam);
}
bt.children = childMotions.ToArray();
return bt;
}
private void ApplyController(BlendTree bt, string stateName)
{
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 stateMachine = new AnimatorStateMachine();
var layers = animController.layers.ToList();
layers.Add(
new AnimatorControllerLayer() { defaultWeight = 1, name = stateName, stateMachine = stateMachine }
);
var state = new AnimatorState();
state.name = stateName;
state.motion = bt;
state.writeDefaultValues = true;
stateMachine.states = new[] { new ChildAnimatorState() { state = state } };
stateMachine.defaultState = state;
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 = layers.ToArray();
}
private static IEnumerable<string> FindParams(BlendTree bt)
{
if (bt == null) yield break;
if (bt.blendType == BlendTreeType.Direct)
{
foreach (var child in bt.children)
{
yield return child.directBlendParameter;
}
}
else
{
yield return bt.blendParameter;
}
foreach (var child in bt.children)
{
foreach (var param in FindParams(child.motion as BlendTree))
{
yield return param;
}
}
}
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 Dictionary<ShapeKey, ShapeKeyInfo> FindShapes(ndmf.BuildContext context)
{
var asc = context.Extension<AnimationServicesContext>();
var changers = context.AvatarRootObject.GetComponentsInChildren<ModularAvatarShapeChanger>(true);
Dictionary<ShapeKey, ShapeKeyInfo> shapeKeys = new();
foreach (var changer in changers)
{
var renderer = changer.targetRenderer.Get(changer)?.GetComponent<SkinnedMeshRenderer>();
if (renderer == null) continue;
int rendererInstanceId = renderer.GetInstanceID();
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 ShapeKey
{
RendererInstanceId = rendererInstanceId,
ShapeIndex = shapeId,
ShapeKeyName = shape.ShapeName,
};
if (!shapeKeys.TryGetValue(key, out var info))
{
info = new ShapeKeyInfo(key);
shapeKeys[key] = info;
// Add initial state
var agk = new ActionGroupKey(asc, key, null, shape);
agk.IsDelete = false;
agk.InitiallyActive = true;
agk.Value = renderer.GetBlendShapeWeight(shapeId);
info.setObjects.Add(agk);
}
var action = new ActionGroupKey(asc, key, changer.gameObject, shape);
var isCurrentlyActive = changer.gameObject.activeInHierarchy;
if (action.IsDelete)
{
if (isCurrentlyActive) info.currentState = 100;
if (action.ControllingObject == null)
{
// always active?
info.alwaysDeleted |= changer.gameObject.activeInHierarchy;
}
else
{
info.deletionObjects.Add(action.ControllingObject);
}
continue;
}
if (changer.gameObject.activeInHierarchy) info.currentState = action.Value;
// TODO: lift controlling object resolution out of loop?
if (action.ControllingObject == null)
{
if (action.InitiallyActive)
{
// always active control
info.setObjects.Clear();
}
else
{
// never active control
continue;
}
}
Debug.Log("Trying merge: " + action);
if (info.setObjects.Count == 0)
{
info.setObjects.Add(action);
}
else if (!info.setObjects[^1].TryMerge(action))
{
Debug.Log("Failed merge");
info.setObjects.Add(action);
}
else
{
Debug.Log("Post merge: " + info.setObjects[^1]);
}
}
}
return shapeKeys;
}
}
}

View File

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 740a387c9d934623a6b06d945b38d8d0
timeCreated: 1717123900

View File

@ -6,6 +6,7 @@ using nadena.dev.modular_avatar.core.editor;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using EditorCurveBinding = UnityEditor.EditorCurveBinding;
public class ActiveAnimationRetargeterTests : TestBase
{

View File

@ -5,6 +5,7 @@ using NUnit.Framework;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
using EditorCurveBinding = UnityEditor.EditorCurveBinding;
namespace ShapeChangerTests
{