diff --git a/Packages/net.fushizen.modular-avatar.core/Editor/AnimatorMerger.cs b/Packages/net.fushizen.modular-avatar.core/Editor/AnimatorMerger.cs new file mode 100644 index 00000000..92197702 --- /dev/null +++ b/Packages/net.fushizen.modular-avatar.core/Editor/AnimatorMerger.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEditor.Animations; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace net.fushizen.modular_avatar.core.editor +{ + public class AnimatorCombiner + { + private readonly AnimatorController _combined; + + private List _layers = new List(); + + private Dictionary _parameters = + new Dictionary(); + + private Dictionary, Motion> _motions = + new Dictionary, Motion>(); + + private Dictionary, AnimatorStateMachine> _stateMachines = + new Dictionary, AnimatorStateMachine>(); + + public AnimatorCombiner() + { + _combined = Util.CreateContainer(); + } + + public AnimatorController Finish() + { + _combined.parameters = _parameters.Values.ToArray(); + _combined.layers = _layers.ToArray(); + AssetDatabase.SaveAssets(); + return _combined; + } + + public void AddController(String basePath, AnimatorController controller) + { + foreach (var param in controller.parameters) + { + if (_parameters.TryGetValue(param.name, out var acp)) + { + if (acp.type != param.type) + { + throw new Exception($"Parameter {param.name} has different types in {basePath} and {controller.name}"); + } + + continue; + } + + _parameters.Add(param.name, param); + } + + bool first = true; + foreach (var layer in controller.layers) + { + insertLayer(basePath, layer, first); + first = false; + } + } + + private void insertLayer(string basePath, AnimatorControllerLayer layer, bool first) + { + var newLayer = new AnimatorControllerLayer() + { + name = layer.name, + avatarMask = layer.avatarMask, // TODO map transforms + blendingMode = layer.blendingMode, + defaultWeight = first ? 1 : layer.defaultWeight, + syncedLayerIndex = layer.syncedLayerIndex, // TODO + syncedLayerAffectsTiming = layer.syncedLayerAffectsTiming, // TODO + iKPass = layer.iKPass, + stateMachine = mapStateMachine(basePath, layer.stateMachine), + }; + + _layers.Add(newLayer); + } + + private AnimatorStateMachine mapStateMachine(string basePath, AnimatorStateMachine layerStateMachine) + { + var cacheKey = new KeyValuePair(basePath, layerStateMachine); + + if (_stateMachines.TryGetValue(cacheKey, out var asm)) + { + return asm; + } + + asm = deepClone(layerStateMachine, (obj) => customClone(obj, basePath)); + + _stateMachines[cacheKey] = asm; + return asm; + } + + private Object customClone(Object o, string basePath) + { + if (basePath == "") return null; + + if (o is AnimationClip clip) + { + AnimationClip newClip = new AnimationClip(); + newClip.name = "rebased " + clip.name; + AssetDatabase.AddObjectToAsset(newClip, _combined); + + foreach (var binding in AnimationUtility.GetCurveBindings(clip)) + { + var newBinding = binding; + newBinding.path = PathMappings.MapPath(basePath + binding.path); + newClip.SetCurve(newBinding.path, newBinding.type, newBinding.propertyName, + AnimationUtility.GetEditorCurve(clip, binding)); + } + + foreach (var objBinding in AnimationUtility.GetObjectReferenceCurveBindings(clip)) + { + var newBinding = objBinding; + newBinding.path = PathMappings.MapPath(basePath + objBinding.path); + AnimationUtility.SetObjectReferenceCurve(newClip, newBinding, + AnimationUtility.GetObjectReferenceCurve(clip, objBinding)); + } + + newClip.wrapMode = clip.wrapMode; + newClip.legacy = clip.legacy; + newClip.frameRate = clip.frameRate; + newClip.localBounds = clip.localBounds; + AnimationUtility.SetAnimationClipSettings(newClip, AnimationUtility.GetAnimationClipSettings(clip)); + + return newClip; + } else if (o is Texture) + { + return o; + } + else + { + return null; + } + } + + private T deepClone(T original, + Func visitor, + Dictionary cloneMap = null + ) where T : Object + { + if (original == null) return null; + + if (cloneMap == null) cloneMap = new Dictionary(); + + if (cloneMap.ContainsKey(original)) + { + return (T) cloneMap[original]; + } + + var obj = visitor(original); + if (obj != null) + { + cloneMap[original] = obj; + return (T) obj; + } + + var ctor = original.GetType().GetConstructor(Type.EmptyTypes); + if (ctor == null || obj is ScriptableObject) + { + obj = Object.Instantiate(original); + } + else + { + obj = (T) ctor.Invoke(Array.Empty()); + EditorUtility.CopySerialized(original, obj); + } + cloneMap[original] = obj; + + AssetDatabase.AddObjectToAsset(obj, _combined); + + SerializedObject so = new SerializedObject(obj); + SerializedProperty prop = so.GetIterator(); + + bool enterChildren = true; + while (prop.Next(enterChildren)) + { + enterChildren = true; + switch (prop.propertyType) + { + case SerializedPropertyType.ObjectReference: + prop.objectReferenceValue = deepClone(prop.objectReferenceValue, visitor, cloneMap); + break; + // Iterating strings can get super slow... + case SerializedPropertyType.String: + enterChildren = false; + break; + } + } + + so.ApplyModifiedPropertiesWithoutUndo(); + + return (T) obj; + } + } +} \ No newline at end of file diff --git a/Packages/net.fushizen.modular-avatar.core/Editor/AnimatorMerger.cs.meta b/Packages/net.fushizen.modular-avatar.core/Editor/AnimatorMerger.cs.meta new file mode 100644 index 00000000..9c206683 --- /dev/null +++ b/Packages/net.fushizen.modular-avatar.core/Editor/AnimatorMerger.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3c76e0db714645f7aa6580f208f98e49 +timeCreated: 1661644852 \ No newline at end of file diff --git a/Packages/net.fushizen.modular-avatar.core/Editor/BoneProxyHook.cs b/Packages/net.fushizen.modular-avatar.core/Editor/BoneProxyHook.cs new file mode 100644 index 00000000..48169306 --- /dev/null +++ b/Packages/net.fushizen.modular-avatar.core/Editor/BoneProxyHook.cs @@ -0,0 +1,32 @@ +using UnityEngine; +using VRC.SDKBase.Editor.BuildPipeline; + +namespace net.fushizen.modular_avatar.core.editor +{ + public class BoneProxyHook : IVRCSDKPreprocessAvatarCallback + { + public int callbackOrder => HookSequence.SEQ_BONE_PROXY; + + public bool OnPreprocessAvatar(GameObject avatarGameObject) + { + var boneProxies = avatarGameObject.GetComponentsInChildren(true); + + foreach (var proxy in boneProxies) + { + if (proxy.constraint != null) UnityEngine.Object.DestroyImmediate(proxy.constraint); + if (proxy.target != null) + { + var oldPath = RuntimeUtil.AvatarRootPath(proxy.gameObject); + Transform transform = proxy.transform; + transform.SetParent(proxy.target, false); + transform.localPosition = Vector3.zero; + transform.localRotation = Quaternion.identity; + PathMappings.Remap(oldPath, RuntimeUtil.AvatarRootPath(proxy.gameObject)); + } + Object.DestroyImmediate(proxy); + } + + return true; + } + } +} \ No newline at end of file diff --git a/Packages/net.fushizen.modular-avatar.core/Editor/BoneProxyHook.cs.meta b/Packages/net.fushizen.modular-avatar.core/Editor/BoneProxyHook.cs.meta new file mode 100644 index 00000000..649c6bb2 --- /dev/null +++ b/Packages/net.fushizen.modular-avatar.core/Editor/BoneProxyHook.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 21339639c2ce435e97773a969d21f43a +timeCreated: 1661649405 \ No newline at end of file diff --git a/Packages/net.fushizen.modular-avatar.core/Editor/HookSequence.cs b/Packages/net.fushizen.modular-avatar.core/Editor/HookSequence.cs index 4de068ea..edef3ec7 100644 --- a/Packages/net.fushizen.modular-avatar.core/Editor/HookSequence.cs +++ b/Packages/net.fushizen.modular-avatar.core/Editor/HookSequence.cs @@ -3,7 +3,9 @@ internal static class HookSequence { public const int SEQ_RESETTERS = -90000; - public const int SEQ_MERGE_ARMATURE = -80001; - public const int SEQ_RETARGET_MESH = -80000; + public const int SEQ_MERGE_ARMATURE = SEQ_RESETTERS + 1; + public const int SEQ_RETARGET_MESH = SEQ_MERGE_ARMATURE + 1; + public const int SEQ_BONE_PROXY = SEQ_RETARGET_MESH + 1; + public const int SEQ_MERGE_ANIMATORS = SEQ_BONE_PROXY + 1; } } \ No newline at end of file diff --git a/Packages/net.fushizen.modular-avatar.core/Editor/MergeAnimatorHook.cs b/Packages/net.fushizen.modular-avatar.core/Editor/MergeAnimatorHook.cs new file mode 100644 index 00000000..74127a3e --- /dev/null +++ b/Packages/net.fushizen.modular-avatar.core/Editor/MergeAnimatorHook.cs @@ -0,0 +1,142 @@ +using System.Collections.Generic; +using UnityEditor; +using UnityEditor.Animations; +using UnityEngine; +using VRC.SDK3.Avatars.Components; +using VRC.SDKBase.Editor.BuildPipeline; + +namespace net.fushizen.modular_avatar.core.editor +{ + public class MergeAnimatorHook : IVRCSDKPreprocessAvatarCallback + { + private const string SAMPLE_PATH_PACKAGE = "Packages/com.vrchat.avatars/Samples/AV3 Demo Assets/Animation"; + private const string SAMPLE_PATH_LEGACY = "Assets/VRCSDK/Examples3/Animation/Controllers"; + + public int callbackOrder => HookSequence.SEQ_MERGE_ANIMATORS; + + Dictionary mergeSessions = + new Dictionary(); + + public bool OnPreprocessAvatar(GameObject avatarGameObject) + { + var descriptor = avatarGameObject.GetComponent(); + + InitSessions(descriptor.baseAnimationLayers); + InitSessions(descriptor.specialAnimationLayers); + + var toMerge = avatarGameObject.transform.GetComponentsInChildren(true); + + foreach (var merge in toMerge) + { + if (merge.animator != null && mergeSessions.TryGetValue(merge.layerType, out var session)) + { + var relativePath = RuntimeUtil.RelativePath(avatarGameObject, merge.gameObject); + mergeSessions[merge.layerType].AddController( + relativePath != "" ? relativePath + "/" : "", + (AnimatorController) merge.animator + ); + } + + if (merge.deleteAttachedAnimator) + { + var animator = merge.GetComponent(); + if (animator != null) Object.DestroyImmediate(animator); + } + } + + descriptor.baseAnimationLayers = FinishSessions(descriptor.baseAnimationLayers); + descriptor.specialAnimationLayers = FinishSessions(descriptor.specialAnimationLayers); + + return true; + } + + private VRCAvatarDescriptor.CustomAnimLayer[] FinishSessions( + VRCAvatarDescriptor.CustomAnimLayer[] layers + ) + { + layers = (VRCAvatarDescriptor.CustomAnimLayer[]) layers.Clone(); + + for (int i = 0; i < layers.Length; i++) + { + layers[i].isDefault = false; + layers[i].animatorController = mergeSessions[layers[i].type].Finish(); + } + + return layers; + } + + private void InitSessions(VRCAvatarDescriptor.CustomAnimLayer[] layers) + { + foreach (var layer in layers) + { + var controller = ResolveLayerController(layer); + if (controller == null) controller = new AnimatorController(); + + var session = new AnimatorCombiner(); + session.AddController("", controller); + + mergeSessions[layer.type] = session; + } + } + + + + private static AnimatorController ResolveLayerController(VRCAvatarDescriptor.CustomAnimLayer layer) + { + AnimatorController controller = null; + + if (!layer.isDefault && layer.animatorController != null && + layer.animatorController is AnimatorController c) + { + controller = c; + } + else + { + string name; + switch (layer.type) + { + case VRCAvatarDescriptor.AnimLayerType.Action: + name = "Action"; + break; + case VRCAvatarDescriptor.AnimLayerType.Additive: + name = "Idle"; + break; + case VRCAvatarDescriptor.AnimLayerType.Base: + name = "Locomotion"; + break; + case VRCAvatarDescriptor.AnimLayerType.Gesture: + name = "Hands"; + break; + case VRCAvatarDescriptor.AnimLayerType.Sitting: + name = "Sitting"; + break; + case VRCAvatarDescriptor.AnimLayerType.FX: + name = "Face"; + 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 + "Layer"; + + controller = AssetDatabase.LoadAssetAtPath(SAMPLE_PATH_PACKAGE + name); + if (controller == null) + { + controller = AssetDatabase.LoadAssetAtPath(SAMPLE_PATH_LEGACY + name); + } + } + } + + return controller; + } + } +} \ No newline at end of file diff --git a/Packages/net.fushizen.modular-avatar.core/Editor/MergeAnimatorHook.cs.meta b/Packages/net.fushizen.modular-avatar.core/Editor/MergeAnimatorHook.cs.meta new file mode 100644 index 00000000..82cd3d4c --- /dev/null +++ b/Packages/net.fushizen.modular-avatar.core/Editor/MergeAnimatorHook.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 32fddef4bb7c4a5fb1a06b13507bdee8 +timeCreated: 1661644932 \ No newline at end of file diff --git a/Packages/net.fushizen.modular-avatar.core/Editor/MergeArmatureHook.cs b/Packages/net.fushizen.modular-avatar.core/Editor/MergeArmatureHook.cs index 4d576964..847525d6 100644 --- a/Packages/net.fushizen.modular-avatar.core/Editor/MergeArmatureHook.cs +++ b/Packages/net.fushizen.modular-avatar.core/Editor/MergeArmatureHook.cs @@ -38,7 +38,7 @@ namespace net.fushizen.modular_avatar.core.editor foreach (var renderer in avatarGameObject.transform.GetComponentsInChildren()) { var bones = renderer.bones; - for (int i = 0; i < bones.Length; i++) bones[i] = MapBoneReference(bones[i]); + for (int i = 0; i < bones.Length; i++) bones[i] = MapBoneReference(bones[i], false); renderer.bones = bones; renderer.rootBone = MapBoneReference(renderer.rootBone); renderer.probeAnchor = MapBoneReference(renderer.probeAnchor); @@ -93,11 +93,11 @@ namespace net.fushizen.modular_avatar.core.editor so.ApplyModifiedPropertiesWithoutUndo(); } - private Transform MapBoneReference(Transform bone) + private Transform MapBoneReference(Transform bone, bool markNonRetargetable = true) { if (bone != null && BoneRemappings.TryGetValue(bone, out var newBone)) { - BoneDatabase.MarkNonRetargetable(newBone); + if (markNonRetargetable) BoneDatabase.MarkNonRetargetable(newBone); bone = newBone; } return bone; diff --git a/Packages/net.fushizen.modular-avatar.core/Editor/PathMappings.cs b/Packages/net.fushizen.modular-avatar.core/Editor/PathMappings.cs new file mode 100644 index 00000000..23e6874c --- /dev/null +++ b/Packages/net.fushizen.modular-avatar.core/Editor/PathMappings.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using UnityEngine; +using VRC.SDKBase.Editor.BuildPipeline; + +namespace net.fushizen.modular_avatar.core.editor +{ + public static class PathMappings + { + private static SortedDictionary Mappings = new SortedDictionary(); + private static List CachedMappingKeys = null; + + internal static void Clear() + { + Mappings.Clear(); + CachedMappingKeys = null; + } + + internal static void Remap(string from, string to) + { + Mappings[from] = to; + CachedMappingKeys = null; + } + + internal static string MapPath(string path) + { + if (CachedMappingKeys == null) CachedMappingKeys = new List(Mappings.Keys); + var bsResult = CachedMappingKeys.BinarySearch(path); + if (bsResult >= 0) return Mappings[path]; + + int index = ~bsResult; + if (index == 0) return path; + + var priorKey = CachedMappingKeys[index - 1]; + if (path.StartsWith(priorKey + "/")) + { + return Mappings[priorKey] + path.Substring(priorKey.Length); + } + return path; + } + } + + internal class ClearPathMappings : IVRCSDKPreprocessAvatarCallback + { + public int callbackOrder => HookSequence.SEQ_RESETTERS; + public bool OnPreprocessAvatar(GameObject avatarGameObject) + { + PathMappings.Clear(); + return true; + } + } +} \ No newline at end of file diff --git a/Packages/net.fushizen.modular-avatar.core/Editor/PathMappings.cs.meta b/Packages/net.fushizen.modular-avatar.core/Editor/PathMappings.cs.meta new file mode 100644 index 00000000..b14f1e00 --- /dev/null +++ b/Packages/net.fushizen.modular-avatar.core/Editor/PathMappings.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 6a5a2ea7723848d1bfe793debcf298cc +timeCreated: 1661649007 \ No newline at end of file diff --git a/Packages/net.fushizen.modular-avatar.core/Runtime/MergeAnimator.cs b/Packages/net.fushizen.modular-avatar.core/Runtime/MergeAnimator.cs new file mode 100644 index 00000000..6f4e1519 --- /dev/null +++ b/Packages/net.fushizen.modular-avatar.core/Runtime/MergeAnimator.cs @@ -0,0 +1,12 @@ +using UnityEngine; +using VRC.SDK3.Avatars.Components; + +namespace net.fushizen.modular_avatar.core +{ + public class MergeAnimator : AvatarTagComponent + { + public RuntimeAnimatorController animator; + public VRCAvatarDescriptor.AnimLayerType layerType; + public bool deleteAttachedAnimator; + } +} \ No newline at end of file diff --git a/Packages/net.fushizen.modular-avatar.core/Runtime/MergeAnimator.cs.meta b/Packages/net.fushizen.modular-avatar.core/Runtime/MergeAnimator.cs.meta new file mode 100644 index 00000000..ce83bcc8 --- /dev/null +++ b/Packages/net.fushizen.modular-avatar.core/Runtime/MergeAnimator.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1bb122659f724ebf85fe095ac02dc339 +timeCreated: 1661644807 \ No newline at end of file diff --git a/Packages/net.fushizen.modular-avatar.core/Runtime/ModularAvatarBoneProxy.cs b/Packages/net.fushizen.modular-avatar.core/Runtime/ModularAvatarBoneProxy.cs new file mode 100644 index 00000000..dd626520 --- /dev/null +++ b/Packages/net.fushizen.modular-avatar.core/Runtime/ModularAvatarBoneProxy.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; +using UnityEngine.Animations; +using Object = System.Object; + +namespace net.fushizen.modular_avatar.core +{ + public class ModularAvatarBoneProxy : AvatarTagComponent + { + public Transform target; + + public HumanBodyBones boneReference = HumanBodyBones.LastBone; + public string subPath; + + [SerializeField] [HideInInspector] public ParentConstraint constraint; + + #if UNITY_EDITOR + void OnValidate() + { + EditorApplication.delayCall += CheckReferences; + } + + void CheckReferences() { + if (this == null) return; // post-destroy + + if (target == null && (boneReference != HumanBodyBones.LastBone || !string.IsNullOrWhiteSpace(subPath))) + { + UpdateDynamicMapping(); + if (target != null) + { + RuntimeUtil.MarkDirty(this); + } + } else if (target != null) + { + var origBoneReference = boneReference; + var origSubpath = subPath; + UpdateStaticMapping(); + if (origSubpath != subPath || origBoneReference != boneReference) + { + RuntimeUtil.MarkDirty(this); + } + } + + CheckConstraint(); + } + + private void CheckConstraint() + { + if (target != null) + { + if (constraint == null) + { + constraint = gameObject.AddComponent(); + constraint.hideFlags = HideFlags.HideInHierarchy | HideFlags.HideInInspector; + constraint.AddSource(new ConstraintSource() + { + weight = 1, + sourceTransform = target + }); + constraint.translationOffsets = new Vector3[] {Vector3.zero}; + constraint.rotationOffsets = new Vector3[] {Vector3.zero}; + constraint.locked = true; + constraint.constraintActive = true; + } + else + { + constraint.SetSource(0, new ConstraintSource() + { + weight = 1, + sourceTransform = target + }); + } + } + } + + private void OnDestroy() + { + if (constraint != null) DestroyImmediate(constraint); + } + + private void UpdateDynamicMapping() + { + var avatar = RuntimeUtil.FindAvatarInParents(transform); + if (avatar == null) return; + + if (subPath == "$$AVATAR") + { + target = avatar.transform; + return; + } + + if (boneReference == HumanBodyBones.LastBone) + { + target = avatar.transform.Find(subPath); + return; + } + + var animator = avatar.GetComponent(); + if (animator == null) return; + var bone = animator.GetBoneTransform(boneReference); + if (bone == null) return; + if (string.IsNullOrWhiteSpace(subPath)) target = bone; + else target = bone.Find(subPath); + } + + private void UpdateStaticMapping() + { + var avatar = RuntimeUtil.FindAvatarInParents(transform); + var humanBones = new Dictionary(); + var animator = avatar.GetComponent(); + if (animator == null) + { + return; + } + + foreach (var boneTypeObj in Enum.GetValues(typeof(HumanBodyBones))) + { + var boneType = (HumanBodyBones) boneTypeObj; + if (boneType == HumanBodyBones.LastBone) continue; + var bone = animator.GetBoneTransform(boneType); + if (bone != null) humanBones[bone] = boneType; + } + + Transform iter = target; + Transform avatarTransform = avatar.transform; + + if (target == avatarTransform) + { + boneReference = HumanBodyBones.LastBone; + subPath = "$$AVATAR"; + return; + } + + while (iter != avatarTransform && !humanBones.ContainsKey(iter)) + { + iter = iter.parent; + } + + if (iter == avatarTransform) + { + boneReference = HumanBodyBones.LastBone; + } + else + { + boneReference = humanBones[iter]; + } + + subPath = RuntimeUtil.RelativePath(iter.gameObject, target.gameObject); + } +#endif + } +} \ No newline at end of file diff --git a/Packages/net.fushizen.modular-avatar.core/Runtime/ModularAvatarBoneProxy.cs.meta b/Packages/net.fushizen.modular-avatar.core/Runtime/ModularAvatarBoneProxy.cs.meta new file mode 100644 index 00000000..3f37902f --- /dev/null +++ b/Packages/net.fushizen.modular-avatar.core/Runtime/ModularAvatarBoneProxy.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 42581d8044b64899834d3d515ab3a144 +timeCreated: 1661648057 \ No newline at end of file diff --git a/Packages/net.fushizen.modular-avatar.core/Runtime/RuntimeUtil.cs b/Packages/net.fushizen.modular-avatar.core/Runtime/RuntimeUtil.cs index 7a86432c..3dc78479 100644 --- a/Packages/net.fushizen.modular-avatar.core/Runtime/RuntimeUtil.cs +++ b/Packages/net.fushizen.modular-avatar.core/Runtime/RuntimeUtil.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using JetBrains.Annotations; +using UnityEditor; using UnityEngine; using VRC.SDK3.Avatars.Components; @@ -45,5 +46,14 @@ namespace net.fushizen.modular_avatar.core return null; } + + public static void MarkDirty(UnityEngine.Object obj) + { + if (PrefabUtility.IsPartOfPrefabInstance(obj)) + { + PrefabUtility.RecordPrefabInstancePropertyModifications(obj); + } + EditorUtility.SetDirty(obj); + } } } \ No newline at end of file