From 341ed7537e96b1ec5b4543e52f73e5df9cfbd342 Mon Sep 17 00:00:00 2001 From: yum Date: Mon, 17 Feb 2025 22:34:39 -0800 Subject: Initial commit --- Editor/Generate_Animator.cs | 1209 +++++++++++++++++++++++++++++++++++++++++++ Examples/ex0_animator.json | 30 ++ README.md | 399 ++++++++++++++ 3 files changed, 1638 insertions(+) create mode 100644 Editor/Generate_Animator.cs create mode 100644 Examples/ex0_animator.json create mode 100644 README.md diff --git a/Editor/Generate_Animator.cs b/Editor/Generate_Animator.cs new file mode 100644 index 0000000..b2211c4 --- /dev/null +++ b/Editor/Generate_Animator.cs @@ -0,0 +1,1209 @@ +using System.Collections.Generic; +using System.IO; +using UnityEngine; +using UnityEditor; +using UnityEditor.Animations; +using System.Linq; +using VRC.SDK3.Avatars.Components; +using VRC.SDK3.Avatars.ScriptableObjects; + +namespace YOTS +{ + [System.Serializable] + public class ToggleSpec + { + [SerializeField] + public string name; + // Dependencies are evaluated before this one. They must share one or + // more attributes with this spec. + [SerializeField] + public List dependencies = new List(); + [SerializeField] + public List meshToggles = new List(); + [SerializeField] + public List blendShapes = new List(); + + public ToggleSpec(string name) + { + this.name = name; + } + + public ToggleSpec() {} + + public IEnumerable GetAffectedAttributes() + { + foreach (var mesh in meshToggles) + { + yield return $"MeshToggle:{mesh}"; + } + + foreach (var blend in blendShapes) + { + yield return $"BlendShape:{blend.path}/{blend.blendShape}"; + } + } + } + + [System.Serializable] + public class BlendShapeSpec + { + [SerializeField] + public string path; + + [SerializeField] + public string blendShape; + + public BlendShapeSpec(string path, string blendShape) + { + this.path = path; + this.blendShape = blendShape; + } + + public BlendShapeSpec() {} + } + + [System.Serializable] + public class AnimatorConfigFile + { + [SerializeField] + public List toggles = new List(); + + [SerializeField] + public string api_version; + } + + [System.Serializable] + public class GeneratedAnimationsConfig + { + public List animations = + new List(); + } + + [System.Serializable] + public class GeneratedAnimationClipConfig + { + public string name; + public List meshToggles = + new List(); + public List blendShapes = + new List(); + } + + [System.Serializable] + public class GeneratedMeshToggle + { + public string path; + public float value; + } + + [System.Serializable] + public class GeneratedBlendShape + { + public string path; + public string blendShape; + public float value; + } + + // These classes describe the generated JSON output for the animator configuration. + [System.Serializable] + public class GeneratedAnimatorConfig + { + public string name; + public List parameters = new List(); + public List layers = new List(); + public List animations = + new List(); + } + + [System.Serializable] + public class AnimatorLayer + { + public string name; + public AnimatorDirectBlendTree directBlendTree = + new AnimatorDirectBlendTree(); + } + + [System.Serializable] + public class AnimatorDirectBlendTree + { + public List entries = + new List(); + } + + [System.Serializable] + public class AnimatorDirectBlendTreeEntry + { + public string name; // animation name + public string parameter; // parameter driving the animation + } + + // Add these new classes at the namespace level + [System.Serializable] + public class VRCMenuConfig + { + public string menuName = "YOTS"; + public List items = new List(); + } + + [System.Serializable] + public class VRCMenuItemConfig + { + public string name; + public string parameter; + public Texture2D icon; + } + + // This class adds both a menu command and GUI window for animator generation + public class GenerateAnimatorCommand : EditorWindow + { + private string jsonPath; + private string animatorName = "YOTS_FX"; + private string existingParamsPath; + private string existingMenuPath; + private VRCExpressionParameters existingParams; + private VRCExpressionsMenu existingMenu; + + [MenuItem("Tools/yum_food/YOTS")] + public static void ShowWindow() + { + GetWindow("YOTS"); + } + + private void OnGUI() + { + GUILayout.Label("YOTS Animator Generator", EditorStyles.boldLabel); + + // Create a drag-drop field for the JSON config + var jsonObj = AssetDatabase.LoadAssetAtPath(jsonPath); + var newJsonObj = (TextAsset)EditorGUILayout.ObjectField( + "Config JSON", + jsonObj, + typeof(TextAsset), + false + ); + if (newJsonObj != jsonObj) + { + jsonPath = AssetDatabase.GetAssetPath(newJsonObj); + } + if (string.IsNullOrEmpty(jsonPath)) + { + EditorGUILayout.HelpBox("Config JSON must be provided.", MessageType.Error); + } + + animatorName = EditorGUILayout.TextField("Animator Name", animatorName); + + // Replace file path fields with Object fields for drag-and-drop + existingParams = (VRCExpressionParameters)EditorGUILayout.ObjectField( + "VRC Parameters", + existingParams, + typeof(VRCExpressionParameters), + false + ); + existingParamsPath = existingParams != null ? AssetDatabase.GetAssetPath(existingParams) : null; + + existingMenu = (VRCExpressionsMenu)EditorGUILayout.ObjectField( + "VRC Menu", + existingMenu, + typeof(VRCExpressionsMenu), + false + ); + existingMenuPath = existingMenu != null ? AssetDatabase.GetAssetPath(existingMenu) : null; + + // Show error message if either field is missing + if (existingParams == null || existingMenu == null) + { + EditorGUILayout.HelpBox("VRC parameters and menu must be provided.", MessageType.Error); + } + + GUI.enabled = !string.IsNullOrEmpty(jsonPath) && existingParams != null && existingMenu != null; + if (GUILayout.Button("Generate Animator")) + { + if (string.IsNullOrEmpty(jsonPath)) + { + EditorUtility.DisplayDialog("Error", "Please select a configuration file.", "OK"); + return; + } + GenerateAnimator(jsonPath, animatorName, existingParamsPath, existingMenuPath); + } + GUI.enabled = true; + } + + private static AnimationClip AssignOrCreateAnimationClip(AnimationClip newClip, string clipPath) + { + AnimationClip existingClip = AssetDatabase.LoadAssetAtPath(clipPath); + if (existingClip != null) + { + // Clear existing curves + var existingBindings = AnimationUtility.GetCurveBindings(existingClip); + foreach (var binding in existingBindings) + { + AnimationUtility.SetEditorCurve(existingClip, binding, null); + } + // Copy new curves from our temporary clip + var newBindings = AnimationUtility.GetCurveBindings(newClip); + foreach (var binding in newBindings) + { + var curve = AnimationUtility.GetEditorCurve(newClip, binding); + AnimationUtility.SetEditorCurve(existingClip, binding, curve); + } + EditorUtility.SetDirty(existingClip); + return existingClip; + } + else + { + AssetDatabase.CreateAsset(newClip, clipPath); + return newClip; + } + } + + private static GeneratedAnimationsConfig GenerateAnimationConfig(List toggleSpecs) + { + GeneratedAnimationsConfig genAnimConfig = new GeneratedAnimationsConfig(); + foreach (var toggle in toggleSpecs) + { + GeneratedAnimationClipConfig onAnim = new GeneratedAnimationClipConfig(); + onAnim.name = toggle.name + "_On"; + if (toggle.meshToggles != null) + { + foreach (var mesh in toggle.meshToggles) + { + onAnim.meshToggles.Add(new GeneratedMeshToggle { path = mesh, value = 1.0f }); + } + } + if (toggle.blendShapes != null) + { + foreach (var bs in toggle.blendShapes) + { + onAnim.blendShapes.Add(new GeneratedBlendShape { path = bs.path, blendShape = bs.blendShape, value = 100.0f }); + } + } + genAnimConfig.animations.Add(onAnim); + + GeneratedAnimationClipConfig offAnim = new GeneratedAnimationClipConfig(); + offAnim.name = toggle.name + "_Off"; + if (toggle.meshToggles != null) + { + foreach (var mesh in toggle.meshToggles) + { + offAnim.meshToggles.Add(new GeneratedMeshToggle { path = mesh, value = 0.0f }); + } + } + if (toggle.blendShapes != null) + { + foreach (var bs in toggle.blendShapes) + { + offAnim.blendShapes.Add(new GeneratedBlendShape { path = bs.path, blendShape = bs.blendShape, value = 0.0f }); + } + } + genAnimConfig.animations.Add(offAnim); + } + return genAnimConfig; + } + + private static void CreateAnimationClips(GeneratedAnimationsConfig animationsConfig, string outputDir) + { + foreach (var clipConfig in animationsConfig.animations) + { + AnimationClip newClip = new AnimationClip(); + newClip.name = clipConfig.name; + + // Apply mesh toggles + foreach (var meshToggle in clipConfig.meshToggles) + { + AnimationCurve curve = new AnimationCurve(new Keyframe(0, meshToggle.value)); + EditorCurveBinding binding = new EditorCurveBinding(); + binding.path = meshToggle.path; + binding.type = typeof(GameObject); + binding.propertyName = "m_IsActive"; + AnimationUtility.SetEditorCurve(newClip, binding, curve); + } + + // Apply blend shapes + foreach (var blendShape in clipConfig.blendShapes) + { + AnimationCurve curve = AnimationCurve.Constant(0, 0, blendShape.value); + EditorCurveBinding binding = new EditorCurveBinding(); + binding.path = blendShape.path; + binding.type = typeof(SkinnedMeshRenderer); + binding.propertyName = "blendShape." + blendShape.blendShape; + AnimationUtility.SetEditorCurve(newClip, binding, curve); + } + + string clipPath = Path.Combine(outputDir, "Animations", $"{clipConfig.name}.anim"); + AnimationClip clip = AssignOrCreateAnimationClip(newClip, clipPath); + Debug.Log("Created/Updated animation clip: " + clipConfig.name + " at path: " + clipPath); + } + } + + private static AnimatorController InitializeAnimatorController(string controllerPath) + { + AnimatorController controller = AssetDatabase.LoadAssetAtPath(controllerPath); + if (controller == null) + { + controller = AnimatorController.CreateAnimatorControllerAtPath(controllerPath); + Debug.Log("Created new AnimatorController at: " + controllerPath); + } + else + { + Debug.Log("Reusing existing AnimatorController GUID at: " + controllerPath); + + // Clean up all sub-assets (BlendTrees, StateMachines) before clearing parameters and layers + var subAssets = AssetDatabase.LoadAllAssetRepresentationsAtPath(controllerPath); + foreach (var subAsset in subAssets) + { + if (subAsset is BlendTree || subAsset is AnimatorStateMachine) + { + Object.DestroyImmediate(subAsset, true); + } + } + + // Clear parameters and layers + while (controller.parameters.Length > 0) + { + controller.RemoveParameter(controller.parameters[0]); + } + while (controller.layers.Length > 0) + { + controller.RemoveLayer(0); + } + } + return controller; + } + + private static void GenerateAnimatorController(GeneratedAnimatorConfig animatorConfig, string generatedOutputDir) + { + string controllerPath = Path.Combine(generatedOutputDir, $"{animatorConfig.name}.controller"); + AnimatorController controller = InitializeAnimatorController(controllerPath); + + // This is always set to 1 and used to ensure that each DBT always + // animates. + // More info on vrc.schooL: + // https://vrc.school/docs/Other/DBT-Combining + controller.AddParameter("YOTS_Weight", AnimatorControllerParameterType.Float); + controller.parameters[0].defaultFloat = 1.0f; + + // Add parameters from the config as float parameters + foreach (var param in animatorConfig.parameters) + { + if (!controller.parameters.Any(p => p.name == param)) + { + controller.AddParameter(param, AnimatorControllerParameterType.Float); + } + } + + // Process base layer first + var baseLayer = animatorConfig.layers[0]; + var baseStateMachine = new AnimatorStateMachine(); + baseStateMachine.name = "BaseLayer_SM"; + AssetDatabase.AddObjectToAsset(baseStateMachine, controller); + + // Create the root Direct Blend Tree + var rootBlendTree = new BlendTree(); + rootBlendTree.name = "BaseLayer_RootBlendTree"; + rootBlendTree.blendType = BlendTreeType.Direct; + AssetDatabase.AddObjectToAsset(rootBlendTree, controller); + + // Create 1D blend trees for each parameter in the base layer + var parameterGroups = baseLayer.directBlendTree.entries + .GroupBy(e => e.parameter) + .ToDictionary(g => g.Key, g => g.ToList()); + + foreach (var group in parameterGroups) + { + var param = group.Key; + var entries = group.Value; + + // Create 1D blend tree for this parameter + var paramBlendTree = new BlendTree(); + paramBlendTree.name = $"BlendTree_{param}"; + paramBlendTree.blendType = BlendTreeType.Simple1D; + paramBlendTree.blendParameter = param; + AssetDatabase.AddObjectToAsset(paramBlendTree, controller); + + // Add On/Off animations to the 1D blend tree + var children = new List(); + foreach (var entry in entries.OrderBy(e => e.name.EndsWith("_On"))) + { + Debug.Log("Adding child motion for: " + entry.name); + string clipPath = Path.Combine(generatedOutputDir, "Animations", $"{entry.name}.anim"); + AnimationClip clip = AssetDatabase.LoadAssetAtPath(clipPath); + if (clip == null) + { + Debug.LogWarning("Animation clip not found at: " + clipPath); + continue; + } + + children.Add(new ChildMotion + { + motion = clip, + timeScale = 1f, + threshold = entry.name.EndsWith("_On") ? 1f : 0f + }); + } + paramBlendTree.children = children.ToArray(); + + // Add this 1D blend tree to the root Direct Blend Tree + rootBlendTree.children = rootBlendTree.children.Append(new ChildMotion + { + motion = paramBlendTree, + timeScale = 1f, + directBlendParameter = "YOTS_Weight" + }).ToArray(); + } + + // Set up base layer state + var baseState = baseStateMachine.AddState("BaseLayer_State"); + baseState.motion = rootBlendTree; + baseState.writeDefaultValues = true; + baseStateMachine.defaultState = baseState; + + // Add base layer to controller + controller.AddLayer(new AnimatorControllerLayer + { + name = "YOTS_BaseLayer", + defaultWeight = 1.0f, + stateMachine = baseStateMachine + }); + + // Process override layers (if any) + for (int i = 1; i < animatorConfig.layers.Count; i++) + { + var layerConfig = animatorConfig.layers[i]; + string layerName = $"YOTS_OverrideLayer{(i-1).ToString("00")}"; + + var stateMachine = new AnimatorStateMachine(); + stateMachine.name = layerName + "_SM"; + AssetDatabase.AddObjectToAsset(stateMachine, controller); + + var blendTree = new BlendTree(); + blendTree.name = layerName + "_BlendTree"; + blendTree.blendType = BlendTreeType.Direct; + + foreach (var entry in layerConfig.directBlendTree.entries) + { + string clipPath = Path.Combine(generatedOutputDir, "Animations", $"{entry.name}.anim"); + AnimationClip clip = AssetDatabase.LoadAssetAtPath(clipPath); + if (clip == null) + { + Debug.LogWarning("Animation clip not found at: " + clipPath); + continue; + } + + blendTree.children = blendTree.children.Append(new ChildMotion + { + motion = clip, + timeScale = 1f, + directBlendParameter = entry.parameter + }).ToArray(); + } + AssetDatabase.AddObjectToAsset(blendTree, controller); + + var state = stateMachine.AddState(layerName + "_State"); + state.motion = blendTree; + state.writeDefaultValues = true; + stateMachine.defaultState = state; + + controller.AddLayer(new AnimatorControllerLayer + { + name = layerName, + defaultWeight = 1.0f, + stateMachine = stateMachine + }); + + Debug.Log($"Added override layer: {layerName}"); + } + + EditorUtility.SetDirty(controller); + AssetDatabase.SaveAssets(); + Debug.Log("Animator Controller generation complete at: " + controllerPath); + } + + private static Dictionary TopologicalSortToggles(List toggleSpecs) + { + // Create adjacency list + Dictionary> graph = new Dictionary>(); + foreach (var toggle in toggleSpecs) + { + if (!graph.ContainsKey(toggle.name)) + { + graph[toggle.name] = new HashSet(); + } + foreach (var dep in toggle.dependencies) + { + if (!graph.ContainsKey(dep)) + { + graph[dep] = new HashSet(); + } + graph[dep].Add(toggle.name); + } + } + + // Calculate in-degrees + Dictionary inDegree = new Dictionary(); + foreach (var toggle in toggleSpecs) + { + inDegree[toggle.name] = toggle.dependencies.Count; + } + + // Perform topological sort with depth tracking + Dictionary depths = new Dictionary(); + Queue queue = new Queue(); + + // Add all nodes with no dependencies to queue with depth 0 + foreach (var pair in inDegree) + { + if (pair.Value == 0) + { + queue.Enqueue(pair.Key); + depths[pair.Key] = 0; + } + } + + int processedNodes = 0; + while (queue.Count > 0) + { + string current = queue.Dequeue(); + processedNodes++; + int currentDepth = depths[current]; + + if (graph.ContainsKey(current)) + { + foreach (var neighbor in graph[current]) + { + inDegree[neighbor]--; + if (inDegree[neighbor] == 0) + { + queue.Enqueue(neighbor); + depths[neighbor] = currentDepth + 1; + } + } + } + } + + // Check for cycles + if (processedNodes != toggleSpecs.Count) + { + // Find nodes involved in the cycle for a better error message + var cycleNodes = toggleSpecs + .Where(t => !depths.ContainsKey(t.name)) + .Select(t => t.name) + .ToList(); + + throw new System.Exception($"Dependency cycle detected in toggle specifications. Nodes involved: {string.Join(", ", cycleNodes)}"); + } + + return depths; + } + + private static GeneratedAnimatorConfig GenerateNaiveAnimatorConfig(List toggleSpecs, string animatorName) + { + GeneratedAnimatorConfig genAnimatorConfig = new GeneratedAnimatorConfig(); + genAnimatorConfig.name = animatorName; + + // Generate animations + GeneratedAnimationsConfig animConfig = GenerateAnimationConfig(toggleSpecs); + genAnimatorConfig.animations = animConfig.animations; // Store animations inline + + // Topologically sort the toggles according to their dependencies + Dictionary depths = TopologicalSortToggles(toggleSpecs); + + var togglesByDepth = toggleSpecs + .GroupBy(t => depths[t.name]) + .OrderBy(g => g.Key) + .ToList(); + + // Create one layer for each set of toggles at a given topological depth + for (int i = 0; i < togglesByDepth.Count; i++) + { + var depthGroup = togglesByDepth[i]; + AnimatorLayer layer = new AnimatorLayer(); + layer.name = i == 0 ? "BaseLayer" : $"OverrideLayer{(i-1).ToString("00")}"; + + foreach (var toggle in depthGroup) + { + string paramName = toggle.name; + if (!genAnimatorConfig.parameters.Contains(paramName)) + { + genAnimatorConfig.parameters.Add(paramName); + } + + layer.directBlendTree.entries.Add(new AnimatorDirectBlendTreeEntry { + name = toggle.name + "_On", + parameter = paramName + }); + + layer.directBlendTree.entries.Add(new AnimatorDirectBlendTreeEntry { + name = toggle.name + "_Off", + parameter = paramName + }); + } + + genAnimatorConfig.layers.Add(layer); + } + + return genAnimatorConfig; + } + + private static GeneratedAnimatorConfig ApplyIndependentFixToAnimatorConfig(GeneratedAnimatorConfig genAnimatorConfig) + { + // Group paired animations by toggle name (extracted from the animation name by removing _On/_Off). + Dictionary toggleAnimations = + new Dictionary(); + + foreach (var anim in genAnimatorConfig.animations) + { + if (anim.name.EndsWith("_On")) + { + string toggleName = anim.name.Substring(0, anim.name.LastIndexOf("_On")); + if (!toggleAnimations.ContainsKey(toggleName)) + { + toggleAnimations[toggleName] = (null, null); + } + var pair = toggleAnimations[toggleName]; + pair.on = anim; + toggleAnimations[toggleName] = pair; + } + else if (anim.name.EndsWith("_Off")) + { + string toggleName = anim.name.Substring(0, anim.name.LastIndexOf("_Off")); + if (!toggleAnimations.ContainsKey(toggleName)) + { + toggleAnimations[toggleName] = (null, null); + } + var pair = toggleAnimations[toggleName]; + pair.off = anim; + toggleAnimations[toggleName] = pair; + } + } + + // Determine in which layer a given toggle exists. + // (We assume that the base layer is named "BaseLayer" or is the first layer.) + Dictionary toggleToLayerIndex = new Dictionary(); + for (int i = 0; i < genAnimatorConfig.layers.Count; i++) + { + var layer = genAnimatorConfig.layers[i]; + foreach (var entry in layer.directBlendTree.entries) + { + string entryName = entry.name; + // Remove any existing suffix (_On, _Off, _Independent, _Dependent) to get the base toggle. + string toggleName = entryName; + if (toggleName.EndsWith("_On")) + { + toggleName = toggleName.Substring(0, toggleName.LastIndexOf("_On")); + } + else if (toggleName.EndsWith("_Off")) + { + toggleName = toggleName.Substring(0, toggleName.LastIndexOf("_Off")); + } + else if (toggleName.Contains("_Independent")) + { + toggleName = toggleName.Replace("_Independent", ""); + } + else if (toggleName.Contains("_Dependent")) + { + toggleName = toggleName.Replace("_Dependent", ""); + } + + if (!toggleToLayerIndex.ContainsKey(toggleName)) + { + toggleToLayerIndex[toggleName] = i; + } + } + } + + // Build a global mapping from each affected attribute to the set of toggles that affect it. + // For mesh toggles the key is "MeshToggle:{path}" and for blend shapes "BlendShape:{path}/{blendShape}". + Dictionary> attributeToToggles = new Dictionary>(); + foreach (var kvp in toggleAnimations) + { + string toggleName = kvp.Key; + var pair = kvp.Value; + if (pair.on == null) continue; // skip if missing + + HashSet attributes = new HashSet(); + if (pair.on.meshToggles != null) + { + foreach (var mt in pair.on.meshToggles) + { + string attr = "MeshToggle:" + mt.path; + attributes.Add(attr); + } + } + if (pair.on.blendShapes != null) + { + foreach (var bs in pair.on.blendShapes) + { + string attr = "BlendShape:" + bs.path + "/" + bs.blendShape; + attributes.Add(attr); + } + } + + foreach (var attr in attributes) + { + if (!attributeToToggles.TryGetValue(attr, out var set)) + { + set = new HashSet(); + attributeToToggles[attr] = set; + } + set.Add(toggleName); + } + } + + // We will rebuild the animations list. + List newAnimations = new List(); + + // Assume that the base layer is named "BaseLayer"; otherwise use the first layer. + AnimatorLayer baseLayer = genAnimatorConfig.layers.FirstOrDefault(l => l.name == "BaseLayer"); + if (baseLayer == null && genAnimatorConfig.layers.Count > 0) + { + baseLayer = genAnimatorConfig.layers[0]; + } + + // Process each toggle pair. + foreach (var kvp in toggleAnimations) + { + string toggleName = kvp.Key; + var pair = kvp.Value; + // Determine the layer index in which this toggle appears. + int layerIndex = toggleToLayerIndex.ContainsKey(toggleName) ? toggleToLayerIndex[toggleName] : 0; + bool isBase = (layerIndex == 0); + + if (isBase) + { + // Base layer toggles remain unchanged. + newAnimations.Add(pair.on); + newAnimations.Add(pair.off); + } + else + { + // For toggles in override layers we subdivide the affected attributes. + List independentMesh = new List(); + List dependentMesh = new List(); + + if (pair.on.meshToggles != null) + { + foreach (var mt in pair.on.meshToggles) + { + string attr = "MeshToggle:" + mt.path; + // If no other toggle affects this attribute, mark as independent. + if (attributeToToggles[attr].Count == 1) + { + independentMesh.Add(mt); + } + else + { + dependentMesh.Add(mt); + } + } + } + + List independentBlend = new List(); + List dependentBlend = new List(); + + if (pair.on.blendShapes != null) + { + foreach (var bs in pair.on.blendShapes) + { + string attr = "BlendShape:" + bs.path + "/" + bs.blendShape; + if (attributeToToggles[attr].Count == 1) + { + independentBlend.Add(bs); + } + else + { + dependentBlend.Add(bs); + } + } + } + + bool hasIndependent = (independentMesh.Count > 0 || independentBlend.Count > 0); + bool hasDependent = (dependentMesh.Count > 0 || dependentBlend.Count > 0); + + if (hasIndependent && hasDependent) + { + // Split into two animation pairs. + // Create the dependent pair (remain in this (override) layer). + GeneratedAnimationClipConfig dependentOn = new GeneratedAnimationClipConfig(); + dependentOn.name = toggleName + "_Dependent_On"; + dependentOn.meshToggles = dependentMesh; + dependentOn.blendShapes = dependentBlend; + + // For the Off animation, assume zero values; so copy and set value = 0. + GeneratedAnimationClipConfig dependentOff = new GeneratedAnimationClipConfig(); + dependentOff.name = toggleName + "_Dependent_Off"; + dependentOff.meshToggles = dependentMesh + .Select(mt => new GeneratedMeshToggle { path = mt.path, value = 0.0f }) + .ToList(); + dependentOff.blendShapes = dependentBlend + .Select(bs => new GeneratedBlendShape { path = bs.path, blendShape = bs.blendShape, value = 0.0f }) + .ToList(); + + // Create the independent pair (to be added in the base layer). + GeneratedAnimationClipConfig independentOn = new GeneratedAnimationClipConfig(); + independentOn.name = toggleName + "_Independent_On"; + independentOn.meshToggles = independentMesh; + independentOn.blendShapes = independentBlend; + + GeneratedAnimationClipConfig independentOff = new GeneratedAnimationClipConfig(); + independentOff.name = toggleName + "_Independent_Off"; + independentOff.meshToggles = independentMesh + .Select(mt => new GeneratedMeshToggle { path = mt.path, value = 0.0f }) + .ToList(); + independentOff.blendShapes = independentBlend + .Select(bs => new GeneratedBlendShape { path = bs.path, blendShape = bs.blendShape, value = 0.0f }) + .ToList(); + + // Add our new animations. + newAnimations.Add(dependentOn); + newAnimations.Add(dependentOff); + newAnimations.Add(independentOn); + newAnimations.Add(independentOff); + + // Update the override layer's direct blend tree entries for this toggle. + AnimatorLayer overrideLayer = genAnimatorConfig.layers[layerIndex]; + foreach (var entry in overrideLayer.directBlendTree.entries) + { + // Here we assume the entry originally matches the toggle name. + if (entry.name.StartsWith(toggleName) && + (entry.name.EndsWith("_On") || entry.name.EndsWith("_Off"))) + { + // Change the suffix to _Dependent_... + if (entry.name.EndsWith("_On")) + { + entry.name = toggleName + "_Dependent_On"; + } + else + { + entry.name = toggleName + "_Dependent_Off"; + } + } + } + + // In the base layer, append new direct blend tree entries for the independent pair. + if (baseLayer != null) + { + baseLayer.directBlendTree.entries.Add(new AnimatorDirectBlendTreeEntry + { + name = toggleName + "_Independent_On", + parameter = toggleName + }); + baseLayer.directBlendTree.entries.Add(new AnimatorDirectBlendTreeEntry + { + name = toggleName + "_Independent_Off", + parameter = toggleName + }); + } + } + else if (hasIndependent && !hasDependent) + { + // All affected attributes are independent. + // Move the entire animation pair into the base layer as _Independent. + GeneratedAnimationClipConfig independentOn = new GeneratedAnimationClipConfig(); + independentOn.name = toggleName + "_Independent_On"; + independentOn.meshToggles = pair.on.meshToggles; + independentOn.blendShapes = pair.on.blendShapes; + GeneratedAnimationClipConfig independentOff = new GeneratedAnimationClipConfig(); + independentOff.name = toggleName + "_Independent_Off"; + independentOff.meshToggles = pair.off.meshToggles; + independentOff.blendShapes = pair.off.blendShapes; + + newAnimations.Add(independentOn); + newAnimations.Add(independentOff); + + // Remove the entries from the override layer. + AnimatorLayer overrideLayer = genAnimatorConfig.layers[layerIndex]; + overrideLayer.directBlendTree.entries.RemoveAll(e => e.name.StartsWith(toggleName)); + // Add new entries to the base layer. + if (baseLayer != null) + { + baseLayer.directBlendTree.entries.Add(new AnimatorDirectBlendTreeEntry + { + name = toggleName + "_Independent_On", + parameter = toggleName + }); + baseLayer.directBlendTree.entries.Add(new AnimatorDirectBlendTreeEntry + { + name = toggleName + "_Independent_Off", + parameter = toggleName + }); + } + } + else if (!hasIndependent && hasDependent) + { + // All affected attributes are shared. + // Simply rename the pair in the override layer with a _Dependent suffix. + GeneratedAnimationClipConfig dependentOn = new GeneratedAnimationClipConfig(); + dependentOn.name = toggleName + "_Dependent_On"; + dependentOn.meshToggles = pair.on.meshToggles; + dependentOn.blendShapes = pair.on.blendShapes; + GeneratedAnimationClipConfig dependentOff = new GeneratedAnimationClipConfig(); + dependentOff.name = toggleName + "_Dependent_Off"; + dependentOff.meshToggles = pair.off.meshToggles; + dependentOff.blendShapes = pair.off.blendShapes; + + newAnimations.Add(dependentOn); + newAnimations.Add(dependentOff); + + // Update the override layer's entries. + AnimatorLayer overrideLayer = genAnimatorConfig.layers[layerIndex]; + foreach (var entry in overrideLayer.directBlendTree.entries) + { + if (entry.name.StartsWith(toggleName) && + (entry.name.EndsWith("_On") || entry.name.EndsWith("_Off"))) + { + if (entry.name.EndsWith("_On")) + { + entry.name = toggleName + "_Dependent_On"; + } + else + { + entry.name = toggleName + "_Dependent_Off"; + } + } + } + } + // In the very unlikely event that there are no affected attributes, nothing is added. + } + } + + // Replace the animations list in the config. + genAnimatorConfig.animations = newAnimations; + return genAnimatorConfig; + } + + private static GeneratedAnimatorConfig RemoveOffAnimationsFromOverrideLayers(GeneratedAnimatorConfig config) + { + for (int i = 1; i < config.layers.Count; i++) + { + var layer = config.layers[i]; + layer.directBlendTree.entries.RemoveAll(entry => entry.name.EndsWith("_Off")); + } + + return config; + } + + private static GeneratedAnimatorConfig RemoveUnusedAnimations(GeneratedAnimatorConfig config) + { + // Collect all animation names referenced in blend tree entries across all layers + HashSet referencedAnimations = new HashSet(); + foreach (var layer in config.layers) + { + foreach (var entry in layer.directBlendTree.entries) + { + referencedAnimations.Add(entry.name); + } + } + + // Filter the animations list to keep only referenced animations + config.animations = config.animations + .Where(anim => referencedAnimations.Contains(anim.name)) + .ToList(); + + Debug.Log($"Removed {config.animations.Count - referencedAnimations.Count} unused animations"); + return config; + } + + private static void GenerateVRChatAssets(List parameters, string generatedDir, string existingParamsPath = null, string existingMenuPath = null) + { + // Create VRC Parameters + VRCExpressionParameters expressionParameters; + if (!string.IsNullOrEmpty(existingParamsPath)) + { + expressionParameters = AssetDatabase.LoadAssetAtPath(existingParamsPath); + if (expressionParameters == null) + { + Debug.LogError($"Could not load existing parameters at path: {existingParamsPath}"); + return; + } + } + else + { + expressionParameters = ScriptableObject.CreateInstance(); + } + + // Convert existing parameters to list for modification + var paramList = new List(); + if (expressionParameters.parameters != null) + { + // Add existing parameters that don't conflict with YOTS parameters + paramList.AddRange(expressionParameters.parameters.Where(p => !parameters.Contains(p.name))); + } + + // Add YOTS parameters + foreach (var param in parameters) + { + paramList.Add(new VRCExpressionParameters.Parameter + { + name = param, + valueType = VRCExpressionParameters.ValueType.Float, + defaultValue = 1.0f, + saved = true + }); + } + + expressionParameters.parameters = paramList.ToArray(); + + // Create YOTS submenu + var yotsSubmenu = ScriptableObject.CreateInstance(); + foreach (var param in parameters) + { + yotsSubmenu.controls.Add(new VRCExpressionsMenu.Control + { + name = param, + type = VRCExpressionsMenu.Control.ControlType.Toggle, + parameter = new VRCExpressionsMenu.Control.Parameter { name = param }, + value = 1f + }); + } + + // Handle the main menu + VRCExpressionsMenu mainMenu; + if (!string.IsNullOrEmpty(existingMenuPath)) + { + mainMenu = AssetDatabase.LoadAssetAtPath(existingMenuPath); + if (mainMenu == null) + { + Debug.LogError($"Could not load existing menu at path: {existingMenuPath}"); + return; + } + + // Remove any existing YOTS submenu + mainMenu.controls.RemoveAll(c => c.name == "YOTS"); + } + else + { + mainMenu = ScriptableObject.CreateInstance(); + } + + // Save the YOTS submenu asset + string submenuPath = Path.Combine(generatedDir, "YOTS_Submenu.asset"); + AssetDatabase.CreateAsset(yotsSubmenu, submenuPath); + + // Add YOTS submenu to main menu + mainMenu.controls.Add(new VRCExpressionsMenu.Control + { + name = "YOTS", + type = VRCExpressionsMenu.Control.ControlType.SubMenu, + subMenu = yotsSubmenu + }); + + // Save the assets + if (string.IsNullOrEmpty(existingParamsPath)) + { + string paramPath = Path.Combine(generatedDir, "YOTS_Parameters.asset"); + AssetDatabase.CreateAsset(expressionParameters, paramPath); + Debug.Log($"Generated new VRChat parameters at: {paramPath}"); + } + else + { + EditorUtility.SetDirty(expressionParameters); + Debug.Log($"Updated existing VRChat parameters at: {existingParamsPath}"); + } + + if (string.IsNullOrEmpty(existingMenuPath)) + { + string menuPath = Path.Combine(generatedDir, "YOTS_Menu.asset"); + AssetDatabase.CreateAsset(mainMenu, menuPath); + Debug.Log($"Generated new VRChat menu at: {menuPath}"); + } + else + { + EditorUtility.SetDirty(mainMenu); + Debug.Log($"Updated existing VRChat menu at: {existingMenuPath}"); + } + + Debug.Log($"Generated YOTS submenu at: {submenuPath}"); + } + + public static void GenerateAnimator(string configPath = null, string animatorName = "YOTS_FX", string existingParamsPath = null, string existingMenuPath = null) + { + Debug.Log("=== Starting Animator Generation Process ==="); + + if (string.IsNullOrEmpty(configPath)) + { + configPath = EditorUtility.OpenFilePanel("Select Animator Config JSON", Application.dataPath, "json"); + if (string.IsNullOrEmpty(configPath)) + { + Debug.LogError("No configuration file selected. Process aborted."); + return; + } + } + Debug.Log("Loading configuration from: " + configPath); + + string jsonContent = File.ReadAllText(configPath); + AnimatorConfigFile config; + try + { + config = JsonUtility.FromJson(jsonContent); + } + catch (System.Exception e) + { + Debug.LogError($"JSON parsing failed: {e.Message}"); + return; + } + if (config == null) + { + Debug.LogError("Configuration file is empty or invalid"); + return; + } + + if (config.toggles == null) + { + Debug.LogError("No toggleSpecs found in configuration"); + return; + } + Debug.Log($"Configuration loaded. Found {config.toggles.Count} toggles."); + + // Ensure all output directories exist + string generatedDir = Path.Combine("Assets", "YOTS_Generated"); + string fullGeneratedDir = Path.Combine(Application.dataPath, "YOTS_Generated"); + string fullAnimationsDir = Path.Combine(fullGeneratedDir, "Animations"); + + if (!Directory.Exists(fullGeneratedDir)) + { + Directory.CreateDirectory(fullGeneratedDir); + Debug.Log("Created config output directory: " + fullGeneratedDir); + } + if (!Directory.Exists(fullAnimationsDir)) + { + Directory.CreateDirectory(fullAnimationsDir); + Debug.Log("Created animations output directory: " + fullAnimationsDir); + } + + // First we generate a naive animator config. We topologically sort + // toggles according to their dependencies and place them into + // layers. Everything is structured as an On/Off pair of + // animations, even though this is only semantically correct + // for the base layer. + GeneratedAnimatorConfig genAnimatorConfig = GenerateNaiveAnimatorConfig(config.toggles, animatorName); + // Next we split animations into independent and dependent parts. + // Independent parts are melded into the base layer. + genAnimatorConfig = ApplyIndependentFixToAnimatorConfig(genAnimatorConfig); + // Next we restructure the override layers as simple "On" + // animations which override the state inherited from previous layers. + genAnimatorConfig = RemoveOffAnimationsFromOverrideLayers(genAnimatorConfig); + // Finally, we scrub out any animations which may have been orphaned. + genAnimatorConfig = RemoveUnusedAnimations(genAnimatorConfig); + + // Generate VRChat parameters and menu + GenerateVRChatAssets(genAnimatorConfig.parameters, generatedDir, existingParamsPath, existingMenuPath); + + // Save the generated animator configuration JSON file. This is for + // debuggability. + string genAnimatorConfigPath = Path.Combine(fullGeneratedDir, "gen_animator.json"); + File.WriteAllText(genAnimatorConfigPath, JsonUtility.ToJson(genAnimatorConfig, true)); + Debug.Log("Saved generated animator config to: " + genAnimatorConfigPath); + + // Create the animation clips directly from the animator config + CreateAnimationClips(new GeneratedAnimationsConfig { animations = genAnimatorConfig.animations }, generatedDir); + + // Generate the animator controller + GenerateAnimatorController(genAnimatorConfig, generatedDir); + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + Debug.Log("=== Animator Generation Process Complete ==="); + } + } +} diff --git a/Examples/ex0_animator.json b/Examples/ex0_animator.json new file mode 100644 index 0000000..ed22d87 --- /dev/null +++ b/Examples/ex0_animator.json @@ -0,0 +1,30 @@ +{ + "api_version": "1.0", + "toggles": [ + { + "name": "Shirt", + "meshToggles": ["Shirt"], + "blendShapes": [ + { + "path": "Body", + "blendShape": "Chest_Hide" + } + ] + }, + { + "name": "Jacket", + "dependencies": ["Shirt"], + "meshToggles": ["Jacket"], + "blendShapes": [ + { + "path": "Body", + "blendShape": "Chest_Hide" + }, + { + "path": "Shirt", + "blendShape": "Chest_Hide" + } + ] + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f4097d --- /dev/null +++ b/README.md @@ -0,0 +1,399 @@ +# YOTS: yum's optimized toggle system + +YOTS is a text-based system for creating and managing VRChat toggles. It solves +two core problems: + +1. Toggles must be flattened into as few layers as possible using direct blend + trees (DBTs). +2. Toggles may have dependencies which prevent them from being combined. + +The core idea is to use declared dependencies between toggles to perform a +topological sort, then flatten each layer of the sort into a single DBT in one +layer. + +yum! + +## Design overview by example + +Consider this basic example: you have a shirt and a jacket. The shirt hides the +chest to avoid clipping. The jacket hides the shirt sleeves to hide clipping: + +- [ToggleSpec] Shirt + - [MeshToggle] Shirt + - [BlendShape] Chest hidden +- [ToggleSpec] Jacket + - [MeshToggle] Jacket + - [BlendShape] Shirt sleeves hidden + +A system could trivially be made to generate animations for this: + +- [Animation] ShirtOn + - [MeshToggle] Shirt on + - [BlendShape] Chest hide -> 100 +- [Animation] ShirtOff + - [MeshToggle] Shirt off + - [BlendShape] Chest hide -> 0 +- [Animation] JacketOn + - [MeshToggle] Jacket on + - [BlendShape] Shirt sleeves hide -> 100 +- [Animation] JacketOff + - [MeshToggle] Jacket off + - [BlendShape] Shirt sleeves hide -> 0 + +This system works perfectly as written and can be trivially implemented by +driving all 4 animations in a single layer with one DBT. +Problems arise when you have two assets that want to animate the same +blendshape. In that case, you must declare a dependency. In our example, +suppose we wanted to add an undershirt. It also wants to hide the chest: + +- [ToggleSpec] Undershirt + - [MeshToggle] Undershirt + - [BlendShape] Chest hidden + +The animations are also trivial: + +- [Animation] UndershirtOn + - [MeshToggle] Undershirt on + - [BlendShape] Chest hide -> 100 +- [Animation] UndershirtOff + - [MeshToggle] Undershirt Off + - [BlendShape] Chest hide -> 0 + +The problem is that since Undershirt{On,Off} and Shirt{On,Off} both animate the +"Chest hide" blendshape, you cannot put them into the same DBT. It's even worse +than that: if you split them into layers, such that the undershirt is evaluated +before the shirt, then if the shirt is toggled off, it will always set the +"Chest hide" blendshape to 0. With the shirt off and undershirt on, the chest +will clip through the undershirt. + +To fix this, we can declare a *dependency*. In this case the order doesn't +matter, so I will just use the convention that outer layers of garments depend +on inner layers. + +- [ToggleSpec] Undershirt + - [MeshToggle] Undershirt + - [BlendShape] Chest hidden +- [ToggleSpec] Shirt + - [Dependency] Undershirt + - [MeshToggle] Shirt + - [BlendShape] Chest hidden + +This situation can be detected robustly. We simply do a topological sort of all +ToggleSpec nodes according to their declared dependencies. This will give us a +set of directed acyclic graphs (a forest). We can maintain a set of attributes +affected by ToggleSpec nodes while iterating through them. **Any two nodes +which affect the same attribute must be in the same DAG and not at the same +level.** This can be surfaced to the user as a critical error. It can tell them +something like: + + Error: ToggleSpec $A and $B both animate the same property $PROPERTY. Declare + a dependency to resolve the conflict. + +We can also detect cycles in the graph (which wouldn't be possible to implement +in the animator anyway!) and report that to the user: + + Error: Cycle detected: ToggleSpecs $ALL\_AFFECTED\_TOGGLES have a cyclic + dependency. Delete one Dependency attribute in the chain to resolve the + conflict. + +The forest of DAGs is then used to generate the animator. To generate it, you +iterate a total of n times, where n is the largest depth of any DAG in the +forest. **Each layer in the animator contains every ToggleSpec of depth k, of +any DAG in the forest.** For example, a forest with 1000 separate DAGs of +maximum depth 3 would only generate a 3-layer animator. A forest with one DAG +of depth 300 would generate a 300 layer animator. The maximum length of the DAG +characterizes the number of nodes in the animator. + +There are two types of layers: the first layer, and every other layer. For the +first layer, because it's free to overwrite anything on the avatar, the DBT can +be constructed of pairs of Thing{On,Off} animations. Because of our topological +sort, we know that these nodes are all independent, so no two pairs are +animating the same thing. The config parser would have errored out by now if +that was the case. + +The successive layers are comprised of ToggleSpecs which animate one or more +attributes. At least one of these attributes is already being animated. We must +split the node into two parts. One part (the independent part) consists +entirely of attributes which are not already animated. The other part consists +entirely of nodes which are being animated (the dependent part). The +independent part may be comprised of the empty set, in which case it is +discarded. If it's not empty, it's added to the first layer's DBT. The +dependent part is guaranteed to be non-empty, and is simply added to a new DBT. + +(TODO is this new DBT actually possible? Can you have a DBT without off +animations? If not, do you just need to use a regular blendtree?) + +## Specification language + +We use JSON to represent the specification. The example above is expressed as +follows: + +```json +{ + "api_version": "1.0", + "toggleSpecs": [ + { + "name": "Undershirt", + "meshToggles": ["Undershirt"], + "blendShapes": [ + { + "path": "Body", + "blendShape": "Chest_Hide" + } + ] + }, + { + "name": "Shirt", + "dependencies": ["Undershirt"], + "meshToggles": ["Shirt"], + "blendShapes": [ + { + "path": "Body", + "blendShape": "Chest_Hide" + } + ] + }, + { + "name": "Jacket", + "meshToggles": ["Jacket"], + "blendShapes": [ + { + "path": "Shirt", + "blendShape": "Sleeves_Hide" + } + ] + } + ] +} +``` + +Given that config, we would run the described topological sort, erroring out if +there are unconnected nodes which affect the same attribute, or if there is a +cycle. + +In the topological sort of the dependency graph, we have Undershirt and Jacket +running on the first layer, and Shirt running on the second layer. + +Given that dependency graph, let's consider how we'd generate the animations. +The first layer's animations are trivial: + +```json +{ + "animations": [ + // Shirt + { + "name": "Shirt_On", + "meshToggles": [ + { + "path": "Shirt", + "value": 1.0 + } + ], + "blendShapes": [ + { + "path": "Body", + "blendShape": "Chest_Hide", + "value": 1.0 + } + ] + }, + { + "name": "Shirt_Off", + "meshToggles": [ + { + "path": "Shirt", + "value": 0.0 + } + ], + "blendShapes": [ + { + "path": "Body", + "blendShape": "Chest_Hide", + "value": 0.0 + } + ] + }, + // Jacket + { + "name": "Jacket_On", + "meshToggles": [ + { + "path": "Jacket", + "value": 1.0 + } + ], + "blendShapes": [ + { + "path": "Shirt", + "blendShape": "Sleeves_Hide", + "value": 1.0 + } + ] + }, + { + "name": "Jacket_Off", + "meshToggles": [ + { + "path": "Jacket", + "value": 0.0 + } + ], + "blendShapes": [ + { + "path": "Shirt", + "blendShape": "Sleeves_Hide", + "value": 0.0 + } + ] + } + ] +} +``` + +Naively, we might expect the second animations to be this: + +```json +{ + "animations": [ + { + "name": "Undershirt_On", + "meshToggles": [ + { + "path": "Undershirt", + "value": 1.0 + } + ], + "blendShapes": [ + { + "path": "Body", + "blendShape": "Chest_Hide", + "value": 0.0 + } + ] + }, + { + "name": "Undershirt_Off", + "meshToggles": [ + { + "path": "Undershirt", + "value": 0.0 + } + ], + "blendShapes": [ + { + "path": "Body", + "blendShape": "Chest_Hide", + "value": 1.0 + } + ] + } + ] +} +``` + +However, we must split the UnderShirt animations into the independent and +dependent parts: + +```json +// Independent part +{ + "animations": [ + { + "name": "Undershirt_On_Independent", + "meshToggles": [ + { + "path": "Undershirt", + "value": 1.0 + } + ], + } + { + "name": "Undershirt_Off_Independent", + "meshToggles": [ + { + "path": "Undershirt", + "value": 0.0 + } + ] + } + ] +} +``` + +```json +// Dependent part +{ + "animations": [ + { + "name": "Undershirt_On_Dependent", + "blendShapes": [ + { + "path": "Body", + "blendShape": "Chest_Hide", + "value": 1.0 + } + ] + } + ] +} +``` + +Then we'd append the independent part to the first layer's animations. We could +then report this in a nice object: + +```json +{ + "animationLayers": [ + // Layer 1 + { + "animations": [ + { "name": "Shirt_On", ... }, + { "name": "Shirt_Off", ... }, + { "name": "Jacket_On", ... }, + { "name": "Jacket_Off", ... }, + { "name": "Undershirt_On_Independent", ... }, + { "name": "Undershirt_Off_Independent", ... } + ] + }, + // Layer 2 + { + "animations": [ + { "name": "Undershirt_On_Dependent", ... }, + { "name": "Undershirt_Off_Dependent", ... } + ] + } + ] +} +``` + +We will also need toggles for material properties. These are discussed in +Extension 2. + +Our animations are complete. Our animator can trivially use the names of each +ToggleSpec as its parameters. To actually generate the first layer, we'll use +Hai's Animator As Code. + +// TODO document this part. For now just look at AnimatorGenerator.cs. + +We have to generate animations, configs for debug purposes, and finally an +animator file. + + +## Extensions + +### 1. Order-agnostic dependency + +For ease of use, a subtype of the [Dependency] attribute called +[OrderAgnosticDependency] may be created. Its function is to allow the runtime +to create an arbitrary ordering whenever two ToggleSpecs try to affect the same +node. For the initial version, only an explicit [Dependency] is created. + +### 2. GameObject material animation resolution + +Animations affecting material properties necessarily animate the same property +on all materials on the same GameObject. The situation can be detected by +iterating all GameObjects on the avatar. For each skinned mesh renderer, we can +check which materials exist. **Any time a (gameobject,materials) pair is +animated, we must generate animations for (gameobject,neighbor_material) for +every neighboring material on that gameobject.** These generated animations +should be logged during generation. -- cgit v1.2.3