mirror of
https://github.com/bdunderscore/modular-avatar.git
synced 2025-01-30 10:12:59 +08:00
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:
parent
746a72f60b
commit
57fe84548c
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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 == "")
|
||||
{
|
||||
|
18
Editor/Animation/EditorCurveBindingComparer.cs
Normal file
18
Editor/Animation/EditorCurveBindingComparer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
3
Editor/Animation/EditorCurveBindingComparer.cs.meta
Normal file
3
Editor/Animation/EditorCurveBindingComparer.cs.meta
Normal file
@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e751f7889323485bbe202285a47cb0d4
|
||||
timeCreated: 1719196767
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
143
Editor/Animation/ReadableProperty.cs
Normal file
143
Editor/Animation/ReadableProperty.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
3
Editor/Animation/ReadableProperty.cs.meta
Normal file
3
Editor/Animation/ReadableProperty.cs.meta
Normal file
@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1074339e2a59465ba585cb8cbbc4a88c
|
||||
timeCreated: 1719195449
|
@ -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);
|
||||
|
682
Editor/ShapeChanger/PropertyOverlayPass.cs
Normal file
682
Editor/ShapeChanger/PropertyOverlayPass.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
3
Editor/ShapeChanger/PropertyOverlayPass.cs.meta
Normal file
3
Editor/ShapeChanger/PropertyOverlayPass.cs.meta
Normal file
@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fe9990c696424aada1212aebd23f4c64
|
||||
timeCreated: 1719196895
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 740a387c9d934623a6b06d945b38d8d0
|
||||
timeCreated: 1717123900
|
@ -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
|
||||
{
|
||||
|
@ -5,6 +5,7 @@ using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEngine;
|
||||
using EditorCurveBinding = UnityEditor.EditorCurveBinding;
|
||||
|
||||
namespace ShapeChangerTests
|
||||
{
|
||||
|
Loading…
Reference in New Issue
Block a user