diff --git a/CHANGELOG.md b/CHANGELOG.md index 67b56e24..8643ec1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- [#467] Added the `AnimatorServicesContext` and lots of supporting APIs for working with animator controllers. ### Fixed diff --git a/Editor/API/AnimatorServices.meta b/Editor/API/AnimatorServices.meta new file mode 100644 index 00000000..3d6c4512 --- /dev/null +++ b/Editor/API/AnimatorServices.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5340b8ecb97e49089a6d95bc16f28fce +timeCreated: 1730064812 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/AnimationIndex.cs b/Editor/API/AnimatorServices/AnimationIndex.cs new file mode 100644 index 00000000..324b97e8 --- /dev/null +++ b/Editor/API/AnimatorServices/AnimationIndex.cs @@ -0,0 +1,261 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using UnityEditor; + +namespace nadena.dev.ndmf.animator +{ + public sealed class AnimationIndex + { + private readonly Func> _getRoots; + private readonly Func _getInvalidationToken; + + private long _lastInvalidationToken; + + private readonly Action _invalidateAction; + private bool _isValid; + + private bool IsValid => _isValid && _lastInvalidationToken == _getInvalidationToken(); + + private readonly Dictionary> _objectPathToClip = new(); + private readonly Dictionary> _bindingToClip = new(); + private readonly Dictionary> _lastBindings = new(); + + internal AnimationIndex( + Func> getRoots, + Func getInvalidationToken) + { + _getRoots = getRoots; + _getInvalidationToken = getInvalidationToken; + _invalidateAction = () => _isValid = false; + } + + public AnimationIndex(IEnumerable controllers) + { + _invalidateAction = () => _isValid = false; + var controllerList = new List(controllers); + _getRoots = () => controllerList; + _getInvalidationToken = () => _lastInvalidationToken; + } + + public IEnumerable GetClipsForObjectPath(string objectPath) + { + if (!IsValid) RebuildCache(); + + if (_objectPathToClip.TryGetValue(objectPath, out var clips)) + { + return clips; + } + + return Enumerable.Empty(); + } + + public IEnumerable GetClipsForBinding(EditorCurveBinding binding) + { + if (!IsValid) RebuildCache(); + + if (_bindingToClip.TryGetValue(binding, out var clips)) + { + return clips; + } + + return Enumerable.Empty(); + } + + public void RewritePaths(Func rewriteRules) + { + if (!IsValid) RebuildCache(); + + var rewriteSet = _objectPathToClip.Values.SelectMany(s => s).Distinct(); + + RewritePaths(rewriteSet, rewriteRules); + + foreach (var root in _getRoots()) + { + if (root is VirtualAnimatorController vac) + { + foreach (var layer in vac.Layers) + { + if (layer.AvatarMask is not null) + { + RewriteAvatarMask(layer.AvatarMask, rewriteRules); + } + } + } + } + } + + private void RewriteAvatarMask(VirtualAvatarMask layerAvatarMask, Func rewriteRules) + { + Dictionary outputDict = new(); + + foreach (var kvp in layerAvatarMask.Elements) + { + var rewritten = rewriteRules(kvp.Key); + if (rewritten != null) + { + outputDict[rewritten] = kvp.Value; + } + } + + layerAvatarMask.Elements = outputDict.ToImmutableDictionary(); + } + + public void RewritePaths(Dictionary rewriteRules) + { + if (!IsValid) RebuildCache(); + + HashSet rewriteSet = new(); + + foreach (var key in rewriteRules.Keys) + { + if (!_objectPathToClip.TryGetValue(key, out var clips)) continue; + rewriteSet.UnionWith(clips); + } + + Func rewriteFunc = k => + { + // Note: We don't use GetValueOrDefault here as we want to distinguish between null and missing keys + // ReSharper disable once CanSimplifyDictionaryTryGetValueWithGetValueOrDefault + if (rewriteRules.TryGetValue(k, out var v)) return v; + return k; + }; + + RewritePaths(rewriteSet, rewriteFunc); + } + + private void RewritePaths(IEnumerable rewriteSet, Func rewriteFunc) + { + List recacheNeeded = new(); + + foreach (var clip in rewriteSet) + { + clip.EditPaths(rewriteFunc); + if (!_isValid) + { + recacheNeeded.Add(clip); + } + + _isValid = true; + } + + foreach (var clip in recacheNeeded) + { + CacheClip(clip); + } + } + + public void EditClipsByBinding(IEnumerable binding, Action processClip) + { + if (!IsValid) RebuildCache(); + + var clips = binding.SelectMany(GetClipsForBinding).ToHashSet(); + var toRecache = new List(); + foreach (var clip in clips) + { + processClip(clip); + if (!_isValid) + { + toRecache.Add(clip); + } + + _isValid = true; + } + + foreach (var clip in toRecache) + { + CacheClip(clip); + } + } + + private void RebuildCache() + { + _objectPathToClip.Clear(); + _bindingToClip.Clear(); + _lastBindings.Clear(); + + foreach (var clip in EnumerateClips()) + { + CacheClip(clip); + } + + _isValid = true; + } + + private void CacheClip(VirtualClip clip) + { + if (_lastBindings.TryGetValue(clip, out var lastBindings)) + { + foreach (var binding in lastBindings) + { + _bindingToClip[binding].Remove(clip); + _objectPathToClip[binding.path].Remove(clip); + } + } + else + { + lastBindings = new HashSet(); + _lastBindings[clip] = lastBindings; + } + + lastBindings.Clear(); + lastBindings.UnionWith(clip.GetObjectCurveBindings()); + lastBindings.UnionWith(clip.GetFloatCurveBindings()); + + foreach (var binding in lastBindings) + { + if (!_bindingToClip.TryGetValue(binding, out var clips)) + { + clips = new HashSet(); + _bindingToClip[binding] = clips; + } + + clips.Add(clip); + + if (!_objectPathToClip.TryGetValue(binding.path, out var pathClips)) + { + pathClips = new HashSet(); + _objectPathToClip[binding.path] = pathClips; + } + + pathClips.Add(clip); + } + } + + private IEnumerable EnumerateClips() + { + HashSet visited = new(); + Queue queue = new(); + + _lastInvalidationToken = _getInvalidationToken(); + foreach (var controller in _getRoots()) + { + queue.Enqueue(controller); + } + + while (queue.Count > 0) + { + var node = queue.Dequeue(); + node.RegisterCacheObserver(_invalidateAction); + + if (!visited.Add(node)) + { + continue; + } + + foreach (var child in node.EnumerateChildren()) + { + if (!visited.Contains(child)) queue.Enqueue(child); + } + + if (node is VirtualClip clip) + { + yield return clip; + } + } + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/AnimationIndex.cs.meta b/Editor/API/AnimatorServices/AnimationIndex.cs.meta new file mode 100644 index 00000000..57f8c042 --- /dev/null +++ b/Editor/API/AnimatorServices/AnimationIndex.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f13c53def200423bbf3324b26652a61b +timeCreated: 1731271206 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/AnimatorServicesContext.cs b/Editor/API/AnimatorServices/AnimatorServicesContext.cs new file mode 100644 index 00000000..4aa98a9e --- /dev/null +++ b/Editor/API/AnimatorServices/AnimatorServicesContext.cs @@ -0,0 +1,58 @@ +#nullable enable + +using System; +using JetBrains.Annotations; + +namespace nadena.dev.ndmf.animator +{ + /// + /// Provides a number of NDMF services based on virtualizing animator controllers. + /// While this context is active, NDMF will automatically track object renames, and apply them to all known + /// animators. It will also keep animators cached in the VirtualControllerContext (which is also, as a convenience, + /// available through this class). + /// Note that any new objects created should be registered in the ObjectPathRemapper if they'll be used in animations; + /// this ensures that subsequent movements will be tracked properly. Likewise, use ObjectPathRemapper to obtain + /// (virtual) object paths for newly created objects. + /// + [DependsOnContext(typeof(VirtualControllerContext))] + [PublicAPI] + public sealed class AnimatorServicesContext : IExtensionContext + { + private VirtualControllerContext? _controllerContext; + + public VirtualControllerContext ControllerContext => _controllerContext ?? + throw new InvalidOperationException( + "ControllerContext is not available outside of the AnimatorServicesContext"); + + private AnimationIndex? _animationIndex; + + public AnimationIndex AnimationIndex => _animationIndex ?? + throw new InvalidOperationException( + "AnimationIndex is not available outside of the AnimatorServicesContext"); + + private ObjectPathRemapper? _objectPathRemapper; + + public ObjectPathRemapper ObjectPathRemapper => _objectPathRemapper ?? + throw new InvalidOperationException( + "ObjectPathRemapper is not available outside of the AnimatorServicesContext"); + + public void OnActivate(BuildContext context) + { + _controllerContext = context.Extension(); + _animationIndex = new AnimationIndex( + () => ControllerContext.GetAllControllers(), + () => ControllerContext.CacheInvalidationToken + ); + _objectPathRemapper = new ObjectPathRemapper(context.AvatarRootTransform); + } + + public void OnDeactivate(BuildContext context) + { + AnimationIndex.RewritePaths(ObjectPathRemapper.GetVirtualToRealPathMap()); + + _objectPathRemapper = null; + _animationIndex = null; + _controllerContext = null; + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/AnimatorServicesContext.cs.meta b/Editor/API/AnimatorServices/AnimatorServicesContext.cs.meta new file mode 100644 index 00000000..9461673a --- /dev/null +++ b/Editor/API/AnimatorServices/AnimatorServicesContext.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: bf57aa6e14724271b580519aa621c540 +timeCreated: 1731987075 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/CloneContext.cs b/Editor/API/AnimatorServices/CloneContext.cs new file mode 100644 index 00000000..cef5f313 --- /dev/null +++ b/Editor/API/AnimatorServices/CloneContext.cs @@ -0,0 +1,255 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; +using UnityEditor.Animations; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace nadena.dev.ndmf.animator +{ + /// + /// The CloneContext keeps track of which virtual objects have been cloned from which original objects, and + /// therefore avoids double-cloning. It also keeps track of various context used during cloning, such as virtual + /// layer offsets. + /// Most users shouldn't use CloneContext directly; use the Clone wrappers in VirtualControllerContext instead. + /// + [PublicAPI] + public sealed class CloneContext + { + public IPlatformAnimatorBindings PlatformBindings { get; private set; } + private readonly Dictionary _clones = new(); + + private int _cloneDepth, _nextVirtualLayer, _virtualLayerBase, _maxMappedPhysLayer; + private readonly Queue _deferredCalls = new(); + + private struct DynamicScopeState + { + public ImmutableList OverrideControllers; + public object? InnateAnimatorKey; + } + + private DynamicScopeState _curDynScope = new() + { + OverrideControllers = ImmutableList.Empty + }; + + private ImmutableList OverrideControllers => _curDynScope.OverrideControllers; + + /// + /// When cloning an innate animator, this property will be set to the key of the animator. + /// In the case of VRChat, this contains the layer type while cloning. + /// + public object? ActiveInnateLayerKey => _curDynScope.InnateAnimatorKey; + + public CloneContext(IPlatformAnimatorBindings platformBindings) + { + PlatformBindings = platformBindings; + _nextVirtualLayer = _virtualLayerBase = 0x10_0000; + } + + private class DynamicScope : IDisposable + { + private readonly CloneContext _context; + private readonly DynamicScopeState _priorStack; + + public DynamicScope(CloneContext context) + { + _context = context; + _priorStack = context._curDynScope; + } + + public void Dispose() + { + _context._curDynScope = _priorStack; + } + } + + internal IDisposable PushOverrideController(AnimatorOverrideController controller) + { + var scope = new DynamicScope(this); + + _curDynScope.OverrideControllers = _curDynScope.OverrideControllers.Add(controller); + + return scope; + } + + internal IDisposable PushActiveInnateKey(object key) + { + var scope = new DynamicScope(this); + + _curDynScope.InnateAnimatorKey = key; + + return scope; + } + + /// + /// Applies any in-scope AnimationOverrideControllers to the given motion to get the effective motion. + /// + /// + /// + public AnimationClip MapClipOnClone(AnimationClip clip) + { + foreach (var controller in OverrideControllers) + { + clip = controller[clip]; + } + + return clip; + } + + internal bool TryGetValue(T key, out U? value) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + + var rv = _clones.TryGetValue(key, out var tmp); + + if (rv) value = (U?)tmp; + else value = default; + + return rv; + } + + private U? GetOrClone(T? key, Func clone) where U : class + { + try + { + _cloneDepth++; + + if (key == null || (key is Object obj && obj == null)) return null; + if (TryGetValue(key, out U? value)) return value; + value = clone(this, key); + _clones[key] = value; + return value; + } + finally + { + if (--_cloneDepth == 0) + { + // Flush deferred actions. Note that deferred actions might spawn other clones, so be careful not + // to recurse while flushing. + try + { + _cloneDepth++; + + while (_deferredCalls.TryDequeue(out var action)) action(); + } + finally + { + _cloneDepth--; + } + } + } + } + + internal int AllocateVirtualLayerSpace(int n) + { + var layerStart = _nextVirtualLayer; + _nextVirtualLayer += n; + _virtualLayerBase = layerStart; + _maxMappedPhysLayer = n; + + return layerStart; + } + + internal int AllocateSingleVirtualLayer() + { + return _nextVirtualLayer++; + } + + internal StateMachineBehaviour ImportBehaviour(StateMachineBehaviour behaviour) + { + behaviour = Object.Instantiate(behaviour); + PlatformBindings.VirtualizeStateBehaviour(this, behaviour); + return behaviour; + } + + [return: NotNullIfNotNull("controller")] + public VirtualAnimatorController? Clone(RuntimeAnimatorController? controller) + { + using var _ = new ProfilerScope("Clone Animator Controller", controller); + return GetOrClone(controller, VirtualAnimatorController.Clone); + } + + [return: NotNullIfNotNull("layer")] + public VirtualLayer? Clone(AnimatorControllerLayer? layer, int index) + { + using var _ = new ProfilerScope("Clone Animator Layer"); + return GetOrClone(layer, (ctx, obj) => VirtualLayer.Clone(ctx, obj, index)); + } + + [return: NotNullIfNotNull("stateMachine")] + public VirtualStateMachine? Clone(AnimatorStateMachine? stateMachine) + { + using var _ = new ProfilerScope("Clone State Machine", stateMachine); + return GetOrClone(stateMachine, VirtualStateMachine.Clone); + } + + [return: NotNullIfNotNull("transition")] + public VirtualStateTransition? Clone(AnimatorStateTransition? transition) + { + using var _ = new ProfilerScope("Clone State Transition", transition); + return GetOrClone(transition, VirtualStateTransition.Clone); + } + + [return: NotNullIfNotNull("transition")] + public VirtualTransition? Clone(AnimatorTransition? transition) + { + using var _ = new ProfilerScope("Clone Transition", transition); + return GetOrClone(transition, VirtualTransition.Clone); + } + + [return: NotNullIfNotNull("state")] + public VirtualState? Clone(AnimatorState? state) + { + using var _ = new ProfilerScope("Clone State", state); + return GetOrClone(state, VirtualState.Clone); + } + + [return: NotNullIfNotNull("m")] + public VirtualMotion? Clone(Motion? m) + { + using var _ = new ProfilerScope("Clone Motion", m); + return GetOrClone(m, VirtualMotion.Clone); + } + + [return: NotNullIfNotNull("clip")] + public VirtualClip? Clone(AnimationClip? clip) + { + using var _ = new ProfilerScope("Clone Clip", clip); + return GetOrClone(clip, VirtualClip.Clone); + } + + public VirtualAvatarMask? Clone(AvatarMask layerAvatarMask) + { + using var _ = new ProfilerScope("Clone Avatar Mask", layerAvatarMask); + return GetOrClone(layerAvatarMask, VirtualAvatarMask.Clone); + } + + public void DeferCall(Action action) + { + var overrideStack = _curDynScope; + // Preserve ambient AnimatorOverrideController context when we defer calls + if (_cloneDepth > 0) + _deferredCalls.Enqueue(() => + { + using var _ = new DynamicScope(this); + _curDynScope = overrideStack; + + action(); + }); + else action(); + } + + public int CloneSourceToVirtualLayerIndex(int layerIndex) + { + return layerIndex < _maxMappedPhysLayer && layerIndex >= 0 + ? layerIndex + _virtualLayerBase + : -1; + } + + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/CloneContext.cs.meta b/Editor/API/AnimatorServices/CloneContext.cs.meta new file mode 100644 index 00000000..f5450c48 --- /dev/null +++ b/Editor/API/AnimatorServices/CloneContext.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: d8371cbdadad4e6fbf9cc3f999abfdb6 +timeCreated: 1731183655 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/ECBComparator.cs b/Editor/API/AnimatorServices/ECBComparator.cs new file mode 100644 index 00000000..78528b9c --- /dev/null +++ b/Editor/API/AnimatorServices/ECBComparator.cs @@ -0,0 +1,43 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using UnityEditor; + +namespace nadena.dev.ndmf.animator +{ + internal class ECBComparator : IComparer, IEqualityComparer + { + internal static ECBComparator Instance { get; } = new(); + + private ECBComparator() + { + } + + public int Compare(EditorCurveBinding x, EditorCurveBinding y) + { + var pathComparison = string.Compare(x.path, y.path, StringComparison.Ordinal); + if (pathComparison != 0) return pathComparison; + var propertyNameComparison = string.Compare(x.propertyName, y.propertyName, StringComparison.Ordinal); + if (propertyNameComparison != 0) return propertyNameComparison; + var isPPtrCurveComparison = x.isPPtrCurve.CompareTo(y.isPPtrCurve); + if (isPPtrCurveComparison != 0) return isPPtrCurveComparison; + var isDiscreteCurveComparison = x.isDiscreteCurve.CompareTo(y.isDiscreteCurve); + if (isDiscreteCurveComparison != 0) return isDiscreteCurveComparison; + return x.isSerializeReferenceCurve.CompareTo(y.isSerializeReferenceCurve); + } + + public bool Equals(EditorCurveBinding x, EditorCurveBinding y) + { + return x.path == y.path && x.propertyName == y.propertyName && x.isPPtrCurve == y.isPPtrCurve && + x.isDiscreteCurve == y.isDiscreteCurve && + x.isSerializeReferenceCurve == y.isSerializeReferenceCurve && Equals(x.type, y.type); + } + + public int GetHashCode(EditorCurveBinding obj) + { + return HashCode.Combine(obj.path, obj.propertyName, obj.isPPtrCurve, obj.isDiscreteCurve, + obj.isSerializeReferenceCurve, obj.type); + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/ECBComparator.cs.meta b/Editor/API/AnimatorServices/ECBComparator.cs.meta new file mode 100644 index 00000000..d973bbe1 --- /dev/null +++ b/Editor/API/AnimatorServices/ECBComparator.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: cadf93f628a44bf3ab0b0e8c09942f11 +timeCreated: 1730067342 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/ICommitable.cs b/Editor/API/AnimatorServices/ICommitable.cs new file mode 100644 index 00000000..df063e67 --- /dev/null +++ b/Editor/API/AnimatorServices/ICommitable.cs @@ -0,0 +1,110 @@ +#nullable enable + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using JetBrains.Annotations; +using UnityEngine; + +namespace nadena.dev.ndmf.animator +{ + internal interface ICommitable + { + /// + /// Allocates the destination unity object, but does not recurse back into the CommitContext. + /// + /// + /// + T Prepare(CommitContext context); + + /// + /// Fills in all fields of the destination unity object. This may recurse back into the CommitContext. + /// + /// + /// Object returned from Prepare + void Commit(CommitContext context, T obj); + } + + [PublicAPI] + public sealed class CommitContext + { + private readonly IPlatformAnimatorBindings _platform; + + private readonly Dictionary _commitCache = new(); + private readonly Dictionary _virtIndexToVirtLayer = new(); + private readonly Dictionary _virtLayerToPhysIndex = new(); + + public object? ActiveInnateLayerKey { get; internal set; } + + internal CommitContext() : this(GenericPlatformAnimatorBindings.Instance) + { + } + + public CommitContext(IPlatformAnimatorBindings platform) + { + _platform = platform; + } + + internal IEnumerable AllObjects => _commitCache.Values.Select(o => + { + if (o is UnityEngine.Object unityObj) return unityObj; + return null; + }).Where(o => o != null)!; + + [return: NotNullIfNotNull("obj")] + internal R? CommitObject(ICommitable? obj) where R : class + { + if (obj == null) return null; + if (_commitCache.TryGetValue(obj, out var result)) return (R)result; + + var resultObj = obj.Prepare(this); + _commitCache[obj] = resultObj; + + obj.Commit(this, resultObj); + + return resultObj; + } + + internal StateMachineBehaviour CommitBehaviour(StateMachineBehaviour behaviour) + { + _platform.CommitStateBehaviour(this, behaviour); + return behaviour; + } + + internal void RegisterVirtualLayerMapping(VirtualLayer virtualLayer, int virtualLayerIndex) + { + _virtIndexToVirtLayer[virtualLayerIndex] = virtualLayer; + } + + internal void RegisterPhysicalLayerMapping(int physicalLayerIndex, VirtualLayer virtualLayer) + { + _virtLayerToPhysIndex[virtualLayer] = physicalLayerIndex; + } + + public int VirtualToPhysicalLayerIndex(int index) + { + if (_virtIndexToVirtLayer.TryGetValue(index, out var virtLayer) + && _virtLayerToPhysIndex.TryGetValue(virtLayer, out var physIndex) + ) + { + return physIndex; + } + + return -1; + } + + /// + /// Destroys all objects committed in this context. Primarily intended for test cleanup. + /// + public void DestroyAllImmediate() + { + foreach (var obj in _commitCache.Values) + { + if (obj is Object unityObj) + { + Object.DestroyImmediate(unityObj); + } + } + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/ICommitable.cs.meta b/Editor/API/AnimatorServices/ICommitable.cs.meta new file mode 100644 index 00000000..e414626b --- /dev/null +++ b/Editor/API/AnimatorServices/ICommitable.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: dc6d34409a5d49f9a0e152f159cb9a2f +timeCreated: 1730066316 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/LayerPriority.cs b/Editor/API/AnimatorServices/LayerPriority.cs new file mode 100644 index 00000000..bcf813f1 --- /dev/null +++ b/Editor/API/AnimatorServices/LayerPriority.cs @@ -0,0 +1,72 @@ +#nullable enable + +using System; +using JetBrains.Annotations; + +namespace nadena.dev.ndmf.animator +{ + [PublicAPI] + public struct LayerPriority : IComparable, IEquatable + { + public static LayerPriority Default = new(); + + private readonly int _priority; + + public LayerPriority(int priority) + { + _priority = priority; + } + + public int CompareTo(LayerPriority other) + { + if (_priority != other._priority) return _priority.CompareTo(other._priority); + + return 0; + } + + public static bool operator <(LayerPriority a, LayerPriority b) + { + return a.CompareTo(b) < 0; + } + + public static bool operator >(LayerPriority a, LayerPriority b) + { + return a.CompareTo(b) > 0; + } + + public static bool operator <=(LayerPriority a, LayerPriority b) + { + return a.CompareTo(b) <= 0; + } + + public static bool operator >=(LayerPriority a, LayerPriority b) + { + return a.CompareTo(b) >= 0; + } + + public static bool operator ==(LayerPriority a, LayerPriority b) + { + return a.CompareTo(b) == 0; + } + + public static bool operator !=(LayerPriority a, LayerPriority b) + { + return !(a == b); + } + + public override bool Equals(object obj) + { + return obj is LayerPriority other && Equals(other); + } + + public override int GetHashCode() + { + return _priority; + } + + public bool Equals(LayerPriority other) + { + return _priority == other._priority; + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/LayerPriority.cs.meta b/Editor/API/AnimatorServices/LayerPriority.cs.meta new file mode 100644 index 00000000..38b05515 --- /dev/null +++ b/Editor/API/AnimatorServices/LayerPriority.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: dabf83342ed94f50bdcebef96785aeae +timeCreated: 1731268277 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/ObjectPathRemapper.cs b/Editor/API/AnimatorServices/ObjectPathRemapper.cs new file mode 100644 index 00000000..47008943 --- /dev/null +++ b/Editor/API/AnimatorServices/ObjectPathRemapper.cs @@ -0,0 +1,194 @@ +#nullable enable + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; +using nadena.dev.ndmf.runtime; +using UnityEngine; + +namespace nadena.dev.ndmf.animator +{ + /// + /// The ObjectPathRemapper is used to track GameObject movement in the hierarchy, and to update animation paths + /// accordingly. + /// While the ObjectPathRemapper is active, there are a few important rules around hierarchy and animation + /// maniuplation that must be observed. + /// 1. The ObjectPathRemapper takes a snapshot of the object paths that were present at time of activation; + /// as such, any new animations added while it is active must use those paths, not those of the current + /// hierarchy. To help with this, you can use the `GetVirtualPathForObject` method to get the path that + /// should be used for newly generated animations. + /// 2. Objects can be moved freely within the hierarchy; however, if you want to _remove_ an object, you must call + /// `ReplaceObject` on it first. + /// 3. Objects can be freely added; if you want to use those objects in animations, use `GetVirtualPathForObject` + /// to get the path that should be used. This will automatically register that object, if necessary. If you'd + /// like to use animation clips with pre-existing paths on, for example, a newly instantiated prefab hierarchy, + /// use `RecordObjectTree` to ensure that those objects have their current paths recorded first. This ensures + /// that if those objects are moved in later stages, the paths will be updated appropriately. + /// Note that it's possible that these paths may collide with paths that _previously_ existed, so it's still + /// recommended to use `GetVirtualPathForObject` to ensure that the path is unique. + /// + [PublicAPI] + public sealed class ObjectPathRemapper + { + private readonly Transform _root; + private readonly Dictionary> _objectToOriginalPaths = new(); + private readonly Dictionary _pathToObject = new(); + + private bool _cacheValid; + private Dictionary _originalToMappedPath = new(); + + internal ObjectPathRemapper(Transform root) + { + _root = root; + RecordObjectTree(root); + } + + /// + /// Clears the path remapping cache. This should be called after making changes to the hierarchy, + /// such as moving objects around. + /// + public void ClearCache() + { + _cacheValid = false; + } + + /// + /// Returns a dictionary mapping from virtual paths (ie - those currently in use in animations) to the corresponding + /// object's current paths. + /// Deleted objects are represented by a null value. + /// + /// + public Dictionary GetVirtualToRealPathMap() + { + ClearCache(); + UpdateCache(); + + var result = _originalToMappedPath; + _originalToMappedPath = new Dictionary(); + + return result; + } + + private void UpdateCache() + { + if (_cacheValid) return; + + _originalToMappedPath.Clear(); + + foreach (var kvp in _objectToOriginalPaths) + { + var realPath = kvp.Key != null ? RuntimeUtil.RelativePath(_root, kvp.Key) : null; + + foreach (var path in kvp.Value) + { + if (path == "") continue; + _originalToMappedPath[path] = realPath; + } + } + } + + /// + /// Ensures all objects in this object and its children are recorded in the object path mapper. + /// + /// + public void RecordObjectTree(Transform subtree) + { + GetVirtualPathForObject(subtree); + + foreach (Transform child in subtree) + { + RecordObjectTree(child); + } + } + + /// + /// Returns the GameObject corresponding to an animation path, if any. This is based on where the object + /// was located at the time it was first discovered, _not_ its current location. + /// + /// + /// + public GameObject? GetObjectForPath(string path) + { + var xform = _pathToObject.GetValueOrDefault(path); + return xform ? xform.gameObject : null; + } + + /// + /// Returns a virtual path for the given GameObject. For most objects, this will be their actual path; however, + /// if that path is unusable (e.g. another object was previously at that path), a new path will be generated + /// instead. + /// + /// + /// + public string GetVirtualPathForObject(GameObject obj) + { + return GetVirtualPathForObject(obj.transform); + } + + /// + /// Returns a virtual path for the given Transform. For most objects, this will be their actual path; however, + /// if that path is unusable (e.g. another object was previously at that path), a new path will be generated + /// instead. + /// + /// + /// + public string GetVirtualPathForObject(Transform t) + { + if (_objectToOriginalPaths.TryGetValue(t, out var paths)) + { + return paths[0]; + } + + var path = RuntimeUtil.RelativePath(_root, t); + if (path == null) path = t.gameObject.name + "###UNROOTED_" + t.GetInstanceID(); + + if (_pathToObject.ContainsKey(path)) + { + path += "###PENDING_" + t.GetInstanceID(); + } + + _objectToOriginalPaths[t] = new List { path }; + _pathToObject[path] = t; + _cacheValid = false; + + return path; + } + + /// + /// Replaces all references to `old` with `newObject`. + /// + /// + /// + public void ReplaceObject(GameObject old, GameObject newObject) + { + ReplaceObject(old.transform, newObject.transform); + } + + /// + /// Replaces all references to `old` with `newObject`. + /// + /// + /// + public void ReplaceObject(Transform old, Transform newObject) + { + if (!_objectToOriginalPaths.TryGetValue(old, out var paths)) return; + + ClearCache(); + + if (_objectToOriginalPaths.TryGetValue(newObject, out var originalPaths)) + { + originalPaths.AddRange(paths); + } + else + { + _objectToOriginalPaths[newObject] = paths; + } + + _objectToOriginalPaths.Remove(old); + foreach (var path in paths) + { + _pathToObject[path] = newObject; + } + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/ObjectPathRemapper.cs.meta b/Editor/API/AnimatorServices/ObjectPathRemapper.cs.meta new file mode 100644 index 00000000..24ad97be --- /dev/null +++ b/Editor/API/AnimatorServices/ObjectPathRemapper.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 555cbff33c8d467ea13832f7b650b15a +timeCreated: 1731273374 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/PlatformBindings.meta b/Editor/API/AnimatorServices/PlatformBindings.meta new file mode 100644 index 00000000..4f8aae74 --- /dev/null +++ b/Editor/API/AnimatorServices/PlatformBindings.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: ddea6d76d6224481afe27d936334597b +timeCreated: 1731206033 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/PlatformBindings/GenericPlatformAnimatorBindings.cs b/Editor/API/AnimatorServices/PlatformBindings/GenericPlatformAnimatorBindings.cs new file mode 100644 index 00000000..36338655 --- /dev/null +++ b/Editor/API/AnimatorServices/PlatformBindings/GenericPlatformAnimatorBindings.cs @@ -0,0 +1,59 @@ +#nullable enable + +using System.Collections.Generic; +using UnityEngine; + +namespace nadena.dev.ndmf.animator +{ + public sealed class GenericPlatformAnimatorBindings : IPlatformAnimatorBindings + { + public static readonly GenericPlatformAnimatorBindings Instance = new(); + + private GenericPlatformAnimatorBindings() + { + } + + public bool IsSpecialMotion(Motion m) + { + return false; + } + + public IEnumerable<(object, RuntimeAnimatorController, bool)> GetInnateControllers(GameObject root) + { + foreach (var animator in root.GetComponentsInChildren(true)) + { + var controller = animator.runtimeAnimatorController; + + if (controller != null) + { + yield return (animator, controller, false); + } + } + + foreach (var custom in root.GetComponentsInChildren(true)) + { + var controller = custom.AnimatorController; + + if (controller != null) + { + yield return (custom, controller, false); + } + } + } + + public void CommitControllers(GameObject root, IDictionary controllers) + { + foreach (var (key, controller) in controllers) + { + if (key is Animator a && a != null) + { + a.runtimeAnimatorController = controller; + } + else if (key is IVirtualizeAnimatorController v && key is Component c && c != null) + { + v.AnimatorController = controller; + } + } + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/PlatformBindings/GenericPlatformAnimatorBindings.cs.meta b/Editor/API/AnimatorServices/PlatformBindings/GenericPlatformAnimatorBindings.cs.meta new file mode 100644 index 00000000..9028f06c --- /dev/null +++ b/Editor/API/AnimatorServices/PlatformBindings/GenericPlatformAnimatorBindings.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7d6bd645576c4314829c799981d50d38 +timeCreated: 1731184527 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/PlatformBindings/IPlatformAnimatorBindings.cs b/Editor/API/AnimatorServices/PlatformBindings/IPlatformAnimatorBindings.cs new file mode 100644 index 00000000..761eca92 --- /dev/null +++ b/Editor/API/AnimatorServices/PlatformBindings/IPlatformAnimatorBindings.cs @@ -0,0 +1,68 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace nadena.dev.ndmf.animator +{ + public interface IPlatformAnimatorBindings + { + /// + /// If true, the motion asset should be maintained as-is without replacement or modification. + /// + /// + /// + bool IsSpecialMotion(Motion m) + { + return false; + } + + /// + /// Returns any animator controllers that are referenced by platform-specific assets (e.g. VRCAvatarDescriptor). + /// The bool flag indicates whether the controller is overridden (true) or left as default (false). + /// + /// + /// + IEnumerable<(object, RuntimeAnimatorController, bool)> GetInnateControllers(GameObject root) + { + return Array.Empty<(object, RuntimeAnimatorController, bool)>(); + } + + /// + /// Updates any innate controllers to reference new animator controllers. + /// + /// + /// + void CommitControllers(GameObject root, IDictionary controllers) + { + + } + + /// + /// Invoked after a StateMachineBehavior is cloned, to allow for any platform-specific modifications. + /// For example, in VRChat, this is used to replace the layer indexes with virtual layer indexes in the + /// VRChatAnimatorLayerControl behavior. + /// + /// Note that, if we're re-activating the virtual animator controller after committing, this will be re-invoked + /// with the same behaviour it had previously cloned. This allows for again converting between virtual and + /// physical layer indexes. + /// + /// + /// + void VirtualizeStateBehaviour(CloneContext context, StateMachineBehaviour behaviour) + { + } + + /// + /// Invoked when a StateMachineBehavior is being committed, to allow for any platform-specific modifications. + /// For example, in VRChat, this is used to replace the virtual layer indexes with the actual layer indexes in the + /// VRChatAnimatorLayerControl behavior. + /// + /// + /// + void CommitStateBehaviour(CommitContext context, StateMachineBehaviour behaviour) + { + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/PlatformBindings/IPlatformAnimatorBindings.cs.meta b/Editor/API/AnimatorServices/PlatformBindings/IPlatformAnimatorBindings.cs.meta new file mode 100644 index 00000000..1bc2a432 --- /dev/null +++ b/Editor/API/AnimatorServices/PlatformBindings/IPlatformAnimatorBindings.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0d015c4cdd524a2785fc4d5c9c24373a +timeCreated: 1730064961 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/PlatformBindings/VRChatPlatformAnimatorBindings.cs b/Editor/API/AnimatorServices/PlatformBindings/VRChatPlatformAnimatorBindings.cs new file mode 100644 index 00000000..847a2e50 --- /dev/null +++ b/Editor/API/AnimatorServices/PlatformBindings/VRChatPlatformAnimatorBindings.cs @@ -0,0 +1,217 @@ +#if NDMF_VRCSDK3_AVATARS +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using HarmonyLib; +using UnityEditor; +using UnityEditor.Animations; +using UnityEngine; +using VRC.SDK3.Avatars.Components; +using VRC.SDKBase; +using Object = UnityEngine.Object; + +namespace nadena.dev.ndmf.animator +{ + public sealed class VRChatPlatformAnimatorBindings : IPlatformAnimatorBindings + { + public static readonly VRChatPlatformAnimatorBindings Instance = new(); + + private const string SAMPLE_PATH_PACKAGE = + "Packages/com.vrchat.avatars"; + + private const string CONTROLLER_PATH_PACKAGE = + "Packages/com.vrchat.avatars/Samples/AV3 Demo Assets/Animation/Controllers"; + + private HashSet? _specialMotions; + + private VRChatPlatformAnimatorBindings() + { + } + + private AnimatorController? GetFallbackController(VRCAvatarDescriptor.AnimLayerType ty) + { + string name; + switch (ty) + { + case VRCAvatarDescriptor.AnimLayerType.Action: + name = "ActionLayer"; + break; + case VRCAvatarDescriptor.AnimLayerType.Additive: + name = "IdleLayer"; + break; + case VRCAvatarDescriptor.AnimLayerType.Base: + name = "LocomotionLayer"; + break; + case VRCAvatarDescriptor.AnimLayerType.Gesture: + name = "HandsLayer"; + break; + case VRCAvatarDescriptor.AnimLayerType.Sitting: + name = "SittingLayer"; + break; + case VRCAvatarDescriptor.AnimLayerType.FX: + name = "FaceLayer"; + break; + case VRCAvatarDescriptor.AnimLayerType.TPose: + name = "UtilityTPose"; + break; + case VRCAvatarDescriptor.AnimLayerType.IKPose: + name = "UtilityIKPose"; + break; + default: + name = null; + break; + } + + if (name != null) + { + name = "/vrc_AvatarV3" + name + ".controller"; + + return AssetDatabase.LoadAssetAtPath(CONTROLLER_PATH_PACKAGE + name); + } + + return null; + } + + public bool IsSpecialMotion(Motion m) + { + if (_specialMotions == null) + { + // https://creators.vrchat.com/avatars/#proxy-animations + _specialMotions = new HashSet( + AssetDatabase.FindAssets("t:AnimationClip", new[] { SAMPLE_PATH_PACKAGE }) + .Select(AssetDatabase.GUIDToAssetPath) + .Select(AssetDatabase.LoadAssetAtPath) + .Where(asset => asset.name.StartsWith("proxy_")) + ); + } + + return _specialMotions.Contains(m); + } + + public IEnumerable<(object, RuntimeAnimatorController, bool)> GetInnateControllers(GameObject root) + { + foreach (var result in GenericPlatformAnimatorBindings.Instance.GetInnateControllers(root)) + { + yield return result; + } + + if (!root.TryGetComponent(out var vrcAvatarDescriptor)) yield break; + + if (vrcAvatarDescriptor.baseAnimationLayers == null || + vrcAvatarDescriptor.baseAnimationLayers.All(l => l.isDefault)) + { + // Initialize the VRChat avatar descriptor. Unfortunately the only way to do this is to run the editor for + // it. Ick. + var editor = Editor.CreateEditor(vrcAvatarDescriptor); + var onEnable = AccessTools.Method(editor.GetType(), "OnEnable"); + onEnable?.Invoke(editor, null); + Object.DestroyImmediate(editor); + } + + // Make sure customizeAnimationLayers is set if we think they've been customized - otherwise the SDK + // likes to reset them automatically. + vrcAvatarDescriptor.customizeAnimationLayers = true; + + // TODO: Fallback layers + foreach (var layer in vrcAvatarDescriptor.baseAnimationLayers) + { + var ac = layer.isDefault ? null : layer.animatorController; + + // Can't use ?? here as we sometimes have unity-null objects sneaking in... + if (ac == null) ac = GetFallbackController(layer.type); + + if (ac == null) continue; + + yield return (layer.type, ac, layer.isDefault); + } + + foreach (var layer in vrcAvatarDescriptor.specialAnimationLayers) + { + var ac = layer.isDefault ? null : layer.animatorController; + + // Can't use ?? here as we sometimes have unity-null objects sneaking in... + if (ac == null) ac = GetFallbackController(layer.type); + + if (ac == null) continue; + + yield return (layer.type, ac, layer.isDefault); + } + } + + public void CommitControllers( + GameObject root, + IDictionary controllers + ) + { + if (!root.TryGetComponent(out var vrcAvatarDescriptor)) return; + + EditLayers(vrcAvatarDescriptor.baseAnimationLayers); + EditLayers(vrcAvatarDescriptor.specialAnimationLayers); + + GenericPlatformAnimatorBindings.Instance.CommitControllers(root, controllers); + + vrcAvatarDescriptor.customizeAnimationLayers = true; + + void EditLayers(VRCAvatarDescriptor.CustomAnimLayer[] layers) + { + for (var i = 0; i < layers.Length; i++) + { + if (controllers.TryGetValue(layers[i].type, out var controller)) + { + layers[i].animatorController = controller; + layers[i].isDefault = false; + } + } + } + } + + public void VirtualizeStateBehaviour(CloneContext context, StateMachineBehaviour behaviour) + { + var key = context.ActiveInnateLayerKey; + + switch (behaviour) + { + case VRCAnimatorLayerControl alc: + // null or equals + if (key?.Equals(ConvertLayer(alc.playable)) != false) + { + alc.layer = context.CloneSourceToVirtualLayerIndex(alc.layer); + } + + break; + } + } + + public void CommitStateBehaviour(CommitContext context, StateMachineBehaviour behaviour) + { + var key = context.ActiveInnateLayerKey; + + switch (behaviour) + { + case VRCAnimatorLayerControl alc: + if (key?.Equals(ConvertLayer(alc.playable)) != false) + { + alc.layer = context.VirtualToPhysicalLayerIndex(alc.layer); + } + + break; + } + } + + private object ConvertLayer(VRC_AnimatorLayerControl.BlendableLayer playable) + { + switch (playable) + { + case VRC_AnimatorLayerControl.BlendableLayer.Action: return VRCAvatarDescriptor.AnimLayerType.Action; + case VRC_AnimatorLayerControl.BlendableLayer.Additive: + return VRCAvatarDescriptor.AnimLayerType.Additive; + case VRC_AnimatorLayerControl.BlendableLayer.FX: return VRCAvatarDescriptor.AnimLayerType.FX; + case VRC_AnimatorLayerControl.BlendableLayer.Gesture: return VRCAvatarDescriptor.AnimLayerType.Gesture; + default: throw new ArgumentOutOfRangeException("Unknown blendable layer type: " + playable); + } + } + } +} +#endif \ No newline at end of file diff --git a/Editor/API/AnimatorServices/PlatformBindings/VRChatPlatformAnimatorBindings.cs.meta b/Editor/API/AnimatorServices/PlatformBindings/VRChatPlatformAnimatorBindings.cs.meta new file mode 100644 index 00000000..0870f553 --- /dev/null +++ b/Editor/API/AnimatorServices/PlatformBindings/VRChatPlatformAnimatorBindings.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 71f5bfb93bde472bbc0788b49c091666 +timeCreated: 1731206084 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualControllerContext.cs b/Editor/API/AnimatorServices/VirtualControllerContext.cs new file mode 100644 index 00000000..f8934574 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualControllerContext.cs @@ -0,0 +1,218 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using JetBrains.Annotations; +using UnityEditor.Animations; +using UnityEngine; +#if NDMF_VRCSDK3_AVATARS +using VRC.SDK3.Avatars.Components; +#endif + +namespace nadena.dev.ndmf.animator +{ + /// + /// This extension context converts all "innate" animator controllers bound to the avatar into virtual controllers. + /// By "innate", we mean controllers which are understood by the underlying platform (e.g. VRChat). As part of this, + /// the controllers and animations are cloned, so that changes to them do not affect the original assets. + /// This context acts as a key-value map from arbitrary context keys to virtual controllers. For VRChat controllers, + /// the context will be a `VRCAvatarDescriptor.AnimLayerType` enum value. For other sub-components which have + /// animator controllers (e.g. the unity `Animator` component), the context key will be that controller. Otherwise, + /// it's up to the IPlatformAnimatorBindings implementation to define the context key. + /// Upon deactivation, any changes to virtual controllers will be written back to their sources. + /// You may also add arbitrary virtual controllers to the context, by setting the value for a given key. These + /// controllers will be generally ignored, unless you choose to do something with them. Note that these controllers + /// will not be preserved across a context deactivation. + /// ## Limitations + /// When this context is active, you must not modify the original controllers or their animations. This is because + /// certain virtual objects may reference the original assets, and thus changes to them may result in undefined + /// behavior. + /// After deactivating this context, you must not modify the virtual controllers or their animations. This is because + /// subsequent NDMF processing steps may directly modify the serialized animator controllers; conversely, when the + /// virtual controller context is reactivated, it may or may not reuse the same virtual nodes as before. + /// + [PublicAPI] + public sealed class VirtualControllerContext : IExtensionContext + { + private class LayerState + { + internal readonly RuntimeAnimatorController? OriginalController; + internal VirtualAnimatorController? VirtualController; + + public LayerState(RuntimeAnimatorController? originalController) + { + OriginalController = originalController; + } + } + + private readonly Dictionary _layerStates = new(); + + // initialized on activate + private IPlatformAnimatorBindings? _platformBindings; + private CloneContext? _cloneContext; + + public CloneContext CloneContext => + _cloneContext ?? throw new InvalidOperationException("Extension context not initialized"); + + /// + /// This value is updated every time the set of virtual controllers changes. + /// + public long CacheInvalidationToken { get; private set; } + + public void OnActivate(BuildContext context) + { + var root = context.AvatarRootObject; + + #if NDMF_VRCSDK3_AVATARS + if (root.TryGetComponent(out _)) + { + _platformBindings = VRChatPlatformAnimatorBindings.Instance; + } + else + { + _platformBindings = GenericPlatformAnimatorBindings.Instance; + } + #else + _platformBindings = GenericPlatformAnimatorBindings.Instance; + #endif + + _cloneContext = new CloneContext(_platformBindings); + + var innateControllers = _platformBindings.GetInnateControllers(root); + _layerStates.Clear(); // TODO - retain and reactivate virtual controllers + CacheInvalidationToken++; + + foreach (var (type, controller, _) in innateControllers) + { + _layerStates[type] = new LayerState(controller); + + // Force all layers to be processed, for now. This avoids compatibility issues with NDMF + // plugins which assume that all layers have been cloned after MA runs. + _ = this[type]; + } + } + + public VirtualAnimatorController? this[object key] + { + get + { + if (!_layerStates.TryGetValue(key, out var state)) + { + return null; + } + + if (state.VirtualController == null) + { + using var _ = _cloneContext!.PushActiveInnateKey(key); + + state.VirtualController = _cloneContext!.Clone(state.OriginalController); + } + + return state.VirtualController; + } + set + { + CacheInvalidationToken++; + _layerStates[key] = new LayerState(null) + { + VirtualController = value + }; + } + } + + /// + /// "Forgets" a specific controller. This should usually only be done for controllers which are no longer + /// relevant, e.g. if the corresponding component has been removed. + /// + /// + public void ForgetController(object key) + { + CacheInvalidationToken++; + _layerStates.Remove(key); + } + + public void OnDeactivate(BuildContext context) + { + var root = context.AvatarRootObject; + + var commitContext = new CommitContext(_cloneContext!.PlatformBindings); + + var controllers = _layerStates + .Where(kvp => kvp.Value.VirtualController != null) + .ToDictionary( + kv => kv.Key, + kv => + { + commitContext.ActiveInnateLayerKey = kv.Key; + return (RuntimeAnimatorController)commitContext.CommitObject(kv.Value.VirtualController!); + }); + + using (var scope = context.OpenSerializationScope()) + { + _platformBindings!.CommitControllers(root, controllers); + + // Save all animator objects to prevent references from breaking later + foreach (var obj in commitContext.AllObjects) + { + context.AssetSaver.SaveAsset(obj); + } + } + } + + public IEnumerable GetAllControllers() + { + return _layerStates.Select(kv => this[kv.Key]).Where(v => v != null)!; + } + + + [return: NotNullIfNotNull("controller")] + public VirtualAnimatorController? Clone(RuntimeAnimatorController? controller) + { + return CloneContext.Clone(controller); + } + + [return: NotNullIfNotNull("layer")] + public VirtualLayer? Clone(AnimatorControllerLayer? layer, int index) + { + return CloneContext.Clone(layer, index); + } + + [return: NotNullIfNotNull("stateMachine")] + public VirtualStateMachine? Clone(AnimatorStateMachine? stateMachine) + { + return CloneContext.Clone(stateMachine); + } + + [return: NotNullIfNotNull("transition")] + public VirtualStateTransition? Clone(AnimatorStateTransition? transition) + { + return CloneContext.Clone(transition); + } + + [return: NotNullIfNotNull("transition")] + public VirtualTransition? Clone(AnimatorTransition? transition) + { + return CloneContext.Clone(transition); + } + + [return: NotNullIfNotNull("state")] + public VirtualState? Clone(AnimatorState? state) + { + return CloneContext.Clone(state); + } + + [return: NotNullIfNotNull("m")] + public VirtualMotion? Clone(Motion? m) + { + return CloneContext.Clone(m); + } + + [return: NotNullIfNotNull("clip")] + public VirtualClip? Clone(AnimationClip? clip) + { + return CloneContext.Clone(clip); + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualControllerContext.cs.meta b/Editor/API/AnimatorServices/VirtualControllerContext.cs.meta new file mode 100644 index 00000000..918a8d7a --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualControllerContext.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 4ab43d27415b4be9930c7b54f0579d33 +timeCreated: 1731206060 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects.meta b/Editor/API/AnimatorServices/VirtualObjects.meta new file mode 100644 index 00000000..093fc20b --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 174427801ddf47a680eb2ef4554bdd34 +timeCreated: 1731206048 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/SyncedLayerOverrideAccess.cs b/Editor/API/AnimatorServices/VirtualObjects/SyncedLayerOverrideAccess.cs new file mode 100644 index 00000000..27cff2fe --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/SyncedLayerOverrideAccess.cs @@ -0,0 +1,152 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Linq.Expressions; +using HarmonyLib; +using UnityEditor.Animations; +using UnityEngine; + +namespace nadena.dev.ndmf.animator +{ + /// + /// The AnimatorControllerLayer class does not provide efficient bulk access to its internal m_Motions and + /// m_Behaviours lists, which would make introspecting a synced layer quite slow. This class constructs some + /// JITtable accessors to help us get at those lists in bulk. + /// + [SuppressMessage("ReSharper", "InconsistentNaming")] + internal class SyncedLayerOverrideAccess + { + public static readonly Func>?> + ExtractStateMotionPairs; + + public static readonly Action>> + SetStateMotionPairs; + + public static readonly + Func>?> + ExtractStateBehaviourPairs; + + public static readonly + Action>> + SetStateBehaviourPairs; + + static SyncedLayerOverrideAccess() + { + ExtractStateMotionPairs = + Generate_ExtractStateMotionPairs("m_Motions", "m_State", "m_Motion"); + SetStateMotionPairs = Generate_Setter("m_Motions", "m_State", "m_Motion"); + + ExtractStateBehaviourPairs = + Generate_ExtractStateMotionPairs("m_Behaviours", "m_State", + "m_Behaviours"); + SetStateBehaviourPairs = + Generate_Setter("m_Behaviours", "m_State", "m_Behaviours"); + } + + private static Action>> Generate_Setter( + string fieldName, + string keyField, + string valueField + ) + { + var arrayField = AccessTools.Field(typeof(AnimatorControllerLayer), fieldName); + var t_Pair_arr = arrayField.FieldType; + var t_Pair = t_Pair_arr.GetElementType(); + + var f_pair_key = AccessTools.Field(t_Pair, keyField); + var f_pair_value = AccessTools.Field(t_Pair, valueField); + + var var_item = Expression.Variable(t_Pair, "item"); + var ex_key = Expression.Field(var_item, f_pair_key); + var ex_value = Expression.Field(var_item, f_pair_value); + + var p_kvp = Expression.Parameter(typeof(KeyValuePair), "kvp"); + + var construct_and_set = Expression.Block( + new[] { var_item }, + // item = new StateMotionPair() + Expression.Assign(var_item, Expression.New(t_Pair)), + // item.m_State = kvp.Key + Expression.Assign(ex_key, Expression.PropertyOrField(p_kvp, "Key")), + // item.m_Motion = kvp.Value + Expression.Assign(ex_value, Expression.PropertyOrField(p_kvp, "Value")), + // return item + var_item + ); + var kvp_to_pair_ty = typeof(Func<,>).MakeGenericType(typeof(KeyValuePair), t_Pair); + var kvp_to_pair = Expression.Lambda(kvp_to_pair_ty, construct_and_set, p_kvp); + + var m_toArray = typeof(Enumerable).GetMethod("ToArray").MakeGenericMethod(t_Pair); + var m_select = typeof(Enumerable).GetMethods() + .First(m => m.Name == "Select" && m.GetParameters().Length == 2 + && m.GetParameters()[1].ParameterType.GenericTypeArguments.Length == 2 + ); + var m_select_t = m_select.MakeGenericMethod(typeof(KeyValuePair), t_Pair); + + var p_layer = Expression.Parameter(typeof(AnimatorControllerLayer), "layer"); + var p_pairs = Expression.Parameter(typeof(IEnumerable>), "pairs"); + + // pairs => layer.m_Motions = pairs.Select(kvp => new StateMotionPair {m_State = kvp.Key, m_Motion = kvp.Value}).ToArray() + var ex_select = Expression.Call(m_select_t, p_pairs, kvp_to_pair); + var ex_toArray = Expression.Call(m_toArray, ex_select); + var ex_assign = Expression.Assign(Expression.Field(p_layer, arrayField), ex_toArray); + var lambda = Expression.Lambda< + Action>> + >(ex_assign, p_layer, p_pairs); + + return lambda.Compile(); + } + + private static Func>> + Generate_ExtractStateMotionPairs( + string fieldName, + string keyField, + string valueField + ) + { + var arrayField = AccessTools.Field(typeof(AnimatorControllerLayer), fieldName); + var t_Pair_arr = arrayField.FieldType; + var t_Pair = t_Pair_arr.GetElementType(); + + var f_pair_key = AccessTools.Field(t_Pair, keyField); + var f_pair_value = AccessTools.Field(t_Pair, valueField); + + var p_item = Expression.Parameter(t_Pair, "item"); + var ex_key = Expression.Field(p_item, f_pair_key); + var ex_value = Expression.Field(p_item, f_pair_value); + + var ctor_kvp = typeof(KeyValuePair).GetConstructor(new[] { typeof(K), typeof(V) }); + var ex_build_kvp = Expression.New(ctor_kvp, ex_key, ex_value); + + // Func> + var lambda_type = typeof(Func<,>).MakeGenericType(t_Pair, typeof(KeyValuePair)); + var lambda_convert = Expression.Lambda(lambda_type, ex_build_kvp, p_item); + + // Now build a lambda which will convert the m_Motions array, using the Linq Select method + var p_layer = Expression.Parameter(typeof(AnimatorControllerLayer), "layer"); + Expression ex_field_array = Expression.Field(p_layer, arrayField); + + // Add a null check and fallback for the array + var enum_empty = typeof(Enumerable).GetMethod("Empty")!.MakeGenericMethod(t_Pair).Invoke(null, null); + var ex_null_check = Expression.Condition( + Expression.Equal(ex_field_array, Expression.Constant(null)), + Expression.Constant(enum_empty), + ex_field_array + ); + + var m_select = typeof(Enumerable).GetMethods() + .First(m => m.Name == "Select" && m.GetParameters().Length == 2 + && m.GetParameters()[1].ParameterType.GenericTypeArguments.Length == 2 + ); + var m_select_t = m_select.MakeGenericMethod(t_Pair, typeof(KeyValuePair)); + var ex_select = Expression.Call(m_select_t, ex_null_check, lambda_convert); + + return Expression.Lambda< + Func>> + >(ex_select, p_layer) + .Compile(); + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/SyncedLayerOverrideAccess.cs.meta b/Editor/API/AnimatorServices/VirtualObjects/SyncedLayerOverrideAccess.cs.meta new file mode 100644 index 00000000..fb37d170 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/SyncedLayerOverrideAccess.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b94f501a3cdb406da9128ed5011e3434 +timeCreated: 1731380722 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualAnimatorController.cs b/Editor/API/AnimatorServices/VirtualObjects/VirtualAnimatorController.cs new file mode 100644 index 00000000..55488775 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualAnimatorController.cs @@ -0,0 +1,152 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using JetBrains.Annotations; +using UnityEditor.Animations; +using UnityEngine; + +namespace nadena.dev.ndmf.animator +{ + /// + /// Represents an animator controller that has been indexed by NDMF for faster manipulation. This class also + /// guarantees that certain assets have been cloned, specifically: + /// - AnimatorController + /// - StateMachine + /// - AnimatorState + /// - AnimatorStateTransition + /// - BlendTree + /// - AnimationClip + /// - Any state behaviors attached to the animator controller + /// + [PublicAPI] + public sealed class VirtualAnimatorController : VirtualNode, ICommitable + { + private readonly CloneContext _context; + public string Name { get; set; } + + private ImmutableDictionary _parameters; + + public ImmutableDictionary Parameters + { + get => _parameters; + set => _parameters = I(value ?? throw new ArgumentNullException(nameof(value))); + } + + private readonly SortedDictionary _layers = new(); + + private struct LayerGroup + { + public List Layers; + } + + public static VirtualAnimatorController Create(CloneContext context, string name = "(unnamed)") + { + return new VirtualAnimatorController(context, name); + } + + private VirtualAnimatorController(CloneContext context, string name) + { + _context = context; + _parameters = ImmutableDictionary.Empty; + Name = name; + } + + public void AddLayer(LayerPriority priority, VirtualLayer layer) + { + Invalidate(); + + if (!_layers.TryGetValue(priority, out var group)) + { + group = new LayerGroup { Layers = new List() }; + _layers.Add(priority, group); + } + + group.Layers.Add(layer); + } + + public VirtualLayer AddLayer(LayerPriority priority, string name) + { + // implicitly creates state machine + var layer = VirtualLayer.Create(_context, name); + + AddLayer(priority, layer); + + return layer; + } + + public IEnumerable Layers + { + get { return _layers.Values.SelectMany(l => l.Layers); } + } + + internal static VirtualAnimatorController Clone(CloneContext context, RuntimeAnimatorController controller) + { + switch (controller) + { + case AnimatorController ac: return new VirtualAnimatorController(context, ac); + case AnimatorOverrideController aoc: + { + using var _ = context.PushOverrideController(aoc); + + return Clone(context, aoc.runtimeAnimatorController); + } + default: throw new NotImplementedException($"Unknown controller type {controller.GetType()}"); + } + } + + private VirtualAnimatorController(CloneContext context, AnimatorController controller) + { + _context = context; + Name = controller.name; + _parameters = controller.parameters.ToImmutableDictionary(p => p.name); + + var srcLayers = controller.layers; + context.AllocateVirtualLayerSpace(srcLayers.Length); + + var p0Layers = srcLayers.Select((l, i) => VirtualLayer.Clone(context, l, i)).ToList(); + foreach (var layer in p0Layers) + { + layer.IsOriginalLayer = true; + } + + _layers[new LayerPriority(0)] = new LayerGroup { Layers = p0Layers }; + } + + AnimatorController ICommitable.Prepare(CommitContext context) + { + var controller = new AnimatorController + { + name = Name, + parameters = Parameters + .OrderBy(p => p.Key) + .Select(p => + { + p.Value.name = p.Key; + return p.Value; + }) + .ToArray() + }; + + foreach (var (layer, index) in Layers.Select((l, i) => (l, i))) + { + context.RegisterVirtualLayerMapping(layer, layer.VirtualLayerIndex); + context.RegisterPhysicalLayerMapping(index, layer); + } + + return controller; + } + + void ICommitable.Commit(CommitContext context, AnimatorController obj) + { + obj.layers = Layers.Select(context.CommitObject).ToArray(); + } + + protected override IEnumerable _EnumerateChildren() + { + return Layers; + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualAnimatorController.cs.meta b/Editor/API/AnimatorServices/VirtualObjects/VirtualAnimatorController.cs.meta new file mode 100644 index 00000000..007e9d05 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualAnimatorController.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f51e941f974849c89bab41a4370c6b90 +timeCreated: 1730064821 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualAvatarMask.cs b/Editor/API/AnimatorServices/VirtualObjects/VirtualAvatarMask.cs new file mode 100644 index 00000000..725dd95f --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualAvatarMask.cs @@ -0,0 +1,107 @@ +#nullable enable + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using JetBrains.Annotations; +using UnityEditor; +using UnityEngine; + +namespace nadena.dev.ndmf.animator +{ + [PublicAPI] + public class VirtualAvatarMask : VirtualNode, ICommitable + { + private ImmutableDictionary _elements; + + public ImmutableDictionary Elements + { + get => _elements; + set + { + _elements = value; + Invalidate(); + } + } + + private readonly AvatarMask _mask; + + internal static VirtualAvatarMask Clone(CloneContext context, AvatarMask mask) + { + return new VirtualAvatarMask(mask); + } + + private VirtualAvatarMask(AvatarMask mask) + { + _mask = Object.Instantiate(mask); + + var elements = ImmutableDictionary.Empty.ToBuilder(); + + var maskSo = new SerializedObject(_mask); + var m_Elements = maskSo.FindProperty("m_Elements"); + var elementCount = m_Elements.arraySize; + + for (var i = 0; i < elementCount; i++) + { + var element = m_Elements.GetArrayElementAtIndex(i); + var path = element.FindPropertyRelative("m_Path").stringValue; + var weight = element.FindPropertyRelative("m_Weight").floatValue; + elements[path] = weight; + } + + _elements = elements.ToImmutable(); + } + + public AvatarMask Prepare(CommitContext context) + { + return _mask; + } + + public void Commit(CommitContext context, AvatarMask obj) + { + var maskSo = new SerializedObject(obj); + var orderedElements = _elements.Keys.OrderBy(k => k).ToList(); + + var m_Elements = maskSo.FindProperty("m_Elements"); + var completeElements = new List(); + var createdElements = new HashSet(); + + foreach (var elem in orderedElements) + { + EnsureParentsPresent(elem); + + completeElements.Add(elem); + createdElements.Add(elem); + } + + m_Elements.arraySize = completeElements.Count; + + for (var i = 0; i < completeElements.Count; i++) + { + var element = m_Elements.GetArrayElementAtIndex(i); + var path = completeElements[i]; + var weight = _elements.GetValueOrDefault(path); + + element.FindPropertyRelative("m_Path").stringValue = path; + element.FindPropertyRelative("m_Weight").floatValue = weight; + } + + maskSo.ApplyModifiedPropertiesWithoutUndo(); + + void EnsureParentsPresent(string path) + { + var nextSlash = -1; + + while ((nextSlash = path.IndexOf('/', nextSlash + 1)) != -1) + { + var parentPath = path.Substring(0, nextSlash); + if (!createdElements.Contains(parentPath)) + { + completeElements.Add(parentPath); + createdElements.Add(parentPath); + } + } + } + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualAvatarMask.cs.meta b/Editor/API/AnimatorServices/VirtualObjects/VirtualAvatarMask.cs.meta new file mode 100644 index 00000000..8e46ea95 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualAvatarMask.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: dc908f0343194deeb72aa3a0d1308754 +timeCreated: 1738556452 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualBlendTree.cs b/Editor/API/AnimatorServices/VirtualObjects/VirtualBlendTree.cs new file mode 100644 index 00000000..3d96c685 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualBlendTree.cs @@ -0,0 +1,147 @@ +#nullable enable +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using JetBrains.Annotations; +using UnityEditor; +using UnityEditor.Animations; +using UnityEngine; + +namespace nadena.dev.ndmf.animator +{ + [PublicAPI] + public sealed class VirtualBlendTree : VirtualMotion + { + private readonly BlendTree _tree; + + public sealed class VirtualChildMotion + { + public VirtualMotion? Motion; + public float CycleOffset; + public string DirectBlendParameter = "Blend"; + public bool Mirror; + public float Threshold; + public Vector2 Position; + public float TimeScale = 1.0f; + } + + public static VirtualBlendTree Create(string name = "(unnamed)") + { + return new VirtualBlendTree(null, new BlendTree { name = name }); + } + + private VirtualBlendTree(CloneContext? context, BlendTree cloned) + { + _tree = cloned; + _children = ImmutableList.Empty; + + context?.DeferCall(() => + { + Children = _tree.children.Select(m => new VirtualChildMotion + { + Motion = context.Clone(m.motion), + CycleOffset = m.cycleOffset, + DirectBlendParameter = m.directBlendParameter, + Mirror = m.mirror, + Threshold = m.threshold, + Position = m.position, + TimeScale = m.timeScale + }).ToImmutableList(); + }); + } + + internal static VirtualBlendTree Clone( + CloneContext context, + BlendTree tree + ) + { + if (context.TryGetValue(tree, out VirtualBlendTree? existing)) return existing!; + + var cloned = new BlendTree(); + EditorUtility.CopySerialized(tree, cloned); + cloned.name = tree.name; + + return new VirtualBlendTree(context, cloned); + } + + public override string Name + { + get => _tree.name; + set => _tree.name = I(value); + } + + public string BlendParameter + { + get => _tree.blendParameter; + set => _tree.blendParameter = I(value); + } + + public string BlendParameterY + { + get => _tree.blendParameterY; + set => _tree.blendParameterY = I(value); + } + + public BlendTreeType BlendType + { + get => _tree.blendType; + set => _tree.blendType = I(value); + } + + public float MaxThreshold + { + get => _tree.maxThreshold; + set => _tree.maxThreshold = I(value); + } + + public float MinThreshold + { + get => _tree.minThreshold; + set => _tree.minThreshold = I(value); + } + + public bool UseAutomaticThresholds + { + get => _tree.useAutomaticThresholds; + set => _tree.useAutomaticThresholds = I(value); + } + + private ImmutableList _children; + + public ImmutableList Children + { + get => _children; + set => _children = I(value); + } + + protected override Motion Prepare(object context) + { + return _tree; + } + + protected override void Commit(object context, Motion obj) + { + var commitContext = (CommitContext)context; + var tree = (BlendTree)obj; + + tree.children = Children.Select(c => + { + return new ChildMotion + { + motion = commitContext.CommitObject(c.Motion), + cycleOffset = c.CycleOffset, + directBlendParameter = c.DirectBlendParameter, + mirror = c.Mirror, + threshold = c.Threshold, + position = c.Position, + timeScale = c.TimeScale + }; + }).ToArray(); + } + + protected override IEnumerable _EnumerateChildren() + { + return Children.Where(c => c.Motion != null).Select(c => c.Motion!); + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualBlendTree.cs.meta b/Editor/API/AnimatorServices/VirtualObjects/VirtualBlendTree.cs.meta new file mode 100644 index 00000000..f87b0b30 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualBlendTree.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 49939022360046009670a833c89b6a8e +timeCreated: 1731211068 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualClip.cs b/Editor/API/AnimatorServices/VirtualObjects/VirtualClip.cs new file mode 100644 index 00000000..a4008875 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualClip.cs @@ -0,0 +1,469 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using JetBrains.Annotations; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace nadena.dev.ndmf.animator +{ + /// + /// An abstraction over Unity AnimationClips. This class is designed to allow for low-overhead mutation of animation + /// clips, and in particular provides helpers for common operations (e.g. rewriting all paths in a clip). + /// + [PublicAPI] + public sealed class VirtualClip : VirtualMotion + { + private AnimationClip _clip; + + public override string Name + { + get => _clip.name; + set => _clip.name = I(value); + } + + /// + /// True if this is a marker clip; in this case, the clip is immutable and any attempt to mutate it will be + /// ignored. The clip will not be cloned on commit. + /// + public bool IsMarkerClip { get; private set; } + + /// + /// True if this clip has been modified since it was cloned or created. + /// + public bool IsDirty { get; private set; } + + private bool _useHighQualityCurves; + /// + /// Controls the (unexposed) High Quality Curve setting on the animation clip. + /// + public bool UseHighQualityCurves + { + get => _useHighQualityCurves; + set => _useHighQualityCurves = I(value); + } + + /// + /// Controls the `legacy` setting on the animation clip. + /// + public bool Legacy + { + get => _clip.legacy; + set => _clip.legacy = I(value); + } + + public Bounds LocalBounds + { + get => _clip.localBounds; + set => _clip.localBounds = I(value); + } + + public AnimationClipSettings Settings + { + get => AnimationUtility.GetAnimationClipSettings(_clip); + set + { + if (value.additiveReferencePoseClip != null) + { + throw new ArgumentException("Use the AdditiveReferencePoseClip property instead", + nameof(value.additiveReferencePoseClip)); + } + + AnimationUtility.SetAnimationClipSettings(_clip, value); + } + } + + private VirtualMotion? _additiveReferencePoseClip; + + public VirtualMotion? AdditiveReferencePoseClip + { + get => _additiveReferencePoseClip; + set => _additiveReferencePoseClip = I(value); + } + + public float AdditiveReferencePoseTime + { + get => Settings.additiveReferencePoseTime; + set + { + var settings = Settings; + settings.additiveReferencePoseTime = I(value); + Settings = settings; + } + } + + public WrapMode WrapMode + { + get => _clip.wrapMode; + set => _clip.wrapMode = I(value); + } + + public float FrameRate + { + get => _clip.frameRate; + set => _clip.frameRate = I(value); + } + + private Dictionary> _curveCache = new(ECBComparator.Instance); + + private Dictionary> _pptrCurveCache = + new(ECBComparator.Instance); + + private struct CachedCurve + { + // If null and Dirty is false, the curve has not been cached yet. + // If null and Dirty is true, the curve has been deleted. + public T? Value; + public bool Dirty; + + public override string ToString() + { + return $"CachedCurve<{typeof(T).Name}> {{ Value = {Value}, Dirty = {Dirty} }}"; + } + } + + /// + /// Creates a VirtualClip representing a "marker" clip. This is a clip which must be preserved, as-is, in the + /// final avatar. For example, VRChat's proxy animations fall under this category. Any attempt to mutate a + /// marker clip will be ignored. + /// + /// + /// + public static VirtualClip FromMarker(AnimationClip clip) + { + return new VirtualClip(clip, true); + } + + /// + /// Clones an animation clip into a VirtualClip. The provided BuildContext is used to determine which platform + /// to use to query for marker clips; if a marker clip is found, it will be treated as immutable. + /// + /// + /// + /// + public static VirtualClip Clone( + CloneContext cloneContext, + AnimationClip clip + ) + { + clip = cloneContext.MapClipOnClone(clip); + + if (cloneContext.PlatformBindings.IsSpecialMotion(clip)) + { + return FromMarker(clip); + } + + if (cloneContext.TryGetValue(clip, out VirtualClip? clonedClip)) + { + return clonedClip!; + } + + var newClip = Object.Instantiate(clip); + newClip.name = clip.name; + + var virtualClip = new VirtualClip(newClip, false); + + var settings = AnimationUtility.GetAnimationClipSettings(clip); + if (settings.additiveReferencePoseClip != null) + { + // defer call until after we register this VirtualClip, to avoid infinite recursion + cloneContext.DeferCall(() => + { + var refPoseClip = cloneContext.Clone(settings.additiveReferencePoseClip); + settings.additiveReferencePoseClip = null; + AnimationUtility.SetAnimationClipSettings(newClip, settings); + virtualClip.AdditiveReferencePoseClip = refPoseClip; + }); + } + + return virtualClip; + } + + /// + /// Clones a VirtualClip. The new VirtualClip is backed by an independent copy of the original clip. + /// + /// + public VirtualClip Clone() + { + var newClip = Object.Instantiate(_clip); + newClip.name = _clip.name; + + var virtualClip = new VirtualClip(newClip, IsMarkerClip); + virtualClip.UseHighQualityCurves = UseHighQualityCurves; + virtualClip.AdditiveReferencePoseClip = AdditiveReferencePoseClip; + virtualClip.AdditiveReferencePoseTime = AdditiveReferencePoseTime; + virtualClip.IsDirty = IsDirty; + virtualClip._curveCache = + _curveCache.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, ECBComparator.Instance); + virtualClip._pptrCurveCache = + _pptrCurveCache.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, ECBComparator.Instance); + + return virtualClip; + } + + public static VirtualClip Create(string name) + { + var clip = new AnimationClip { name = name }; + return new VirtualClip(clip, false); + } + + private VirtualClip(AnimationClip clip, bool isMarker) + { + _clip = clip; + IsDirty = false; + IsMarkerClip = isMarker; + + // This secret property can be changed by SetCurves calls, so preserve its current value. + UseHighQualityCurves = new SerializedObject(clip).FindProperty("m_UseHighQualityCurve").boolValue; + + foreach (var binding in AnimationUtility.GetCurveBindings(clip)) + { + _curveCache.Add(binding, new CachedCurve()); + } + + foreach (var binding in AnimationUtility.GetObjectReferenceCurveBindings(clip)) + { + _pptrCurveCache.Add(binding, new CachedCurve()); + } + } + + public IEnumerable GetFloatCurveBindings() + { + return _curveCache + .Where(kvp => kvp.Value.Value != null || !kvp.Value.Dirty) + .Select(kvp => kvp.Key).ToList(); + } + + public IEnumerable GetObjectCurveBindings() + { + return _pptrCurveCache + .Where(kvp => kvp.Value.Value != null || !kvp.Value.Dirty) + .Select(kvp => kvp.Key).ToList(); + } + + /// + /// Edit the paths of all bindings in this clip using the provided function. If this results in a path collision, + /// it is indeterminate which binding will be preserved. If null is returned, the binding will be deleted. + /// + /// + public void EditPaths(Func pathEditor) + { + if (IsMarkerClip) return; + + _curveCache = Transform(_curveCache, AnimationUtility.GetEditorCurve); + _pptrCurveCache = Transform(_pptrCurveCache, AnimationUtility.GetObjectReferenceCurve); + + Dictionary> Transform( + Dictionary> cache, Func getter) + { + var newCache = new Dictionary>(ECBComparator.Instance); + foreach (var kvp in cache) + { + var binding = kvp.Key; + var newBinding = binding; + newBinding.path = pathEditor(binding.path); + + if (ECBComparator.Instance.Equals(binding, newBinding) + || (binding.type == typeof(Animator) && binding.path == "")) + { + newCache[binding] = kvp.Value; + continue; + } + + IsDirty = true; + Invalidate(); + + // Any binding originally present needs some kind of presence in the new cache; start off by + // inserting a deleted entry, we'll overwrite it later if appropriate. + if (!newCache.ContainsKey(binding)) + { + newCache[binding] = new CachedCurve + { + Dirty = true + }; + } + + if (newBinding.path == null) + { + // Delete the binding + continue; + } + + // Load cache entry if not loaded + var entry = kvp.Value; + if (!entry.Dirty && entry.Value == null) + { + entry.Value = getter(_clip, binding); + entry.Dirty = true; + } + + newCache[newBinding] = entry; + } + + return newCache; + } + } + + public AnimationCurve GetFloatCurve(EditorCurveBinding binding) + { + if (_curveCache.TryGetValue(binding, out var cached)) + { + if (cached.Dirty == false && cached.Value == null) + { + cached.Value = AnimationUtility.GetEditorCurve(_clip, binding); + _curveCache[binding] = cached; + } + } + + return cached.Value!; + } + + public ObjectReferenceKeyframe[] GetObjectCurve(EditorCurveBinding binding) + { + if (_pptrCurveCache.TryGetValue(binding, out var cached)) + { + if (cached.Dirty == false && cached.Value == null) + { + cached.Value = AnimationUtility.GetObjectReferenceCurve(_clip, binding); + _pptrCurveCache[binding] = cached; + } + } + + return cached.Value!; + } + + public void SetFloatCurve(EditorCurveBinding binding, AnimationCurve curve) + { + if (binding.isPPtrCurve || binding.isDiscreteCurve) + { + throw new ArgumentException("Binding must be a float curve", nameof(binding)); + } + + if (IsMarkerClip) return; + + Invalidate(); + + if (!_curveCache.TryGetValue(binding, out var cached)) + { + cached = new CachedCurve(); + } + + cached.Value = curve; + cached.Dirty = true; + IsDirty = true; + + _curveCache[binding] = cached; + } + + public void SetObjectCurve(EditorCurveBinding binding, ObjectReferenceKeyframe[] curve) + { + if (!binding.isPPtrCurve) + { + throw new ArgumentException("Binding must be a PPtr curve", nameof(binding)); + } + + if (IsMarkerClip) return; + + Invalidate(); + + if (!_pptrCurveCache.TryGetValue(binding, out var cached)) + { + cached = new CachedCurve(); + } + + cached.Value = curve; + cached.Dirty = true; + IsDirty = true; + + _pptrCurveCache[binding] = cached; + } + + protected override Motion Prepare(object context) + { + return _clip; + } + + protected override void Commit( + [SuppressMessage("ReSharper", "InconsistentNaming")] + object context_, + Motion obj + ) + { + if (IsMarkerClip || !IsDirty) return; + + var context = (CommitContext)context_; + + var clip = (AnimationClip)obj; + + // WORKAROUND: AnimationUtility.SetEditorCurves doesn't actually delete curves when null, despite the + // documentation claiming it will. Fault in all uncached curves, then clear everything. + foreach (var curve in _curveCache.ToList()) + { + if (!curve.Value.Dirty && curve.Value.Value == null) GetFloatCurve(curve.Key); + } + + foreach (var curve in _pptrCurveCache.ToList()) + { + if (!curve.Value.Dirty && curve.Value.Value == null) GetObjectCurve(curve.Key); + } + + clip.ClearCurves(); + + var changedBindings = _curveCache.Where(c => c.Value.Dirty || c.Value.Value != null).ToList(); + var changedPptrBindings = _pptrCurveCache.Where(c => c.Value.Dirty || c.Value.Value != null).ToList(); + + if (changedBindings.Count > 0) + { + var bindings = changedBindings.Select(c => c.Key).ToArray(); + var curves = changedBindings.Select(c => c.Value.Value).ToArray(); + + AnimationUtility.SetEditorCurves(clip, bindings, curves); + } + + if (changedPptrBindings.Count > 0) + { + var bindings = changedPptrBindings.Select(c => c.Key).ToArray(); + var curves = changedPptrBindings.Select(c => c.Value.Value).ToArray(); + + AnimationUtility.SetObjectReferenceCurves(clip, bindings, curves); + } + + // Restore HighQualityCurves value + var serializedObject = new SerializedObject(clip); + serializedObject.FindProperty("m_UseHighQualityCurve").boolValue = UseHighQualityCurves; + serializedObject.ApplyModifiedPropertiesWithoutUndo(); + + // Restore additive reference pose + var settings = AnimationUtility.GetAnimationClipSettings(clip); + settings.additiveReferencePoseClip = AdditiveReferencePoseClip != null + ? (AnimationClip)context.CommitObject(AdditiveReferencePoseClip) + : null; + settings.additiveReferencePoseTime = AdditiveReferencePoseTime; + AnimationUtility.SetAnimationClipSettings(clip, settings); + } + + public AnimationCurve GetFloatCurve(string path, Type type, string prop) + { + return GetFloatCurve(EditorCurveBinding.FloatCurve(path, type, prop)); + } + + public ObjectReferenceKeyframe[] GetObjectCurve(string path, Type type, string prop) + { + return GetObjectCurve(EditorCurveBinding.PPtrCurve(path, type, prop)); + } + + public void SetFloatCurve(string path, Type type, string prop, AnimationCurve curve) + { + SetFloatCurve(EditorCurveBinding.FloatCurve(path, type, prop), curve); + } + + public void SetObjectCurve(string path, Type type, string prop, ObjectReferenceKeyframe[] curve) + { + SetObjectCurve(EditorCurveBinding.PPtrCurve(path, type, prop), curve); + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualClip.cs.meta b/Editor/API/AnimatorServices/VirtualObjects/VirtualClip.cs.meta new file mode 100644 index 00000000..5dce41dd --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualClip.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5783bb0dfce34aceab5778a205e8399e +timeCreated: 1730067145 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualLayer.cs b/Editor/API/AnimatorServices/VirtualObjects/VirtualLayer.cs new file mode 100644 index 00000000..0f1466cb --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualLayer.cs @@ -0,0 +1,212 @@ +#nullable enable + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using JetBrains.Annotations; +using UnityEditor.Animations; +using UnityEngine; + +namespace nadena.dev.ndmf.animator +{ + /// + /// A layer within a VirtualAnimatorController + /// + [PublicAPI] + public sealed class VirtualLayer : VirtualNode, ICommitable + { + /// + /// Returns a "virtual layer index" which can be used to map to the actual layer index in the animator controller, + /// even if layer order changes. This will typically be a very large value (>2^16). + /// + public int VirtualLayerIndex { get; } + + private VirtualStateMachine _stateMachine; + + public VirtualStateMachine StateMachine + { + get => _stateMachine; + set => _stateMachine = I(value); + } + + private VirtualAvatarMask? _avatarMask; + + public VirtualAvatarMask? AvatarMask + { + get => _avatarMask; + set => _avatarMask = I(value); + } + + private AnimatorLayerBlendingMode _blendingMode; + + public AnimatorLayerBlendingMode BlendingMode + { + get => _blendingMode; + set => _blendingMode = I(value); + } + + private float _defaultWeight; + + public float DefaultWeight + { + get => _defaultWeight; + set => _defaultWeight = I(value); + } + + private bool _ikPass; + + public bool IKPass + { + get => _ikPass; + set => _ikPass = I(value); + } + + private string _name; + + public string Name + { + get => _name; + set => _name = I(value); + } + + private bool _syncedLayerAffectsTiming; + + public bool SyncedLayerAffectsTiming + { + get => _syncedLayerAffectsTiming; + set => _syncedLayerAffectsTiming = I(value); + } + + private int _syncedLayerIndex; + + public int SyncedLayerIndex + { + get => _syncedLayerIndex; + set => _syncedLayerIndex = I(value); + } + + private bool _isOriginalLayer; + + public bool IsOriginalLayer + { + get => _isOriginalLayer; + set => _isOriginalLayer = I(value); + } + + private ImmutableDictionary _syncedLayerMotionOverrides; + + public ImmutableDictionary SyncedLayerMotionOverrides + { + get => _syncedLayerMotionOverrides; + set => _syncedLayerMotionOverrides = I(value); + } + + private ImmutableDictionary> _syncedLayerBehaviourOverrides; + + public ImmutableDictionary> SyncedLayerBehaviourOverrides + { + get => _syncedLayerBehaviourOverrides; + set => _syncedLayerBehaviourOverrides = I(value); + } + + internal static VirtualLayer Clone(CloneContext context, AnimatorControllerLayer layer, int physicalLayerIndex) + { + var clone = new VirtualLayer(context, layer, physicalLayerIndex); + + return clone; + } + + public static VirtualLayer Create(CloneContext context, string name = "(unnamed)") + { + return new VirtualLayer(context, name); + } + + private VirtualLayer(CloneContext context, AnimatorControllerLayer layer, int physicalLayerIndex) + { + VirtualLayerIndex = context.CloneSourceToVirtualLayerIndex(physicalLayerIndex); + _name = layer.name; + AvatarMask = layer.avatarMask == null ? null : context.Clone(layer.avatarMask); + BlendingMode = layer.blendingMode; + DefaultWeight = layer.defaultWeight; + IKPass = layer.iKPass; + SyncedLayerAffectsTiming = layer.syncedLayerAffectsTiming; + SyncedLayerIndex = context.CloneSourceToVirtualLayerIndex(layer.syncedLayerIndex); + + _stateMachine = context.Clone(layer.stateMachine); + + _syncedLayerMotionOverrides = SyncedLayerOverrideAccess.ExtractStateMotionPairs(layer) + ?.ToImmutableDictionary(kvp => context.Clone(kvp.Key), + kvp => context.Clone(kvp.Value)) + ?? ImmutableDictionary.Empty; + + // TODO: Apply state behavior import processing + _syncedLayerBehaviourOverrides = SyncedLayerOverrideAccess.ExtractStateBehaviourPairs(layer) + ?.ToImmutableDictionary(kvp => context.Clone(kvp.Key), + kvp => kvp.Value.Cast().ToImmutableList()) + ?? ImmutableDictionary> + .Empty; + } + + private VirtualLayer(CloneContext context, string name) + { + VirtualLayerIndex = context.AllocateSingleVirtualLayer(); + _name = name; + AvatarMask = null; + BlendingMode = AnimatorLayerBlendingMode.Override; + DefaultWeight = 1; + IKPass = false; + SyncedLayerAffectsTiming = false; + SyncedLayerIndex = -1; + + _stateMachine = VirtualStateMachine.Create(context, name); + _syncedLayerMotionOverrides = ImmutableDictionary.Empty; + _syncedLayerBehaviourOverrides = + ImmutableDictionary>.Empty; + } + + AnimatorControllerLayer ICommitable.Prepare(CommitContext context) + { + var layer = new AnimatorControllerLayer + { + name = Name, + avatarMask = null, + blendingMode = BlendingMode, + defaultWeight = DefaultWeight, + iKPass = IKPass, + syncedLayerAffectsTiming = SyncedLayerAffectsTiming + }; + + return layer; + } + + void ICommitable.Commit(CommitContext context, AnimatorControllerLayer obj) + { + obj.avatarMask = context.CommitObject(AvatarMask); + obj.syncedLayerIndex = context.VirtualToPhysicalLayerIndex(SyncedLayerIndex); + obj.stateMachine = context.CommitObject(StateMachine); + + SyncedLayerOverrideAccess.SetStateMotionPairs(obj, SyncedLayerMotionOverrides.Select(kvp => + new KeyValuePair( + context.CommitObject(kvp.Key), + context.CommitObject(kvp.Value) + ))); + + // TODO: commit state behaviours + SyncedLayerOverrideAccess.SetStateBehaviourPairs(obj, SyncedLayerBehaviourOverrides.Select(kvp => + new KeyValuePair( + context.CommitObject(kvp.Key), + kvp.Value.Cast().ToArray() + ))); + } + + public override string ToString() + { + return $"VirtualLayer[{VirtualLayerIndex}]: {Name}"; + } + + protected override IEnumerable _EnumerateChildren() + { + yield return StateMachine; + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualLayer.cs.meta b/Editor/API/AnimatorServices/VirtualObjects/VirtualLayer.cs.meta new file mode 100644 index 00000000..ebe42471 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualLayer.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e601f7ba786f4371b9dec591a0253bb0 +timeCreated: 1730065090 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualMotion.cs b/Editor/API/AnimatorServices/VirtualObjects/VirtualMotion.cs new file mode 100644 index 00000000..2d0f8982 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualMotion.cs @@ -0,0 +1,50 @@ +#nullable enable + +using System; +using System.Collections; +using System.Collections.Generic; +using JetBrains.Annotations; +using UnityEditor.Animations; +using UnityEngine; + +namespace nadena.dev.ndmf.animator +{ + [PublicAPI] + public abstract class VirtualMotion : VirtualNode, ICommitable + { + internal VirtualMotion() + { + } + + internal static VirtualMotion Clone( + CloneContext context, + Motion motion + ) + { + switch (motion) + { + case AnimationClip clip: return VirtualClip.Clone(context, clip); + case BlendTree tree: return VirtualBlendTree.Clone(context, tree); + default: throw new NotImplementedException(); + } + } + + public abstract string Name { get; set; } + + [ExcludeFromDocs] + protected abstract Motion Prepare(object /* CommitContext */ context); + + [ExcludeFromDocs] + protected abstract void Commit(object /* CommitContext */ context, Motion obj); + + Motion ICommitable.Prepare(CommitContext context) + { + return Prepare(context); + } + + void ICommitable.Commit(CommitContext context, Motion obj) + { + Commit(context, obj); + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualMotion.cs.meta b/Editor/API/AnimatorServices/VirtualObjects/VirtualMotion.cs.meta new file mode 100644 index 00000000..7e96ae05 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualMotion.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b8334ee9be934f45825a1b375d3b036a +timeCreated: 1730067047 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualNode.cs b/Editor/API/AnimatorServices/VirtualObjects/VirtualNode.cs new file mode 100644 index 00000000..837c623c --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualNode.cs @@ -0,0 +1,77 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using JetBrains.Annotations; + +namespace nadena.dev.ndmf.animator +{ + /// + /// Base class for all virtual animation nodes. Contains common functionality for cache invalidation. + /// Generally, external libraries should not use this class directly. + /// + [PublicAPI] + public abstract class VirtualNode + { + private Action? _lastCacheObserver; + + internal VirtualNode() + { + } + + internal void Invalidate() + { + _lastCacheObserver?.Invoke(); + _lastCacheObserver = null; + } + + internal T I(T val) + { + Invalidate(); + return val; + } + + internal void RegisterCacheObserver(Action? observer) + { + if (observer != _lastCacheObserver && _lastCacheObserver != null) + { + _lastCacheObserver.Invoke(); + } + + _lastCacheObserver = observer; + } + + public IEnumerable AllReachableNodes() + { + var visited = new HashSet(); + var queue = new Queue(); + + queue.Enqueue(this); + visited.Add(this); + + while (queue.Count > 0) + { + var node = queue.Dequeue(); + yield return node; + + foreach (var child in node.EnumerateChildren()) + { + if (visited.Add(child)) + { + queue.Enqueue(child); + } + } + } + } + + public IEnumerable EnumerateChildren() + { + return _EnumerateChildren(); + } + + protected virtual IEnumerable _EnumerateChildren() + { + yield break; + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualNode.cs.meta b/Editor/API/AnimatorServices/VirtualObjects/VirtualNode.cs.meta new file mode 100644 index 00000000..fc496fc6 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualNode.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 33140fffcfc84a4ebf37dae7e249e4bd +timeCreated: 1731269315 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualState.cs b/Editor/API/AnimatorServices/VirtualObjects/VirtualState.cs new file mode 100644 index 00000000..9959109c --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualState.cs @@ -0,0 +1,204 @@ +#nullable enable + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using JetBrains.Annotations; +using UnityEditor; +using UnityEditor.Animations; +using UnityEngine; + +namespace nadena.dev.ndmf.animator +{ + [PublicAPI] + public sealed class VirtualState : VirtualNode, ICommitable + { + private AnimatorState _state; + + private ImmutableList _behaviours = ImmutableList.Empty; + + public ImmutableList Behaviours + { + get => _behaviours; + set => _behaviours = I(value); + } + + internal static VirtualState Clone( + CloneContext context, + AnimatorState state + ) + { + if (context.TryGetValue(state, out VirtualState? clone)) return clone!; + + var clonedState = new AnimatorState(); + // We can't use Instantiate for AnimatorStates, for some reason... + EditorUtility.CopySerialized(state, clonedState); + + return new VirtualState(context, clonedState); + } + + public static VirtualState Create(string name = "unnamed") + { + return new VirtualState { Name = name }; + } + + private VirtualState() + { + _state = new AnimatorState(); + Behaviours = ImmutableList.Empty; + _transitions = ImmutableList.Empty; + } + + private VirtualState(CloneContext context, AnimatorState clonedState) + { + _state = clonedState; + + Behaviours = _state.behaviours.Select(context.ImportBehaviour).ToImmutableList(); + + _transitions = ImmutableList.Empty; + context.DeferCall(() => + { + Transitions = _state.transitions + .Where(t => t != null) + .Select(context.Clone) + .ToImmutableList()!; + }); + + Motion = context.Clone(_state.motion); + } + + private VirtualMotion? _motion; + + public VirtualMotion? Motion + { + get => _motion; + set => _motion = I(value); + } + + public string Name + { + get => _state.name; + set => _state.name = I(value); + } + + public float CycleOffset + { + get => _state.cycleOffset; + set => _state.cycleOffset = I(value); + } + + public string? CycleOffsetParameter + { + get => _state.cycleOffsetParameterActive ? _state.cycleOffsetParameter : null; + set + { + Invalidate(); + _state.cycleOffsetParameterActive = value != null; + _state.cycleOffsetParameter = value ?? ""; + } + } + + public bool IKOnFeet + { + get => _state.iKOnFeet; + set => _state.iKOnFeet = I(value); + } + + public bool Mirror + { + get => _state.mirror; + set => _state.mirror = I(value); + } + + public string? MirrorParameter + { + get => _state.mirrorParameterActive ? _state.mirrorParameter : null; + set + { + Invalidate(); + _state.mirrorParameterActive = value != null; + _state.mirrorParameter = value ?? ""; + } + } + + // public VirtualMotion Motion; + + public float Speed + { + get => _state.speed; + set => _state.speed = I(value); + } + + public string? SpeedParameter + { + get => _state.speedParameterActive ? _state.speedParameter : null; + set + { + Invalidate(); + _state.speedParameterActive = value != null; + _state.speedParameter = value ?? ""; + } + } + + public string Tag + { + get => _state.tag; + set => _state.tag = I(value); + } + + public string? TimeParameter + { + get => _state.timeParameterActive ? _state.timeParameter : null; + set + { + Invalidate(); + _state.timeParameterActive = value != null; + _state.timeParameter = value ?? ""; + } + } + + private ImmutableList _transitions; + + public ImmutableList Transitions + { + get => _transitions; + set => _transitions = I(value); + } + + public bool WriteDefaultValues + { + get => _state.writeDefaultValues; + set => _state.writeDefaultValues = I(value); + } + + // Helpers + + // AddExitTransition + // AddStateMachineBehaviour + // AddTransition + // RemoveTransition + + AnimatorState ICommitable.Prepare(CommitContext context) + { + return _state; + } + + void ICommitable.Commit(CommitContext context, AnimatorState obj) + { + obj.behaviours = Behaviours.Select(context.CommitBehaviour).ToArray(); + obj.transitions = Transitions.Select(t => (AnimatorStateTransition)context.CommitObject(t)).ToArray(); + obj.motion = context.CommitObject(Motion); + } + + public override string ToString() + { + return $"VirtualState({Name})"; + } + + protected override IEnumerable _EnumerateChildren() + { + if (Motion != null) yield return Motion; + foreach (var transition in Transitions) yield return transition; + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualState.cs.meta b/Editor/API/AnimatorServices/VirtualObjects/VirtualState.cs.meta new file mode 100644 index 00000000..4d440787 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualState.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 752085aab816498d89a77dc3e80de8de +timeCreated: 1730065672 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualStateMachine.cs b/Editor/API/AnimatorServices/VirtualObjects/VirtualStateMachine.cs new file mode 100644 index 00000000..cd6d6e64 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualStateMachine.cs @@ -0,0 +1,314 @@ +#nullable enable + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using UnityEditor.Animations; +using UnityEngine; + +namespace nadena.dev.ndmf.animator +{ + /// + /// Represents a state machine in a virtual layer. + /// + public sealed class VirtualStateMachine : VirtualNode, ICommitable + { + private readonly CloneContext _context; + private AnimatorStateMachine _stateMachine; + + internal static VirtualStateMachine Clone(CloneContext context, AnimatorStateMachine stateMachine) + { + if (context.TryGetValue(stateMachine, out VirtualStateMachine? clone)) return clone!; + + var vsm = new VirtualStateMachine(context, stateMachine.name); + + context.DeferCall(() => + { + vsm.AnyStatePosition = stateMachine.anyStatePosition; + vsm.AnyStateTransitions = stateMachine.anyStateTransitions + .Where(t => t != null) + .Select(context.Clone) + .ToImmutableList()!; + vsm.Behaviours = stateMachine.behaviours.Select(context.ImportBehaviour).ToImmutableList(); + vsm.DefaultState = context.Clone(stateMachine.defaultState); + vsm.EntryPosition = stateMachine.entryPosition; + vsm.EntryTransitions = stateMachine.entryTransitions + .Where(t => t != null) + .Select(context.Clone) + .ToImmutableList()!; + vsm.ExitPosition = stateMachine.exitPosition; + vsm.ParentStateMachinePosition = stateMachine.parentStateMachinePosition; + + vsm.StateMachines = stateMachine.stateMachines + .Where(sm => sm.stateMachine != null) + .Select(sm => new VirtualChildStateMachine + { + StateMachine = context.Clone(sm.stateMachine), + Position = sm.position + }).ToImmutableList(); + + vsm.States = stateMachine.states + .Where(s => s.state != null) + .Select(s => new VirtualChildState + { + State = context.Clone(s.state), + Position = s.position + }).ToImmutableList(); + + vsm.StateMachineTransitions = stateMachine.stateMachines + .Where(sm => sm.stateMachine != null) + .ToImmutableDictionary( + sm => context.Clone(sm.stateMachine), + sm => stateMachine.GetStateMachineTransitions(sm.stateMachine) + .Where(t => t != null) + .Select(context.Clone) + .ToImmutableList() + )!; + }); + + return vsm; + } + + public static VirtualStateMachine Create(CloneContext context, string name = "") + { + return new VirtualStateMachine(context, name); + } + + private VirtualStateMachine(CloneContext context, string name = "") + { + _context = context; + _stateMachine = new AnimatorStateMachine(); + _name = name; + AnyStatePosition = _stateMachine.anyStatePosition; + EntryPosition = _stateMachine.entryPosition; + ExitPosition = _stateMachine.exitPosition; + + _entryTransitions = ImmutableList.Empty; + _anyStateTransitions = ImmutableList.Empty; + _behaviours = ImmutableList.Empty; + _stateMachines = ImmutableList.Empty; + _states = ImmutableList.Empty; + _stateMachineTransitions = ImmutableDictionary>.Empty; + } + + AnimatorStateMachine ICommitable.Prepare(CommitContext context) + { + return _stateMachine; + } + + void ICommitable.Commit(CommitContext context, AnimatorStateMachine obj) + { + obj.name = Name; + obj.anyStatePosition = AnyStatePosition; + + obj.behaviours = Behaviours.Select(context.CommitBehaviour).ToArray(); + obj.entryPosition = EntryPosition; + obj.exitPosition = ExitPosition; + obj.parentStateMachinePosition = ParentStateMachinePosition; + obj.stateMachines = StateMachines.Select(sm => new ChildAnimatorStateMachine + { + stateMachine = context.CommitObject(sm.StateMachine), + position = sm.Position + }).ToArray(); + obj.states = States.Select(s => new ChildAnimatorState + { + state = context.CommitObject(s.State), + position = s.Position + }).ToArray(); + + // Set transitions after registering states/state machines, in case there's some kind of validation happening + obj.entryTransitions = EntryTransitions.Select(t => (AnimatorTransition)context.CommitObject(t)).ToArray(); + + obj.anyStateTransitions = AnyStateTransitions.Select(t => (AnimatorStateTransition)context.CommitObject(t)) + .ToArray(); + // DefaultState will be overwritten if we set it too soon; set it last. + obj.defaultState = context.CommitObject(DefaultState); + + foreach (var (sm, transitions) in StateMachineTransitions) + { + obj.SetStateMachineTransitions( + context.CommitObject(sm), + transitions.Select(t => (AnimatorTransition)context.CommitObject(t)).ToArray() + ); + } + } + + private string _name; + + public string Name + { + get => _name; + set => _name = I(value); + } + + private Vector3 _anyStatePosition; + + public Vector3 AnyStatePosition + { + get => _anyStatePosition; + set => _anyStatePosition = I(value); + } + + private Vector3 _entryPosition; + + public Vector3 EntryPosition + { + get => _entryPosition; + set => _entryPosition = I(value); + } + + private Vector3 _exitPosition; + + public Vector3 ExitPosition + { + get => _exitPosition; + set => _exitPosition = I(value); + } + + private Vector3 _parentStateMachinePosition; + + public Vector3 ParentStateMachinePosition + { + get => _parentStateMachinePosition; + set => _parentStateMachinePosition = I(value); + } + + private ImmutableList _entryTransitions; + + public ImmutableList EntryTransitions + { + get => _entryTransitions; + set => _entryTransitions = I(value); + } + + private ImmutableList _anyStateTransitions; + + public ImmutableList AnyStateTransitions + { + get => _anyStateTransitions; + set => _anyStateTransitions = I(value); + } + + private ImmutableList _behaviours; + + public ImmutableList Behaviours + { + get => _behaviours; + set => _behaviours = I(value); + } + + private ImmutableList _stateMachines; + + public ImmutableList StateMachines + { + get => _stateMachines; + set => _stateMachines = I(value); + } + + private ImmutableList _states; + + public ImmutableList States + { + get => _states; + set => _states = I(value); + } + + private ImmutableDictionary> _stateMachineTransitions; + + public ImmutableDictionary> StateMachineTransitions + { + get => _stateMachineTransitions; + set => _stateMachineTransitions = I(value); + } + + private VirtualState? _defaultState; + + public VirtualState? DefaultState + { + get => _defaultState; + set => _defaultState = I(value); + } + + public struct VirtualChildStateMachine + { + public VirtualStateMachine StateMachine; + public Vector3 Position; + } + + public struct VirtualChildState + { + public VirtualState State; + public Vector3 Position; + } + + protected override IEnumerable _EnumerateChildren() + { + foreach (var sm in StateMachines) + { + yield return sm.StateMachine; + } + + foreach (var state in States) + { + yield return state.State; + } + + if (DefaultState != null) yield return DefaultState; + + foreach (var transition in AnyStateTransitions) + { + yield return transition; + } + + foreach (var transition in EntryTransitions) + { + yield return transition; + } + } + + public VirtualState AddState(string name, VirtualMotion? motion = null, Vector3? position = null) + { + var state = VirtualState.Create(name); + + state.Motion = motion; + var childState = new VirtualChildState + { + State = state, + // TODO: Better automatic positioning + Position = position ?? Vector3.zero + }; + + States = States.Add(childState); + + return state; + } + + /// + /// Returns an enumerator of all states reachable from this state machine (including sub-state machines) + /// + /// + public IEnumerable AllStates() + { + foreach (var state in Walk(this, new())) yield return state; + + IEnumerable Walk(VirtualStateMachine sm, HashSet visited) + { + if (!visited.Add(sm)) yield break; + + foreach (var state in States) + { + yield return state.State; + } + + foreach (var ssm in StateMachines) + { + foreach (var state in Walk(ssm.StateMachine, visited)) + { + yield return state; + } + } + } + + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualStateMachine.cs.meta b/Editor/API/AnimatorServices/VirtualObjects/VirtualStateMachine.cs.meta new file mode 100644 index 00000000..3ea5eeab --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualStateMachine.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 98c18be0e6364dc5b5a47c79c4cc9bfc +timeCreated: 1730065344 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualStateTransition.cs b/Editor/API/AnimatorServices/VirtualObjects/VirtualStateTransition.cs new file mode 100644 index 00000000..f89d29cb --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualStateTransition.cs @@ -0,0 +1,84 @@ +#nullable enable + +using JetBrains.Annotations; +using UnityEditor.Animations; + +namespace nadena.dev.ndmf.animator +{ + [PublicAPI] + public sealed class VirtualStateTransition : VirtualTransitionBase + { + private readonly AnimatorStateTransition _stateTransition; + + internal VirtualStateTransition(CloneContext context, AnimatorStateTransition cloned) : base(context, cloned) + { + _stateTransition = cloned; + } + + public static VirtualStateTransition Create() + { + return new VirtualStateTransition(null, new AnimatorStateTransition()); + } + + private VirtualStateTransition() : base(null, new AnimatorStateTransition()) + { + _stateTransition = (AnimatorStateTransition)_transition; + } + + public static VirtualStateTransition Clone( + CloneContext context, + AnimatorStateTransition transition + ) + { + return (VirtualStateTransition)CloneInternal(context, transition); + } + + // AnimatorStateTransition + public bool CanTransitionToSelf + { + get => _stateTransition.canTransitionToSelf; + set => _stateTransition.canTransitionToSelf = I(value); + } + + public float Duration + { + get => _stateTransition.duration; + set => _stateTransition.duration = I(value); + } + + public float? ExitTime + { + get => _stateTransition.hasExitTime ? _stateTransition.exitTime : null; + set + { + Invalidate(); + _stateTransition.hasExitTime = value.HasValue; + _stateTransition.exitTime = value ?? 0; + } + } + + public bool HasFixedDuration + { + get => _stateTransition.hasFixedDuration; + set => _stateTransition.hasFixedDuration = I(value); + } + + public TransitionInterruptionSource InterruptionSource + { + get => _stateTransition.interruptionSource; + set => _stateTransition.interruptionSource = I(value); + } + + public float Offset + { + get => _stateTransition.offset; + set => _stateTransition.offset = I(value); + } + + public bool OrderedInterruption + { + get => _stateTransition.orderedInterruption; + set => _stateTransition.orderedInterruption = I(value); + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualStateTransition.cs.meta b/Editor/API/AnimatorServices/VirtualObjects/VirtualStateTransition.cs.meta new file mode 100644 index 00000000..f5b2aed7 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualStateTransition.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a8a9026c7034405d8064127214c480bc +timeCreated: 1730065684 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualTransition.cs b/Editor/API/AnimatorServices/VirtualObjects/VirtualTransition.cs new file mode 100644 index 00000000..249682fa --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualTransition.cs @@ -0,0 +1,28 @@ +#nullable enable + +using JetBrains.Annotations; +using UnityEditor.Animations; + +namespace nadena.dev.ndmf.animator +{ + [PublicAPI] + public sealed class VirtualTransition : VirtualTransitionBase + { + internal VirtualTransition(CloneContext context, AnimatorTransitionBase cloned) : base(context, cloned) + { + } + + public static VirtualTransition Create() + { + return new VirtualTransition(null, new AnimatorTransition()); + } + + internal static VirtualTransition Clone( + CloneContext context, + AnimatorTransition transition + ) + { + return (VirtualTransition)CloneInternal(context, transition); + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualTransition.cs.meta b/Editor/API/AnimatorServices/VirtualObjects/VirtualTransition.cs.meta new file mode 100644 index 00000000..37e71472 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualTransition.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 43b1f697c2204969a21f8fa742d46f7d +timeCreated: 1731193421 \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualTransitionBase.cs b/Editor/API/AnimatorServices/VirtualObjects/VirtualTransitionBase.cs new file mode 100644 index 00000000..0c46f1d9 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualTransitionBase.cs @@ -0,0 +1,170 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using JetBrains.Annotations; +using UnityEditor.Animations; +using Object = UnityEngine.Object; + +namespace nadena.dev.ndmf.animator +{ + [PublicAPI] + public class VirtualTransitionBase : VirtualNode, ICommitable + { + protected AnimatorTransitionBase _transition; + + // null indicates we've deferred reading the conditions from the transition object + private ImmutableList? _conditions; + + internal VirtualTransitionBase(CloneContext? context, AnimatorTransitionBase cloned) + { + _transition = cloned; + + context?.DeferCall(() => + { + if (cloned.destinationState != null) + { + SetDestination(context.Clone(cloned.destinationState)); + } + else if (cloned.destinationStateMachine != null) + { + SetDestination(context.Clone(cloned.destinationStateMachine)); + } + else if (cloned.isExit) + { + SetExitDestination(); + } + }); + } + + public string Name + { + get => _transition.name; + set => _transition.name = I(value); + } + + public ImmutableList Conditions + { + get + { + _conditions ??= _transition.conditions.ToImmutableList(); + return _conditions; + } + set + { + if (value == null) throw new ArgumentNullException(nameof(value)); + Invalidate(); + _conditions = value; + } + } + + private VirtualState? _destinationState; + + public VirtualState? DestinationState + { + get => _destinationState; + private set + { + _destinationState = value; + Invalidate(); + } + } + + private VirtualStateMachine? _destinationStateMachine; + + public VirtualStateMachine? DestinationStateMachine + { + get => _destinationStateMachine; + private set + { + _destinationStateMachine = value; + Invalidate(); + } + } + public bool IsExit => _transition.isExit; + + public bool Mute + { + get => _transition.mute; + set => _transition.mute = I(value); + } + + public bool Solo + { + get => _transition.solo; + set => _transition.solo = I(value); + } + + protected static VirtualTransitionBase CloneInternal( + CloneContext context, + AnimatorTransitionBase transition + ) + { + if (context.TryGetValue(transition, out VirtualStateTransition? clone)) return clone!; + + var cloned = Object.Instantiate(transition)!; + cloned.name = transition.name; + + switch (cloned) + { + case AnimatorStateTransition ast: return new VirtualStateTransition(context, ast); + default: return new VirtualTransition(context, cloned); + } + } + + public void SetDestination(VirtualState state) + { + Invalidate(); + DestinationState = state; + DestinationStateMachine = null; + _transition.isExit = false; + } + + public void SetDestination(VirtualStateMachine stateMachine) + { + Invalidate(); + DestinationState = null; + DestinationStateMachine = stateMachine; + _transition.isExit = false; + } + + public void SetExitDestination() + { + Invalidate(); + DestinationState = null; + DestinationStateMachine = null; + _transition.isExit = true; + } + + AnimatorTransitionBase ICommitable.Prepare(CommitContext context) + { + return _transition; + } + + void ICommitable.Commit(CommitContext context, AnimatorTransitionBase obj) + { + if (DestinationState != null) + { + obj.destinationState = context.CommitObject(DestinationState); + obj.destinationStateMachine = null; + } + else if (DestinationStateMachine != null) + { + obj.destinationState = null; + obj.destinationStateMachine = context.CommitObject(DestinationStateMachine); + } + else + { + obj.destinationState = null; + obj.destinationStateMachine = null; + } + } + + protected override IEnumerable _EnumerateChildren() + { + if (DestinationState != null) yield return DestinationState; + if (DestinationStateMachine != null) yield return DestinationStateMachine; + } + } +} \ No newline at end of file diff --git a/Editor/API/AnimatorServices/VirtualObjects/VirtualTransitionBase.cs.meta b/Editor/API/AnimatorServices/VirtualObjects/VirtualTransitionBase.cs.meta new file mode 100644 index 00000000..2cfd1f56 --- /dev/null +++ b/Editor/API/AnimatorServices/VirtualObjects/VirtualTransitionBase.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 451a358275d8443a99ceb18051a40732 +timeCreated: 1731193294 \ No newline at end of file diff --git a/Editor/API/BuildContext.cs b/Editor/API/BuildContext.cs index 87450837..120dac07 100644 --- a/Editor/API/BuildContext.cs +++ b/Editor/API/BuildContext.cs @@ -15,6 +15,7 @@ using UnityEditor; using UnityEngine; using UnityEngine.Profiling; +using VRC.SDK3.Avatars.Components; using Debug = UnityEngine.Debug; using UnityObject = UnityEngine.Object; @@ -346,11 +347,58 @@ internal void RunPass(ConcretePass pass) } } + public void DeactivateAllExtensionContexts() + { + Dictionary> depIndex = new(); + foreach (var ty in _activeExtensions.Keys) + { + foreach (var dep in ty.ContextDependencies()) + { + if (!depIndex.ContainsKey(dep)) + { + depIndex[dep] = new List(); + } + + depIndex[dep].Add(ty); + } + } + + while (_activeExtensions.Keys.Count > 0) + { + Type next = _activeExtensions.Keys.First(); + Type candidate; + do + { + candidate = next; + var revDeps = depIndex.GetValueOrDefault(next) as IEnumerable + ?? Array.Empty(); + next = revDeps.FirstOrDefault(t => _activeExtensions.ContainsKey(t)); + } while (next != null); + + DeactivateExtensionContext(candidate); + } + } + + public T ActivateExtensionContextRecursive() where T : IExtensionContext + { + return (T) ActivateExtensionContextRecursive(typeof(T)); + } + + public IExtensionContext ActivateExtensionContextRecursive(Type ty) + { + foreach (var dependency in ty.ContextDependencies()) + { + ActivateExtensionContextRecursive(dependency); + } + + return ActivateExtensionContext(ty); + } + public T ActivateExtensionContext() where T : IExtensionContext { return (T)ActivateExtensionContext(typeof(T)); } - + public IExtensionContext ActivateExtensionContext(Type ty) { using (new ExecutionScope(this)) diff --git a/Editor/UI/Menus.cs b/Editor/UI/Menus.cs index d94af2e7..fbcbf012 100644 --- a/Editor/UI/Menus.cs +++ b/Editor/UI/Menus.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; using nadena.dev.ndmf.config; +using nadena.dev.ndmf.cs; using nadena.dev.ndmf.runtime; using UnityEditor; using UnityEngine; diff --git a/Runtime/API.meta b/Runtime/API.meta new file mode 100644 index 00000000..d03ad2b5 --- /dev/null +++ b/Runtime/API.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 31d7d841982149a3ae0575c9b5cc4568 +timeCreated: 1731985741 \ No newline at end of file diff --git a/Runtime/API/IVirtualizeAnimatorController.cs b/Runtime/API/IVirtualizeAnimatorController.cs new file mode 100644 index 00000000..dd1e6aaa --- /dev/null +++ b/Runtime/API/IVirtualizeAnimatorController.cs @@ -0,0 +1,15 @@ +using UnityEngine; + +namespace nadena.dev.ndmf.animator +{ + /// + /// Implement this interface on your component to request that an animator attached to this component be + /// automatically registered in the VirtualControllerContext, with the component being the context key. + /// This is, for example, implemented by ModularAvatarMergeAnimator to ensure that object renames are tracked + /// appropriately at all times. + /// + public interface IVirtualizeAnimatorController + { + public RuntimeAnimatorController AnimatorController { get; set; } + } +} \ No newline at end of file diff --git a/Runtime/API/IVirtualizeAnimatorController.cs.meta b/Runtime/API/IVirtualizeAnimatorController.cs.meta new file mode 100644 index 00000000..7c398094 --- /dev/null +++ b/Runtime/API/IVirtualizeAnimatorController.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 2c187ab3d6794aeca88a7e94c063cae8 +timeCreated: 1731985669 \ No newline at end of file diff --git a/Runtime/RuntimeUtil.cs b/Runtime/RuntimeUtil.cs index 54cbe8ef..f203d692 100644 --- a/Runtime/RuntimeUtil.cs +++ b/Runtime/RuntimeUtil.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; using JetBrains.Annotations; +using UnityEditor; using UnityEngine; using UnityEngine.SceneManagement; - #if NDMF_VRCSDK3_AVATARS using VRC.SDK3.Avatars.Components; #endif @@ -42,7 +42,7 @@ internal static T GetOrAddComponent(this Component obj) where T : Component /// Returns whether the editor is in play mode. /// #if UNITY_EDITOR - public static bool IsPlaying => UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode; + public static bool IsPlaying => EditorApplication.isPlayingOrWillChangePlaymode; #else public static bool IsPlaying => true; #endif @@ -55,22 +55,34 @@ internal static T GetOrAddComponent(this Component obj) where T : Component /// [CanBeNull] public static string RelativePath(GameObject root, GameObject child) + { + return RelativePath(root?.transform, child?.transform); + } + + /// + /// Returns the relative path from root to child, or null is child is not a descendant of root. + /// + /// + /// + /// + [CanBeNull] + public static string RelativePath(Transform root, Transform child) { if (root == child) return ""; - List pathSegments = new List(); + var pathSegments = new List(); while (child != root && child != null) { - pathSegments.Add(child.name); - child = child.transform.parent?.gameObject; + pathSegments.Add(child.gameObject.name); + child = child.parent; } if (child == null && root != null) return null; pathSegments.Reverse(); - return String.Join("/", pathSegments); + return string.Join("/", pathSegments); } - + /// /// Returns the path of a game object relative to the avatar root, or null if the avatar root could not be /// located. diff --git a/Runtime/TestSupport/VirtualizedComponent.cs b/Runtime/TestSupport/VirtualizedComponent.cs new file mode 100644 index 00000000..1d778537 --- /dev/null +++ b/Runtime/TestSupport/VirtualizedComponent.cs @@ -0,0 +1,10 @@ +using nadena.dev.ndmf.animator; +using UnityEngine; + +namespace nadena.dev.ndmf.UnitTestSupport +{ + public class VirtualizedComponent : MonoBehaviour, IVirtualizeAnimatorController + { + public RuntimeAnimatorController AnimatorController { get; set; } + } +} \ No newline at end of file diff --git a/Runtime/TestSupport/VirtualizedComponent.cs.meta b/Runtime/TestSupport/VirtualizedComponent.cs.meta new file mode 100644 index 00000000..fbb58366 --- /dev/null +++ b/Runtime/TestSupport/VirtualizedComponent.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7b7148b3ac3f4d28b756ad5137de0d6e +timeCreated: 1731986867 \ No newline at end of file diff --git a/UnitTests~/AnimationServices.meta b/UnitTests~/AnimationServices.meta new file mode 100644 index 00000000..eed60df5 --- /dev/null +++ b/UnitTests~/AnimationServices.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: d7558ad5ddc4496bb1d853be8ea29994 +timeCreated: 1730069545 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/AnimationIndexTest.cs b/UnitTests~/AnimationServices/AnimationIndexTest.cs new file mode 100644 index 00000000..dda120b7 --- /dev/null +++ b/UnitTests~/AnimationServices/AnimationIndexTest.cs @@ -0,0 +1,227 @@ +using System.Collections.Generic; +using System.Linq; +using nadena.dev.ndmf.animator; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace UnitTests.AnimationServices +{ + public class AnimationIndexTest + { + [Test] + public void TestBasicIndexing() + { + var context = new CloneContext(GenericPlatformAnimatorBindings.Instance); + var controller = VirtualAnimatorController.Create(context, "test"); + var layer = controller.AddLayer(LayerPriority.Default, "test"); + + var clip1 = VirtualClip.Create("c1"); + var clip2 = VirtualClip.Create("c2"); + + layer.StateMachine.AddState("s1", motion: clip1); + layer.StateMachine.AddState("s2", motion: clip2); + + var index = new AnimationIndex( new [] { controller }); + + // Verify the index starts empty. This also sets us up to test cache invalidation. + Assert.IsEmpty(index.GetClipsForObjectPath("x")); + + var binding1 = EditorCurveBinding.FloatCurve("path1", typeof(Transform), "prop1"); + var binding2 = EditorCurveBinding.FloatCurve("path2", typeof(Transform), "prop2"); + + clip1.SetFloatCurve(binding1, AnimationCurve.Constant(0, 1, 1)); + clip1.SetFloatCurve(binding2, AnimationCurve.Constant(0, 1, 1)); + clip2.SetFloatCurve(binding2, AnimationCurve.Constant(0, 1, 1)); + + var p1clips = index.GetClipsForObjectPath("path1").ToList(); + var p2clips = index.GetClipsForObjectPath("path2").ToList(); + + Assert.AreEqual(1, p1clips.Count); + Assert.AreEqual(2, p2clips.Count); + Assert.AreEqual(clip1, p1clips[0]); + Assert.Contains(clip1, p2clips); + Assert.Contains(clip2, p2clips); + + var b1clips = index.GetClipsForBinding(binding1).ToList(); + var b2clips = index.GetClipsForBinding(binding2).ToList(); + + Assert.AreEqual(1, b1clips.Count); + Assert.AreEqual(2, b2clips.Count); + + Assert.AreEqual(clip1, b1clips[0]); + Assert.Contains(clip1, b2clips); + Assert.Contains(clip2, b2clips); + } + + [Test] + public void TestRewritePaths() + { + var context = new CloneContext(GenericPlatformAnimatorBindings.Instance); + var controller = VirtualAnimatorController.Create(context, "test"); + var layer = controller.AddLayer(LayerPriority.Default, "test"); + + var clip1 = VirtualClip.Create("c1"); + var clip2 = VirtualClip.Create("c2"); + + layer.StateMachine.AddState("s1", motion: clip1); + layer.StateMachine.AddState("s2", motion: clip2); + + var index = new AnimationIndex( new [] { controller }); + + var binding1 = EditorCurveBinding.FloatCurve("path1", typeof(Transform), "prop1"); + var binding2 = EditorCurveBinding.FloatCurve("path2", typeof(Transform), "prop2"); + + clip1.SetFloatCurve(binding1, AnimationCurve.Constant(0, 1, 1)); + clip1.SetFloatCurve(binding2, AnimationCurve.Constant(0, 1, 1)); + clip2.SetFloatCurve(binding2, AnimationCurve.Constant(0, 1, 1)); + + index.RewritePaths(new Dictionary + { + { "path1", "path3" }, + { "path2", "path4" } + }); + + // Verify the clips were in fact rewritten + var clip1paths = clip1.GetFloatCurveBindings().Select(ecb => ecb.path).ToList(); + var clip2paths = clip2.GetFloatCurveBindings().Select(ecb => ecb.path).ToList(); + + Assert.Contains("path3", clip1paths); + Assert.Contains("path4", clip1paths); + Assert.Contains("path4", clip2paths); + + Assert.IsFalse(clip1paths.Contains("path1")); + Assert.IsFalse(clip1paths.Contains("path2")); + Assert.IsFalse(clip2paths.Contains("path2")); + + // Verify the index was updated + + var p1clips = index.GetClipsForObjectPath("path1").ToList(); + var p2clips = index.GetClipsForObjectPath("path2").ToList(); + var p3clips = index.GetClipsForObjectPath("path3").ToList(); + var p4clips = index.GetClipsForObjectPath("path4").ToList(); + + Assert.IsEmpty(p1clips); + Assert.IsEmpty(p2clips); + Assert.AreEqual(1, p3clips.Count); + Assert.AreEqual(2, p4clips.Count); + Assert.AreEqual(clip1, p3clips[0]); + Assert.Contains(clip1, p4clips); + Assert.Contains(clip2, p4clips); + + var b1clips = index.GetClipsForBinding(binding1).ToList(); + var b2clips = index.GetClipsForBinding(binding2).ToList(); + + Assert.IsEmpty(b1clips); + Assert.IsEmpty(b2clips); + } + + [Test] + public void RewritePathDistinguishesBetweenMissingAndNullMappings() + { + var context = new CloneContext(GenericPlatformAnimatorBindings.Instance); + var controller = VirtualAnimatorController.Create(context, "test"); + var layer = controller.AddLayer(LayerPriority.Default, "test"); + + var clip1 = VirtualClip.Create("c1"); + var clip2 = VirtualClip.Create("c2"); + + layer.StateMachine.AddState("s1", motion: clip1); + layer.StateMachine.AddState("s2", motion: clip2); + + var index = new AnimationIndex( new [] { controller }); + + var binding1 = EditorCurveBinding.FloatCurve("path1", typeof(Transform), "prop1"); + var binding2 = EditorCurveBinding.FloatCurve("path2", typeof(Transform), "prop2"); + + clip1.SetFloatCurve(binding1, AnimationCurve.Constant(0, 1, 1)); + clip1.SetFloatCurve(binding2, AnimationCurve.Constant(0, 1, 1)); + + index.RewritePaths(new Dictionary + { + { "path1", null } + }); + + var p1clips = index.GetClipsForObjectPath("path1").ToList(); + var p2clips = index.GetClipsForObjectPath("path2").ToList(); + + Assert.IsEmpty(p1clips); + Assert.AreEqual(1, p2clips.Count); + } + + [Test] + public void TestEditClipsByBinding() + { + var context = new CloneContext(GenericPlatformAnimatorBindings.Instance); + var controller = VirtualAnimatorController.Create(context, "test"); + var layer = controller.AddLayer(LayerPriority.Default, "test"); + + var clip1 = VirtualClip.Create("c1"); + var clip2 = VirtualClip.Create("c2"); + + layer.StateMachine.AddState("s1", motion: clip1); + layer.StateMachine.AddState("s2", motion: clip2); + + var index = new AnimationIndex( new [] { controller }); + + var binding1 = EditorCurveBinding.FloatCurve("path1", typeof(Transform), "prop1"); + var binding2 = EditorCurveBinding.FloatCurve("path2", typeof(Transform), "prop2"); + var binding3 = EditorCurveBinding.FloatCurve("path3", typeof(Transform), "prop3"); + + clip1.SetFloatCurve(binding1, AnimationCurve.Constant(0, 1, 1)); + clip1.SetFloatCurve(binding2, AnimationCurve.Constant(0, 1, 1)); + clip2.SetFloatCurve(binding2, AnimationCurve.Constant(0, 1, 1)); + + List visited = new(); + index.EditClipsByBinding(new [] { binding1 }, clip => + { + visited.Add(clip); + clip.SetFloatCurve(binding1, AnimationCurve.Constant(0, 1, 2)); + clip.SetFloatCurve(binding2, null); + clip.SetFloatCurve(binding3, AnimationCurve.Constant(0, 1, 2)); + }); + + // Verify only the correct clips were visited + Assert.AreEqual(1, visited.Count); + Assert.AreEqual(clip1, visited[0]); + + // Verify that we updated the index + var b2clips = index.GetClipsForBinding(binding2).ToList(); + var b3clips = index.GetClipsForBinding(binding3).ToList(); + + Assert.AreEqual(1, b2clips.Count); + Assert.AreEqual(1, b3clips.Count); + Assert.AreEqual(clip2, b2clips[0]); + Assert.AreEqual(clip1, b3clips[0]); + } + + [Test] + public void TestGraphLoops() + { + var context = new CloneContext(GenericPlatformAnimatorBindings.Instance); + var controller = VirtualAnimatorController.Create(context, "test"); + var layer = controller.AddLayer(LayerPriority.Default, "test"); + + var sm1 = VirtualStateMachine.Create(context, "sm1"); + var sm2 = VirtualStateMachine.Create(context, "sm2"); + + layer.StateMachine.StateMachines = layer.StateMachine.StateMachines.Add(new () + { + StateMachine = sm1 + }); + sm1.StateMachines = sm1.StateMachines.Add(new () + { + StateMachine = sm2 + }); + sm2.StateMachines = sm2.StateMachines.Add(new () + { + StateMachine = sm1 + }); + + var index = new AnimationIndex( new [] { controller }); + + // Make sure we don't infinite loop + Assert.IsEmpty(index.GetClipsForObjectPath("x")); + } + } +} \ No newline at end of file diff --git a/UnitTests~/AnimationServices/AnimationIndexTest.cs.meta b/UnitTests~/AnimationServices/AnimationIndexTest.cs.meta new file mode 100644 index 00000000..e3c63f49 --- /dev/null +++ b/UnitTests~/AnimationServices/AnimationIndexTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 9afe61c701274e1b9f34eb20a3cbb804 +timeCreated: 1731275476 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/AnimatorOverrideControllerTest.cs b/UnitTests~/AnimationServices/AnimatorOverrideControllerTest.cs new file mode 100644 index 00000000..1a4092a2 --- /dev/null +++ b/UnitTests~/AnimationServices/AnimatorOverrideControllerTest.cs @@ -0,0 +1,81 @@ +using System.Linq; +using nadena.dev.ndmf.animator; +using NUnit.Framework; +using UnityEditor.Animations; +using UnityEngine; + +namespace UnitTests.AnimationServices +{ + public class AnimatorOverrideControllerTest + { + [Test] + public void TestSimpleOverride() + { + var cloneContext = new CloneContext(GenericPlatformAnimatorBindings.Instance); + + var originalController = new AnimatorController(); + var originalStateMachine = new AnimatorStateMachine(); + originalController.layers = new[] {new AnimatorControllerLayer {stateMachine = originalStateMachine}}; + + var clip1 = new AnimationClip {name = "c1"}; + var clip2 = new AnimationClip {name = "c2"}; + + var s1 = new AnimatorState {name = "s1", motion = clip1}; + var s2 = new AnimatorState {name = "s2", motion = clip2}; + + originalStateMachine.states = new[] {new ChildAnimatorState {state = s1}, new ChildAnimatorState {state = s2}}; + originalStateMachine.defaultState = s1; + + var overrideController = new AnimatorOverrideController(); + overrideController.runtimeAnimatorController = originalController; + + var clip3 = new AnimationClip {name = "c3"}; + overrideController[clip1] = clip3; + + var virtualController = cloneContext.Clone(overrideController); + var virtualStateMachine = virtualController.Layers.First().StateMachine; + var virtualS1 = virtualStateMachine.States.First(s => s.State.Name == "s1"); + var virtualS2 = virtualStateMachine.States.First(s => s.State.Name == "s2"); + + Assert.AreEqual("c3", virtualS1.State.Motion.Name); + Assert.AreEqual("c2", virtualS2.State.Motion.Name); + } + + [Test] + public void TestBlendTreeChildOverride() + { + var cloneContext = new CloneContext(GenericPlatformAnimatorBindings.Instance); + + var originalController = new AnimatorController(); + var originalStateMachine = new AnimatorStateMachine(); + originalController.layers = new[] {new AnimatorControllerLayer {stateMachine = originalStateMachine}}; + originalController.AddParameter("Blend", AnimatorControllerParameterType.Float); + + var clip1 = new AnimationClip {name = "c1"}; + var clip2 = new AnimationClip {name = "c2"}; + var clip3 = new AnimationClip {name = "c3"}; + var bt = new BlendTree {name = "bt", blendType = BlendTreeType.Simple1D}; + bt.children = new[] + { + new ChildMotion {motion = clip1, timeScale = 1}, + new ChildMotion {motion = clip2, timeScale = 1} + }; + + var s1 = new AnimatorState {name = "s1", motion = bt}; + originalStateMachine.states = new[] {new ChildAnimatorState {state = s1}}; + originalStateMachine.defaultState = s1; + + var overrideController = new AnimatorOverrideController(); + overrideController.runtimeAnimatorController = originalController; + overrideController[clip1] = clip3; + + var virtualController = cloneContext.Clone(overrideController); + var virtualStateMachine = virtualController.Layers.First().StateMachine; + var virtualS1 = virtualStateMachine.States.First(s => s.State.Name == "s1"); + var virtualBlendTree = (VirtualBlendTree) virtualS1.State.Motion; + + Assert.AreEqual("c3", virtualBlendTree.Children.First().Motion.Name); + Assert.AreEqual("c2", virtualBlendTree.Children.Last().Motion.Name); + } + } +} \ No newline at end of file diff --git a/UnitTests~/AnimationServices/AnimatorOverrideControllerTest.cs.meta b/UnitTests~/AnimationServices/AnimatorOverrideControllerTest.cs.meta new file mode 100644 index 00000000..67a8f10f --- /dev/null +++ b/UnitTests~/AnimationServices/AnimatorOverrideControllerTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e3e4ff2810b94a228fe24fc9ab833f28 +timeCreated: 1731378836 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/AssertHelpers.cs b/UnitTests~/AnimationServices/AssertHelpers.cs new file mode 100644 index 00000000..00e2d278 --- /dev/null +++ b/UnitTests~/AnimationServices/AssertHelpers.cs @@ -0,0 +1,23 @@ +using System; +using nadena.dev.ndmf.animator; + +namespace UnitTests.AnimationServices +{ + public class AssertInvalidate : IDisposable + { + private bool wasInvalidated; + + public AssertInvalidate(VirtualNode node) + { + node.RegisterCacheObserver(() => { wasInvalidated = true;}); + } + + public void Dispose() + { + if (!wasInvalidated) + { + throw new Exception("Expected node to be invalidated"); + } + } + } +} \ No newline at end of file diff --git a/UnitTests~/AnimationServices/AssertHelpers.cs.meta b/UnitTests~/AnimationServices/AssertHelpers.cs.meta new file mode 100644 index 00000000..5afca1db --- /dev/null +++ b/UnitTests~/AnimationServices/AssertHelpers.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5883a743d02d46458dcbae4ca9d9613a +timeCreated: 1731270964 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/GenericPlatformTests.cs b/UnitTests~/AnimationServices/GenericPlatformTests.cs new file mode 100644 index 00000000..6310063b --- /dev/null +++ b/UnitTests~/AnimationServices/GenericPlatformTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using nadena.dev.ndmf.animator; +using nadena.dev.ndmf.UnitTestSupport; +using NUnit.Framework; +using UnityEditor.Animations; +using UnityEngine; + +namespace UnitTests.AnimationServices +{ + public class GenericPlatformTests : TestBase + { + public static IEnumerable<(string, Func)> CreateAvatarSource() + { + yield return ("Generic", t => + { + var obj = t.TrackObject(new GameObject("test")); + obj.AddComponent(); + return obj; + }); + +#if NDMF_VRCSDK3_AVATARS + yield return ("VRChat", t => t.CreateRoot("VRChat")); +#endif + } + + [Test] + public void TracksAnimationsForAnimators( + [ValueSource(nameof(CreateAvatarSource))] + (string, Func) createAvatar + ) + { + var root = createAvatar.Item2(this); + var animator = root.GetComponent(); + + var child = TrackObject(new GameObject("child")); + child.transform.parent = root.transform; + var childAnimator = child.AddComponent(); + + var startingController = new AnimatorController(); + childAnimator.runtimeAnimatorController = startingController; + + var buildContext = CreateContext(root); + var ctx = buildContext.ActivateExtensionContext(); + + Assert.IsNotNull(ctx[childAnimator]); + Assert.IsNull(ctx[animator]); + + buildContext.DeactivateExtensionContext(); + + Assert.AreNotEqual(startingController, childAnimator.runtimeAnimatorController); + Assert.NotNull(childAnimator.runtimeAnimatorController); + } + + [Test] + public void TracksAnimationsForCustomComponents( + [ValueSource(nameof(CreateAvatarSource))] + (string, Func) createAvatar + ) + { + var root = createAvatar.Item2(this); + + var child = TrackObject(new GameObject("child")); + child.transform.parent = root.transform; + var childComponent = child.AddComponent(); + + var startingController = new AnimatorController(); + childComponent.AnimatorController = startingController; + + var buildContext = CreateContext(root); + var ctx = buildContext.ActivateExtensionContext(); + + Assert.IsNotNull(ctx[childComponent]); + + buildContext.DeactivateExtensionContext(); + + Assert.AreNotEqual(startingController, childComponent.AnimatorController); + } + } +} \ No newline at end of file diff --git a/UnitTests~/AnimationServices/GenericPlatformTests.cs.meta b/UnitTests~/AnimationServices/GenericPlatformTests.cs.meta new file mode 100644 index 00000000..4738d28a --- /dev/null +++ b/UnitTests~/AnimationServices/GenericPlatformTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: cd11be62b9c54ac8b78af6521b51e2df +timeCreated: 1731986076 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/HQ_OFF.anim b/UnitTests~/AnimationServices/HQ_OFF.anim new file mode 100644 index 00000000..2d9ed074 --- /dev/null +++ b/UnitTests~/AnimationServices/HQ_OFF.anim @@ -0,0 +1,98 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!74 &7400000 +AnimationClip: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: HQ_OFF + serializedVersion: 6 + m_Legacy: 0 + m_Compressed: 0 + m_UseHighQualityCurve: 0 + m_RotationCurves: [] + m_CompressedRotationCurves: [] + m_EulerCurves: [] + m_PositionCurves: [] + m_ScaleCurves: [] + m_FloatCurves: + - curve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 1 + inSlope: Infinity + outSlope: Infinity + tangentMode: 103 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + attribute: m_Enabled + path: + classID: 114 + script: {fileID: -1427037861, guid: 4ecd63eff847044b68db9453ce219299, type: 3} + m_PPtrCurves: [] + m_SampleRate: 60 + m_WrapMode: 0 + m_Bounds: + m_Center: {x: 0, y: 0, z: 0} + m_Extent: {x: 0, y: 0, z: 0} + m_ClipBindingConstant: + genericBindings: + - serializedVersion: 2 + path: 0 + attribute: 3305885265 + script: {fileID: -1427037861, guid: 4ecd63eff847044b68db9453ce219299, type: 3} + typeID: 114 + customType: 24 + isPPtrCurve: 0 + pptrCurveMapping: [] + m_AnimationClipSettings: + serializedVersion: 2 + m_AdditiveReferencePoseClip: {fileID: 0} + m_AdditiveReferencePoseTime: 0 + m_StartTime: 0 + m_StopTime: 0 + m_OrientationOffsetY: 0 + m_Level: 0 + m_CycleOffset: 0 + m_HasAdditiveReferencePose: 0 + m_LoopTime: 0 + m_LoopBlend: 0 + m_LoopBlendOrientation: 0 + m_LoopBlendPositionY: 0 + m_LoopBlendPositionXZ: 0 + m_KeepOriginalOrientation: 0 + m_KeepOriginalPositionY: 1 + m_KeepOriginalPositionXZ: 0 + m_HeightFromFeet: 0 + m_Mirror: 0 + m_EditorCurves: + - curve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 1 + inSlope: Infinity + outSlope: Infinity + tangentMode: 103 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + attribute: m_Enabled + path: + classID: 114 + script: {fileID: -1427037861, guid: 4ecd63eff847044b68db9453ce219299, type: 3} + m_EulerEditorCurves: [] + m_HasGenericRootTransform: 0 + m_HasMotionFloatCurves: 0 + m_Events: [] diff --git a/UnitTests~/AnimationServices/HQ_OFF.anim.meta b/UnitTests~/AnimationServices/HQ_OFF.anim.meta new file mode 100644 index 00000000..2a9cb984 --- /dev/null +++ b/UnitTests~/AnimationServices/HQ_OFF.anim.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ef6f162083551c84c8d82cab46f87eab +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 7400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnitTests~/AnimationServices/HQ_ON.anim b/UnitTests~/AnimationServices/HQ_ON.anim new file mode 100644 index 00000000..930700f8 --- /dev/null +++ b/UnitTests~/AnimationServices/HQ_ON.anim @@ -0,0 +1,98 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!74 &7400000 +AnimationClip: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: HQ_ON + serializedVersion: 6 + m_Legacy: 0 + m_Compressed: 0 + m_UseHighQualityCurve: 1 + m_RotationCurves: [] + m_CompressedRotationCurves: [] + m_EulerCurves: [] + m_PositionCurves: [] + m_ScaleCurves: [] + m_FloatCurves: + - curve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 1 + inSlope: Infinity + outSlope: Infinity + tangentMode: 103 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + attribute: m_Enabled + path: + classID: 114 + script: {fileID: -1427037861, guid: 4ecd63eff847044b68db9453ce219299, type: 3} + m_PPtrCurves: [] + m_SampleRate: 60 + m_WrapMode: 0 + m_Bounds: + m_Center: {x: 0, y: 0, z: 0} + m_Extent: {x: 0, y: 0, z: 0} + m_ClipBindingConstant: + genericBindings: + - serializedVersion: 2 + path: 0 + attribute: 3305885265 + script: {fileID: -1427037861, guid: 4ecd63eff847044b68db9453ce219299, type: 3} + typeID: 114 + customType: 24 + isPPtrCurve: 0 + pptrCurveMapping: [] + m_AnimationClipSettings: + serializedVersion: 2 + m_AdditiveReferencePoseClip: {fileID: 0} + m_AdditiveReferencePoseTime: 0 + m_StartTime: 0 + m_StopTime: 0 + m_OrientationOffsetY: 0 + m_Level: 0 + m_CycleOffset: 0 + m_HasAdditiveReferencePose: 0 + m_LoopTime: 0 + m_LoopBlend: 0 + m_LoopBlendOrientation: 0 + m_LoopBlendPositionY: 0 + m_LoopBlendPositionXZ: 0 + m_KeepOriginalOrientation: 0 + m_KeepOriginalPositionY: 1 + m_KeepOriginalPositionXZ: 0 + m_HeightFromFeet: 0 + m_Mirror: 0 + m_EditorCurves: + - curve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 1 + inSlope: Infinity + outSlope: Infinity + tangentMode: 103 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + attribute: m_Enabled + path: + classID: 114 + script: {fileID: -1427037861, guid: 4ecd63eff847044b68db9453ce219299, type: 3} + m_EulerEditorCurves: [] + m_HasGenericRootTransform: 0 + m_HasMotionFloatCurves: 0 + m_Events: [] diff --git a/UnitTests~/AnimationServices/HQ_ON.anim.meta b/UnitTests~/AnimationServices/HQ_ON.anim.meta new file mode 100644 index 00000000..74bd7a8e --- /dev/null +++ b/UnitTests~/AnimationServices/HQ_ON.anim.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 02136d7a0efc77b468045eb159f2762a +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 7400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnitTests~/AnimationServices/ObjectPathRemapperTest.cs b/UnitTests~/AnimationServices/ObjectPathRemapperTest.cs new file mode 100644 index 00000000..5a965b7b --- /dev/null +++ b/UnitTests~/AnimationServices/ObjectPathRemapperTest.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using nadena.dev.ndmf.animator; +using NUnit.Framework; + +namespace UnitTests.AnimationServices +{ + public class ObjectPathRemapperTest : TestBase + { + [Test] + public void TracksRenames() + { + var root = CreateRoot("x"); + + var c1 = CreateChild(root, "c1"); + + var remapper = new ObjectPathRemapper(root.transform); + + c1.name = "c2"; + + Assert.AreEqual("c1", remapper.GetVirtualPathForObject(c1)); + Assert.AreEqual("c1", remapper.GetVirtualPathForObject(c1.transform)); + + Assert.That(remapper.GetVirtualToRealPathMap(), Is.EquivalentTo( + new[] + { + new KeyValuePair("c1", "c2") + } + )); + } + + [Test] + public void WhenObjectIsRenamed_AndANewObjectWithTheSameNameAppears_CorrectlyTracked() + { + var root = CreateRoot("x"); + + var c1 = CreateChild(root, "c1"); + + var remapper = new ObjectPathRemapper(root.transform); + + c1.name = "c2"; + + var c1x = CreateChild(root, "c1"); + var vpath = remapper.GetVirtualPathForObject(c1x); + Assert.AreNotEqual("c1", vpath); + Assert.AreEqual("c1", remapper.GetVirtualPathForObject(c1)); + + Assert.That(remapper.GetVirtualToRealPathMap(), Is.EquivalentTo( + new[] + { + new KeyValuePair("c1", "c2"), + new KeyValuePair(vpath, "c1") + } + )); + } + + [Test] + public void RemembersMultipleHierarchyLevels() + { + var root = CreateRoot("x"); + var c1 = CreateChild(root, "c1"); + var c2 = CreateChild(c1, "c2"); + var c3 = CreateChild(c2, "c3"); + + var remapper = new ObjectPathRemapper(root.transform); + c1.name = "c1x"; + c2.name = "c2x"; + c3.name = "c3x"; + + Assert.AreEqual("c1/c2/c3", remapper.GetVirtualPathForObject(c3)); + + Assert.That(remapper.GetVirtualToRealPathMap(), Is.EquivalentTo( + new[] + { + new KeyValuePair("c1", "c1x"), + new KeyValuePair("c1/c2", "c1x/c2x"), + new KeyValuePair("c1/c2/c3", "c1x/c2x/c3x") + } + )); + } + + [Test] + public void Test_RecordObjectTree() + { + var root = CreateRoot("x"); + + var mapper = new ObjectPathRemapper(root.transform); + + var c1 = CreateChild(root, "c1"); + var c2 = CreateChild(c1, "c2"); + + mapper.RecordObjectTree(c1.transform); + + c1.name = "x"; + + Assert.AreEqual("c1", mapper.GetVirtualPathForObject(c1)); + + Assert.That(mapper.GetVirtualToRealPathMap(), Is.EquivalentTo( + new[] + { + new KeyValuePair("c1", "x"), + new KeyValuePair("c1/c2", "x/c2") + } + )); + } + + [Test] + public void Test_GetObjectForPath() + { + var root = CreateRoot("x"); + var c1 = CreateChild(root, "c1"); + + var mapper = new ObjectPathRemapper(root.transform); + c1.name = "xyz"; + + Assert.AreEqual(c1, mapper.GetObjectForPath("c1")); + } + + [Test] + public void Test_ReplaceObject() + { + var root = CreateRoot("x"); + var c1 = CreateChild(root, "c1"); + + var mapper = new ObjectPathRemapper(root.transform); + + var c2 = CreateChild(root, "c2"); + mapper.ReplaceObject(c1, c2); + UnityEngine.Object.DestroyImmediate(c1); + + Assert.AreEqual("c1", mapper.GetVirtualPathForObject(c2)); + + Assert.That(mapper.GetVirtualToRealPathMap(), Is.EquivalentTo( + new[] + { + new KeyValuePair("c1", "c2") + } + )); + } + } +} \ No newline at end of file diff --git a/UnitTests~/AnimationServices/ObjectPathRemapperTest.cs.meta b/UnitTests~/AnimationServices/ObjectPathRemapperTest.cs.meta new file mode 100644 index 00000000..cdb87ebd --- /dev/null +++ b/UnitTests~/AnimationServices/ObjectPathRemapperTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: ddedf108afb74a0e99fd26c1c54c5fe1 +timeCreated: 1731902650 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/StateGraphTest.cs b/UnitTests~/AnimationServices/StateGraphTest.cs new file mode 100644 index 00000000..874b3ade --- /dev/null +++ b/UnitTests~/AnimationServices/StateGraphTest.cs @@ -0,0 +1,173 @@ +using System.Collections.Immutable; +using nadena.dev.ndmf.animator; +using NUnit.Framework; +using UnityEditor.Animations; + +namespace UnitTests.AnimationServices +{ + public class StateGraphTest : TestBase + { + [Test] + public void TestStateGraphConvergence() + { + var s1 = new AnimatorState(); + var s2 = new AnimatorState(); + var s3 = new AnimatorState(); + + s1.transitions = new[] + { + new AnimatorStateTransition() + { + destinationState = s2 + }, + new AnimatorStateTransition() + { + conditions = new [] + { + new AnimatorCondition() + { + parameter = "x" + } + }, + destinationState = s3 + }, + new AnimatorStateTransition() + { + destinationState = s2 + }, + new AnimatorStateTransition() + { + destinationState = s1 + } + }; + + s2.transitions = new[] + { + new AnimatorStateTransition() { destinationState = s1 }, + new AnimatorStateTransition() { destinationState = s3 }, + new AnimatorStateTransition() { isExit = true }, + }; + + var cloneContext = new CloneContext(GenericPlatformAnimatorBindings.Instance); + var clonedS1 = cloneContext.Clone(s1); + + Assert.AreEqual(clonedS1.Transitions.Count, 4); + var clonedS2 = clonedS1.Transitions[0].DestinationState; + var clonedS3 = clonedS1.Transitions[1].DestinationState; + Assert.AreEqual(clonedS2, clonedS1.Transitions[2].DestinationState); + Assert.AreEqual(clonedS1, clonedS1.Transitions[3].DestinationState); + + Assert.AreEqual(clonedS2.Transitions.Count, 3); + Assert.AreEqual(clonedS1, clonedS2.Transitions[0].DestinationState); + Assert.AreEqual(clonedS3, clonedS2.Transitions[1].DestinationState); + Assert.IsTrue(clonedS2.Transitions[2].IsExit); + + // Check that we cache clones appropriately + Assert.AreEqual(clonedS1, cloneContext.Clone(s1)); + Assert.AreEqual(clonedS2, cloneContext.Clone(s2)); + Assert.AreEqual(clonedS3, cloneContext.Clone(s3)); + + // Commit and check that we preserve the graph appropriately + var commitContext = new CommitContext(); + + var committedS1 = commitContext.CommitObject(clonedS1); + Assert.AreNotEqual(s1, committedS1); + + var committedS2 = committedS1.transitions[0].destinationState; + Assert.AreNotEqual(s2, committedS2); + + var committedS3 = committedS1.transitions[1].destinationState; + Assert.AreNotEqual(s3, committedS3); + + Assert.AreEqual(committedS2, committedS1.transitions[2].destinationState); + Assert.AreEqual(committedS1, committedS1.transitions[3].destinationState); + + Assert.AreEqual(committedS2.transitions.Length, 3); + Assert.AreEqual(committedS1, committedS2.transitions[0].destinationState); + Assert.AreEqual(committedS3, committedS2.transitions[1].destinationState); + Assert.IsTrue(committedS2.transitions[2].isExit); + } + + [Test] + public void TestStateMachineTransitions() + { + var sm1 = new AnimatorStateMachine() { name = "sm1" }; + var sm2 = new AnimatorStateMachine() { name = "sm2" }; + var sm3 = new AnimatorStateMachine() { name = "sm3" }; + + var s1 = new AnimatorState() { name = "s1" }; + var s2 = new AnimatorState() { name = "s2" }; + + sm1.stateMachines = new[] + { + new ChildAnimatorStateMachine() + { + stateMachine = sm2 + }, + new ChildAnimatorStateMachine() + { + stateMachine = sm3 + } + }; + sm1.states = new[] + { + new ChildAnimatorState() + { + state = s1 + }, + new ChildAnimatorState() + { + state = s2 + } + }; + + sm1.SetStateMachineTransitions(sm2, new[] + { + new AnimatorTransition() + { + destinationState = s1, + conditions = new AnimatorCondition[0] + } + }); + + var cloneContext = new CloneContext(GenericPlatformAnimatorBindings.Instance); + var clonedSM1 = cloneContext.Clone(sm1); + + Assert.AreEqual(clonedSM1.StateMachines.Count, 2); + var clonedSM2 = clonedSM1.StateMachines[0].StateMachine; + var clonedSM3 = clonedSM1.StateMachines[1].StateMachine; + + var clonedS1 = clonedSM1.States[0].State; + var clonedS2 = clonedSM1.States[1].State; + + Assert.AreEqual(clonedSM1.StateMachineTransitions.Count, 2); + Assert.AreEqual(clonedS1, clonedSM1.StateMachineTransitions[clonedSM2][0].DestinationState); + Assert.AreEqual(0, clonedSM1.StateMachineTransitions[clonedSM3].Count); + + var vt = VirtualTransition.Create(); + vt.SetDestination(clonedS2); + + clonedSM1.StateMachineTransitions = clonedSM1.StateMachineTransitions.SetItem( + clonedSM3, + ImmutableList.Empty.Add(vt) + ); + + var commitContext = new CommitContext(); + var outSM1 = commitContext.CommitObject(clonedSM1); + + var outSM2 = outSM1.stateMachines[0].stateMachine; + var outSM3 = outSM1.stateMachines[1].stateMachine; + + var outS1 = outSM1.states[0].state; + var outS2 = outSM1.states[1].state; + + var stateTransitions2 = outSM1.GetStateMachineTransitions(outSM2); + Assert.AreEqual(stateTransitions2.Length, 1); + Assert.AreEqual(outS1, stateTransitions2[0].destinationState); + + var stateTransitions3 = outSM1.GetStateMachineTransitions(outSM3); + Assert.AreEqual(stateTransitions3.Length, 1); + Assert.AreEqual(outS2, stateTransitions3[0].destinationState); + } + } +} \ No newline at end of file diff --git a/UnitTests~/AnimationServices/StateGraphTest.cs.meta b/UnitTests~/AnimationServices/StateGraphTest.cs.meta new file mode 100644 index 00000000..0f0bd16a --- /dev/null +++ b/UnitTests~/AnimationServices/StateGraphTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e6e6a4933542475c89925e093f43af77 +timeCreated: 1731187066 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/SyncedLayerOverrideAccessTest.cs b/UnitTests~/AnimationServices/SyncedLayerOverrideAccessTest.cs new file mode 100644 index 00000000..8f7644f8 --- /dev/null +++ b/UnitTests~/AnimationServices/SyncedLayerOverrideAccessTest.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using System.Linq; +using nadena.dev.ndmf.animator; +using nadena.dev.ndmf.UnitTestSupport; +using NUnit.Framework; +using UnityEditor.Animations; +using UnityEngine; + +namespace UnitTests.AnimationServices +{ + public class SyncedLayerOverrideAccessTest : TestBase + { + [Test] + public void Test_ExtractStateMotionPairs() + { + var ac = CreateTestController(out var clip1, out var clip2, out var s1); + + var l1 = ac.layers[1]; + l1.SetOverrideMotion(s1, clip1); + ac.layers = new[] + { + ac.layers[0], + l1 + }; + + var pairs = SyncedLayerOverrideAccess.ExtractStateMotionPairs(ac.layers[1]).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + Assert.AreEqual(1, pairs.Count); + Assert.AreEqual(clip1, pairs[s1]); + } + + [Test] + public void Test_ExtractStateBehaviorPairs() + { + var ac = CreateTestController(out var clip1, out var clip2, out var s1); + + var l1 = ac.layers[1]; + l1.SetOverrideBehaviours(s1, new StateMachineBehaviour[] { ScriptableObject.CreateInstance() }); + ac.layers = new[] + { + ac.layers[0], + l1 + }; + + var pairs = SyncedLayerOverrideAccess.ExtractStateBehaviourPairs(ac.layers[1]).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + Assert.AreEqual(1, pairs.Count); + Assert.AreEqual(1, pairs[s1].Length); + Assert.AreEqual(typeof(TestStateBehavior), pairs[s1][0].GetType()); + } + + + [Test] + public void Test_SetStateMotionPairs() + { + var ac = CreateTestController(out var clip1, out var clip2, out var s1); + + var l1 = ac.layers[1]; + SyncedLayerOverrideAccess.SetStateMotionPairs(l1, new Dictionary + { + {s1, clip2} + }); + + // Make sure we can save back to the controller (Unity native code) and read back + ac.layers = new[] + { + ac.layers[0], + l1 + }; + l1 = ac.layers[1]; + + Assert.AreEqual(clip2, l1.GetOverrideMotion(s1)); + } + + [Test] + public void Test_SetStateBehaviourPairs() + { + var ac = CreateTestController(out var clip1, out var clip2, out var s1); + + var l1 = ac.layers[1]; + SyncedLayerOverrideAccess.SetStateBehaviourPairs(l1, new Dictionary + { + {s1, new ScriptableObject[] {ScriptableObject.CreateInstance()}} + }); + + // Make sure we can save back to the controller (Unity native code) and read back + ac.layers = new[] + { + ac.layers[0], + l1 + }; + l1 = ac.layers[1]; + + Assert.AreEqual(1, l1.GetOverrideBehaviours(s1).Length); + } + + + private AnimatorController CreateTestController(out AnimationClip clip1, out AnimationClip clip2, out AnimatorState s1) + { + var ac = TrackObject(new AnimatorController()); + var sm = TrackObject(new AnimatorStateMachine()); + ac.layers = new[] + { + new AnimatorControllerLayer {stateMachine = sm}, + new AnimatorControllerLayer {syncedLayerIndex = 0} + }; + + clip1 = TrackObject(new AnimationClip {name = "c1"}); + clip2 = TrackObject(new AnimationClip {name = "c2"}); + + s1 = TrackObject(new AnimatorState {name = "s1", motion = clip1}); + var s2 = TrackObject(new AnimatorState {name = "s2", motion = clip2}); + + sm.states = new[] + { + new ChildAnimatorState {state = s1}, + new ChildAnimatorState {state = s2} + }; + return ac; + } + } +} \ No newline at end of file diff --git a/UnitTests~/AnimationServices/SyncedLayerOverrideAccessTest.cs.meta b/UnitTests~/AnimationServices/SyncedLayerOverrideAccessTest.cs.meta new file mode 100644 index 00000000..d0dba8a5 --- /dev/null +++ b/UnitTests~/AnimationServices/SyncedLayerOverrideAccessTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f2564e692d59415cafec4e2945f4212a +timeCreated: 1731381689 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/TestAssets.meta b/UnitTests~/AnimationServices/TestAssets.meta new file mode 100644 index 00000000..e76e2758 --- /dev/null +++ b/UnitTests~/AnimationServices/TestAssets.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 73c5c1292ca323a49af4affdf8af8dfd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnitTests~/AnimationServices/TestAssets/EmptyAvatar.prefab b/UnitTests~/AnimationServices/TestAssets/EmptyAvatar.prefab new file mode 100644 index 00000000..5c7b42f9 --- /dev/null +++ b/UnitTests~/AnimationServices/TestAssets/EmptyAvatar.prefab @@ -0,0 +1,324 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &480315407963147436 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 6586619323650946541} + - component: {fileID: 8461433870003703915} + - component: {fileID: 2958119849365294533} + - component: {fileID: 1578906050896144558} + m_Layer: 0 + m_Name: EmptyAvatar + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &6586619323650946541 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 480315407963147436} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 1.393002, y: 0.8608244, z: -2.394842} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 1 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!95 &8461433870003703915 +Animator: + serializedVersion: 5 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 480315407963147436} + m_Enabled: 1 + m_Avatar: {fileID: 0} + m_Controller: {fileID: 0} + m_CullingMode: 0 + m_UpdateMode: 0 + m_ApplyRootMotion: 0 + m_LinearVelocityBlending: 0 + m_StabilizeFeet: 0 + m_WarningMessage: + m_HasTransformHierarchy: 1 + m_AllowConstantClipSamplingOptimization: 1 + m_KeepAnimatorStateOnDisable: 0 + m_WriteDefaultValuesOnDisable: 0 +--- !u!114 &2958119849365294533 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 480315407963147436} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 542108242, guid: 67cc4cb7839cd3741b63733d5adf0442, type: 3} + m_Name: + m_EditorClassIdentifier: + Name: + ViewPosition: {x: 0, y: 1.6, z: 0.2} + Animations: 0 + ScaleIPD: 1 + lipSync: 0 + lipSyncJawBone: {fileID: 0} + lipSyncJawClosed: {x: 0, y: 0, z: 0, w: 1} + lipSyncJawOpen: {x: 0, y: 0, z: 0, w: 1} + VisemeSkinnedMesh: {fileID: 0} + MouthOpenBlendShapeName: Facial_Blends.Jaw_Down + VisemeBlendShapes: [] + unityVersion: + portraitCameraPositionOffset: {x: 0, y: 0, z: 0} + portraitCameraRotationOffset: {x: 0, y: 1, z: 0, w: -0.00000004371139} + networkIDs: [] + customExpressions: 0 + expressionsMenu: {fileID: 0} + expressionParameters: {fileID: 0} + enableEyeLook: 0 + customEyeLookSettings: + eyeMovement: + confidence: 0.5 + excitement: 0.5 + leftEye: {fileID: 0} + rightEye: {fileID: 0} + eyesLookingStraight: + linked: 1 + left: {x: 0, y: 0, z: 0, w: 0} + right: {x: 0, y: 0, z: 0, w: 0} + eyesLookingUp: + linked: 1 + left: {x: 0, y: 0, z: 0, w: 0} + right: {x: 0, y: 0, z: 0, w: 0} + eyesLookingDown: + linked: 1 + left: {x: 0, y: 0, z: 0, w: 0} + right: {x: 0, y: 0, z: 0, w: 0} + eyesLookingLeft: + linked: 1 + left: {x: 0, y: 0, z: 0, w: 0} + right: {x: 0, y: 0, z: 0, w: 0} + eyesLookingRight: + linked: 1 + left: {x: 0, y: 0, z: 0, w: 0} + right: {x: 0, y: 0, z: 0, w: 0} + eyelidType: 0 + upperLeftEyelid: {fileID: 0} + upperRightEyelid: {fileID: 0} + lowerLeftEyelid: {fileID: 0} + lowerRightEyelid: {fileID: 0} + eyelidsDefault: + upper: + linked: 1 + left: {x: 0, y: 0, z: 0, w: 0} + right: {x: 0, y: 0, z: 0, w: 0} + lower: + linked: 1 + left: {x: 0, y: 0, z: 0, w: 0} + right: {x: 0, y: 0, z: 0, w: 0} + eyelidsClosed: + upper: + linked: 1 + left: {x: 0, y: 0, z: 0, w: 0} + right: {x: 0, y: 0, z: 0, w: 0} + lower: + linked: 1 + left: {x: 0, y: 0, z: 0, w: 0} + right: {x: 0, y: 0, z: 0, w: 0} + eyelidsLookingUp: + upper: + linked: 1 + left: {x: 0, y: 0, z: 0, w: 0} + right: {x: 0, y: 0, z: 0, w: 0} + lower: + linked: 1 + left: {x: 0, y: 0, z: 0, w: 0} + right: {x: 0, y: 0, z: 0, w: 0} + eyelidsLookingDown: + upper: + linked: 1 + left: {x: 0, y: 0, z: 0, w: 0} + right: {x: 0, y: 0, z: 0, w: 0} + lower: + linked: 1 + left: {x: 0, y: 0, z: 0, w: 0} + right: {x: 0, y: 0, z: 0, w: 0} + eyelidsSkinnedMesh: {fileID: 0} + eyelidsBlendshapes: + customizeAnimationLayers: 0 + baseAnimationLayers: + - isEnabled: 0 + type: 0 + animatorController: {fileID: 0} + mask: {fileID: 0} + isDefault: 1 + - isEnabled: 0 + type: 4 + animatorController: {fileID: 0} + mask: {fileID: 0} + isDefault: 1 + - isEnabled: 0 + type: 5 + animatorController: {fileID: 0} + mask: {fileID: 0} + isDefault: 1 + specialAnimationLayers: + - isEnabled: 0 + type: 6 + animatorController: {fileID: 0} + mask: {fileID: 0} + isDefault: 1 + - isEnabled: 0 + type: 7 + animatorController: {fileID: 0} + mask: {fileID: 0} + isDefault: 1 + - isEnabled: 0 + type: 8 + animatorController: {fileID: 0} + mask: {fileID: 0} + isDefault: 1 + AnimationPreset: {fileID: 0} + animationHashSet: [] + autoFootsteps: 1 + autoLocomotion: 1 + collider_head: + isMirrored: 1 + state: 0 + transform: {fileID: 0} + radius: 0 + height: 0 + position: {x: 0, y: 0, z: 0} + rotation: {x: 0, y: 0, z: 0, w: 1} + collider_torso: + isMirrored: 1 + state: 0 + transform: {fileID: 0} + radius: 0 + height: 0 + position: {x: 0, y: 0, z: 0} + rotation: {x: 0, y: 0, z: 0, w: 1} + collider_footR: + isMirrored: 1 + state: 0 + transform: {fileID: 0} + radius: 0 + height: 0 + position: {x: 0, y: 0, z: 0} + rotation: {x: 0, y: 0, z: 0, w: 1} + collider_footL: + isMirrored: 1 + state: 0 + transform: {fileID: 0} + radius: 0 + height: 0 + position: {x: 0, y: 0, z: 0} + rotation: {x: 0, y: 0, z: 0, w: 1} + collider_handR: + isMirrored: 1 + state: 0 + transform: {fileID: 0} + radius: 0 + height: 0 + position: {x: 0, y: 0, z: 0} + rotation: {x: 0, y: 0, z: 0, w: 1} + collider_handL: + isMirrored: 1 + state: 0 + transform: {fileID: 0} + radius: 0 + height: 0 + position: {x: 0, y: 0, z: 0} + rotation: {x: 0, y: 0, z: 0, w: 1} + collider_fingerIndexL: + isMirrored: 1 + state: 0 + transform: {fileID: 0} + radius: 0 + height: 0 + position: {x: 0, y: 0, z: 0} + rotation: {x: 0, y: 0, z: 0, w: 1} + collider_fingerMiddleL: + isMirrored: 1 + state: 0 + transform: {fileID: 0} + radius: 0 + height: 0 + position: {x: 0, y: 0, z: 0} + rotation: {x: 0, y: 0, z: 0, w: 1} + collider_fingerRingL: + isMirrored: 1 + state: 0 + transform: {fileID: 0} + radius: 0 + height: 0 + position: {x: 0, y: 0, z: 0} + rotation: {x: 0, y: 0, z: 0, w: 1} + collider_fingerLittleL: + isMirrored: 1 + state: 0 + transform: {fileID: 0} + radius: 0 + height: 0 + position: {x: 0, y: 0, z: 0} + rotation: {x: 0, y: 0, z: 0, w: 1} + collider_fingerIndexR: + isMirrored: 1 + state: 0 + transform: {fileID: 0} + radius: 0 + height: 0 + position: {x: 0, y: 0, z: 0} + rotation: {x: 0, y: 0, z: 0, w: 1} + collider_fingerMiddleR: + isMirrored: 1 + state: 0 + transform: {fileID: 0} + radius: 0 + height: 0 + position: {x: 0, y: 0, z: 0} + rotation: {x: 0, y: 0, z: 0, w: 1} + collider_fingerRingR: + isMirrored: 1 + state: 0 + transform: {fileID: 0} + radius: 0 + height: 0 + position: {x: 0, y: 0, z: 0} + rotation: {x: 0, y: 0, z: 0, w: 1} + collider_fingerLittleR: + isMirrored: 1 + state: 0 + transform: {fileID: 0} + radius: 0 + height: 0 + position: {x: 0, y: 0, z: 0} + rotation: {x: 0, y: 0, z: 0, w: 1} +--- !u!114 &1578906050896144558 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 480315407963147436} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: -1427037861, guid: 4ecd63eff847044b68db9453ce219299, type: 3} + m_Name: + m_EditorClassIdentifier: + launchedFromSDKPipeline: 0 + completedSDKPipeline: 0 + blueprintId: + contentType: 0 + assetBundleUnityVersion: + fallbackStatus: 0 diff --git a/UnitTests~/AnimationServices/TestAssets/EmptyAvatar.prefab.meta b/UnitTests~/AnimationServices/TestAssets/EmptyAvatar.prefab.meta new file mode 100644 index 00000000..19d0a5b4 --- /dev/null +++ b/UnitTests~/AnimationServices/TestAssets/EmptyAvatar.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8200a81952f06a84ea27740654e84131 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnitTests~/AnimationServices/TestAssets/SyncedLayers.controller b/UnitTests~/AnimationServices/TestAssets/SyncedLayers.controller new file mode 100644 index 00000000..409706db --- /dev/null +++ b/UnitTests~/AnimationServices/TestAssets/SyncedLayers.controller @@ -0,0 +1,163 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-4880960033897082852 +MonoBehaviour: + m_ObjectHideFlags: 1 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 56a07721b4fa4641bbbeabd99a5ca6f5, type: 3} + m_Name: + m_EditorClassIdentifier: +--- !u!1107 &-4280938553123073255 +AnimatorStateMachine: + serializedVersion: 6 + m_ObjectHideFlags: 1 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: Base Layer + m_ChildStates: + - serializedVersion: 1 + m_State: {fileID: 3523188436526479027} + m_Position: {x: 290, y: -20, z: 0} + - serializedVersion: 1 + m_State: {fileID: 8821374870359653147} + m_Position: {x: 290, y: 70, z: 0} + m_ChildStateMachines: [] + m_AnyStateTransitions: [] + m_EntryTransitions: [] + m_StateMachineTransitions: {} + m_StateMachineBehaviours: [] + m_AnyStatePosition: {x: 50, y: 20, z: 0} + m_EntryPosition: {x: 50, y: 120, z: 0} + m_ExitPosition: {x: 800, y: 120, z: 0} + m_ParentStateMachinePosition: {x: 800, y: 20, z: 0} + m_DefaultState: {fileID: 3523188436526479027} +--- !u!91 &9100000 +AnimatorController: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: SyncedLayers + serializedVersion: 5 + m_AnimatorParameters: [] + m_AnimatorLayers: + - serializedVersion: 5 + m_Name: Base Layer + m_StateMachine: {fileID: -4280938553123073255} + m_Mask: {fileID: 0} + m_Motions: [] + m_Behaviours: [] + m_BlendingMode: 0 + m_SyncedLayerIndex: -1 + m_DefaultWeight: 0 + m_IKPass: 0 + m_SyncedLayerAffectsTiming: 0 + m_Controller: {fileID: 9100000} + - serializedVersion: 5 + m_Name: New Layer + m_StateMachine: {fileID: 1401791441323627166} + m_Mask: {fileID: 0} + m_Motions: + - serializedVersion: 2 + m_State: {fileID: 3523188436526479027} + m_Motion: {fileID: 7400000, guid: 27077069e78dc4b41ae5b37140c1f92d, type: 2} + m_Behaviours: + - m_State: {fileID: 8821374870359653147} + m_StateMachineBehaviours: + - {fileID: -4880960033897082852} + m_BlendingMode: 0 + m_SyncedLayerIndex: 0 + m_DefaultWeight: 0 + m_IKPass: 0 + m_SyncedLayerAffectsTiming: 0 + m_Controller: {fileID: 9100000} +--- !u!1107 &1401791441323627166 +AnimatorStateMachine: + serializedVersion: 6 + m_ObjectHideFlags: 1 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: New Layer + m_ChildStates: [] + m_ChildStateMachines: [] + m_AnyStateTransitions: [] + m_EntryTransitions: [] + m_StateMachineTransitions: {} + m_StateMachineBehaviours: [] + m_AnyStatePosition: {x: 50, y: 20, z: 0} + m_EntryPosition: {x: 50, y: 120, z: 0} + m_ExitPosition: {x: 800, y: 120, z: 0} + m_ParentStateMachinePosition: {x: 800, y: 20, z: 0} + m_DefaultState: {fileID: 0} +--- !u!1102 &3523188436526479027 +AnimatorState: + serializedVersion: 6 + m_ObjectHideFlags: 1 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: c1 + m_Speed: 1 + m_CycleOffset: 0 + m_Transitions: [] + m_StateMachineBehaviours: [] + m_Position: {x: 50, y: 50, z: 0} + m_IKOnFeet: 0 + m_WriteDefaultValues: 1 + m_Mirror: 0 + m_SpeedParameterActive: 0 + m_MirrorParameterActive: 0 + m_CycleOffsetParameterActive: 0 + m_TimeParameterActive: 0 + m_Motion: {fileID: 7400000, guid: c343e51fcd0dae3499b06070ba4ee938, type: 2} + m_Tag: + m_SpeedParameter: + m_MirrorParameter: + m_CycleOffsetParameter: + m_TimeParameter: +--- !u!114 &5814980541234309298 +MonoBehaviour: + m_ObjectHideFlags: 1 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 56a07721b4fa4641bbbeabd99a5ca6f5, type: 3} + m_Name: + m_EditorClassIdentifier: +--- !u!1102 &8821374870359653147 +AnimatorState: + serializedVersion: 6 + m_ObjectHideFlags: 1 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: behavior + m_Speed: 1 + m_CycleOffset: 0 + m_Transitions: [] + m_StateMachineBehaviours: + - {fileID: 5814980541234309298} + m_Position: {x: 50, y: 50, z: 0} + m_IKOnFeet: 0 + m_WriteDefaultValues: 1 + m_Mirror: 0 + m_SpeedParameterActive: 0 + m_MirrorParameterActive: 0 + m_CycleOffsetParameterActive: 0 + m_TimeParameterActive: 0 + m_Motion: {fileID: 0} + m_Tag: + m_SpeedParameter: + m_MirrorParameter: + m_CycleOffsetParameter: + m_TimeParameter: diff --git a/UnitTests~/AnimationServices/TestAssets/SyncedLayers.controller.meta b/UnitTests~/AnimationServices/TestAssets/SyncedLayers.controller.meta new file mode 100644 index 00000000..dca9dcfe --- /dev/null +++ b/UnitTests~/AnimationServices/TestAssets/SyncedLayers.controller.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1fba4c901694c264b8cefb2d1236e101 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 9100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnitTests~/AnimationServices/TestAssets/c1.anim b/UnitTests~/AnimationServices/TestAssets/c1.anim new file mode 100644 index 00000000..d0a95804 --- /dev/null +++ b/UnitTests~/AnimationServices/TestAssets/c1.anim @@ -0,0 +1,53 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!74 &7400000 +AnimationClip: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: c1 + serializedVersion: 7 + m_Legacy: 0 + m_Compressed: 0 + m_UseHighQualityCurve: 1 + m_RotationCurves: [] + m_CompressedRotationCurves: [] + m_EulerCurves: [] + m_PositionCurves: [] + m_ScaleCurves: [] + m_FloatCurves: [] + m_PPtrCurves: [] + m_SampleRate: 60 + m_WrapMode: 0 + m_Bounds: + m_Center: {x: 0, y: 0, z: 0} + m_Extent: {x: 0, y: 0, z: 0} + m_ClipBindingConstant: + genericBindings: [] + pptrCurveMapping: [] + m_AnimationClipSettings: + serializedVersion: 2 + m_AdditiveReferencePoseClip: {fileID: 0} + m_AdditiveReferencePoseTime: 0 + m_StartTime: 0 + m_StopTime: 1 + m_OrientationOffsetY: 0 + m_Level: 0 + m_CycleOffset: 0 + m_HasAdditiveReferencePose: 0 + m_LoopTime: 0 + m_LoopBlend: 0 + m_LoopBlendOrientation: 0 + m_LoopBlendPositionY: 0 + m_LoopBlendPositionXZ: 0 + m_KeepOriginalOrientation: 0 + m_KeepOriginalPositionY: 1 + m_KeepOriginalPositionXZ: 0 + m_HeightFromFeet: 0 + m_Mirror: 0 + m_EditorCurves: [] + m_EulerEditorCurves: [] + m_HasGenericRootTransform: 0 + m_HasMotionFloatCurves: 0 + m_Events: [] diff --git a/UnitTests~/AnimationServices/TestAssets/c1.anim.meta b/UnitTests~/AnimationServices/TestAssets/c1.anim.meta new file mode 100644 index 00000000..5964278e --- /dev/null +++ b/UnitTests~/AnimationServices/TestAssets/c1.anim.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c343e51fcd0dae3499b06070ba4ee938 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 7400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnitTests~/AnimationServices/TestAssets/c2.anim b/UnitTests~/AnimationServices/TestAssets/c2.anim new file mode 100644 index 00000000..74c9c3cb --- /dev/null +++ b/UnitTests~/AnimationServices/TestAssets/c2.anim @@ -0,0 +1,53 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!74 &7400000 +AnimationClip: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: c2 + serializedVersion: 7 + m_Legacy: 0 + m_Compressed: 0 + m_UseHighQualityCurve: 1 + m_RotationCurves: [] + m_CompressedRotationCurves: [] + m_EulerCurves: [] + m_PositionCurves: [] + m_ScaleCurves: [] + m_FloatCurves: [] + m_PPtrCurves: [] + m_SampleRate: 60 + m_WrapMode: 0 + m_Bounds: + m_Center: {x: 0, y: 0, z: 0} + m_Extent: {x: 0, y: 0, z: 0} + m_ClipBindingConstant: + genericBindings: [] + pptrCurveMapping: [] + m_AnimationClipSettings: + serializedVersion: 2 + m_AdditiveReferencePoseClip: {fileID: 0} + m_AdditiveReferencePoseTime: 0 + m_StartTime: 0 + m_StopTime: 1 + m_OrientationOffsetY: 0 + m_Level: 0 + m_CycleOffset: 0 + m_HasAdditiveReferencePose: 0 + m_LoopTime: 0 + m_LoopBlend: 0 + m_LoopBlendOrientation: 0 + m_LoopBlendPositionY: 0 + m_LoopBlendPositionXZ: 0 + m_KeepOriginalOrientation: 0 + m_KeepOriginalPositionY: 1 + m_KeepOriginalPositionXZ: 0 + m_HeightFromFeet: 0 + m_Mirror: 0 + m_EditorCurves: [] + m_EulerEditorCurves: [] + m_HasGenericRootTransform: 0 + m_HasMotionFloatCurves: 0 + m_Events: [] diff --git a/UnitTests~/AnimationServices/TestAssets/c2.anim.meta b/UnitTests~/AnimationServices/TestAssets/c2.anim.meta new file mode 100644 index 00000000..c8b73fd8 --- /dev/null +++ b/UnitTests~/AnimationServices/TestAssets/c2.anim.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 27077069e78dc4b41ae5b37140c1f92d +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 7400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnitTests~/AnimationServices/TestAssets/c3.anim b/UnitTests~/AnimationServices/TestAssets/c3.anim new file mode 100644 index 00000000..daa36e18 --- /dev/null +++ b/UnitTests~/AnimationServices/TestAssets/c3.anim @@ -0,0 +1,53 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!74 &7400000 +AnimationClip: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: c3 + serializedVersion: 7 + m_Legacy: 0 + m_Compressed: 0 + m_UseHighQualityCurve: 1 + m_RotationCurves: [] + m_CompressedRotationCurves: [] + m_EulerCurves: [] + m_PositionCurves: [] + m_ScaleCurves: [] + m_FloatCurves: [] + m_PPtrCurves: [] + m_SampleRate: 60 + m_WrapMode: 0 + m_Bounds: + m_Center: {x: 0, y: 0, z: 0} + m_Extent: {x: 0, y: 0, z: 0} + m_ClipBindingConstant: + genericBindings: [] + pptrCurveMapping: [] + m_AnimationClipSettings: + serializedVersion: 2 + m_AdditiveReferencePoseClip: {fileID: 0} + m_AdditiveReferencePoseTime: 0 + m_StartTime: 0 + m_StopTime: 1 + m_OrientationOffsetY: 0 + m_Level: 0 + m_CycleOffset: 0 + m_HasAdditiveReferencePose: 0 + m_LoopTime: 0 + m_LoopBlend: 0 + m_LoopBlendOrientation: 0 + m_LoopBlendPositionY: 0 + m_LoopBlendPositionXZ: 0 + m_KeepOriginalOrientation: 0 + m_KeepOriginalPositionY: 1 + m_KeepOriginalPositionXZ: 0 + m_HeightFromFeet: 0 + m_Mirror: 0 + m_EditorCurves: [] + m_EulerEditorCurves: [] + m_HasGenericRootTransform: 0 + m_HasMotionFloatCurves: 0 + m_Events: [] diff --git a/UnitTests~/AnimationServices/TestAssets/c3.anim.meta b/UnitTests~/AnimationServices/TestAssets/c3.anim.meta new file mode 100644 index 00000000..5b63cc16 --- /dev/null +++ b/UnitTests~/AnimationServices/TestAssets/c3.anim.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 74b8905b44621a84fb690d898a76f14b +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 7400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnitTests~/AnimationServices/VRChatTests.cs b/UnitTests~/AnimationServices/VRChatTests.cs new file mode 100644 index 00000000..73a68bce --- /dev/null +++ b/UnitTests~/AnimationServices/VRChatTests.cs @@ -0,0 +1,229 @@ +#if NDMF_VRCSDK3_AVATARS + +using System.Linq; +using nadena.dev.ndmf.animator; +using NUnit.Framework; +using UnityEditor.Animations; +using UnityEngine; +using VRC.SDK3.Avatars.Components; +using VRC.SDKBase; + +namespace UnitTests.AnimationServices +{ + public class VRChatTests : TestBase + { + [Test] + public void LoadsDefaultControllersIfNoneProvided() + { + var root = CreatePrefab("TestAssets/EmptyAvatar.prefab"); + var ctx = CreateContext(root); + + var anim = ctx.ActivateExtensionContext(); + + var fx = anim[VRCAvatarDescriptor.AnimLayerType.FX]; + Assert.IsNotNull(fx); + Assert.AreEqual("vrc_AvatarV3FaceLayer", fx.Name); + + var ikPose = anim[VRCAvatarDescriptor.AnimLayerType.IKPose]; + Assert.IsNotNull(ikPose); + Assert.AreEqual("vrc_AvatarV3UtilityIKPose", ikPose.Name); + } + + [Test] + public void LoadsOverrideControllers() + { + var root = CreatePrefab("TestAssets/EmptyAvatar.prefab"); + var avDesc = root.GetComponent(); + + var controller = new AnimatorController() { name = "TEST" }; + + SetBaseLayer(avDesc, controller, VRCAvatarDescriptor.AnimLayerType.FX); + + var ctx = CreateContext(root); + var anim = ctx.ActivateExtensionContext(); + + var fx = anim[VRCAvatarDescriptor.AnimLayerType.FX]; + Assert.IsNotNull(fx); + Assert.AreEqual("TEST", fx.Name); + } + + private static void SetBaseLayer(VRCAvatarDescriptor avDesc, AnimatorController controller, VRCAvatarDescriptor.AnimLayerType layerType) + { + var layers = avDesc.baseAnimationLayers; + for (int i = 0; i < layers.Length; i++) + { + var layer = layers[i]; + if (layer.type == layerType) + { + layer.animatorController = controller; + layer.isDefault = false; + layers[i] = layer; + break; + } + } + avDesc.baseAnimationLayers = layers; + } + + [Test] + public void WritesBackOverrideControllers() + { + var root = CreatePrefab("TestAssets/EmptyAvatar.prefab"); + + var ctx = CreateContext(root); + var anim = ctx.ActivateExtensionContext(); + + anim[VRCAvatarDescriptor.AnimLayerType.FX]!.Name = "FX"; + anim[VRCAvatarDescriptor.AnimLayerType.IKPose]!.Name = "IK"; + + ctx.DeactivateExtensionContext(); + + var avDesc = root.GetComponent(); + var fxLayer = avDesc.baseAnimationLayers.First(l => l.type == VRCAvatarDescriptor.AnimLayerType.FX); + var ikLayer = avDesc.specialAnimationLayers.First(l => l.type == VRCAvatarDescriptor.AnimLayerType.IKPose); + + Assert.AreEqual("FX", fxLayer.animatorController.name); + Assert.AreEqual("IK", ikLayer.animatorController.name); + } + + [Test] + public void CorrectsInterLayerReferences() + { + var root = CreatePrefab("TestAssets/EmptyAvatar.prefab"); + var avDesc = root.GetComponent(); + + SetBaseLayer(avDesc, BuildInterlayerController("c1"), VRCAvatarDescriptor.AnimLayerType.FX); + + var ctx = CreateContext(root); + var anim = ctx.ActivateExtensionContext(); + + var fx = anim[VRCAvatarDescriptor.AnimLayerType.FX]; + var c1_1 = fx.Layers.First(l => l.Name == "c1:1"); + var lc = (VRCAnimatorLayerControl) c1_1.StateMachine.DefaultState!.Behaviours.First(); + var c1_2 = fx.Layers.First(l => l.Name == "c1:2"); + Assert.AreEqual(c1_2.VirtualLayerIndex, lc.layer); + + var newController = anim.CloneContext.Clone(BuildInterlayerController("c2"))!; + lc = (VRCAnimatorLayerControl) newController.Layers.ToList()[0].StateMachine.DefaultState!.Behaviours.First(); + Assert.AreEqual(newController.Layers.ToList()[1].VirtualLayerIndex, lc.layer); + + foreach (var l in newController.Layers) + { + fx.AddLayer(LayerPriority.Default, l); + } + + ctx.DeactivateExtensionContext(); + + var fxLayer = avDesc.baseAnimationLayers.First(l => l.type == VRCAvatarDescriptor.AnimLayerType.FX); + var stateMachines = ((AnimatorController)fxLayer.animatorController).layers + .Select(l => l.stateMachine).ToArray(); + + var out_c1_1 = stateMachines.First(sm => sm.name == "c1:1"); + var out_c2_1 = stateMachines.First(sm => sm.name == "c2:1"); + var out_c1_2_idx = stateMachines.ToList().IndexOf(stateMachines.First(sm => sm.name == "c1:2")); + var out_c2_2_idx = stateMachines.ToList().IndexOf(stateMachines.First(sm => sm.name == "c2:2")); + + lc = out_c1_1.defaultState.behaviours.First() as VRCAnimatorLayerControl; + Assert.AreEqual(out_c1_2_idx, lc.layer); + lc = out_c2_1.defaultState.behaviours.First() as VRCAnimatorLayerControl; + Assert.AreEqual(out_c2_2_idx, lc.layer); + } + + [Test] + public void IgnoresCrossLayerReferences() + { + var root = CreatePrefab("TestAssets/EmptyAvatar.prefab"); + var avDesc = root.GetComponent(); + + // Base -> FX + SetBaseLayer(avDesc, BuildInterlayerController("c1"), VRCAvatarDescriptor.AnimLayerType.Base); + + var ctx = CreateContext(root); + var anim = ctx.ActivateExtensionContext(); + + var baseLayer = anim[VRCAvatarDescriptor.AnimLayerType.Base]; + var lc = (VRCAnimatorLayerControl) baseLayer.Layers + .First(l => l.Name == "c1:1") + .StateMachine.DefaultState.Behaviours.First(); + Assert.AreEqual(1, lc.layer); + + ctx.DeactivateExtensionContext(); + + var baseLayerController = avDesc.baseAnimationLayers.First(l => l.type == VRCAvatarDescriptor.AnimLayerType.Base); + var c11layer = ((AnimatorController)baseLayerController.animatorController).layers + .First(l => l.stateMachine.name == "c1:1"); + lc = c11layer.stateMachine.defaultState.behaviours.First() as VRCAnimatorLayerControl; + Assert.AreEqual(1, lc.layer); + } + + [Test] + public void HandlesStateMachineBehaviours() + { + var root = CreatePrefab("TestAssets/EmptyAvatar.prefab"); + var avDesc = root.GetComponent(); + + var controller = new AnimatorController() { name = "TEST" }; + var sm = new AnimatorStateMachine() { name = "SM" }; + controller.layers = new[] + { + new AnimatorControllerLayer() { stateMachine = sm } + }; + var layerControl = ScriptableObject.CreateInstance(); + layerControl.layer = 0; + layerControl.playable = VRC_AnimatorLayerControl.BlendableLayer.FX; + sm.behaviours = new StateMachineBehaviour[] + { + layerControl + }; + + SetBaseLayer(avDesc, controller, VRCAvatarDescriptor.AnimLayerType.FX); + + var ctx = CreateContext(root); + var anim = ctx.ActivateExtensionContext(); + + var fx = anim[VRCAvatarDescriptor.AnimLayerType.FX]; + var lc = (VRCAnimatorLayerControl) fx.Layers.First().StateMachine.Behaviours.First(); + Assert.AreEqual(fx.Layers.First().VirtualLayerIndex, lc.layer); + + var l2 = fx.AddLayer(LayerPriority.Default, "test"); + lc.layer = l2.VirtualLayerIndex; + + ctx.DeactivateExtensionContext(); + + var fxLayer = avDesc.baseAnimationLayers.First(l => l.type == VRCAvatarDescriptor.AnimLayerType.FX); + var stateMachine = ((AnimatorController)fxLayer.animatorController).layers.First().stateMachine; + lc = stateMachine.behaviours.First() as VRCAnimatorLayerControl; + Assert.AreEqual(1, lc.layer); + } + + private AnimatorController BuildInterlayerController(string prefix) + { + var ac = new AnimatorController(); + var sm1 = new AnimatorStateMachine() { name = prefix + ":1" }; + var sm2 = new AnimatorStateMachine() { name = prefix + ":2" }; + + var s1 = new AnimatorState() { name = prefix + ":1" }; + var s2 = new AnimatorState() { name = prefix + ":2" }; + + sm1.states = new[] { new ChildAnimatorState() { state = s1 } }; + sm2.states = new[] { new ChildAnimatorState() { state = s2 } }; + sm1.defaultState = s1; + sm2.defaultState = s2; + + var lc = ScriptableObject.CreateInstance(); + lc.layer = 1; + lc.playable = VRC_AnimatorLayerControl.BlendableLayer.FX; + + s1.behaviours = new StateMachineBehaviour[] { lc }; + + ac.layers = new[] + { + new AnimatorControllerLayer() { stateMachine = sm1, name = prefix + ":1" }, + new AnimatorControllerLayer() { stateMachine = sm2, name = prefix + ":2" } + }; + + return ac; + } + } +} + +#endif \ No newline at end of file diff --git a/UnitTests~/AnimationServices/VRChatTests.cs.meta b/UnitTests~/AnimationServices/VRChatTests.cs.meta new file mode 100644 index 00000000..f43418fb --- /dev/null +++ b/UnitTests~/AnimationServices/VRChatTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: fde5591d0d4e4a4db77676bc7bb81241 +timeCreated: 1731895653 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/VirtualAnimatorControllerTest.cs b/UnitTests~/AnimationServices/VirtualAnimatorControllerTest.cs new file mode 100644 index 00000000..64b20ca0 --- /dev/null +++ b/UnitTests~/AnimationServices/VirtualAnimatorControllerTest.cs @@ -0,0 +1,110 @@ +using nadena.dev.ndmf.animator; +using NUnit.Framework; +using UnityEditor.Animations; +using UnityEngine; +using AnimatorController = UnityEditor.Animations.AnimatorController; +using Assert = UnityEngine.Assertions.Assert; + +namespace UnitTests.AnimationServices +{ + public class VirtualAnimatorControllerTest : TestBase + { + [Test] + public void PreservesParameters() + { + CloneContext context = new CloneContext(GenericPlatformAnimatorBindings.Instance); + + var controller = TrackObject(new AnimatorController()); + controller.AddParameter("x", AnimatorControllerParameterType.Float); + controller.AddParameter("y", AnimatorControllerParameterType.Bool); + + var virtualController = context.Clone(controller); + Assert.AreEqual(2, virtualController.Parameters.Count); + Assert.AreEqual(AnimatorControllerParameterType.Float, virtualController.Parameters["x"].type); + Assert.AreEqual(AnimatorControllerParameterType.Bool, virtualController.Parameters["y"].type); + + using (new AssertInvalidate(virtualController)) + { + virtualController.Parameters = virtualController.Parameters.Add("z", new AnimatorControllerParameter() + { + name = "ignored", + type = AnimatorControllerParameterType.Trigger + }); + } + + var commitContext = new CommitContext(); + var committed = commitContext.CommitObject(virtualController); + + Assert.AreEqual(3, committed.parameters.Length); + Assert.AreEqual("x", committed.parameters[0].name); + Assert.AreEqual(AnimatorControllerParameterType.Float, committed.parameters[0].type); + Assert.AreEqual("y", committed.parameters[1].name); + Assert.AreEqual(AnimatorControllerParameterType.Bool, committed.parameters[1].type); + Assert.AreEqual("z", committed.parameters[2].name); + Assert.AreEqual(AnimatorControllerParameterType.Trigger, committed.parameters[2].type); + + commitContext.DestroyAllImmediate(); + } + + [Test] + public void PreservesLayersAndReferences() + { + CloneContext context = new CloneContext(GenericPlatformAnimatorBindings.Instance); + + var ac1 = TrackObject(new AnimatorController()); + var ac2 = TrackObject(new AnimatorController()); + + ac1.layers = new[] + { + new AnimatorControllerLayer() + { + name = "1", + stateMachine = new AnimatorStateMachine() { name = "1" } + }, + new AnimatorControllerLayer() + { + name = "2", + syncedLayerIndex = 0 + } + }; + ac2.layers = new[] + { + new AnimatorControllerLayer() + { + name = "3", + stateMachine = new AnimatorStateMachine() { name = "1" } + }, + new AnimatorControllerLayer() + { + name = "4", + syncedLayerIndex = 0 + } + }; + + var vc1 = context.Clone(ac1); + var vc2 = context.Clone(ac2); + + foreach (var l in vc2.Layers) + { + using (new AssertInvalidate(vc1)) vc1.AddLayer(new LayerPriority(), l); + } + + var commitContext = new CommitContext(); + var committed = commitContext.CommitObject(vc1); + + Assert.AreEqual(4, committed.layers.Length); + Assert.AreEqual("1", committed.layers[0].name); + Assert.AreEqual("2", committed.layers[1].name); + Assert.AreEqual("3", committed.layers[2].name); + Assert.AreEqual("4", committed.layers[3].name); + + Assert.AreEqual("1", committed.layers[0].stateMachine.name); + Assert.AreEqual(-1, committed.layers[0].syncedLayerIndex); + Assert.AreEqual(0, committed.layers[1].syncedLayerIndex); + Assert.AreEqual(-1, committed.layers[2].syncedLayerIndex); + Assert.AreEqual(2, committed.layers[3].syncedLayerIndex); + + commitContext.DestroyAllImmediate(); + } + } +} \ No newline at end of file diff --git a/UnitTests~/AnimationServices/VirtualAnimatorControllerTest.cs.meta b/UnitTests~/AnimationServices/VirtualAnimatorControllerTest.cs.meta new file mode 100644 index 00000000..deb4dccf --- /dev/null +++ b/UnitTests~/AnimationServices/VirtualAnimatorControllerTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 6c350f2ae4a54445a48af06b35f74ae0 +timeCreated: 1731196536 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/VirtualBlendTreeTest.cs b/UnitTests~/AnimationServices/VirtualBlendTreeTest.cs new file mode 100644 index 00000000..90a4f55d --- /dev/null +++ b/UnitTests~/AnimationServices/VirtualBlendTreeTest.cs @@ -0,0 +1,175 @@ +using System; +using nadena.dev.ndmf.animator; +using NUnit.Framework; +using UnityEditor.Animations; +using UnityEngine; + +namespace UnitTests.AnimationServices +{ + public class VirtualBlendTreeTest : TestBase + { + private void AssertPreserveProperty( + Action setup, + Action setupViaVirtualState, + Action assert, + Action assertViaVirtualState + ) + { + var tree = new BlendTree(); + setup(tree); + + var cloneContext = new CloneContext(GenericPlatformAnimatorBindings.Instance); + + var virtTree = (VirtualBlendTree) cloneContext.Clone(tree); + assertViaVirtualState(virtTree); + + var commitContext = new CommitContext(); + var committed = (BlendTree) commitContext.CommitObject(virtTree); + Assert.AreNotEqual(tree, committed); + assert(committed); + + tree = new BlendTree(); + + virtTree = (VirtualBlendTree) cloneContext.Clone(tree); + using (new AssertInvalidate(virtTree)) + { + setupViaVirtualState(virtTree); + } + + committed = (BlendTree) commitContext.CommitObject(virtTree); + + assert(committed); + + commitContext.DestroyAllImmediate(); + } + + [Test] + public void PreservesName() + { + AssertPreserveProperty( + state => state.name = "Test", + virtualState => virtualState.Name = "Test", + state => Assert.AreEqual("Test", state.name), + virtualState => Assert.AreEqual("Test", virtualState.Name) + ); + } + + [Test] + public void PreservesBlendParameter() + { + AssertPreserveProperty( + state => state.blendParameter = "Test", + virtualState => virtualState.BlendParameter = "Test", + state => Assert.AreEqual("Test", state.blendParameter), + virtualState => Assert.AreEqual("Test", virtualState.BlendParameter) + ); + } + + [Test] + public void PreservesBlendParameterY() + { + AssertPreserveProperty( + state => state.blendParameterY = "Test", + virtualState => virtualState.BlendParameterY = "Test", + state => Assert.AreEqual("Test", state.blendParameterY), + virtualState => Assert.AreEqual("Test", virtualState.BlendParameterY) + ); + } + + [Test] + public void PreservesBlendType() + { + AssertPreserveProperty( + state => state.blendType = BlendTreeType.Simple1D, + virtualState => virtualState.BlendType = BlendTreeType.Simple1D, + state => Assert.AreEqual(BlendTreeType.Simple1D, state.blendType), + virtualState => Assert.AreEqual(BlendTreeType.Simple1D, virtualState.BlendType) + ); + } + + [Test] + public void PreservesMaxThreshold() + { + AssertPreserveProperty( + state => state.maxThreshold = 0.5f, + virtualState => virtualState.MaxThreshold = 0.5f, + state => Assert.AreEqual(0.5f, state.maxThreshold), + virtualState => Assert.AreEqual(0.5f, virtualState.MaxThreshold) + ); + } + + [Test] + public void PreservesMinThreshold() + { + AssertPreserveProperty( + state => state.minThreshold = 0.5f, + virtualState => virtualState.MinThreshold = 0.5f, + state => Assert.AreEqual(0.5f, state.minThreshold), + virtualState => Assert.AreEqual(0.5f, virtualState.MinThreshold) + ); + } + + [Test] + public void PreservesUseAutomaticThresholds() + { + AssertPreserveProperty( + state => state.useAutomaticThresholds = true, + virtualState => virtualState.UseAutomaticThresholds = true, + state => Assert.AreEqual(true, state.useAutomaticThresholds), + virtualState => Assert.AreEqual(true, virtualState.UseAutomaticThresholds) + ); + } + + [Test] + public void PreservesBlendTreeChildren() + { + var tree = TrackObject(new BlendTree()); + tree.useAutomaticThresholds = false; + tree.children = new[] + { + new ChildMotion() + { + motion = TrackObject(new AnimationClip() { name = "1"}), + threshold = 0.5f, + cycleOffset = 0.25f, + directBlendParameter = "Test", + mirror = true, + position = new Vector2(0.5f, 0.5f), + timeScale = 0.9f + }, + new ChildMotion() + { + motion = TrackObject(new AnimationClip() { name = "2" }), + timeScale = 0.1f + } + }; + + var cloneContext = new CloneContext(GenericPlatformAnimatorBindings.Instance); + + var virtTree = (VirtualBlendTree) cloneContext.Clone(tree); + Assert.AreEqual(2, virtTree.Children.Count); + + virtTree.Children = virtTree.Children.Add(new VirtualBlendTree.VirtualChildMotion() + { + Motion = VirtualClip.Create("3") + }); + + var commitContext = new CommitContext(); + var committed = (BlendTree) commitContext.CommitObject(virtTree); + Assert.AreNotEqual(tree, committed); + Assert.AreEqual(3, committed.children.Length); + Assert.AreEqual("1", committed.children[0].motion.name); + Assert.AreEqual(0.5f, committed.children[0].threshold); + Assert.AreEqual(0.25f, committed.children[0].cycleOffset); + Assert.AreEqual("Test", committed.children[0].directBlendParameter); + Assert.AreEqual(true, committed.children[0].mirror); + Assert.AreEqual(new Vector2(0.5f, 0.5f), committed.children[0].position); + Assert.AreEqual(0.9f, committed.children[0].timeScale); + + Assert.AreEqual("2", committed.children[1].motion.name); + Assert.AreEqual("3", committed.children[2].motion.name); + + commitContext.DestroyAllImmediate(); + } + } +} \ No newline at end of file diff --git a/UnitTests~/AnimationServices/VirtualBlendTreeTest.cs.meta b/UnitTests~/AnimationServices/VirtualBlendTreeTest.cs.meta new file mode 100644 index 00000000..491644f3 --- /dev/null +++ b/UnitTests~/AnimationServices/VirtualBlendTreeTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a16075137ed54004b6d9ecd574554275 +timeCreated: 1731215774 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/VirtualClipTest.cs b/UnitTests~/AnimationServices/VirtualClipTest.cs new file mode 100644 index 00000000..5150c6f6 --- /dev/null +++ b/UnitTests~/AnimationServices/VirtualClipTest.cs @@ -0,0 +1,277 @@ +using System; +using System.Linq; +using nadena.dev.ndmf; +using nadena.dev.ndmf.animator; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace UnitTests.AnimationServices +{ + public class VirtualClipTest : TestBase + { + private GameObject avatarRoot; + private CloneContext context; + + [SetUp] + public void Setup() + { + avatarRoot = CreateRoot("root"); + context = CreateCloneContext(); + } + + private CloneContext CreateCloneContext() + { + return new CloneContext(GenericPlatformAnimatorBindings.Instance); + } + + AnimationClip Commit(VirtualClip clip) + { + return TrackObject((AnimationClip) new CommitContext().CommitObject(clip)); + } + + [Test] + public void PreservesInitialCurves() + { + var material = NewTestMaterial(); + AnimationClip ac = TrackObject(new AnimationClip()); + ac.name = "foo"; + var originalCurve = new AnimationCurve(new Keyframe(0, 0), new Keyframe(1, 1)); + ac.SetCurve("abc", typeof(GameObject), "m_IsActive", originalCurve); + + AnimationUtility.SetObjectReferenceCurve(ac, EditorCurveBinding.PPtrCurve("def", typeof(MeshRenderer), "m_Materials"), new ObjectReferenceKeyframe[] + { + new() {time = 0, value = material}, + }); + + VirtualClip vc = VirtualClip.Clone(context, ac); + var committedClip = Commit(vc); + + var bindings = AnimationUtility.GetCurveBindings(committedClip).ToList(); + Assert.AreEqual(1, bindings.Count); + Assert.AreEqual("abc", bindings[0].path); + Assert.AreEqual(originalCurve, AnimationUtility.GetEditorCurve(committedClip, bindings[0])); + + var objBindings = AnimationUtility.GetObjectReferenceCurveBindings(committedClip); + Assert.AreEqual(1, objBindings.Length); + Assert.AreEqual("def", objBindings[0].path); + Assert.AreEqual(material, AnimationUtility.GetObjectReferenceCurve(committedClip, objBindings[0])[0].value); + } + + [Test] + public void EditExistingFloatCurve() + { + AnimationClip ac = TrackObject(new AnimationClip()); + ac.name = "foo"; + var originalCurve = new AnimationCurve(new Keyframe(0, 0), new Keyframe(1, 1)); + ac.SetCurve("abc", typeof(GameObject), "m_IsActive", originalCurve); + + VirtualClip vc = VirtualClip.Clone(context, ac); + + var bindings = vc.GetFloatCurveBindings().ToList(); + Assert.AreEqual(1, bindings.Count); + Assert.AreEqual("abc", bindings[0].path); + Assert.AreEqual(typeof(GameObject), bindings[0].type); + Assert.AreEqual("m_IsActive", bindings[0].propertyName); + + var existingCurve = vc.GetFloatCurve("abc", typeof(GameObject), "m_IsActive"); + Assert.IsNotNull(existingCurve); + AssertEqualNotSame(existingCurve, originalCurve); + + // Replace the curve and see if it commits + var newCurve = new AnimationCurve(new Keyframe(0, 0), new Keyframe(1, 2)); + using (new AssertInvalidate(vc)) + { + vc.SetFloatCurve("abc", typeof(GameObject), "m_IsActive", newCurve); + } + + var committedClip = Commit(vc); + var newCommittedCurve = AnimationUtility.GetEditorCurve(committedClip, bindings[0]); + AssertEqualNotSame(newCommittedCurve, newCurve); + Assert.AreEqual(ac.name, committedClip.name); + } + + [Test] + public void CreateDeleteFloatCurve() + { + AnimationClip ac = TrackObject(new AnimationClip()); + ac.SetCurve("abc", typeof(GameObject), "m_IsActive", new AnimationCurve(new Keyframe(0, 0), new Keyframe(1, 1))); + + VirtualClip vc = VirtualClip.Clone(context, ac); + using (new AssertInvalidate(vc)) + { + vc.SetFloatCurve("abc", typeof(GameObject), "m_IsActive", null); + } + + using (new AssertInvalidate(vc)) + { + vc.SetFloatCurve("def", typeof(GameObject), "m_IsActive", + new AnimationCurve(new Keyframe(0, 0), new Keyframe(1, 1))); + } + + Assert.AreEqual(vc.GetFloatCurveBindings().Count(), 1); + + var committedClip = Commit(vc); + var bindings = AnimationUtility.GetCurveBindings(committedClip).ToList(); + Assert.AreEqual(1, bindings.Count); + Assert.AreEqual("def", bindings[0].path); + } + + [Test] + public void EditExistingObjectCurve() + { + var m1 = TrackObject(NewTestMaterial()); + var m2 = TrackObject(NewTestMaterial()); + + AnimationClip ac = TrackObject(new AnimationClip()); + AnimationUtility.SetObjectReferenceCurve(ac, new EditorCurveBinding() + { + path = "abc", + type = typeof(MeshRenderer), + propertyName = "m_Materials.Array.data[0]" + }, new ObjectReferenceKeyframe[] + { + new() {time = 0, value = m1}, + }); + + VirtualClip vc = VirtualClip.Clone(context, ac); + using (new AssertInvalidate(vc)) + { + vc.SetObjectCurve("abc", typeof(MeshRenderer), "m_Materials.Array.data[0]", + new ObjectReferenceKeyframe[] + { + new() { time = 0, value = m2 }, + }); + } + + var committedClip = Commit(vc); + var newCommittedCurve = AnimationUtility.GetObjectReferenceCurve(committedClip, new EditorCurveBinding() + { + path = "abc", + type = typeof(MeshRenderer), + propertyName = "m_Materials.Array.data[0]" + }); + Assert.IsNotNull(newCommittedCurve); + Assert.AreEqual(1, newCommittedCurve.Length); + Assert.AreEqual(0, newCommittedCurve[0].time); + Assert.AreEqual(m2, newCommittedCurve[0].value); + + // check that the original clip is not modified + var originalCurve = AnimationUtility.GetObjectReferenceCurve(ac, new EditorCurveBinding() + { + path = "abc", + type = typeof(MeshRenderer), + propertyName = "m_Materials.Array.data[0]" + }); + Assert.IsNotNull(originalCurve); + Assert.AreEqual(m1, originalCurve[0].value); + } + + private Material NewTestMaterial() + { + Shader s = Shader.Find("Unlit/Color"); + return new Material(s); + } + + [Test] + public void CreateDeleteObjectCurve() + { + var m1 = TrackObject(NewTestMaterial()); + + var ac = TrackObject(new AnimationClip()); + AnimationUtility.SetObjectReferenceCurve(ac, new EditorCurveBinding() + { + path = "abc", + type = typeof(MeshRenderer), + propertyName = "m_Materials.Array.data[0]" + }, new ObjectReferenceKeyframe[] + { + new() {time = 0, value = m1}, + }); + + VirtualClip vc = VirtualClip.Clone(context, ac); + vc.SetObjectCurve("abc", typeof(MeshRenderer), "m_Materials.Array.data[0]", null); + using (new AssertInvalidate(vc)) + { + vc.SetObjectCurve("def", typeof(MeshRenderer), "m_Materials.Array.data[0]", + new ObjectReferenceKeyframe[] + { + new() { time = 0, value = m1 }, + }); + } + + Assert.AreEqual(1, vc.GetObjectCurveBindings().Count()); + + var committedClip = Commit(vc); + var bindings = AnimationUtility.GetObjectReferenceCurveBindings(committedClip); + Assert.AreEqual(1, bindings.Length); + Assert.AreEqual("def", bindings[0].path); + } + + [Test] + public void TestEditPath() + { + AnimationClip ac = TrackObject(new AnimationClip()); + ac.SetCurve("abc", typeof(GameObject), "m_IsActive", new AnimationCurve(new Keyframe(0, 0), new Keyframe(1, 1))); + ac.SetCurve("DEF", typeof(GameObject), "m_IsActive", new AnimationCurve(new Keyframe(0, 0), new Keyframe(1, 1))); + ac.SetCurve("x", typeof(GameObject), "m_IsActive", new AnimationCurve(new Keyframe(0, 0), new Keyframe(1, 1))); + ac.SetCurve("X", typeof(GameObject), "m_IsActive", new AnimationCurve(new Keyframe(0, 0), new Keyframe(1, 1))); + AnimationUtility.SetObjectReferenceCurve(ac, new EditorCurveBinding() + { + path = "foo", + type = typeof(MeshRenderer), + propertyName = "m_Materials.Array.data[0]" + }, new ObjectReferenceKeyframe[] + { + new() {time = 0, value = new Material(Shader.Find("Standard"))}, + }); + + VirtualClip vc = VirtualClip.Clone(context, ac); + using (new AssertInvalidate(vc)) + { + vc.EditPaths(s => s.ToUpperInvariant()); + } + + Assert.AreEqual(new[] { "ABC", "DEF", "X" }, vc.GetFloatCurveBindings().Select(b => b.path).OrderBy(b => b).ToArray()); + Assert.AreEqual(new[] { "FOO" }, vc.GetObjectCurveBindings().Select(b => b.path).OrderBy(b => b).ToArray()); + + var committedClip = Commit(vc); + var bindings = AnimationUtility.GetCurveBindings(committedClip).Select(b => b.path).OrderBy(b => b).ToList(); + Assert.AreEqual(new[] { "ABC", "DEF", "X" }, bindings); + + var objBindings = AnimationUtility.GetObjectReferenceCurveBindings(committedClip).Select(b => b.path).OrderBy(b => b).ToList(); + Assert.AreEqual( new[] { "FOO" }, objBindings); + } + + [Test] + public void PreservesHighQualityMode([Values("HQ_ON.anim", "HQ_OFF.anim")] string testAsset) + { + AnimationClip ac = LoadAsset(testAsset); + bool hq = new SerializedObject(ac).FindProperty("m_UseHighQualityCurve").boolValue; + + VirtualClip vc = VirtualClip.Clone(context, ac); + + vc.SetFloatCurve(EditorCurveBinding.FloatCurve("abc", typeof(GameObject), "m_IsActive"), + new AnimationCurve(new Keyframe(0, 0), new Keyframe(1, 1))); + + var committedClip = Commit(vc); + + Assert.AreEqual(hq, new SerializedObject(committedClip).FindProperty("m_UseHighQualityCurve").boolValue); + } + + // TODO: additive reference pose, animation clip settings/misc properties tests + + private static void AssertEqualNotSame(AnimationCurve newCommittedCurve, AnimationCurve newCurve) + { + Assert.IsNotNull(newCommittedCurve); + Assert.AreNotSame(newCommittedCurve, newCurve); + Assert.AreEqual(newCurve.keys.Length, newCommittedCurve.keys.Length); + for (int i = 0; i < newCurve.keys.Length; i++) + { + Assert.AreEqual(newCurve.keys[i].time, newCommittedCurve.keys[i].time); + Assert.AreEqual(newCurve.keys[i].value, newCommittedCurve.keys[i].value); + } + } + } +} \ No newline at end of file diff --git a/UnitTests~/AnimationServices/VirtualClipTest.cs.meta b/UnitTests~/AnimationServices/VirtualClipTest.cs.meta new file mode 100644 index 00000000..156a958b --- /dev/null +++ b/UnitTests~/AnimationServices/VirtualClipTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0e8efd4fa18b4639888637ff7eae35a1 +timeCreated: 1730069550 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/VirtualLayerTest.cs b/UnitTests~/AnimationServices/VirtualLayerTest.cs new file mode 100644 index 00000000..d00ac996 --- /dev/null +++ b/UnitTests~/AnimationServices/VirtualLayerTest.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using nadena.dev.ndmf.animator; +using nadena.dev.ndmf.UnitTestSupport; +using NUnit.Framework; +using UnityEditor.Animations; +using UnityEngine; + +namespace UnitTests.AnimationServices +{ + public class VirtualLayerTest : TestBase + { + private void AssertPreserveProperty( + Action setup, + Action setupViaVirtualState, + Action assert, + Action assertViaVirtualState + ) + { + var layer = new AnimatorControllerLayer(); + setup(layer); + + var cloneContext = new CloneContext(GenericPlatformAnimatorBindings.Instance); + + var virtVal = cloneContext.Clone(layer, 0); + assertViaVirtualState(virtVal); + + var commitContext = new CommitContext(); + var committed = commitContext.CommitObject(virtVal); + Assert.AreNotEqual(layer, committed); + assert(committed); + + layer = new AnimatorControllerLayer(); + + virtVal = cloneContext.Clone(layer, 0); + using (new AssertInvalidate(virtVal)) + { + setupViaVirtualState(virtVal); + } + + committed = commitContext.CommitObject(virtVal); + + assert(committed); + + commitContext.DestroyAllImmediate(); + } + + [Test] + public void PreservesName() + { + AssertPreserveProperty( + state => state.name = "Test", + virtualState => virtualState.Name = "Test", + state => Assert.AreEqual("Test", state.name), + virtualState => Assert.AreEqual("Test", virtualState.Name) + ); + } + + // TODO: AvatarMask + + [Test] + public void PreservesBlendingMode() + { + AssertPreserveProperty( + state => state.blendingMode = AnimatorLayerBlendingMode.Override, + virtualState => virtualState.BlendingMode = AnimatorLayerBlendingMode.Override, + state => Assert.AreEqual(AnimatorLayerBlendingMode.Override, state.blendingMode), + virtualState => Assert.AreEqual(AnimatorLayerBlendingMode.Override, virtualState.BlendingMode) + ); + } + + [Test] + public void PreservesDefaultWeight() + { + AssertPreserveProperty( + state => state.defaultWeight = 0.5f, + virtualState => virtualState.DefaultWeight = 0.5f, + state => Assert.AreEqual(0.5f, state.defaultWeight), + virtualState => Assert.AreEqual(0.5f, virtualState.DefaultWeight) + ); + } + + [Test] + public void PreservesIKPass() + { + AssertPreserveProperty( + state => state.iKPass = true, + virtualState => virtualState.IKPass = true, + state => Assert.AreEqual(true, state.iKPass), + virtualState => Assert.AreEqual(true, virtualState.IKPass) + ); + } + + [Test] + public void DoesNotPreserveSyncedLayerIndex() + { + // We'll demonstrate preservation of this in the VirtualAnimatorControllerTest + AssertPreserveProperty( + state => state.syncedLayerIndex = 123, + virtualState => virtualState.SyncedLayerIndex = 123, + state => Assert.AreEqual(-1, state.syncedLayerIndex), + virtualState => Assert.AreEqual(-1, virtualState.SyncedLayerIndex) + ); + } + + [Test] + public void PreservesSyncedLayerAffectsTiming() + { + AssertPreserveProperty( + state => state.syncedLayerAffectsTiming = true, + virtualState => virtualState.SyncedLayerAffectsTiming = true, + state => Assert.AreEqual(true, state.syncedLayerAffectsTiming), + virtualState => Assert.AreEqual(true, virtualState.SyncedLayerAffectsTiming) + ); + } + + [Test] + public void PreservesStateMachine() + { + AssertPreserveProperty( + state => state.stateMachine = TrackObject(new AnimatorStateMachine() { name = "x" }), + state => state.StateMachine = VirtualStateMachine.Create(new CloneContext(GenericPlatformAnimatorBindings.Instance), "x"), + state => Assert.AreEqual("x", state.stateMachine.name), + state => Assert.AreEqual("x", state.StateMachine.Name) + ); + } + + [Test] + public void SyncedLayerOverridesArePreserved() + { + var testController = LoadAsset("TestAssets/SyncedLayers.controller"); + var context = new CloneContext(GenericPlatformAnimatorBindings.Instance); + var virtualController = context.Clone(testController); + + var commitContext = new CommitContext(); + var committed = commitContext.CommitObject(virtualController); + + var baseLayer = committed.layers[0]; + var c1 = baseLayer.stateMachine.states.First(s => s.state.name == "c1").state; + var behavior = baseLayer.stateMachine.states.First(s => s.state.name == "behavior").state; + + var syncedLayer = committed.layers[1]; + Assert.AreEqual("c2", syncedLayer.GetOverrideMotion(c1).name); + Assert.AreEqual(1, syncedLayer.GetOverrideBehaviours(behavior).Length); + Assert.AreEqual(typeof(TestStateBehavior), syncedLayer.GetOverrideBehaviours(behavior)[0].GetType()); + } + + [Test] + public void SyncedLayerOverridesCanBeChanged() + { + var testController = LoadAsset("TestAssets/SyncedLayers.controller"); + var c3 = LoadAsset("TestAssets/c3.anim"); + var context = new CloneContext(GenericPlatformAnimatorBindings.Instance); + var virtualController = context.Clone(testController); + + var virtBaseLayer = virtualController.Layers.ToList()[0]; + var v_c1 = virtBaseLayer.StateMachine.States.First(s => s.State.Name == "c1").State; + var v_behavior = virtBaseLayer.StateMachine.States.First(s => s.State.Name == "behavior").State; + + var virtLayer = virtualController.Layers.ToList()[1]; + using (new AssertInvalidate(virtLayer)) + { + virtLayer.SyncedLayerMotionOverrides = + ImmutableDictionary.Empty.Add(v_c1, context.Clone(c3)); + } + using (new AssertInvalidate(virtLayer)) + { + virtLayer.SyncedLayerBehaviourOverrides = ImmutableDictionary>.Empty + .Add(v_behavior, ImmutableList.Empty.Add(ScriptableObject.CreateInstance())); + } + + var commitContext = new CommitContext(); + var committed = commitContext.CommitObject(virtualController); + + var baseLayer = committed.layers[0]; + var c1 = baseLayer.stateMachine.states.First(s => s.state.name == "c1").state; + var behavior = baseLayer.stateMachine.states.First(s => s.state.name == "behavior").state; + + var syncedLayer = committed.layers[1]; + Assert.AreEqual("c3", syncedLayer.GetOverrideMotion(c1).name); + Assert.AreEqual(1, syncedLayer.GetOverrideBehaviours(behavior).Length); + Assert.AreEqual(typeof(TestStateBehavior1), syncedLayer.GetOverrideBehaviours(behavior)[0].GetType()); + } + } +} \ No newline at end of file diff --git a/UnitTests~/AnimationServices/VirtualLayerTest.cs.meta b/UnitTests~/AnimationServices/VirtualLayerTest.cs.meta new file mode 100644 index 00000000..dba08999 --- /dev/null +++ b/UnitTests~/AnimationServices/VirtualLayerTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1404d0f969e54ced9f92a6229d212059 +timeCreated: 1731194961 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/VirtualStateMachineTest.cs b/UnitTests~/AnimationServices/VirtualStateMachineTest.cs new file mode 100644 index 00000000..1dd85cc2 --- /dev/null +++ b/UnitTests~/AnimationServices/VirtualStateMachineTest.cs @@ -0,0 +1,107 @@ +using System; +using nadena.dev.ndmf.animator; +using NUnit.Framework; +using UnityEditor.Animations; +using UnityEngine; + +namespace UnitTests.AnimationServices +{ + public class VirtualStateMachineTest + { + private void AssertPreserveProperty( + Action setup, + Action setupViaVirtualState, + Action assert, + Action assertViaVirtualState + ) + { + var state = new AnimatorStateMachine(); + setup(state); + + var cloneContext = new CloneContext(GenericPlatformAnimatorBindings.Instance); + + var virtualState = cloneContext.Clone(state); + assertViaVirtualState(virtualState); + + var commitContext = new CommitContext(); + var committed = commitContext.CommitObject(virtualState); + Assert.AreNotEqual(state, committed); + assert(committed); + + UnityEngine.Object.DestroyImmediate(state); + UnityEngine.Object.DestroyImmediate(committed); + + state = new AnimatorStateMachine(); + + virtualState = cloneContext.Clone(state); + using (new AssertInvalidate(virtualState)) + { + setupViaVirtualState(virtualState); + } + + committed = commitContext.CommitObject(virtualState); + + assert(committed); + + UnityEngine.Object.DestroyImmediate(state); + UnityEngine.Object.DestroyImmediate(committed); + } + + [Test] + public void PreservesName() + { + AssertPreserveProperty( + state => state.name = "Test", + virtualState => virtualState.Name = "Test", + state => Assert.AreEqual("Test", state.name), + virtualState => Assert.AreEqual("Test", virtualState.Name) + ); + } + + [Test] + public void PreservesAnyStatePosition() + { + AssertPreserveProperty( + state => state.anyStatePosition = new Vector3(1, 2, 3), + virtualState => virtualState.AnyStatePosition = new Vector3(1, 2, 3), + state => Assert.AreEqual(new Vector3(1, 2, 3), state.anyStatePosition), + virtualState => Assert.AreEqual(new Vector3(1, 2, 3), virtualState.AnyStatePosition) + ); + } + + [Test] + public void PreservesEntryPosition() + { + AssertPreserveProperty( + state => state.entryPosition = new Vector3(1, 2, 3), + virtualState => virtualState.EntryPosition = new Vector3(1, 2, 3), + state => Assert.AreEqual(new Vector3(1, 2, 3), state.entryPosition), + virtualState => Assert.AreEqual(new Vector3(1, 2, 3), virtualState.EntryPosition) + ); + } + + [Test] + public void PreservesExitPosition() + { + AssertPreserveProperty( + state => state.exitPosition = new Vector3(1, 2, 3), + virtualState => virtualState.ExitPosition = new Vector3(1, 2, 3), + state => Assert.AreEqual(new Vector3(1, 2, 3), state.exitPosition), + virtualState => Assert.AreEqual(new Vector3(1, 2, 3), virtualState.ExitPosition) + ); + } + + [Test] + public void PreservesParentStateMachinePosition() + { + AssertPreserveProperty( + state => state.parentStateMachinePosition = new Vector3(1, 2, 3), + virtualState => virtualState.ParentStateMachinePosition = new Vector3(1, 2, 3), + state => Assert.AreEqual(new Vector3(1, 2, 3), state.parentStateMachinePosition), + virtualState => Assert.AreEqual(new Vector3(1, 2, 3), virtualState.ParentStateMachinePosition) + ); + } + } + + +} \ No newline at end of file diff --git a/UnitTests~/AnimationServices/VirtualStateMachineTest.cs.meta b/UnitTests~/AnimationServices/VirtualStateMachineTest.cs.meta new file mode 100644 index 00000000..25393b69 --- /dev/null +++ b/UnitTests~/AnimationServices/VirtualStateMachineTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 610c664363454f838fcefa673f339199 +timeCreated: 1731192934 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/VirtualStateTest.cs b/UnitTests~/AnimationServices/VirtualStateTest.cs new file mode 100644 index 00000000..0c334ff4 --- /dev/null +++ b/UnitTests~/AnimationServices/VirtualStateTest.cs @@ -0,0 +1,225 @@ +using System; +using nadena.dev.ndmf.animator; +using NUnit.Framework; +using UnityEditor.Animations; +using UnityEngine; +using Assert = UnityEngine.Assertions.Assert; + +namespace UnitTests.AnimationServices +{ + public class VirtualStateTest + { + private void AssertPreserveProperty( + Action setup, + Action setupViaVirtualState, + Action assert, + Action assertViaVirtualState + ) + { + var state = new AnimatorState(); + setup(state); + + var cloneContext = new CloneContext(GenericPlatformAnimatorBindings.Instance); + + VirtualState virtualState = cloneContext.Clone(state); + assertViaVirtualState(virtualState); + + var commitContext = new CommitContext(); + var committed = commitContext.CommitObject(virtualState); + Assert.AreNotEqual(state, committed); + assert(committed); + + UnityEngine.Object.DestroyImmediate(state); + UnityEngine.Object.DestroyImmediate(committed); + + state = new AnimatorState(); + + virtualState = cloneContext.Clone(state); + bool wasInvalidated = false; + virtualState.RegisterCacheObserver(() => { wasInvalidated = true; }); + setupViaVirtualState(virtualState); + Assert.IsTrue(wasInvalidated); + + committed = commitContext.CommitObject(virtualState); + + assert(committed); + + UnityEngine.Object.DestroyImmediate(state); + UnityEngine.Object.DestroyImmediate(committed); + } + + [Test] + public void PreservesName() + { + AssertPreserveProperty( + state => state.name = "Test", + virtualState => virtualState.Name = "Test", + state => Assert.AreEqual("Test", state.name), + virtualState => Assert.AreEqual("Test", virtualState.Name) + ); + } + + [Test] + public void PreservesCycleOffset() + { + AssertPreserveProperty( + state => state.cycleOffset = 0.5f, + virtualState => virtualState.CycleOffset = 0.5f, + state => Assert.AreEqual(0.5f, state.cycleOffset), + virtualState => Assert.AreEqual(0.5f, virtualState.CycleOffset) + ); + } + + [Test] + public void PreservesCycleOffsetParameter() + { + AssertPreserveProperty( + state => + { + state.cycleOffsetParameterActive = true; + state.cycleOffsetParameter = "Test"; + }, + virtualState => virtualState.CycleOffsetParameter = "Test", + state => + { + Assert.IsTrue(state.cycleOffsetParameterActive); + Assert.AreEqual("Test", state.cycleOffsetParameter); + }, + virtualState => + { + Assert.AreEqual("Test", virtualState.CycleOffsetParameter); + } + ); + } + + [Test] + public void PreservesIKOnFeet() + { + AssertPreserveProperty( + state => state.iKOnFeet = true, + virtualState => virtualState.IKOnFeet = true, + state => Assert.IsTrue(state.iKOnFeet), + virtualState => Assert.IsTrue(virtualState.IKOnFeet) + ); + } + + [Test] + public void PreservesMirror() + { + AssertPreserveProperty( + state => state.mirror = true, + virtualState => virtualState.Mirror = true, + state => Assert.IsTrue(state.mirror), + virtualState => Assert.IsTrue(virtualState.Mirror) + ); + } + + [Test] + public void PreservesMirrorParameter() + { + AssertPreserveProperty( + state => + { + state.mirrorParameterActive = true; + state.mirrorParameter = "Test"; + }, + virtualState => virtualState.MirrorParameter = "Test", + state => + { + Assert.IsTrue(state.mirrorParameterActive); + Assert.AreEqual("Test", state.mirrorParameter); + }, + virtualState => + { + Assert.AreEqual("Test", virtualState.MirrorParameter); + } + ); + } + + [Test] + public void PreservesSpeed() + { + AssertPreserveProperty( + state => state.speed = 0.5f, + virtualState => virtualState.Speed = 0.5f, + state => Assert.AreEqual(0.5f, state.speed), + virtualState => Assert.AreEqual(0.5f, virtualState.Speed) + ); + } + + [Test] + public void PreservesSpeedParameter() + { + AssertPreserveProperty( + state => + { + state.speedParameterActive = true; + state.speedParameter = "Test"; + }, + virtualState => virtualState.SpeedParameter = "Test", + state => + { + Assert.IsTrue(state.speedParameterActive); + Assert.AreEqual("Test", state.speedParameter); + }, + virtualState => + { + Assert.AreEqual("Test", virtualState.SpeedParameter); + } + ); + } + + [Test] + public void PreservesTag() + { + AssertPreserveProperty( + state => state.tag = "Test", + virtualState => virtualState.Tag = "Test", + state => Assert.AreEqual("Test", state.tag), + virtualState => Assert.AreEqual("Test", virtualState.Tag) + ); + } + + [Test] + public void PreservesTimeParameter() + { + AssertPreserveProperty( + state => + { + state.timeParameterActive = true; + state.timeParameter = "Test"; + }, + virtualState => virtualState.TimeParameter = "Test", + state => + { + Assert.IsTrue(state.timeParameterActive); + Assert.AreEqual("Test", state.timeParameter); + }, + virtualState => + { + Assert.AreEqual("Test", virtualState.TimeParameter); + } + ); + } + + [Test] + public void PreservesWriteDefaultValues() + { + AssertPreserveProperty( + state => + { + state.writeDefaultValues = true; + }, + virtualState => virtualState.WriteDefaultValues = true, + state => + { + Assert.IsTrue(state.writeDefaultValues); + }, + virtualState => + { + Assert.IsTrue(virtualState.WriteDefaultValues); + } + ); + } + } +} \ No newline at end of file diff --git a/UnitTests~/AnimationServices/VirtualStateTest.cs.meta b/UnitTests~/AnimationServices/VirtualStateTest.cs.meta new file mode 100644 index 00000000..a5169ee2 --- /dev/null +++ b/UnitTests~/AnimationServices/VirtualStateTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: ce109947dee047ed910b2164dcace0cd +timeCreated: 1731184574 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/VirtualTransitionTest.cs b/UnitTests~/AnimationServices/VirtualTransitionTest.cs new file mode 100644 index 00000000..ca6064a4 --- /dev/null +++ b/UnitTests~/AnimationServices/VirtualTransitionTest.cs @@ -0,0 +1,293 @@ +using System; +using nadena.dev.ndmf.animator; +using NUnit.Framework; +using UnityEditor.Animations; + +namespace UnitTests.AnimationServices +{ + public class VirtualStateTransitionTest : TestBase + { + // This method is copypasted a bit due to limitations of C#'s type system - specifically, + // I can't put this into a base class, because that would force ICommitable to be public. + private void AssertPreservePropertyST( + Func create, + Action setup, + Action setupViaVirtualState, + Action assert, + Action assertViaVirtualState + ) + { + var transition = create(); + setup(transition); + + var cloneContext = new CloneContext(GenericPlatformAnimatorBindings.Instance); + + VirtualStateTransition virtualStateTransition = cloneContext.Clone(transition); + assertViaVirtualState(virtualStateTransition); + + var commitContext = new CommitContext(); + var committed = (AnimatorStateTransition) commitContext.CommitObject(virtualStateTransition); + Assert.AreNotEqual(transition, committed); + assert(committed); + + UnityEngine.Object.DestroyImmediate(transition); + UnityEngine.Object.DestroyImmediate(committed); + + transition = create(); + + virtualStateTransition = cloneContext.Clone(transition); + + bool wasInvalidated = false; + virtualStateTransition.RegisterCacheObserver(() => { wasInvalidated = true; }); + setupViaVirtualState(virtualStateTransition); + Assert.IsTrue(wasInvalidated); + + committed = (AnimatorStateTransition) commitContext.CommitObject(virtualStateTransition); + + assert(committed); + + UnityEngine.Object.DestroyImmediate(transition); + UnityEngine.Object.DestroyImmediate(committed); + } + + private void AssertPreserveProperty( + Func create, + Action setup, + Action setupViaVirtualState, + Action assert, + Action assertViaVirtualState + ) + { + var transition = create(); + setup(transition); + + var cloneContext = new CloneContext(GenericPlatformAnimatorBindings.Instance); + + VirtualTransition virtualStateTransition = cloneContext.Clone(transition); + assertViaVirtualState(virtualStateTransition); + + var commitContext = new CommitContext(); + var committed = (AnimatorTransition) commitContext.CommitObject(virtualStateTransition); + Assert.AreNotEqual(transition, committed); + assert(committed); + + UnityEngine.Object.DestroyImmediate(transition); + UnityEngine.Object.DestroyImmediate(committed); + + transition = create(); + + virtualStateTransition = cloneContext.Clone(transition); + + bool wasInvalidated = false; + virtualStateTransition.RegisterCacheObserver(() => { wasInvalidated = true; }); + setupViaVirtualState(virtualStateTransition); + Assert.IsTrue(wasInvalidated); + + committed = (AnimatorTransition) commitContext.CommitObject(virtualStateTransition); + + assert(committed); + + UnityEngine.Object.DestroyImmediate(transition); + UnityEngine.Object.DestroyImmediate(committed); + } + + + [Test] + public void PreservesCanTransitionToSelf() + { + AssertPreservePropertyST( + () => new AnimatorStateTransition(), + transition => transition.canTransitionToSelf = true, + virtualTransition => virtualTransition.CanTransitionToSelf = true, + transition => Assert.IsTrue(transition.canTransitionToSelf), + virtualTransition => Assert.IsTrue(virtualTransition.CanTransitionToSelf) + ); + } + + [Test] + public void PreservesDuration() + { + AssertPreservePropertyST( + () => new AnimatorStateTransition(), + transition => transition.duration = 0.5f, + virtualTransition => virtualTransition.Duration = 0.5f, + transition => Assert.AreEqual(0.5f, transition.duration), + virtualTransition => Assert.AreEqual(0.5f, virtualTransition.Duration) + ); + } + + [Test] + public void PreservesExitTime() + { + AssertPreservePropertyST( + () => new AnimatorStateTransition(), + transition => + { + transition.exitTime = 0.5f; + transition.hasExitTime = true; + }, + virtualTransition => virtualTransition.ExitTime = 0.5f, + transition => + { + Assert.IsTrue(transition.hasExitTime); + Assert.AreEqual(0.5f, transition.exitTime); + }, + virtualTransition => Assert.AreEqual(0.5f, virtualTransition.ExitTime) + ); + + // Reset to null after creation + AssertPreservePropertyST( + () => + { + var t = new AnimatorStateTransition(); + t.exitTime = 123; + t.hasExitTime = true; + + return t; + }, + transition => { transition.hasExitTime = false; }, + virtualTransition => virtualTransition.ExitTime = null, + transition => Assert.IsFalse((bool) transition.hasExitTime), + virtualTransition => Assert.IsNull(virtualTransition.ExitTime) + ); + } + + [Test] + public void PreservesHasFixedDuration() + { + AssertPreservePropertyST( + () => new AnimatorStateTransition(), + transition => transition.hasFixedDuration = true, + virtualTransition => virtualTransition.HasFixedDuration = true, + transition => Assert.IsTrue(transition.hasFixedDuration), + virtualTransition => Assert.IsTrue(virtualTransition.HasFixedDuration) + ); + } + + [Test] + public void PreservesInterruptionSource() + { + AssertPreservePropertyST( + () => new AnimatorStateTransition(), + transition => transition.interruptionSource = TransitionInterruptionSource.Destination, + virtualTransition => virtualTransition.InterruptionSource = TransitionInterruptionSource.Destination, + transition => Assert.AreEqual(TransitionInterruptionSource.Destination, transition.interruptionSource), + virtualTransition => Assert.AreEqual(TransitionInterruptionSource.Destination, virtualTransition.InterruptionSource) + ); + } + + [Test] + public void PreservesOffset() + { + AssertPreservePropertyST( + () => new AnimatorStateTransition(), + transition => transition.offset = 0.5f, + virtualTransition => virtualTransition.Offset = 0.5f, + transition => Assert.AreEqual(0.5f, transition.offset), + virtualTransition => Assert.AreEqual(0.5f, virtualTransition.Offset) + ); + } + + [Test] + public void PreservesOrderedInterruption() + { + AssertPreservePropertyST( + () => new AnimatorStateTransition(), + transition => transition.orderedInterruption = true, + virtualTransition => virtualTransition.OrderedInterruption = true, + transition => Assert.IsTrue(transition.orderedInterruption), + virtualTransition => Assert.IsTrue(virtualTransition.OrderedInterruption) + ); + } + + [Test] + public void PreservesConditions() + { + var conditions = new[] + { + new AnimatorCondition + { + mode = AnimatorConditionMode.Equals, + parameter = "Test", + threshold = 0.5f + } + }; + + AssertPreserveProperty( + () => new AnimatorTransition() { conditions = conditions }, + transition => { }, + virtualTransition => { virtualTransition.Invalidate(); }, + transition => + { + Assert.AreEqual(1, transition.conditions.Length); + Assert.AreEqual(AnimatorConditionMode.Equals, transition.conditions[0].mode); + Assert.AreEqual("Test", transition.conditions[0].parameter); + Assert.AreEqual(0.5f, transition.conditions[0].threshold); + }, + virtualTransition => + { + Assert.AreEqual(1, virtualTransition.Conditions.Count); + Assert.AreEqual(AnimatorConditionMode.Equals, virtualTransition.Conditions[0].mode); + Assert.AreEqual("Test", virtualTransition.Conditions[0].parameter); + Assert.AreEqual(0.5f, virtualTransition.Conditions[0].threshold); + } + ); + + // TODO: Should we clone the conditions list? + } + + [Test] + public void PreservesDestinationStateMachine() + { + // TODO + } + + [Test] + public void PreservesDestinationState() + { + // TODO + } + + [Test] + public void PreservesExitIsDestination() + { + // TODO + } + + [Test] + public void PreservesMute() + { + AssertPreserveProperty( + () => new AnimatorTransition(), + transition => transition.mute = true, + virtualTransition => virtualTransition.Mute = true, + transition => Assert.IsTrue(transition.mute), + virtualTransition => Assert.IsTrue(virtualTransition.Mute) + ); + } + + [Test] + public void PreservesSolo() + { + AssertPreserveProperty( + () => new AnimatorTransition(), + transition => transition.solo = true, + virtualTransition => virtualTransition.Solo = true, + transition => Assert.IsTrue(transition.solo), + virtualTransition => Assert.IsTrue(virtualTransition.Solo) + ); + } + + [Test] + public void PreservesName() + { + AssertPreserveProperty( + () => new AnimatorTransition(), + transition => transition.name = "Test", + virtualTransition => virtualTransition.Name = "Test", + transition => Assert.AreEqual("Test", transition.name), + virtualTransition => Assert.AreEqual("Test", virtualTransition.Name) + ); + } + } +} \ No newline at end of file diff --git a/UnitTests~/AnimationServices/VirtualTransitionTest.cs.meta b/UnitTests~/AnimationServices/VirtualTransitionTest.cs.meta new file mode 100644 index 00000000..9d969b67 --- /dev/null +++ b/UnitTests~/AnimationServices/VirtualTransitionTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: de896c57c5c845f69a03c7f77aa74deb +timeCreated: 1731185399 \ No newline at end of file diff --git a/UnitTests~/AnimationServices/clipProperties.anim b/UnitTests~/AnimationServices/clipProperties.anim new file mode 100644 index 00000000..a6adfefa --- /dev/null +++ b/UnitTests~/AnimationServices/clipProperties.anim @@ -0,0 +1,98 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!74 &7400000 +AnimationClip: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: clipProperties + serializedVersion: 6 + m_Legacy: 0 + m_Compressed: 0 + m_UseHighQualityCurve: 1 + m_RotationCurves: [] + m_CompressedRotationCurves: [] + m_EulerCurves: [] + m_PositionCurves: [] + m_ScaleCurves: [] + m_FloatCurves: + - curve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 1 + inSlope: Infinity + outSlope: Infinity + tangentMode: 103 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + attribute: m_Enabled + path: + classID: 114 + script: {fileID: -1427037861, guid: 4ecd63eff847044b68db9453ce219299, type: 3} + m_PPtrCurves: [] + m_SampleRate: 60 + m_WrapMode: 0 + m_Bounds: + m_Center: {x: 0, y: 0, z: 0} + m_Extent: {x: 0, y: 0, z: 0} + m_ClipBindingConstant: + genericBindings: + - serializedVersion: 2 + path: 0 + attribute: 3305885265 + script: {fileID: -1427037861, guid: 4ecd63eff847044b68db9453ce219299, type: 3} + typeID: 114 + customType: 24 + isPPtrCurve: 0 + pptrCurveMapping: [] + m_AnimationClipSettings: + serializedVersion: 2 + m_AdditiveReferencePoseClip: {fileID: 0} + m_AdditiveReferencePoseTime: 0 + m_StartTime: 0 + m_StopTime: 0 + m_OrientationOffsetY: 0 + m_Level: 0 + m_CycleOffset: 0 + m_HasAdditiveReferencePose: 0 + m_LoopTime: 0 + m_LoopBlend: 0 + m_LoopBlendOrientation: 0 + m_LoopBlendPositionY: 0 + m_LoopBlendPositionXZ: 0 + m_KeepOriginalOrientation: 0 + m_KeepOriginalPositionY: 1 + m_KeepOriginalPositionXZ: 0 + m_HeightFromFeet: 0 + m_Mirror: 0 + m_EditorCurves: + - curve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 1 + inSlope: Infinity + outSlope: Infinity + tangentMode: 103 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + attribute: m_Enabled + path: + classID: 114 + script: {fileID: -1427037861, guid: 4ecd63eff847044b68db9453ce219299, type: 3} + m_EulerEditorCurves: [] + m_HasGenericRootTransform: 0 + m_HasMotionFloatCurves: 0 + m_Events: [] diff --git a/UnitTests~/AnimationServices/clipProperties.anim.meta b/UnitTests~/AnimationServices/clipProperties.anim.meta new file mode 100644 index 00000000..8997deb9 --- /dev/null +++ b/UnitTests~/AnimationServices/clipProperties.anim.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: dc1092af50978ab4dbfe252aab93b26a +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 7400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnitTests~/RuntimeTestSupport.meta b/UnitTests~/RuntimeTestSupport.meta new file mode 100644 index 00000000..63341e37 --- /dev/null +++ b/UnitTests~/RuntimeTestSupport.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: c1e2e13b05cf4f0a9a3de0195d4a6a5d +timeCreated: 1731380561 \ No newline at end of file diff --git a/UnitTests~/RuntimeTestSupport/TestStateBehavior.cs b/UnitTests~/RuntimeTestSupport/TestStateBehavior.cs new file mode 100644 index 00000000..000ccd4e --- /dev/null +++ b/UnitTests~/RuntimeTestSupport/TestStateBehavior.cs @@ -0,0 +1,8 @@ +using UnityEngine; + +namespace nadena.dev.ndmf.UnitTestSupport +{ + public class TestStateBehavior : StateMachineBehaviour + { + } +} \ No newline at end of file diff --git a/UnitTests~/RuntimeTestSupport/TestStateBehavior.cs.meta b/UnitTests~/RuntimeTestSupport/TestStateBehavior.cs.meta new file mode 100644 index 00000000..b3e00eef --- /dev/null +++ b/UnitTests~/RuntimeTestSupport/TestStateBehavior.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 56a07721b4fa4641bbbeabd99a5ca6f5 +timeCreated: 1731380248 \ No newline at end of file diff --git a/UnitTests~/RuntimeTestSupport/TestStateBehavior1.cs b/UnitTests~/RuntimeTestSupport/TestStateBehavior1.cs new file mode 100644 index 00000000..7aa2818b --- /dev/null +++ b/UnitTests~/RuntimeTestSupport/TestStateBehavior1.cs @@ -0,0 +1,8 @@ +using UnityEngine; + +namespace nadena.dev.ndmf.UnitTestSupport +{ + public class TestStateBehavior1 : StateMachineBehaviour + { + } +} \ No newline at end of file diff --git a/UnitTests~/RuntimeTestSupport/TestStateBehavior1.cs.meta b/UnitTests~/RuntimeTestSupport/TestStateBehavior1.cs.meta new file mode 100644 index 00000000..edaf6278 --- /dev/null +++ b/UnitTests~/RuntimeTestSupport/TestStateBehavior1.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: c11f3c658143e61469e04d5ba12c7cbf +timeCreated: 1731380248 \ No newline at end of file diff --git a/UnitTests~/RuntimeTestSupport/nadena.dev.ndmf.tests.runtime.asmdef b/UnitTests~/RuntimeTestSupport/nadena.dev.ndmf.tests.runtime.asmdef new file mode 100644 index 00000000..32671031 --- /dev/null +++ b/UnitTests~/RuntimeTestSupport/nadena.dev.ndmf.tests.runtime.asmdef @@ -0,0 +1,3 @@ +{ + "name": "nadena.dev.ndmf.tests.runtime" +} diff --git a/UnitTests~/RuntimeTestSupport/nadena.dev.ndmf.tests.runtime.asmdef.meta b/UnitTests~/RuntimeTestSupport/nadena.dev.ndmf.tests.runtime.asmdef.meta new file mode 100644 index 00000000..a63d066e --- /dev/null +++ b/UnitTests~/RuntimeTestSupport/nadena.dev.ndmf.tests.runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 122ad54f277fe33469d0cf2907e2857a +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnitTests~/TestBase.cs b/UnitTests~/TestBase.cs index a8c923b5..61942f6e 100644 --- a/UnitTests~/TestBase.cs +++ b/UnitTests~/TestBase.cs @@ -8,6 +8,7 @@ using UnityEditor.Build.Reporting; using UnityEngine; #if NDMF_VRCSDK3_AVATARS +using HarmonyLib; using VRC.Core; using VRC.SDK3.Avatars.Components; #endif @@ -18,10 +19,10 @@ public class TestBase { private const string TEMP_ASSET_PATH = "Assets/ZZZ_Temp"; private static Dictionary _scriptToDirectory = null; - private List objects; + private List objects; [SetUp] - public virtual void Setup() + public virtual void TestBaseSetup() { if (_scriptToDirectory == null) { @@ -38,11 +39,17 @@ public virtual void Setup() } //BuildReport.Clear(); - objects = new List(); + objects = new (); + } + + protected T TrackObject(T obj) where T : Object + { + objects.Add(obj); + return obj; } [TearDown] - public virtual void Teardown() + public virtual void TestBaseTeardown() { foreach (var obj in objects) { @@ -66,8 +73,12 @@ protected GameObject CreateRoot(string name) go.name = name; go.AddComponent(); #if NDMF_VRCSDK3_AVATARS - go.AddComponent(); + var avdesc = go.AddComponent(); go.AddComponent(); + + // VRCAvatarDescriptor is initialized in the editor's OnEnable... + var editor = Editor.CreateEditor(avdesc); + AccessTools.Method(editor.GetType(), "OnEnable").Invoke(editor, null); #endif objects.Add(go); diff --git a/UnitTests~/nadena.dev.ndmf.UnitTests.asmdef b/UnitTests~/nadena.dev.ndmf.UnitTests.asmdef index 456ba559..11b732f7 100644 --- a/UnitTests~/nadena.dev.ndmf.UnitTests.asmdef +++ b/UnitTests~/nadena.dev.ndmf.UnitTests.asmdef @@ -4,7 +4,8 @@ "references": [ "nadena.dev.ndmf", "nadena.dev.ndmf.vrchat", - "nadena.dev.ndmf.runtime" + "nadena.dev.ndmf.runtime", + "nadena.dev.ndmf.tests.runtime" ], "includePlatforms": [ "Editor"