From 3c7d7df216fe1537362b19520b304cc6e5d856a6 Mon Sep 17 00:00:00 2001 From: yum Date: Wed, 19 Feb 2025 03:26:58 -0800 Subject: organize, update readme --- Scripts/YOTSCore.cs | 1059 ++++++++++++++++++++++++++++++++++++++++++ Scripts/YOTSNDMFConfig.cs | 22 + Scripts/YOTSNDMFGenerator.cs | 111 +++++ 3 files changed, 1192 insertions(+) create mode 100644 Scripts/YOTSCore.cs create mode 100644 Scripts/YOTSNDMFConfig.cs create mode 100644 Scripts/YOTSNDMFGenerator.cs (limited to 'Scripts') 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 -- cgit v1.2.3