From 3c7d7df216fe1537362b19520b304cc6e5d856a6 Mon Sep 17 00:00:00 2001 From: yum Date: Wed, 19 Feb 2025 03:26:58 -0800 Subject: organize, update readme --- .gitignore | 2 + Examples/example0.json | 26 ++ Examples/example1.json | 80 ++++ README.md | 169 ++++--- Scripts/YOTSCore.cs | 1059 ++++++++++++++++++++++++++++++++++++++++++ Scripts/YOTSNDMFConfig.cs | 22 + Scripts/YOTSNDMFGenerator.cs | 111 +++++ YOTS.prefab | 47 ++ YOTSCore.cs | 1059 ------------------------------------------ YOTSNDMFConfig.cs | 22 - YOTSNDMFGenerator.cs | 111 ----- 11 files changed, 1431 insertions(+), 1277 deletions(-) create mode 100644 .gitignore create mode 100644 Examples/example0.json create mode 100644 Examples/example1.json create mode 100644 Scripts/YOTSCore.cs create mode 100644 Scripts/YOTSNDMFConfig.cs create mode 100644 Scripts/YOTSNDMFGenerator.cs create mode 100644 YOTS.prefab delete mode 100644 YOTSCore.cs delete mode 100644 YOTSNDMFConfig.cs delete mode 100644 YOTSNDMFGenerator.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fda7039 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +**/.*.sw[po] +**/*.meta diff --git a/Examples/example0.json b/Examples/example0.json new file mode 100644 index 0000000..d77e3e7 --- /dev/null +++ b/Examples/example0.json @@ -0,0 +1,26 @@ +{ + "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" + } + ] + } + ] +} diff --git a/Examples/example1.json b/Examples/example1.json new file mode 100644 index 0000000..df27537 --- /dev/null +++ b/Examples/example1.json @@ -0,0 +1,80 @@ +{ + "api_version": "1.0", + "toggles": [ + { + "name": "Shirt", + "menuPath": "/Clothes", + "dependencies": ["Bra"], + "meshToggles": ["Shirt"], + "defaultValue": 0.0, + "blendShapes": [ + { + "path": "Body", + "blendShape": "Bra on" + }, + { + "path": "Body", + "blendShape": "Smush nips" + }, + { + "path": "Skirt", + "blendShape": "Shirt off", + "offValue": 100.0, + "onValue": 0.0 + } + ] + }, + { + "name": "Skirt", + "menuPath": "/Clothes", + "meshToggles": ["Skirt"], + "blendShapes": [] + }, + { + "name": "Socks", + "menuPath": "/Clothes", + "meshToggles": ["Socks"], + "blendShapes": [ + { + "path": "Body", + "blendShape": "Thigh squish" + } + ] + }, + { + "name": "Shoes", + "menuPath": "/Clothes", + "meshToggles": ["Shoes"], + "blendShapes": [] + }, + + { + "name": "Bra", + "menuPath": "/NSFW", + "meshToggles": ["Bra"], + "blendShapes": [ + { + "path": "Body", + "blendShape": "Bra on" + }, + { + "path": "Body", + "blendShape": "Smush nips" + } + ] + }, + { + "name": "Panties", + "menuPath": "/NSFW", + "meshToggles": ["Panties"], + "blendShapes": [] + }, + + { + "name": "Hair", + "menuPath": "/Misc", + "meshToggles": ["Hair"], + "blendShapes": [] + } + ] +} diff --git a/README.md b/README.md index a6b7537..94cf27f 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,23 @@ # YOTS: yum's optimized toggle system -YOTS is a text-based system for creating and managing VRChat toggles. It solves -two core problems: +YOTS is a text-based system for managing VRChat toggles and sliders. It +translates a single config file into VRChat menus, parameters, animations, and +animator layers. They are added to your avatar non-destructively. -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. +YOTS is efficient, maintainable, non-destructive, and compatible with any +existing animator. yum! -## Real world example +## Installation + +First, install [Modular Avatar](https://modular-avatar.nadena.dev/). + +Then grab the latest release from [the releases page](https://github.com/yum-food/YOTS/releases/latest). + +## Creating your first toggle -Here's a fairly simple animator I made for an avatar I'm working on. You would -write and maintain this config file. YOTS uses it to generate the menus, -parameters, and animator. You hook those into your avatar and upload. +Open your text editor of choice and paste this in: ```json { @@ -24,83 +25,80 @@ parameters, and animator. You hook those into your avatar and upload. "toggles": [ { "name": "Shirt", - "menuPath": "/Clothes", - "dependencies": ["Bra"], "meshToggles": ["Shirt"], - "blendShapes": [ - { - "path": "Body", - "blendShape": "Bra on" - }, - { - "path": "Body", - "blendShape": "Smush nips" - }, - { - "path": "Skirt", - "blendShape": "Shirt off", - "offValue": 100.0, - "onValue": 0.0 - } - ] - }, - { - "name": "Skirt", - "menuPath": "/Clothes", - "meshToggles": ["Skirt"], - "blendShapes": [] - }, - { - "name": "Socks", - "menuPath": "/Clothes", - "meshToggles": ["Socks"], - "blendShapes": [ - { - "path": "Body", - "blendShape": "Thigh squish" - } - ] - }, - { - "name": "Shoes", - "menuPath": "/Clothes", - "meshToggles": ["Shoes"], - "blendShapes": [] - }, - - { - "name": "Bra", - "menuPath": "/NSFW", - "meshToggles": ["Bra"], - "blendShapes": [ - { - "path": "Body", - "blendShape": "Bra on" - }, - { - "path": "Body", - "blendShape": "Smush nips" - } - ] - }, - { - "name": "Panties", - "menuPath": "/NSFW", - "meshToggles": ["Panties"], - "blendShapes": [] - }, - - { - "name": "Hair", - "menuPath": "/Misc", - "meshToggles": ["Hair"], "blendShapes": [] } ] } ``` -## Design overview by example +Feel free to replace "Shirt" with the name of some other mesh on your avatar. + +Save it to Assets/animator.json. + +Drag Assets/yum\_food/YOTS.prefab anywhere on your avatar. Select it in the +hierarchy, and drag Assets/animator.json onto the "JSON config" field. + +Enter play mode. Enable an emulator (I use Lyuma's av3emulator). Open your +menu. You should see a YOTS submenu. Click it, then click Shirt. Your shirt +should toggle off. + +Congratulations! + +A logical sequence of things to try: + +1. Add more toggles. +2. Add blendshape toggles in addition to mesh toggles. +3. Declare a dependency on another toggle with `"dependencies": ["someOtherToggle"]`. +4. Install a toggle at a custom path with `"menuPath": "/my/custom/path"`. +5. Add a radial puppet with `"type": "radial"`. + +Toggle options are documented in two places: + +1. Read the ToggleSpec definition at the top of + [YOTSCore.cs](./Scripts/YOTSCore.cs). This is the definitive source of + truth. +2. Skim the examples under Examples/ + +## Motivation + +Animators have a two fundamental problems: + +1. Layers are extremely slow. Their runtime scales with O(n^2). +2. Animations which affect the same thing cannot be in the same layer. + +Any efficient animator must minimize the number of layers. Any real-world +animator requires multiple layers because some toggles will need to override +other toggles. Thus the goal is to provide this overriding capability while +still minimizing the number of layers. + +In addition, whenever you add a toggle to a VRChat avatar, you need to edit at +least 4 files: + +1. An animation file for the toggle (usually two) +2. The avatar menu +3. The avatar parameters +4. The animator + +With YOTS, you only have to edit one file. + +Finally, there are many unduly tedious tasks which you wind up performing over +the lifetime of an avatar: + +1. Adding new articles of clothing. You now have to edit all your existing + avatar-wide animations to include them. +2. Adding new kinds of toggles or sliders. You may want them to affect a large + set of items. For example: you added a minimum brightness slider, and now + have to animate 20 different articles of clothing. +3. Removing articles of clothing. You should remove them from your avatar-wide + animations. (No one does this because it's a pain in the ass!) +4. Removing toggles or sliders. It's easy to accidentally orphan an animator + layer, or a parameter somewhere. + +These are all vastly easier to perform through a textual configuration system +than through VRChat's native GUI approach. + +## Design derivation 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: @@ -217,7 +215,7 @@ 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. -## Specification language +### Specification language We use JSON to represent the specification. The example above is expressed as follows: @@ -465,13 +463,14 @@ 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. +Unity's built in APIs. -// TODO document this part. For now just look at AnimatorGenerator.cs. +// TODO document this part. For now just look at YOTSCore.cs. We have to generate animations, configs for debug purposes, and finally an animator file. +// TODO document this. Again just look at YOTSCore.cs. ## Extensions diff --git a/Scripts/YOTSCore.cs b/Scripts/YOTSCore.cs new file mode 100644 index 0000000..a7e275a --- /dev/null +++ b/Scripts/YOTSCore.cs @@ -0,0 +1,1059 @@ +#if UNITY_EDITOR + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEngine; +using UnityEditor; +using UnityEditor.Animations; +using VRC.SDK3.Avatars.Components; +using VRC.SDK3.Avatars.ScriptableObjects; + +namespace YOTS +{ + [System.Serializable] + public class ToggleSpec + { + // The name of the toggle. This is plumbed into the menu, the VRChat + // parameters, and the animator parameters. + [SerializeField] + public string name; + + // The type of toggle. + // Accepted values: + // "toggle" - A boolean toggle. Creates a boolean sync param. + // "radial" - A radial puppet. Creates a float sync param. + [SerializeField] + public string type = "toggle"; + + // Dependencies are toggles that will be evaluated before this one. If + // you have two toggles which animate the same thing, one must depend + // on the other. + [SerializeField] + public List dependencies = new List(); + + // The name of meshes to toggle. + // For example, "Body" or "Shirt". + [SerializeField] + public List meshToggles = new List(); + + // Blendshapes to animate. + [SerializeField] + public List blendShapes = new List(); + + // Where to put the toggle in the menu. All toggles are placed under + // /YOTS. So if you put "Clothes" here, it'll be placed under + // /YOTS/Clothes. + [SerializeField] + public string menuPath = "/"; + + // The default value of the toggle. + // For example, if you want a gimmick to start toggled off, set this to + // 0.0f. + [SerializeField] + public float defaultValue = 1.0f; + + 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 + { + // The path to the mesh renderer to apply the blend shape to. + // For example, "Body" or "Shirt". + [SerializeField] + public string path; + + // The name of the blend shape to apply. + // For example, "Chest_Hide" or "Boobs+". + [SerializeField] + public string blendShape; + + // The value of the blendshape when the toggle is off. Range from 0-100. + [SerializeField] + public float offValue = 0.0f; + + // The value of the blendshape when the toggle is on. Range from 0-100. + [SerializeField] + public float onValue = 100.0f; + + public BlendShapeSpec(string path, string blendShape, float offValue = 0, float onValue = 100) + { + this.path = path; + this.blendShape = blendShape; + this.offValue = offValue; + this.onValue = onValue; + } + + 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 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; + } + + [System.Serializable] + public class AnimatorParameterSetting + { + public string name; + public float defaultValue; + } + + public class YOTSCore + { + public static AnimatorController GenerateAnimator( + string configJson, + VRCExpressionParameters vrcParams, + VRCExpressionsMenu vrcMenu + ) + { + Debug.Log("=== Starting Animator Generation Process ==="); + + if (string.IsNullOrEmpty(configJson)) + { + Debug.LogError("No config JSON provided."); + return null; + } + Debug.Log("Parsing JSON configuration"); + + AnimatorConfigFile config; + try + { + config = JsonUtility.FromJson(configJson); + } + catch (System.Exception e) + { + Debug.LogError($"JSON parsing failed: {e.Message}"); + return null; + } + if (config == null) + { + Debug.LogError("JSON config is empty or invalid"); + return null; + } + + if (config.toggles == null) + { + Debug.LogError("No toggleSpecs found in configuration"); + return null; + } + Debug.Log($"Configuration loaded. Found {config.toggles.Count} toggles."); + + GeneratedAnimatorConfig genAnimatorConfig = GenerateNaiveAnimatorConfig(config.toggles); + genAnimatorConfig = ApplyIndependentFixToAnimatorConfig(genAnimatorConfig); + genAnimatorConfig = RemoveOffAnimationsFromOverrideLayers(genAnimatorConfig); + genAnimatorConfig = RemoveUnusedAnimations(genAnimatorConfig); + + // Generate VRChat parameters and menu + GenerateVRChatAssets(config.toggles, vrcParams, vrcMenu); + + // Create the animation clips directly from the animator config + // TODO animations should not be persisted to disk unless requested for debuggability + CreateAnimationClips(new GeneratedAnimationsConfig { animations = genAnimatorConfig.animations }); + + // Generate and return the animator controller + AnimatorController controller = GenerateAnimatorController(genAnimatorConfig); + + Debug.Log("=== Animator Generation Process Complete ==="); + return controller; + } + + private static void CreateAnimationClips(GeneratedAnimationsConfig animationsConfig) + { + // Ensure the directory exists + string baseDir = "Assets/YOTS_generated"; + string animDir = Path.Combine(baseDir, "Animations"); + + if (!Directory.Exists(baseDir)) + Directory.CreateDirectory(baseDir); + if (!Directory.Exists(animDir)) + Directory.CreateDirectory(animDir); + + 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); + } + + // Save the animation clip to the specified directory + string assetPath = Path.Combine(animDir, clipConfig.name + ".anim"); + AssetDatabase.CreateAsset(newClip, assetPath); + Debug.Log("Created/Updated animation clip: " + assetPath); + } + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + } + + private static AnimatorController GenerateAnimatorController(GeneratedAnimatorConfig animatorConfig) + { + AnimatorController controller = new AnimatorController(); + // Add weight parameter used to ensure that the blendtrees always + // run. All layers use this. Documented on vrc.school: + // http://vrc.school/docs/Other/DBT-Combining#ed504c95853f4924adeffb6b125234ad + List parameters_list = new List(); + var yots_weight = new AnimatorControllerParameter(); + yots_weight.name = "YOTS_Weight"; + yots_weight.type = AnimatorControllerParameterType.Float; + yots_weight.defaultFloat = 1.0f; + parameters_list.Add(yots_weight); + // Add all other parameters + foreach (var param in animatorConfig.parameters) + { + var p = new AnimatorControllerParameter(); + p.name = param.name; + p.type = AnimatorControllerParameterType.Float; + p.defaultFloat = param.defaultValue; + parameters_list.Add(p); + } + controller.parameters = parameters_list.ToArray(); + + // Add base layer + var baseLayer = animatorConfig.layers[0]; + var baseStateMachine = new AnimatorStateMachine(); + baseStateMachine.name = "BaseLayer_SM"; + + var rootBlendTree = new BlendTree(); + rootBlendTree.name = "BaseLayer_RootBlendTree"; + rootBlendTree.blendType = BlendTreeType.Direct; + + 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; + + var paramBlendTree = new BlendTree(); + paramBlendTree.name = $"BlendTree_{param}"; + paramBlendTree.blendType = BlendTreeType.Simple1D; + paramBlendTree.blendParameter = param; + + 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 = $"Assets/YOTS_generated/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(); + + rootBlendTree.children = rootBlendTree.children.Append(new ChildMotion + { + motion = paramBlendTree, + timeScale = 1f, + directBlendParameter = "YOTS_Weight" + }).ToArray(); + } + + var baseState = baseStateMachine.AddState("BaseLayer_State"); + baseState.motion = rootBlendTree; + baseState.writeDefaultValues = true; + baseStateMachine.defaultState = baseState; + + controller.AddLayer(new AnimatorControllerLayer + { + name = "YOTS_BaseLayer", + defaultWeight = 1.0f, + stateMachine = baseStateMachine + }); + + // Add override layers + 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"; + + var blendTree = new BlendTree(); + blendTree.name = layerName + "_BlendTree"; + blendTree.blendType = BlendTreeType.Direct; + + foreach (var entry in layerConfig.directBlendTree.entries) + { + string clipPath = $"Assets/YOTS_generated/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(); + } + + 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}"); + } + + return controller; + } + + private static Dictionary TopologicalSortToggles(List toggleSpecs) + { + 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); + } + } + + Dictionary inDegree = new Dictionary(); + foreach (var toggle in toggleSpecs) + { + inDegree[toggle.name] = toggle.dependencies.Count; + } + + Dictionary depths = new Dictionary(); + Queue queue = new Queue(); + + 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; + } + } + } + } + + if (processedNodes != toggleSpecs.Count) + { + 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) + { + GeneratedAnimatorConfig genAnimatorConfig = new GeneratedAnimatorConfig(); + // Sort toggles into layers + Dictionary depths = TopologicalSortToggles(toggleSpecs); + var togglesByDepth = toggleSpecs + .GroupBy(t => depths[t.name]) + .OrderBy(g => g.Key) + .ToList(); + // Add layers + 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.Any(p => p.name == paramName)) + genAnimatorConfig.parameters.Add(new AnimatorParameterSetting + { + name = paramName, + defaultValue = toggle.defaultValue + }); + + 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); + } + // Add animations + GeneratedAnimationsConfig animConfig = GenerateAnimationConfig(toggleSpecs); + genAnimatorConfig.animations = animConfig.animations; + return genAnimatorConfig; + } + + 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 = bs.onValue + }); + } + } + 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 = bs.offValue + }); + } + } + genAnimConfig.animations.Add(offAnim); + } + return genAnimConfig; + } + + private static GeneratedAnimatorConfig ApplyIndependentFixToAnimatorConfig(GeneratedAnimatorConfig genAnimatorConfig) + { + float GetOffValueForMesh(string path, List offList) + { + var offToggle = offList?.FirstOrDefault(mt => mt.path == path); + return offToggle != null ? offToggle.value : 0.0f; + } + + float GetOffValueForBlend(string path, string blendShapeName, List offList) + { + var offBlend = offList?.FirstOrDefault(bs => bs.path == path && bs.blendShape == blendShapeName); + return offBlend != null ? offBlend.value : 0.0f; + } + + 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; + } + } + + 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; + 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; + } + } + + Dictionary> attributeToToggles = new Dictionary>(); + foreach (var kvp in toggleAnimations) + { + string toggleName = kvp.Key; + var pair = kvp.Value; + if (pair.on == null) continue; + + 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); + } + } + + List newAnimations = new List(); + + AnimatorLayer baseLayer = genAnimatorConfig.layers.FirstOrDefault(l => l.name == "BaseLayer"); + if (baseLayer == null && genAnimatorConfig.layers.Count > 0) + baseLayer = genAnimatorConfig.layers[0]; + + foreach (var kvp in toggleAnimations) + { + string toggleName = kvp.Key; + var pair = kvp.Value; + int layerIndex = toggleToLayerIndex.ContainsKey(toggleName) ? toggleToLayerIndex[toggleName] : 0; + bool isBase = (layerIndex == 0); + + if (isBase) + { + newAnimations.Add(pair.on); + newAnimations.Add(pair.off); + } + else + { + 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 (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) + { + GeneratedAnimationClipConfig dependentOn = new GeneratedAnimationClipConfig(); + dependentOn.name = toggleName + "_Dependent_On"; + dependentOn.meshToggles = dependentMesh; + dependentOn.blendShapes = dependentBlend; + + GeneratedAnimationClipConfig dependentOff = new GeneratedAnimationClipConfig(); + dependentOff.name = toggleName + "_Dependent_Off"; + dependentOff.meshToggles = dependentMesh + .Select(mt => new GeneratedMeshToggle { + path = mt.path, + value = GetOffValueForMesh(mt.path, pair.off.meshToggles) + }) + .ToList(); + dependentOff.blendShapes = dependentBlend + .Select(bs => new GeneratedBlendShape { + path = bs.path, + blendShape = bs.blendShape, + value = GetOffValueForBlend(bs.path, bs.blendShape, pair.off.blendShapes) + }) + .ToList(); + + 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 = GetOffValueForMesh(mt.path, pair.off.meshToggles) + }) + .ToList(); + independentOff.blendShapes = independentBlend + .Select(bs => new GeneratedBlendShape { + path = bs.path, + blendShape = bs.blendShape, + value = GetOffValueForBlend(bs.path, bs.blendShape, pair.off.blendShapes) + }) + .ToList(); + + newAnimations.Add(dependentOn); + newAnimations.Add(dependentOff); + newAnimations.Add(independentOn); + newAnimations.Add(independentOff); + + 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"))) + { + entry.name = entry.name.EndsWith("_On") ? toggleName + "_Dependent_On" : toggleName + "_Dependent_Off"; + } + } + + 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) + { + 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); + + AnimatorLayer overrideLayer = genAnimatorConfig.layers[layerIndex]; + overrideLayer.directBlendTree.entries.RemoveAll(e => e.name.StartsWith(toggleName)); + 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) + { + 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); + + 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"))) + { + entry.name = entry.name.EndsWith("_On") ? toggleName + "_Dependent_On" : toggleName + "_Dependent_Off"; + } + } + } + } + } + + 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) + { + HashSet referencedAnimations = new HashSet(); + foreach (var layer in config.layers) + { + foreach (var entry in layer.directBlendTree.entries) + referencedAnimations.Add(entry.name); + } + + config.animations = config.animations + .Where(anim => referencedAnimations.Contains(anim.name)) + .ToList(); + + return config; + } + + private static VRCExpressionsMenu GetOrCreateSubmenu( + VRCExpressionsMenu parentMenu, + string submenuName + ) + { + if (parentMenu.controls == null) + parentMenu.controls = new List(); + + // Check if submenu already exists + foreach (var control in parentMenu.controls) + { + if (control.type == VRCExpressionsMenu.Control.ControlType.SubMenu && + control.name == submenuName && control.subMenu != null) + { + // Clone existing submenu to avoid modifying original + var clonedSubmenu = UnityEngine.Object.Instantiate(control.subMenu); + control.subMenu = clonedSubmenu; + return clonedSubmenu; + } + } + + // Create new submenu + var newSubmenu = ScriptableObject.CreateInstance(); + newSubmenu.name = submenuName; + newSubmenu.controls = new List(); + + var newControl = new VRCExpressionsMenu.Control + { + name = submenuName, + type = VRCExpressionsMenu.Control.ControlType.SubMenu, + subMenu = newSubmenu + }; + parentMenu.controls.Add(newControl); + + return newSubmenu; + } + + private static void InitializeSubmenu(VRCExpressionsMenu menu) + { + if (menu == null) return; + + if (menu.controls != null) + { + foreach (var control in menu.controls) + { + if (control.type == VRCExpressionsMenu.Control.ControlType.SubMenu && control.subMenu != null) + InitializeSubmenu(control.subMenu); + } + menu.controls.Clear(); + } + else + { + menu.controls = new List(); + } + } + + private static void GenerateVRChatAssets( + List toggleSpecs, + VRCExpressionParameters vrcParams, + VRCExpressionsMenu vrcMenu + ) + { + var uniqueToggles = toggleSpecs + .Where(t => t.name != "YOTS_Weight") + .GroupBy(t => t.name) + .Select(g => g.First()) + .ToList(); + + var paramList = new List(); + paramList.AddRange(vrcParams.parameters.Where(p => !uniqueToggles.Any(t => t.name == p.name))); + foreach (var toggle in uniqueToggles) + { + paramList.Add(new VRCExpressionParameters.Parameter + { + name = toggle.name, + valueType = toggle.type == "radial" ? VRCExpressionParameters.ValueType.Float : VRCExpressionParameters.ValueType.Bool, + defaultValue = toggle.defaultValue, + saved = true + }); + } + vrcParams.parameters = paramList.ToArray(); + vrcMenu.controls.RemoveAll(c => c.name == "YOTS"); + // Create YOTS submenu + VRCExpressionsMenu yotsSubmenu = ScriptableObject.CreateInstance(); + yotsSubmenu.name = "YOTS"; + yotsSubmenu.controls = new List(); + // Track all created/modified menus to ensure they're saved + HashSet modifiedMenus = new HashSet { vrcMenu, yotsSubmenu }; + foreach (var toggle in toggleSpecs) + { + VRCExpressionsMenu currentMenu = yotsSubmenu; + if (!string.IsNullOrEmpty(toggle.menuPath) && toggle.menuPath != "/") + { + string trimmedPath = toggle.menuPath.Trim('/'); + var sections = trimmedPath.Split('/'); + foreach (var section in sections) + { + currentMenu = GetOrCreateSubmenu(currentMenu, section); + modifiedMenus.Add(currentMenu); + } + } + // Add toggle controls + if (toggle.type == "radial") + { + currentMenu.controls.Add(new VRCExpressionsMenu.Control + { + name = toggle.name, + type = VRCExpressionsMenu.Control.ControlType.RadialPuppet, + subParameters = new VRCExpressionsMenu.Control.Parameter[] { + new VRCExpressionsMenu.Control.Parameter { name = toggle.name } + } + }); + } + else + { + currentMenu.controls.Add(new VRCExpressionsMenu.Control + { + name = toggle.name, + type = VRCExpressionsMenu.Control.ControlType.Toggle, + parameter = new VRCExpressionsMenu.Control.Parameter { name = toggle.name }, + value = 1f + }); + } + } + + // Add YOTS submenu to main menu + vrcMenu.controls.Add(new VRCExpressionsMenu.Control + { + name = "YOTS", + type = VRCExpressionsMenu.Control.ControlType.SubMenu, + subMenu = yotsSubmenu + }); + } + } +} + +#endif // UNITY_EDITOR diff --git a/Scripts/YOTSNDMFConfig.cs b/Scripts/YOTSNDMFConfig.cs new file mode 100644 index 0000000..0609d18 --- /dev/null +++ b/Scripts/YOTSNDMFConfig.cs @@ -0,0 +1,22 @@ +#if UNITY_EDITOR + +using UnityEngine; +using nadena.dev.ndmf; +using VRC.SDK3.Avatars.ScriptableObjects; + +namespace YOTS +{ + [DisallowMultipleComponent] + public class YOTSNDMFConfig : MonoBehaviour + { + [Tooltip("The JSON configuration file.")] + public TextAsset jsonConfig; + + void OnValidate() + { + gameObject.tag = "EditorOnly"; + } + } +} + +#endif // UNITY_EDITOR diff --git a/Scripts/YOTSNDMFGenerator.cs b/Scripts/YOTSNDMFGenerator.cs new file mode 100644 index 0000000..e6e0585 --- /dev/null +++ b/Scripts/YOTSNDMFGenerator.cs @@ -0,0 +1,111 @@ +#if UNITY_EDITOR + +using UnityEngine; +using nadena.dev.ndmf; +using nadena.dev.ndmf.builtin; +using nadena.dev.ndmf.localization; +using nadena.dev.ndmf.VRChat; +using VRC.SDK3.Avatars.ScriptableObjects; +using UnityEditor; +using System; +using System.Collections.Generic; + +[assembly: ExportsPlugin(typeof(YOTS.YOTSNDMFGenerator))] + +namespace YOTS +{ + public class YOTSNDMFGenerator : Plugin + { + private readonly Localizer localizer = new Localizer("en-us", () => new List<(string, Func)> + { + ("en-us", key => key) // Simple pass-through for English + }); + + public override string DisplayName => "YOTS Animator Generator"; + + protected override void Configure() + { + // First pass: Store config data + InPhase(BuildPhase.Resolving) + .Run("Cache YOTS Config", ctx => { + var config = ctx.AvatarRootObject.GetComponentInChildren(); + if (config == null || config.jsonConfig == null) { + ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.no_config", + "No YOTS config found on the avatar."); + return; + } + ctx.GetState().jsonConfig = config.jsonConfig.text; + }) + // Shoutsout anatawa12/AvatarOptimizer + .BeforePass(RemoveEditorOnlyPass.Instance); + + // Second pass: Generate animator + InPhase(BuildPhase.Transforming) + .Run("Generate YOTS Animator", ctx => { + var state = ctx.GetState(); + if (string.IsNullOrEmpty(state.jsonConfig)) { + ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.no_config", + "No YOTS config found on the avatar."); + return; + } + // Get config + var config = ctx.GetState(); + if (config == null) { + ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.no_config", + "No YOTS config component found on the avatar."); + return; + } + if (config.jsonConfig == null) { + ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.no_json", + "YOTS config component is missing JSON config file."); + return; + } + // Get descriptor + var descriptor = ctx.AvatarDescriptor; + if (descriptor == null) { + ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.no_descriptor", + "Avatar descriptor is missing."); + return; + } + RuntimeAnimatorController animator = descriptor.baseAnimationLayers[4].animatorController; + if (animator == null) { + ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.no_animator", + "FX layer is missing."); + return; + } + var menu = descriptor.expressionsMenu; + var parameters = descriptor.expressionParameters; + if (parameters == null || menu == null) + { + ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.missing_assets", + "Avatar parameters or menu is missing."); + return; + } + menu = UnityEngine.Object.Instantiate(menu); + parameters = UnityEngine.Object.Instantiate(parameters); + descriptor.expressionsMenu = menu; + descriptor.expressionParameters = parameters; + RuntimeAnimatorController generatedAnimator = YOTSCore.GenerateAnimator( + state.jsonConfig, + parameters, + menu + ); + if (generatedAnimator == null) { + ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.generation_failed", + "Failed to generate animator controller."); + return; + } + // TODO merge animators + descriptor.baseAnimationLayers[4].animatorController = generatedAnimator; + } + ); + } + + private class YOTSBuildState + { + public string jsonConfig; + } + } +} + +#endif // UNITY_EDITOR diff --git a/YOTS.prefab b/YOTS.prefab new file mode 100644 index 0000000..7e296dc --- /dev/null +++ b/YOTS.prefab @@ -0,0 +1,47 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &2194070440052669926 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 371625702662608969} + - component: {fileID: 386227433558459277} + m_Layer: 0 + m_Name: YOTS + m_TagString: EditorOnly + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &371625702662608969 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2194070440052669926} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + 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!114 &386227433558459277 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2194070440052669926} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fc2ca698e5be6e446861920d8c4e4462, type: 3} + m_Name: + m_EditorClassIdentifier: + jsonConfig: {fileID: 4900000, guid: 7bfaea61dd9889641977c91a93757fce, type: 3} diff --git a/YOTSCore.cs b/YOTSCore.cs deleted file mode 100644 index a7e275a..0000000 --- a/YOTSCore.cs +++ /dev/null @@ -1,1059 +0,0 @@ -#if UNITY_EDITOR - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using UnityEngine; -using UnityEditor; -using UnityEditor.Animations; -using VRC.SDK3.Avatars.Components; -using VRC.SDK3.Avatars.ScriptableObjects; - -namespace YOTS -{ - [System.Serializable] - public class ToggleSpec - { - // The name of the toggle. This is plumbed into the menu, the VRChat - // parameters, and the animator parameters. - [SerializeField] - public string name; - - // The type of toggle. - // Accepted values: - // "toggle" - A boolean toggle. Creates a boolean sync param. - // "radial" - A radial puppet. Creates a float sync param. - [SerializeField] - public string type = "toggle"; - - // Dependencies are toggles that will be evaluated before this one. If - // you have two toggles which animate the same thing, one must depend - // on the other. - [SerializeField] - public List dependencies = new List(); - - // The name of meshes to toggle. - // For example, "Body" or "Shirt". - [SerializeField] - public List meshToggles = new List(); - - // Blendshapes to animate. - [SerializeField] - public List blendShapes = new List(); - - // Where to put the toggle in the menu. All toggles are placed under - // /YOTS. So if you put "Clothes" here, it'll be placed under - // /YOTS/Clothes. - [SerializeField] - public string menuPath = "/"; - - // The default value of the toggle. - // For example, if you want a gimmick to start toggled off, set this to - // 0.0f. - [SerializeField] - public float defaultValue = 1.0f; - - 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 - { - // The path to the mesh renderer to apply the blend shape to. - // For example, "Body" or "Shirt". - [SerializeField] - public string path; - - // The name of the blend shape to apply. - // For example, "Chest_Hide" or "Boobs+". - [SerializeField] - public string blendShape; - - // The value of the blendshape when the toggle is off. Range from 0-100. - [SerializeField] - public float offValue = 0.0f; - - // The value of the blendshape when the toggle is on. Range from 0-100. - [SerializeField] - public float onValue = 100.0f; - - public BlendShapeSpec(string path, string blendShape, float offValue = 0, float onValue = 100) - { - this.path = path; - this.blendShape = blendShape; - this.offValue = offValue; - this.onValue = onValue; - } - - 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 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; - } - - [System.Serializable] - public class AnimatorParameterSetting - { - public string name; - public float defaultValue; - } - - public class YOTSCore - { - public static AnimatorController GenerateAnimator( - string configJson, - VRCExpressionParameters vrcParams, - VRCExpressionsMenu vrcMenu - ) - { - Debug.Log("=== Starting Animator Generation Process ==="); - - if (string.IsNullOrEmpty(configJson)) - { - Debug.LogError("No config JSON provided."); - return null; - } - Debug.Log("Parsing JSON configuration"); - - AnimatorConfigFile config; - try - { - config = JsonUtility.FromJson(configJson); - } - catch (System.Exception e) - { - Debug.LogError($"JSON parsing failed: {e.Message}"); - return null; - } - if (config == null) - { - Debug.LogError("JSON config is empty or invalid"); - return null; - } - - if (config.toggles == null) - { - Debug.LogError("No toggleSpecs found in configuration"); - return null; - } - Debug.Log($"Configuration loaded. Found {config.toggles.Count} toggles."); - - GeneratedAnimatorConfig genAnimatorConfig = GenerateNaiveAnimatorConfig(config.toggles); - genAnimatorConfig = ApplyIndependentFixToAnimatorConfig(genAnimatorConfig); - genAnimatorConfig = RemoveOffAnimationsFromOverrideLayers(genAnimatorConfig); - genAnimatorConfig = RemoveUnusedAnimations(genAnimatorConfig); - - // Generate VRChat parameters and menu - GenerateVRChatAssets(config.toggles, vrcParams, vrcMenu); - - // Create the animation clips directly from the animator config - // TODO animations should not be persisted to disk unless requested for debuggability - CreateAnimationClips(new GeneratedAnimationsConfig { animations = genAnimatorConfig.animations }); - - // Generate and return the animator controller - AnimatorController controller = GenerateAnimatorController(genAnimatorConfig); - - Debug.Log("=== Animator Generation Process Complete ==="); - return controller; - } - - private static void CreateAnimationClips(GeneratedAnimationsConfig animationsConfig) - { - // Ensure the directory exists - string baseDir = "Assets/YOTS_generated"; - string animDir = Path.Combine(baseDir, "Animations"); - - if (!Directory.Exists(baseDir)) - Directory.CreateDirectory(baseDir); - if (!Directory.Exists(animDir)) - Directory.CreateDirectory(animDir); - - 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); - } - - // Save the animation clip to the specified directory - string assetPath = Path.Combine(animDir, clipConfig.name + ".anim"); - AssetDatabase.CreateAsset(newClip, assetPath); - Debug.Log("Created/Updated animation clip: " + assetPath); - } - - AssetDatabase.SaveAssets(); - AssetDatabase.Refresh(); - } - - private static AnimatorController GenerateAnimatorController(GeneratedAnimatorConfig animatorConfig) - { - AnimatorController controller = new AnimatorController(); - // Add weight parameter used to ensure that the blendtrees always - // run. All layers use this. Documented on vrc.school: - // http://vrc.school/docs/Other/DBT-Combining#ed504c95853f4924adeffb6b125234ad - List parameters_list = new List(); - var yots_weight = new AnimatorControllerParameter(); - yots_weight.name = "YOTS_Weight"; - yots_weight.type = AnimatorControllerParameterType.Float; - yots_weight.defaultFloat = 1.0f; - parameters_list.Add(yots_weight); - // Add all other parameters - foreach (var param in animatorConfig.parameters) - { - var p = new AnimatorControllerParameter(); - p.name = param.name; - p.type = AnimatorControllerParameterType.Float; - p.defaultFloat = param.defaultValue; - parameters_list.Add(p); - } - controller.parameters = parameters_list.ToArray(); - - // Add base layer - var baseLayer = animatorConfig.layers[0]; - var baseStateMachine = new AnimatorStateMachine(); - baseStateMachine.name = "BaseLayer_SM"; - - var rootBlendTree = new BlendTree(); - rootBlendTree.name = "BaseLayer_RootBlendTree"; - rootBlendTree.blendType = BlendTreeType.Direct; - - 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; - - var paramBlendTree = new BlendTree(); - paramBlendTree.name = $"BlendTree_{param}"; - paramBlendTree.blendType = BlendTreeType.Simple1D; - paramBlendTree.blendParameter = param; - - 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 = $"Assets/YOTS_generated/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(); - - rootBlendTree.children = rootBlendTree.children.Append(new ChildMotion - { - motion = paramBlendTree, - timeScale = 1f, - directBlendParameter = "YOTS_Weight" - }).ToArray(); - } - - var baseState = baseStateMachine.AddState("BaseLayer_State"); - baseState.motion = rootBlendTree; - baseState.writeDefaultValues = true; - baseStateMachine.defaultState = baseState; - - controller.AddLayer(new AnimatorControllerLayer - { - name = "YOTS_BaseLayer", - defaultWeight = 1.0f, - stateMachine = baseStateMachine - }); - - // Add override layers - 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"; - - var blendTree = new BlendTree(); - blendTree.name = layerName + "_BlendTree"; - blendTree.blendType = BlendTreeType.Direct; - - foreach (var entry in layerConfig.directBlendTree.entries) - { - string clipPath = $"Assets/YOTS_generated/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(); - } - - 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}"); - } - - return controller; - } - - private static Dictionary TopologicalSortToggles(List toggleSpecs) - { - 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); - } - } - - Dictionary inDegree = new Dictionary(); - foreach (var toggle in toggleSpecs) - { - inDegree[toggle.name] = toggle.dependencies.Count; - } - - Dictionary depths = new Dictionary(); - Queue queue = new Queue(); - - 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; - } - } - } - } - - if (processedNodes != toggleSpecs.Count) - { - 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) - { - GeneratedAnimatorConfig genAnimatorConfig = new GeneratedAnimatorConfig(); - // Sort toggles into layers - Dictionary depths = TopologicalSortToggles(toggleSpecs); - var togglesByDepth = toggleSpecs - .GroupBy(t => depths[t.name]) - .OrderBy(g => g.Key) - .ToList(); - // Add layers - 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.Any(p => p.name == paramName)) - genAnimatorConfig.parameters.Add(new AnimatorParameterSetting - { - name = paramName, - defaultValue = toggle.defaultValue - }); - - 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); - } - // Add animations - GeneratedAnimationsConfig animConfig = GenerateAnimationConfig(toggleSpecs); - genAnimatorConfig.animations = animConfig.animations; - return genAnimatorConfig; - } - - 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 = bs.onValue - }); - } - } - 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 = bs.offValue - }); - } - } - genAnimConfig.animations.Add(offAnim); - } - return genAnimConfig; - } - - private static GeneratedAnimatorConfig ApplyIndependentFixToAnimatorConfig(GeneratedAnimatorConfig genAnimatorConfig) - { - float GetOffValueForMesh(string path, List offList) - { - var offToggle = offList?.FirstOrDefault(mt => mt.path == path); - return offToggle != null ? offToggle.value : 0.0f; - } - - float GetOffValueForBlend(string path, string blendShapeName, List offList) - { - var offBlend = offList?.FirstOrDefault(bs => bs.path == path && bs.blendShape == blendShapeName); - return offBlend != null ? offBlend.value : 0.0f; - } - - 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; - } - } - - 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; - 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; - } - } - - Dictionary> attributeToToggles = new Dictionary>(); - foreach (var kvp in toggleAnimations) - { - string toggleName = kvp.Key; - var pair = kvp.Value; - if (pair.on == null) continue; - - 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); - } - } - - List newAnimations = new List(); - - AnimatorLayer baseLayer = genAnimatorConfig.layers.FirstOrDefault(l => l.name == "BaseLayer"); - if (baseLayer == null && genAnimatorConfig.layers.Count > 0) - baseLayer = genAnimatorConfig.layers[0]; - - foreach (var kvp in toggleAnimations) - { - string toggleName = kvp.Key; - var pair = kvp.Value; - int layerIndex = toggleToLayerIndex.ContainsKey(toggleName) ? toggleToLayerIndex[toggleName] : 0; - bool isBase = (layerIndex == 0); - - if (isBase) - { - newAnimations.Add(pair.on); - newAnimations.Add(pair.off); - } - else - { - 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 (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) - { - GeneratedAnimationClipConfig dependentOn = new GeneratedAnimationClipConfig(); - dependentOn.name = toggleName + "_Dependent_On"; - dependentOn.meshToggles = dependentMesh; - dependentOn.blendShapes = dependentBlend; - - GeneratedAnimationClipConfig dependentOff = new GeneratedAnimationClipConfig(); - dependentOff.name = toggleName + "_Dependent_Off"; - dependentOff.meshToggles = dependentMesh - .Select(mt => new GeneratedMeshToggle { - path = mt.path, - value = GetOffValueForMesh(mt.path, pair.off.meshToggles) - }) - .ToList(); - dependentOff.blendShapes = dependentBlend - .Select(bs => new GeneratedBlendShape { - path = bs.path, - blendShape = bs.blendShape, - value = GetOffValueForBlend(bs.path, bs.blendShape, pair.off.blendShapes) - }) - .ToList(); - - 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 = GetOffValueForMesh(mt.path, pair.off.meshToggles) - }) - .ToList(); - independentOff.blendShapes = independentBlend - .Select(bs => new GeneratedBlendShape { - path = bs.path, - blendShape = bs.blendShape, - value = GetOffValueForBlend(bs.path, bs.blendShape, pair.off.blendShapes) - }) - .ToList(); - - newAnimations.Add(dependentOn); - newAnimations.Add(dependentOff); - newAnimations.Add(independentOn); - newAnimations.Add(independentOff); - - 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"))) - { - entry.name = entry.name.EndsWith("_On") ? toggleName + "_Dependent_On" : toggleName + "_Dependent_Off"; - } - } - - 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) - { - 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); - - AnimatorLayer overrideLayer = genAnimatorConfig.layers[layerIndex]; - overrideLayer.directBlendTree.entries.RemoveAll(e => e.name.StartsWith(toggleName)); - 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) - { - 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); - - 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"))) - { - entry.name = entry.name.EndsWith("_On") ? toggleName + "_Dependent_On" : toggleName + "_Dependent_Off"; - } - } - } - } - } - - 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) - { - HashSet referencedAnimations = new HashSet(); - foreach (var layer in config.layers) - { - foreach (var entry in layer.directBlendTree.entries) - referencedAnimations.Add(entry.name); - } - - config.animations = config.animations - .Where(anim => referencedAnimations.Contains(anim.name)) - .ToList(); - - return config; - } - - private static VRCExpressionsMenu GetOrCreateSubmenu( - VRCExpressionsMenu parentMenu, - string submenuName - ) - { - if (parentMenu.controls == null) - parentMenu.controls = new List(); - - // Check if submenu already exists - foreach (var control in parentMenu.controls) - { - if (control.type == VRCExpressionsMenu.Control.ControlType.SubMenu && - control.name == submenuName && control.subMenu != null) - { - // Clone existing submenu to avoid modifying original - var clonedSubmenu = UnityEngine.Object.Instantiate(control.subMenu); - control.subMenu = clonedSubmenu; - return clonedSubmenu; - } - } - - // Create new submenu - var newSubmenu = ScriptableObject.CreateInstance(); - newSubmenu.name = submenuName; - newSubmenu.controls = new List(); - - var newControl = new VRCExpressionsMenu.Control - { - name = submenuName, - type = VRCExpressionsMenu.Control.ControlType.SubMenu, - subMenu = newSubmenu - }; - parentMenu.controls.Add(newControl); - - return newSubmenu; - } - - private static void InitializeSubmenu(VRCExpressionsMenu menu) - { - if (menu == null) return; - - if (menu.controls != null) - { - foreach (var control in menu.controls) - { - if (control.type == VRCExpressionsMenu.Control.ControlType.SubMenu && control.subMenu != null) - InitializeSubmenu(control.subMenu); - } - menu.controls.Clear(); - } - else - { - menu.controls = new List(); - } - } - - private static void GenerateVRChatAssets( - List toggleSpecs, - VRCExpressionParameters vrcParams, - VRCExpressionsMenu vrcMenu - ) - { - var uniqueToggles = toggleSpecs - .Where(t => t.name != "YOTS_Weight") - .GroupBy(t => t.name) - .Select(g => g.First()) - .ToList(); - - var paramList = new List(); - paramList.AddRange(vrcParams.parameters.Where(p => !uniqueToggles.Any(t => t.name == p.name))); - foreach (var toggle in uniqueToggles) - { - paramList.Add(new VRCExpressionParameters.Parameter - { - name = toggle.name, - valueType = toggle.type == "radial" ? VRCExpressionParameters.ValueType.Float : VRCExpressionParameters.ValueType.Bool, - defaultValue = toggle.defaultValue, - saved = true - }); - } - vrcParams.parameters = paramList.ToArray(); - vrcMenu.controls.RemoveAll(c => c.name == "YOTS"); - // Create YOTS submenu - VRCExpressionsMenu yotsSubmenu = ScriptableObject.CreateInstance(); - yotsSubmenu.name = "YOTS"; - yotsSubmenu.controls = new List(); - // Track all created/modified menus to ensure they're saved - HashSet modifiedMenus = new HashSet { vrcMenu, yotsSubmenu }; - foreach (var toggle in toggleSpecs) - { - VRCExpressionsMenu currentMenu = yotsSubmenu; - if (!string.IsNullOrEmpty(toggle.menuPath) && toggle.menuPath != "/") - { - string trimmedPath = toggle.menuPath.Trim('/'); - var sections = trimmedPath.Split('/'); - foreach (var section in sections) - { - currentMenu = GetOrCreateSubmenu(currentMenu, section); - modifiedMenus.Add(currentMenu); - } - } - // Add toggle controls - if (toggle.type == "radial") - { - currentMenu.controls.Add(new VRCExpressionsMenu.Control - { - name = toggle.name, - type = VRCExpressionsMenu.Control.ControlType.RadialPuppet, - subParameters = new VRCExpressionsMenu.Control.Parameter[] { - new VRCExpressionsMenu.Control.Parameter { name = toggle.name } - } - }); - } - else - { - currentMenu.controls.Add(new VRCExpressionsMenu.Control - { - name = toggle.name, - type = VRCExpressionsMenu.Control.ControlType.Toggle, - parameter = new VRCExpressionsMenu.Control.Parameter { name = toggle.name }, - value = 1f - }); - } - } - - // Add YOTS submenu to main menu - vrcMenu.controls.Add(new VRCExpressionsMenu.Control - { - name = "YOTS", - type = VRCExpressionsMenu.Control.ControlType.SubMenu, - subMenu = yotsSubmenu - }); - } - } -} - -#endif // UNITY_EDITOR diff --git a/YOTSNDMFConfig.cs b/YOTSNDMFConfig.cs deleted file mode 100644 index 0609d18..0000000 --- a/YOTSNDMFConfig.cs +++ /dev/null @@ -1,22 +0,0 @@ -#if UNITY_EDITOR - -using UnityEngine; -using nadena.dev.ndmf; -using VRC.SDK3.Avatars.ScriptableObjects; - -namespace YOTS -{ - [DisallowMultipleComponent] - public class YOTSNDMFConfig : MonoBehaviour - { - [Tooltip("The JSON configuration file.")] - public TextAsset jsonConfig; - - void OnValidate() - { - gameObject.tag = "EditorOnly"; - } - } -} - -#endif // UNITY_EDITOR diff --git a/YOTSNDMFGenerator.cs b/YOTSNDMFGenerator.cs deleted file mode 100644 index e6e0585..0000000 --- a/YOTSNDMFGenerator.cs +++ /dev/null @@ -1,111 +0,0 @@ -#if UNITY_EDITOR - -using UnityEngine; -using nadena.dev.ndmf; -using nadena.dev.ndmf.builtin; -using nadena.dev.ndmf.localization; -using nadena.dev.ndmf.VRChat; -using VRC.SDK3.Avatars.ScriptableObjects; -using UnityEditor; -using System; -using System.Collections.Generic; - -[assembly: ExportsPlugin(typeof(YOTS.YOTSNDMFGenerator))] - -namespace YOTS -{ - public class YOTSNDMFGenerator : Plugin - { - private readonly Localizer localizer = new Localizer("en-us", () => new List<(string, Func)> - { - ("en-us", key => key) // Simple pass-through for English - }); - - public override string DisplayName => "YOTS Animator Generator"; - - protected override void Configure() - { - // First pass: Store config data - InPhase(BuildPhase.Resolving) - .Run("Cache YOTS Config", ctx => { - var config = ctx.AvatarRootObject.GetComponentInChildren(); - if (config == null || config.jsonConfig == null) { - ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.no_config", - "No YOTS config found on the avatar."); - return; - } - ctx.GetState().jsonConfig = config.jsonConfig.text; - }) - // Shoutsout anatawa12/AvatarOptimizer - .BeforePass(RemoveEditorOnlyPass.Instance); - - // Second pass: Generate animator - InPhase(BuildPhase.Transforming) - .Run("Generate YOTS Animator", ctx => { - var state = ctx.GetState(); - if (string.IsNullOrEmpty(state.jsonConfig)) { - ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.no_config", - "No YOTS config found on the avatar."); - return; - } - // Get config - var config = ctx.GetState(); - if (config == null) { - ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.no_config", - "No YOTS config component found on the avatar."); - return; - } - if (config.jsonConfig == null) { - ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.no_json", - "YOTS config component is missing JSON config file."); - return; - } - // Get descriptor - var descriptor = ctx.AvatarDescriptor; - if (descriptor == null) { - ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.no_descriptor", - "Avatar descriptor is missing."); - return; - } - RuntimeAnimatorController animator = descriptor.baseAnimationLayers[4].animatorController; - if (animator == null) { - ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.no_animator", - "FX layer is missing."); - return; - } - var menu = descriptor.expressionsMenu; - var parameters = descriptor.expressionParameters; - if (parameters == null || menu == null) - { - ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.missing_assets", - "Avatar parameters or menu is missing."); - return; - } - menu = UnityEngine.Object.Instantiate(menu); - parameters = UnityEngine.Object.Instantiate(parameters); - descriptor.expressionsMenu = menu; - descriptor.expressionParameters = parameters; - RuntimeAnimatorController generatedAnimator = YOTSCore.GenerateAnimator( - state.jsonConfig, - parameters, - menu - ); - if (generatedAnimator == null) { - ErrorReport.ReportError(localizer, ErrorSeverity.Error, "yots.error.generation_failed", - "Failed to generate animator controller."); - return; - } - // TODO merge animators - descriptor.baseAnimationLayers[4].animatorController = generatedAnimator; - } - ); - } - - private class YOTSBuildState - { - public string jsonConfig; - } - } -} - -#endif // UNITY_EDITOR -- cgit v1.2.3