Add blendshape sync component

This commit is contained in:
bd_ 2022-10-19 19:42:33 -07:00
parent 7e2534ebe3
commit 232d0c43bc
18 changed files with 660 additions and 65 deletions

View File

@ -114,6 +114,7 @@ namespace net.fushizen.modular_avatar.core.editor
new RetargetMeshes().OnPreprocessAvatar(avatarGameObject);
new BoneProxyProcessor().OnPreprocessAvatar(avatarGameObject);
new MergeAnimatorProcessor().OnPreprocessAvatar(avatarGameObject);
new BlendshapeSyncAnimationProcessor().OnPreprocessAvatar(avatarGameObject);
AfterProcessing?.Invoke(avatarGameObject);

View File

@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
using VRC.SDK3.Avatars.Components;
using Object = UnityEngine.Object;
namespace net.fushizen.modular_avatar.core.editor
{
/**
* Ensures that any blendshapes marked for syncing by BlendshapeSync propagate values in all animation clips.
*
* Note that we only look at the FX layer, as any other layer won't work properly with mirror reflections anyway.
*/
internal class BlendshapeSyncAnimationProcessor
{
private Object _container;
private Dictionary<Motion, Motion> _motionCache;
private Dictionary<SummaryBinding, List<SummaryBinding>> _bindingMappings;
private struct SummaryBinding
{
private const string PREFIX = "blendShape.";
public string path;
public string propertyName;
public SummaryBinding(string path, string blendShape)
{
this.path = path;
this.propertyName = PREFIX + blendShape;
}
public static SummaryBinding FromEditorBinding(EditorCurveBinding binding)
{
if (binding.type != typeof(SkinnedMeshRenderer) || !binding.propertyName.StartsWith(PREFIX))
{
return new SummaryBinding();
}
return new SummaryBinding(binding.path, binding.propertyName.Substring(PREFIX.Length));
}
}
public void OnPreprocessAvatar(GameObject avatar)
{
var avatarDescriptor = avatar.GetComponent<VRCAvatarDescriptor>();
_bindingMappings = new Dictionary<SummaryBinding, List<SummaryBinding>>();
_motionCache = new Dictionary<Motion, Motion>();
var components = avatarDescriptor.GetComponentsInChildren<ModularAvatarBlendshapeSync>(true);
if (components.Length == 0) return;
var layers = avatarDescriptor.baseAnimationLayers;
var fxIndex = -1;
AnimatorController controller = null;
for (int i = 0; i < layers.Length; i++)
{
if (layers[i].type == VRCAvatarDescriptor.AnimLayerType.FX && !layers[i].isDefault)
{
if (layers[i].animatorController is AnimatorController c && c != null)
{
fxIndex = i;
controller = c;
break;
}
}
}
if (controller == null)
{
// Nothing to do, return
}
foreach (var component in components)
{
var targetObj = RuntimeUtil.RelativePath(avatarDescriptor.gameObject, component.gameObject);
foreach (var binding in component.Bindings)
{
var refObj = binding.ReferenceMesh.Get(component);
if (refObj == null) continue;
var refSmr = refObj.GetComponent<SkinnedMeshRenderer>();
if (refSmr == null) continue;
var refPath = RuntimeUtil.RelativePath(avatarDescriptor.gameObject, refObj);
var srcBinding = new SummaryBinding(refPath, binding.Blendshape);
if (!_bindingMappings.TryGetValue(srcBinding, out var dstBindings))
{
dstBindings = new List<SummaryBinding>();
_bindingMappings[srcBinding] = dstBindings;
}
dstBindings.Add(new SummaryBinding(targetObj, binding.Blendshape));
}
}
// Ensure we have a unique copy of the controller.
if (!Util.IsTemporaryAsset(controller))
{
controller = Util.DeepCloneAnimator(controller);
AssetDatabase.CreateAsset(controller, Util.GenerateAssetPath());
layers[fxIndex].animatorController = controller;
avatarDescriptor.baseAnimationLayers = layers;
}
_container = controller;
// Walk and transform all clips
foreach (var state in AllStates(controller))
{
state.motion = TransformMotion(state.motion);
}
}
Motion TransformMotion(Motion motion)
{
if (motion == null) return null;
if (_motionCache.TryGetValue(motion, out var cached)) return cached;
switch (motion)
{
case AnimationClip clip:
{
motion = ProcessClip(clip);
break;
}
case BlendTree tree:
{
bool anyChanged = false;
var children = tree.children;
for (int i = 0; i < children.Length; i++)
{
var newM = TransformMotion(children[i].motion);
if (newM != children[i].motion)
{
anyChanged = true;
children[i].motion = newM;
}
}
if (anyChanged)
{
var newTree = new BlendTree();
EditorUtility.CopySerialized(tree, newTree);
AssetDatabase.AddObjectToAsset(newTree, _container);
newTree.children = children;
motion = newTree;
}
break;
}
default:
Debug.LogWarning($"Ignoring unsupported motion type {motion.GetType()}");
break;
}
_motionCache[motion] = motion;
return motion;
}
AnimationClip ProcessClip(AnimationClip origClip)
{
var clip = origClip;
EditorCurveBinding[] bindings = AnimationUtility.GetCurveBindings(clip);
foreach (var binding in bindings)
{
if (!_bindingMappings.TryGetValue(SummaryBinding.FromEditorBinding(binding), out var dstBindings))
{
continue;
}
if (clip == origClip)
{
clip = Object.Instantiate(clip);
AssetDatabase.AddObjectToAsset(clip, _container);
}
foreach (var dst in dstBindings)
{
clip.SetCurve(dst.path, typeof(SkinnedMeshRenderer), dst.propertyName,
AnimationUtility.GetEditorCurve(origClip, binding));
}
}
return clip;
}
IEnumerable<AnimatorState> AllStates(AnimatorController controller)
{
HashSet<AnimatorStateMachine> visitedStateMachines = new HashSet<AnimatorStateMachine>();
Queue<AnimatorStateMachine> stateMachines = new Queue<AnimatorStateMachine>();
foreach (var layer in controller.layers)
{
if (layer.stateMachine != null)
stateMachines.Enqueue(layer.stateMachine);
}
while (stateMachines.Count > 0)
{
var next = stateMachines.Dequeue();
if (visitedStateMachines.Contains(next)) continue;
visitedStateMachines.Add(next);
foreach (var state in next.states)
{
yield return state.state;
}
foreach (var sm in next.stateMachines)
{
stateMachines.Enqueue(sm.stateMachine);
}
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9af2c505aa6d417b9964078edd71131f
timeCreated: 1666226691

View File

@ -18,14 +18,16 @@ namespace net.fushizen.modular_avatar.core.editor
position = EditorGUI.PrefixLabel(position, label);
EditorGUI.LabelField(position,
string.IsNullOrEmpty(property.stringValue) ? "(null)" : property.stringValue);
using (var scope = new ZeroIndentScope())
{
EditorGUI.LabelField(position,
string.IsNullOrEmpty(property.stringValue) ? "(null)" : property.stringValue);
}
}
}
private bool CustomGUI(Rect position, SerializedProperty property, GUIContent label)
{
var indentLevel = EditorGUI.indentLevel;
var color = GUI.contentColor;
property = property.FindPropertyRelative(nameof(AvatarObjectReference.referencePath));
@ -56,73 +58,75 @@ namespace net.fushizen.modular_avatar.core.editor
var nullContent = GUIContent.none;
if (target != null || isNull)
using (var scope = new ZeroIndentScope())
{
EditorGUI.BeginChangeCheck();
var newTarget = EditorGUI.ObjectField(position, nullContent, target, typeof(Transform), true);
if (EditorGUI.EndChangeCheck())
if (target != null || isNull)
{
if (newTarget == null)
EditorGUI.BeginChangeCheck();
var newTarget = EditorGUI.ObjectField(position, nullContent, target, typeof(Transform), true);
if (EditorGUI.EndChangeCheck())
{
property.stringValue = "";
}
else if (newTarget == avatar.transform)
{
property.stringValue = AvatarObjectReference.AVATAR_ROOT;
}
else
{
var relPath =
RuntimeUtil.RelativePath(avatar.gameObject, ((Transform) newTarget).gameObject);
if (relPath == null) return true;
if (newTarget == null)
{
property.stringValue = "";
}
else if (newTarget == avatar.transform)
{
property.stringValue = AvatarObjectReference.AVATAR_ROOT;
}
else
{
var relPath =
RuntimeUtil.RelativePath(avatar.gameObject, ((Transform) newTarget).gameObject);
if (relPath == null) return true;
property.stringValue = relPath;
}
}
}
else
{
// For some reason, this color change retroactively affects the prefix label above, so draw our own
// label as well (we still want the prefix label for highlights, etc).
EditorGUI.LabelField(labelRect, label);
GUI.contentColor = new Color(0, 0, 0, 0);
EditorGUI.BeginChangeCheck();
var newTarget = EditorGUI.ObjectField(position, nullContent, target, typeof(Transform), true);
GUI.contentColor = color;
if (EditorGUI.EndChangeCheck())
{
if (newTarget == null)
{
property.stringValue = "";
}
else if (newTarget == avatar.transform)
{
property.stringValue = AvatarObjectReference.AVATAR_ROOT;
}
else
{
var relPath =
RuntimeUtil.RelativePath(avatar.gameObject, ((Transform) newTarget).gameObject);
if (relPath == null) return true;
property.stringValue = relPath;
property.stringValue = relPath;
}
}
}
else
{
GUI.contentColor = Color.red;
EditorGUI.LabelField(position, property.stringValue);
}
}
// For some reason, this color change retroactively affects the prefix label above, so draw our own
// label as well (we still want the prefix label for highlights, etc).
EditorGUI.LabelField(labelRect, label);
return true;
GUI.contentColor = new Color(0, 0, 0, 0);
EditorGUI.BeginChangeCheck();
var newTarget = EditorGUI.ObjectField(position, nullContent, target, typeof(Transform), true);
GUI.contentColor = color;
if (EditorGUI.EndChangeCheck())
{
if (newTarget == null)
{
property.stringValue = "";
}
else if (newTarget == avatar.transform)
{
property.stringValue = AvatarObjectReference.AVATAR_ROOT;
}
else
{
var relPath =
RuntimeUtil.RelativePath(avatar.gameObject, ((Transform) newTarget).gameObject);
if (relPath == null) return true;
property.stringValue = relPath;
}
}
else
{
GUI.contentColor = Color.red;
EditorGUI.LabelField(position, property.stringValue);
}
}
return true;
}
}
finally
{
GUI.contentColor = color;
EditorGUI.indentLevel = indentLevel;
}
}
}

View File

@ -0,0 +1,37 @@
using UnityEditor;
using UnityEngine;
namespace net.fushizen.modular_avatar.core.editor
{
[CustomEditor(typeof(ModularAvatarBlendshapeSync))]
internal class BlendshapeSyncEditor : Editor
{
private BlendshapeSelectWindow _window;
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
if (GUILayout.Button("Add blendshape"))
{
if (_window != null) DestroyImmediate(_window);
_window = ScriptableObject.CreateInstance<BlendshapeSelectWindow>();
_window.AvatarRoot = RuntimeUtil.FindAvatarInParents(((ModularAvatarBlendshapeSync) target).transform)
.gameObject;
_window.OfferBinding += OfferBinding;
_window.Show();
}
}
private void OfferBinding(BlendshapeBinding binding)
{
foreach (var obj in targets)
{
var sync = (ModularAvatarBlendshapeSync) obj;
Undo.RecordObject(sync, "Adding blendshape binding");
if (!sync.Bindings.Contains(binding)) sync.Bindings.Add(binding);
EditorUtility.SetDirty(sync);
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e8771528af9b49509738c0939b1399fa
timeCreated: 1666232014

View File

@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.Animations;
using UnityEditor.IMGUI.Controls;
using UnityEditor.PackageManager.UI;
using UnityEngine;
namespace net.fushizen.modular_avatar.core.editor
{
public class BlendshapeSelectWindow : EditorWindow
{
internal GameObject AvatarRoot;
private BlendshapeTree _tree;
internal Action<BlendshapeBinding> OfferBinding;
private void Awake()
{
titleContent = new GUIContent("Select blendshapes");
}
void OnGUI()
{
if (_tree == null)
{
_tree = new BlendshapeTree(AvatarRoot, new TreeViewState());
_tree.OfferBinding = (binding) => OfferBinding?.Invoke(binding);
_tree.Reload();
_tree.SetExpanded(0, true);
}
_tree.OnGUI(new Rect(0, 0, position.width, position.height));
}
}
internal class BlendshapeTree : TreeView
{
private readonly GameObject _avatarRoot;
private List<BlendshapeBinding?> _candidateBindings;
internal Action<BlendshapeBinding> OfferBinding;
public BlendshapeTree(GameObject avatarRoot, TreeViewState state) : base(state)
{
this._avatarRoot = avatarRoot;
}
public BlendshapeTree(GameObject avatarRoot, TreeViewState state, MultiColumnHeader multiColumnHeader) : base(
state, multiColumnHeader)
{
this._avatarRoot = avatarRoot;
}
protected override void DoubleClickedItem(int id)
{
var binding = _candidateBindings[id];
if (binding.HasValue)
{
OfferBinding.Invoke(binding.Value);
}
}
protected override TreeViewItem BuildRoot()
{
var root = new TreeViewItem {id = 0, depth = -1, displayName = "Root"};
_candidateBindings = new List<BlendshapeBinding?>();
_candidateBindings.Add(null);
var allItems = new List<TreeViewItem>();
int createdDepth = 0;
List<string> ObjectDisplayNames = new List<string>();
WalkTree(_avatarRoot, allItems, ObjectDisplayNames, ref createdDepth);
SetupParentsAndChildrenFromDepths(root, allItems);
return root;
}
private void WalkTree(GameObject node, List<TreeViewItem> items, List<string> objectDisplayNames,
ref int createdDepth)
{
objectDisplayNames.Add(node.name);
var smr = node.GetComponent<SkinnedMeshRenderer>();
if (smr != null && smr.sharedMesh != null && smr.sharedMesh.blendShapeCount > 0)
{
while (createdDepth < objectDisplayNames.Count)
{
items.Add(new TreeViewItem
{
id = _candidateBindings.Count, depth = createdDepth,
displayName = objectDisplayNames[createdDepth]
});
_candidateBindings.Add(null);
createdDepth++;
}
CreateBlendshapes(smr, items, ref createdDepth);
}
foreach (Transform child in node.transform)
{
WalkTree(child.gameObject, items, objectDisplayNames, ref createdDepth);
}
objectDisplayNames.RemoveAt(objectDisplayNames.Count - 1);
createdDepth = Math.Min(createdDepth, objectDisplayNames.Count);
}
private void CreateBlendshapes(SkinnedMeshRenderer smr, List<TreeViewItem> items, ref int createdDepth)
{
items.Add(new TreeViewItem
{id = _candidateBindings.Count, depth = createdDepth, displayName = "BlendShapes"});
_candidateBindings.Add(null);
createdDepth++;
var path = RuntimeUtil.RelativePath(_avatarRoot, smr.gameObject);
var mesh = smr.sharedMesh;
List<BlendshapeBinding> bindings = Enumerable.Range(0, mesh.blendShapeCount)
.Select(n =>
{
var name = mesh.GetBlendShapeName(n);
return new BlendshapeBinding()
{
Blendshape = name,
ReferenceMesh = new AvatarObjectReference()
{
referencePath = path
}
};
})
.ToList();
foreach (var binding in bindings)
{
items.Add(new TreeViewItem
{id = _candidateBindings.Count, depth = createdDepth, displayName = binding.Blendshape});
_candidateBindings.Add(binding);
}
createdDepth--;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: cde70f5d28c74132b955a5819545e264
timeCreated: 1666231105

View File

@ -0,0 +1,21 @@
using System;
using UnityEditor;
namespace net.fushizen.modular_avatar.core.editor
{
public class ZeroIndentScope : IDisposable
{
private int oldIndentLevel;
public ZeroIndentScope()
{
oldIndentLevel = EditorGUI.indentLevel;
EditorGUI.indentLevel = 0;
}
public void Dispose()
{
EditorGUI.indentLevel = oldIndentLevel;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 556e4b16293c47ae8080a26c5206eb9e
timeCreated: 1666232888

View File

@ -205,9 +205,7 @@ namespace net.fushizen.modular_avatar.core.editor
var queue = new Queue<AnimatorStateMachine>();
// Deep clone the animator
var merger = new AnimatorCombiner();
merger.AddController("", controller, null);
controller = merger.Finish();
controller = Util.DeepCloneAnimator(controller);
var parameters = controller.parameters;
for (int i = 0; i < parameters.Length; i++)

View File

@ -107,5 +107,19 @@ namespace net.fushizen.modular_avatar.core.editor
FileUtil.DeleteFileOrDirectory(subdir);
};
}
public static AnimatorController DeepCloneAnimator(AnimatorController controller)
{
var merger = new AnimatorCombiner();
merger.AddController("", controller, null);
return merger.Finish();
}
public static bool IsTemporaryAsset(Object obj)
{
var path = AssetDatabase.GetAssetPath(obj);
return path != null && path.StartsWith(GetGeneratedAssetsFolder() + "/");
}
}
}

View File

@ -4,7 +4,7 @@ using UnityEngine;
namespace net.fushizen.modular_avatar.core
{
[Serializable]
public struct AvatarObjectReference
public class AvatarObjectReference
{
public static string AVATAR_ROOT = "$$$AVATAR_ROOT$$$";
public string referencePath;
@ -46,5 +46,23 @@ namespace net.fushizen.modular_avatar.core
RuntimeUtil.OnHierarchyChanged -= InvalidateCache;
_cacheValid = false;
}
protected bool Equals(AvatarObjectReference other)
{
return referencePath == other.referencePath;
}
public override bool Equals(object obj)
{
if (ReferenceEquals(null, obj)) return false;
if (ReferenceEquals(this, obj)) return true;
if (obj.GetType() != this.GetType()) return false;
return Equals((AvatarObjectReference) obj);
}
public override int GetHashCode()
{
return (referencePath != null ? referencePath.GetHashCode() : 0);
}
}
}

View File

@ -0,0 +1,113 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using VRC.PackageManagement.Core;
namespace net.fushizen.modular_avatar.core
{
[Serializable]
public struct BlendshapeBinding
{
public AvatarObjectReference ReferenceMesh;
public string Blendshape;
public bool Equals(BlendshapeBinding other)
{
return Equals(ReferenceMesh, other.ReferenceMesh) && Blendshape == other.Blendshape;
}
public override bool Equals(object obj)
{
return obj is BlendshapeBinding other && Equals(other);
}
public override int GetHashCode()
{
unchecked
{
return ((ReferenceMesh != null ? ReferenceMesh.GetHashCode() : 0) * 397) ^
(Blendshape != null ? Blendshape.GetHashCode() : 0);
}
}
}
[RequireComponent(typeof(SkinnedMeshRenderer))]
[DisallowMultipleComponent]
[ExecuteInEditMode]
public class ModularAvatarBlendshapeSync : AvatarTagComponent
{
public List<BlendshapeBinding> Bindings = new List<BlendshapeBinding>();
struct EditorBlendshapeBinding
{
public SkinnedMeshRenderer TargetMesh;
public int RemoteBlendshapeIndex;
public int LocalBlendshapeIndex;
}
private List<EditorBlendshapeBinding> _editorBindings;
private void OnValidate()
{
RuntimeUtil.delayCall(Rebind);
RuntimeUtil.OnHierarchyChanged -= Rebind;
RuntimeUtil.OnHierarchyChanged += Rebind;
}
private void OnDestroy()
{
RuntimeUtil.OnHierarchyChanged -= Rebind;
}
private void Rebind()
{
_editorBindings = new List<EditorBlendshapeBinding>();
var localRenderer = GetComponent<SkinnedMeshRenderer>();
var localMesh = localRenderer.sharedMesh;
if (localMesh == null)
return;
foreach (var binding in Bindings)
{
var obj = binding.ReferenceMesh.Get(this);
if (obj == null)
continue;
var smr = obj.GetComponent<SkinnedMeshRenderer>();
if (smr == null)
continue;
var mesh = smr.sharedMesh;
if (mesh == null)
continue;
var localIndex = localMesh.GetBlendShapeIndex(binding.Blendshape);
var refIndex = mesh.GetBlendShapeIndex(binding.Blendshape);
if (localIndex == -1 || refIndex == -1)
continue;
_editorBindings.Add(new EditorBlendshapeBinding()
{
TargetMesh = smr,
RemoteBlendshapeIndex = refIndex,
LocalBlendshapeIndex = localIndex
});
}
}
private void Update()
{
if (RuntimeUtil.isPlaying) return;
if (_editorBindings == null) return;
var localRenderer = GetComponent<SkinnedMeshRenderer>();
if (localRenderer == null) return;
foreach (var binding in _editorBindings)
{
if (binding.TargetMesh == null) return;
var weight = binding.TargetMesh.GetBlendShapeWeight(binding.RemoteBlendshapeIndex);
localRenderer.SetBlendShapeWeight(binding.LocalBlendshapeIndex, weight);
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 6fd7cab7d93b403280f2f9da978d8a4f
timeCreated: 1666226053

View File

@ -29,6 +29,7 @@ using UnityEngine;
namespace net.fushizen.modular_avatar.core
{
[ExecuteInEditMode]
[DisallowMultipleComponent]
public class ModularAvatarBoneProxy : AvatarTagComponent
{
private Transform _targetCache;

View File

@ -32,6 +32,7 @@ using UnityEditor;
namespace net.fushizen.modular_avatar.core
{
[ExecuteInEditMode]
[DisallowMultipleComponent]
public class ModularAvatarMergeArmature : AvatarTagComponent
{
private const float POS_EPSILON = 0.01f;

View File

@ -35,11 +35,9 @@ namespace net.fushizen.modular_avatar.core
{
public static class RuntimeUtil
{
public delegate void NullCallback();
// Initialized in Util
public static Action<NullCallback> delayCall = (_) => { };
public static event NullCallback OnHierarchyChanged;
public static Action<Action> delayCall = (_) => { };
public static event Action OnHierarchyChanged;
public enum OnDemandSource
{