diff --git a/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs b/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs index 66cc997d..c288d7bc 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs @@ -174,6 +174,7 @@ namespace nadena.dev.modular_avatar.core.editor var context = new BuildContext(vrcAvatarDescriptor); + new ActionGenerator(context).OnPreprocessAvatar(vrcAvatarDescriptor); new RenameParametersHook().OnPreprocessAvatar(avatarGameObject, context); new MergeAnimatorProcessor().OnPreprocessAvatar(avatarGameObject, context); context.AnimationDatabase.Bootstrap(vrcAvatarDescriptor); diff --git a/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing.meta b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing.meta new file mode 100644 index 00000000..a4f309b9 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing.meta @@ -0,0 +1,4 @@ +fileFormatVersion: 2 +guid: d1cf0a36e200446cb099ec448b446495 +timeCreated: 1677334996 +folderAsset: yes \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs new file mode 100644 index 00000000..c4b61927 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using nadena.dev.modular_avatar.editor.ErrorReporting; +using UnityEditor; +using UnityEditor.Animations; +using UnityEngine; +using VRC.SDK3.Avatars.Components; +using VRC.SDK3.Avatars.ScriptableObjects; +using VRC.SDKBase; +using Object = UnityEngine.Object; + +namespace nadena.dev.modular_avatar.core.editor +{ + internal class ActionGenerator + { + private const string DIRECT_BLEND_TREE_PARAM = "_MA/ONE"; + private readonly BuildContext _context; + + public ActionGenerator(BuildContext context) + { + _context = context; + } + + public void OnPreprocessAvatar(VRCAvatarDescriptor avatar) + { + // Locate MenuActions + var actionMenus = avatar.GetComponentsInChildren(true) + .Select(a => ((Component) a).gameObject.GetComponent()) + .Where(item => item != null) + .ToImmutableHashSet(); + + // Generate the root blendtree and animation; insert into the FX layer + var animLayers = avatar.baseAnimationLayers; + int fxLayerIndex = -1; + AnimatorController controller = null; + + // TODO: refactor out layer manipulation here (+ the base state generator) + + for (int i = 0; i < animLayers.Length; i++) + { + if (animLayers[i].type == VRCAvatarDescriptor.AnimLayerType.FX) + { + fxLayerIndex = i; + controller = _context.DeepCloneAnimator(animLayers[i].animatorController); + break; + } + } + + if (controller == null) + { + controller = new AnimatorController(); + controller.name = "FX Controller"; + _context.SaveAsset(controller); + } + + animLayers[fxLayerIndex].animatorController = controller; + avatar.baseAnimationLayers = animLayers; + + var parameters = controller.parameters.ToList(); + parameters.Add(new AnimatorControllerParameter() + { + name = DIRECT_BLEND_TREE_PARAM, + type = AnimatorControllerParameterType.Float, + defaultFloat = 1, + }); + + var actions = GenerateActions(avatar, actionMenus, parameters); + + controller.parameters = parameters.ToArray(); + + int layersToInsert = 2; // TODO + + var rootBlendTree = GenerateRootBlendLayer(actions); + AdjustAllBehaviors(controller, b => + { + if (b is VRCAnimatorLayerControl lc && lc.playable == VRC_AnimatorLayerControl.BlendableLayer.FX) + { + lc.layer += layersToInsert; + } + }); + foreach (var layer in controller.layers) + { + layer.syncedLayerIndex += layersToInsert; + } + + var layerList = controller.layers.ToList(); + layerList.Insert(0, GenerateBlendshapeBaseLayer(avatar)); + rootBlendTree.defaultWeight = 1; + layerList.Insert(0, rootBlendTree); + layerList[2].defaultWeight = 1; + controller.layers = layerList.ToArray(); + + foreach (var action in avatar.GetComponentsInChildren(true)) + { + Object.DestroyImmediate((UnityEngine.Object) action); + } + } + + private AnimatorControllerLayer GenerateBlendshapeBaseLayer(VRCAvatarDescriptor avatar) + { + AnimatorControllerLayer layer = new AnimatorControllerLayer(); + AnimationClip clip = new AnimationClip(); + foreach (var renderer in avatar.GetComponentsInChildren()) + { + int nShapes = renderer.sharedMesh.blendShapeCount; + for (int i = 0; i < nShapes; i++) + { + float value = renderer.GetBlendShapeWeight(i); + if (value > 0.000001f) + { + clip.SetCurve( + RuntimeUtil.AvatarRootPath(renderer.gameObject), + typeof(SkinnedMeshRenderer), + "blendShape." + renderer.sharedMesh.GetBlendShapeName(i), + AnimationCurve.Constant(0, 1, value) + ); + } + } + } + + layer.stateMachine = new AnimatorStateMachine(); + _context.SaveAsset(layer.stateMachine); + + var state = layer.stateMachine.AddState("Base"); + state.motion = clip; + state.writeDefaultValues = false; + + layer.defaultWeight = 1; + + return layer; + } + + private void AdjustAllBehaviors(AnimatorController controller, Action action) + { + HashSet visited = new HashSet(); + foreach (var layer in controller.layers) + { + VisitStateMachine(layer.stateMachine); + } + + void VisitStateMachine(AnimatorStateMachine layerStateMachine) + { + if (!visited.Add(layerStateMachine)) return; + foreach (var state in layerStateMachine.states) + { + foreach (var behaviour in state.state.behaviours) + { + action(behaviour); + } + } + + foreach (var child in layerStateMachine.stateMachines) + { + VisitStateMachine(child.stateMachine); + } + } + } + + private AnimatorControllerLayer GenerateRootBlendLayer(List actions) + { + var motion = new BlendTree(); + motion.name = "Menu Actions (generated)"; + motion.blendParameter = DIRECT_BLEND_TREE_PARAM; + motion.blendType = BlendTreeType.Direct; + motion.children = actions.Select(a => new ChildMotion() + { + motion = a, + directBlendParameter = DIRECT_BLEND_TREE_PARAM, + timeScale = 1, + }).ToArray(); + + var layer = new AnimatorControllerLayer(); + layer.name = "Menu Actions (generated)"; + layer.defaultWeight = 1; + layer.blendingMode = AnimatorLayerBlendingMode.Override; + layer.stateMachine = new AnimatorStateMachine(); + layer.stateMachine.name = "Menu Actions (generated)"; + //layer.stateMachine.hideFlags = HideFlags.HideInHierarchy; + _context.SaveAsset(layer.stateMachine); + + var rootState = layer.stateMachine.AddState("Root"); + rootState.motion = motion; + + return layer; + } + + private List GenerateActions( + VRCAvatarDescriptor descriptor, + IEnumerable items, + List acParameters) + { + var expParams = descriptor.expressionParameters; + if (expParams != null) + { + expParams = Object.Instantiate(expParams); + _context.SaveAsset(expParams); + } + else + { + expParams = ScriptableObject.CreateInstance(); + expParams.name = "Expression Parameters"; + _context.SaveAsset(expParams); + descriptor.expressionParameters = expParams; + } + + List expParameters = expParams.parameters.ToList(); + List blendTrees = new List(); + + int index = 0; + foreach (var item in items) + { + var paramname = "_MA/A/" + item.gameObject.name + "/" + (index++); + // TODO toggle group handling + expParameters.Add(new VRCExpressionParameters.Parameter() + { + name = paramname, + defaultValue = 0, // TODO + valueType = VRCExpressionParameters.ValueType.Bool, + saved = false, // TODO + }); + acParameters.Add(new AnimatorControllerParameter() + { + name = paramname, + type = AnimatorControllerParameterType.Float, + defaultFloat = 0, // TODO + }); + + item.Control.parameter = new VRCExpressionsMenu.Control.Parameter() {name = paramname}; + item.Control.value = 1; + + var blendTree = new BlendTree(); + blendTree.name = paramname; + blendTree.blendParameter = paramname; + blendTree.blendType = BlendTreeType.Simple1D; + blendTree.useAutomaticThresholds = false; + blendTree.children = new[] + { + new ChildMotion() + { + motion = GenerateMotion(item, false), + position = new Vector2(0, 0), + threshold = 0.25f, + timeScale = 1, + }, + new ChildMotion() + { + motion = GenerateMotion(item, true), + position = new Vector2(1, 0), + threshold = 0.75f, + timeScale = 1, + }, + }; + + _context.SaveAsset(blendTree); + blendTrees.Add(blendTree); + } + + expParams.parameters = expParameters.ToArray(); + descriptor.expressionParameters = expParams; + + return blendTrees; + } + + private Motion GenerateMotion(ModularAvatarMenuItem item, bool active) + { + AnimationClip clip = new AnimationClip(); + _context.SaveAsset(clip); + clip.name = item.gameObject.name + (active ? " (On)" : " (Off)"); + + Dictionary curves = + new Dictionary(); + + foreach (var action in item.GetComponents()) + { + var newCurves = active ? action.GetCurves() : action.GetDefaultCurves(); + + foreach (var curvePair in newCurves) + { + var binding = curvePair.Key; + var curve = curvePair.Value; + + if (curves.TryGetValue(binding, out var existing)) + { + if (active) + { + BuildReport.LogFatal("animation_gen.conflict", new object[] + { + binding, + existing.Item1.gameObject.name, + existing.Item1.GetType().Name, + item.gameObject.name, + item.GetType().Name + }, binding.target, existing.Item1, item); + } + } + else + { + curves.Add(binding, (item, curve)); + } + } + } + + foreach (var entry in curves) + { + clip.SetCurve( + RuntimeUtil.AvatarRootPath(entry.Key.target), + entry.Key.type, + entry.Key.property, + entry.Value.Item2 // curve + ); + } + + return clip; + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs.meta new file mode 100644 index 00000000..afb8152e --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Menu/ActionProcessing/ActionGenerator.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: be5c2a67d3b448c5bd8c439f537a1766 +timeCreated: 1677335026 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions.meta b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions.meta new file mode 100644 index 00000000..e354c23a --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions.meta @@ -0,0 +1,4 @@ +fileFormatVersion: 2 +guid: 31ce1123a74443c1ba7a126d4b8919b1 +timeCreated: 1677317241 +folderAsset: yes \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs new file mode 100644 index 00000000..dcc9b567 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using UnityEngine; + +namespace nadena.dev.modular_avatar.core +{ + public sealed class MenuCurveBinding + { + public readonly GameObject target; + public readonly Type type; + public readonly string property; + + public MenuCurveBinding(GameObject target, Type type, string property) + { + this.target = target; + this.type = type; + this.property = property; + } + + private bool Equals(MenuCurveBinding other) + { + return target == other.target && type == other.type && property == other.property; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((MenuCurveBinding) obj); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = (target != null ? target.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ (type != null ? type.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ (property != null ? property.GetHashCode() : 0); + return hashCode; + } + } + } + + public interface MenuAction + { + ImmutableDictionary GetCurves(); + ImmutableDictionary GetDefaultCurves(); + } + + [RequireComponent(typeof(ModularAvatarMenuItem))] + public class ActionToggleObject : AvatarTagComponent, MenuAction + { + [Serializable] + public class ObjectEntry + { + public GameObject target; + public bool Active; + } + + public List Objects; + + public ImmutableDictionary GetCurves() + { + return Objects.Select(obj => + new KeyValuePair( + new MenuCurveBinding(obj.target, typeof(GameObject), "m_IsActive"), + AnimationCurve.Constant(0, 1, obj.Active ? 1 : 0)) + ).ToImmutableDictionary(); + } + + public ImmutableDictionary GetDefaultCurves() + { + return Objects.Select(obj => + new KeyValuePair( + new MenuCurveBinding(obj.target, typeof(GameObject), "m_IsActive"), + AnimationCurve.Constant(0, 1, obj.target.activeSelf ? 1 : 0)) + ).ToImmutableDictionary(); + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs.meta b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs.meta new file mode 100644 index 00000000..a0b00d61 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ActionToggleObject.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: fcac4d9412424173bd294ffd5fc5f9db +timeCreated: 1677316809 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ToggleGroup.cs b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ToggleGroup.cs new file mode 100644 index 00000000..d24519d5 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ToggleGroup.cs @@ -0,0 +1,8 @@ +using UnityEngine; + +namespace nadena.dev.modular_avatar.core +{ + public class ToggleGroup : AvatarTagComponent + { + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ToggleGroup.cs.meta b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ToggleGroup.cs.meta new file mode 100644 index 00000000..c04947a3 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/Actions/ToggleGroup.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 99c60d83ad614e81a0488d98b83b5c1c +timeCreated: 1677317301 \ No newline at end of file